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.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
bindsight/cli.py
ADDED
|
@@ -0,0 +1,2429 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""bindsight command-line interface.
|
|
4
|
+
|
|
5
|
+
A Click app exposing the pipeline stages as subcommands: ``discover`` (CPU
|
|
6
|
+
genomics → target shortlist), ``design`` / ``validate`` (GPU binder design +
|
|
7
|
+
structure/affinity prediction, dispatched to a runner backend), ``rank``,
|
|
8
|
+
``report``, ``export``, ``benchmark`` (rediscovery scoring against the held-out
|
|
9
|
+
known-antigen set), plus ``run`` (full pipeline), ``demo``, ``ui``, ``doctor``
|
|
10
|
+
and ``verify-licenses``.
|
|
11
|
+
|
|
12
|
+
GPU stages require a CUDA backend (``--backend modal|local_docker|kaggle`` for
|
|
13
|
+
headless execution, or ``--backend colab`` to generate a notebook). The CPU
|
|
14
|
+
stages run anywhere; ``--backend mock`` exercises the full chain in CI.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import sys
|
|
22
|
+
from importlib.metadata import PackageNotFoundError
|
|
23
|
+
from importlib.metadata import version as pkg_version
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
import click
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.logging import RichHandler
|
|
30
|
+
from rich.panel import Panel
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
|
|
33
|
+
from bindsight import __version__
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING: # pragma: no cover - typing only; runtime imports stay lazy
|
|
36
|
+
from bindsight.config import RunConfig
|
|
37
|
+
from bindsight.runners.protocol import CostEstimate
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _force_utf8_io() -> None:
|
|
41
|
+
"""Reconfigure stdout/stderr to UTF-8.
|
|
42
|
+
|
|
43
|
+
Rich uses Unicode box-drawing characters for panels (┃ ━ etc.) and the
|
|
44
|
+
pipeline log messages contain ≥, ×, → and other non-cp1252 glyphs. The
|
|
45
|
+
default Windows console is cp1252 and will raise UnicodeEncodeError when
|
|
46
|
+
asked to write those. ``sys.stdout.reconfigure(encoding='utf-8')`` is the
|
|
47
|
+
one-line fix that works on Python 3.7+ on all platforms.
|
|
48
|
+
"""
|
|
49
|
+
import contextlib
|
|
50
|
+
|
|
51
|
+
for stream_name in ("stdout", "stderr"):
|
|
52
|
+
stream = getattr(sys, stream_name, None)
|
|
53
|
+
if stream is not None and hasattr(stream, "reconfigure"):
|
|
54
|
+
# Tests sometimes wrap sys.stdout in a non-reconfigurable buffer;
|
|
55
|
+
# tolerate that and fall back to silently dropping bad chars.
|
|
56
|
+
with contextlib.suppress(Exception):
|
|
57
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_force_utf8_io()
|
|
61
|
+
|
|
62
|
+
LOG_CLI = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _default_known_antigens() -> Path:
|
|
66
|
+
"""The shipped known-antigen set, resolved from the source tree if present.
|
|
67
|
+
|
|
68
|
+
The default was the bare relative path ``benchmarks/known.tsv``, which only
|
|
69
|
+
exists when the command is run from a repository checkout — and Click checks
|
|
70
|
+
``exists=True`` against it, so from a pip install the option failed before
|
|
71
|
+
the command ran, naming a path the user has no reason to expect.
|
|
72
|
+
"""
|
|
73
|
+
candidate = Path(__file__).resolve().parent.parent / "benchmarks" / "known.tsv"
|
|
74
|
+
return candidate if candidate.is_file() else Path("benchmarks/known.tsv")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# From `defaults`, not `core`: core imports pandas, which lives in the
|
|
78
|
+
# `discover` extra, and this line runs for every `bindsight` invocation.
|
|
79
|
+
from bindsight.benchmark.defaults import DEFAULT_KS # noqa: E402
|
|
80
|
+
from bindsight.provenance import append as provenance # noqa: E402
|
|
81
|
+
|
|
82
|
+
# Rich console; legacy-windows mode off so box-drawing chars work after the
|
|
83
|
+
# UTF-8 reconfigure above.
|
|
84
|
+
console = Console(force_terminal=True, legacy_windows=False, soft_wrap=False)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _setup_logging(verbose: bool) -> None:
|
|
88
|
+
logging.basicConfig(
|
|
89
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
90
|
+
format="%(message)s",
|
|
91
|
+
datefmt="[%X]",
|
|
92
|
+
handlers=[RichHandler(console=console, rich_tracebacks=True, show_path=False)],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Top-level group
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
#: Python versions the CI matrix actually runs, as (major, minor).
|
|
100
|
+
#:
|
|
101
|
+
#: Kept beside the check rather than read from `pyproject.toml` classifiers,
|
|
102
|
+
#: which are a publishing hint rather than a statement about what was executed.
|
|
103
|
+
#: `tests/test_counted_self_claims.py` asserts this matches the workflow matrix.
|
|
104
|
+
TESTED_PYTHONS: tuple[tuple[int, int], ...] = ((3, 11), (3, 12), (3, 13))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _PYTHON_SUPPORT_STATUS() -> bool:
|
|
108
|
+
"""True when this interpreter is one the CI matrix runs.
|
|
109
|
+
|
|
110
|
+
Below the floor is a hard no. Above the ceiling is also not "ok": nothing
|
|
111
|
+
has run the suite there, and saying otherwise is the check reporting a
|
|
112
|
+
result it does not have.
|
|
113
|
+
"""
|
|
114
|
+
return sys.version_info[:2] in TESTED_PYTHONS
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _python_support_note() -> str:
|
|
118
|
+
"""What to add beside the version when it is not one that was tested."""
|
|
119
|
+
if _PYTHON_SUPPORT_STATUS():
|
|
120
|
+
return ""
|
|
121
|
+
lowest = ".".join(str(n) for n in min(TESTED_PYTHONS))
|
|
122
|
+
highest = ".".join(str(n) for n in max(TESTED_PYTHONS))
|
|
123
|
+
if sys.version_info[:2] < min(TESTED_PYTHONS):
|
|
124
|
+
return f" — below the minimum; bindsight needs at least {lowest}"
|
|
125
|
+
return f" — untested: CI runs {lowest}–{highest}, so nothing has exercised this one"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _designer_choice() -> click.Choice[str]:
|
|
129
|
+
"""The designers this installation can run, for `--designer`.
|
|
130
|
+
|
|
131
|
+
These were three `click.Choice` literals repeated across `design`, `run` and
|
|
132
|
+
the benchmark, each of which rejected a designer registered through the
|
|
133
|
+
`bindsight.designers` entry-point group before the command body ran -- so
|
|
134
|
+
`--designer my_designer`, the command `docs/use-cases.md` tells a method
|
|
135
|
+
developer to run, could not work no matter what they registered.
|
|
136
|
+
"""
|
|
137
|
+
from bindsight.plugins import all_designers
|
|
138
|
+
|
|
139
|
+
return click.Choice(sorted(all_designers()))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _validator_choice() -> click.Choice[str]:
|
|
143
|
+
"""The validators this installation can run, for `--validator`."""
|
|
144
|
+
from bindsight.plugins import all_validators
|
|
145
|
+
|
|
146
|
+
return click.Choice(sorted(all_validators()))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _backend_choice() -> click.Choice[str]:
|
|
150
|
+
"""The runner backends this installation can run, for `--backend`."""
|
|
151
|
+
from bindsight.plugins import all_runners
|
|
152
|
+
|
|
153
|
+
return click.Choice(sorted(all_runners()))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@click.group(
|
|
157
|
+
name="bindsight",
|
|
158
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
159
|
+
)
|
|
160
|
+
@click.version_option(__version__, prog_name="bindsight")
|
|
161
|
+
def main() -> None:
|
|
162
|
+
"""Bridge from RNA-seq to de novo protein binder design.
|
|
163
|
+
|
|
164
|
+
See https://github.com/mikhaeelatefrizk/bindsight for the manual.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# discover — CPU only; produces targets.parquet + epitopes.parquet
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
@main.command()
|
|
172
|
+
@click.argument(
|
|
173
|
+
"config",
|
|
174
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
175
|
+
)
|
|
176
|
+
@click.option(
|
|
177
|
+
"--out",
|
|
178
|
+
"out_dir",
|
|
179
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
180
|
+
required=True,
|
|
181
|
+
help="Output directory for this run (will be created).",
|
|
182
|
+
)
|
|
183
|
+
@click.option(
|
|
184
|
+
"--top-n",
|
|
185
|
+
# Ranged, because this value is assigned straight onto the pydantic model
|
|
186
|
+
# (``cfg.params.target_discovery.top_n = top_n``) and assignment does not
|
|
187
|
+
# re-validate. ``--top-n 0`` therefore reached the pipeline and produced an
|
|
188
|
+
# empty shortlist that looked like a finding.
|
|
189
|
+
type=click.IntRange(min=1),
|
|
190
|
+
default=None,
|
|
191
|
+
help="Override params.target_discovery.top_n in the config.",
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"-v",
|
|
195
|
+
"--verbose",
|
|
196
|
+
is_flag=True,
|
|
197
|
+
help="Verbose (DEBUG) logging.",
|
|
198
|
+
)
|
|
199
|
+
def discover(config: Path, out_dir: Path, top_n: int | None, verbose: bool) -> None:
|
|
200
|
+
"""Discover surface antigen targets from RNA-seq counts.
|
|
201
|
+
|
|
202
|
+
Runs the discovery half of the pipeline (CPU only):
|
|
203
|
+
DEG → surfaceome filter → Open Targets enrichment → AlphaFoldDB pull.
|
|
204
|
+
(SURFACE-Bind targetable-site lookup is wired in: it reads a vendored data
|
|
205
|
+
tree — there is no public API — and focuses design on real sites when
|
|
206
|
+
present, else targets the whole surface.)
|
|
207
|
+
"""
|
|
208
|
+
_setup_logging(verbose)
|
|
209
|
+
from bindsight.config import RunConfig
|
|
210
|
+
from bindsight.pipelines import discover as discover_pipeline
|
|
211
|
+
|
|
212
|
+
cfg = RunConfig.from_yaml(config)
|
|
213
|
+
cfg.out_dir = out_dir
|
|
214
|
+
if top_n is not None:
|
|
215
|
+
cfg.params.target_discovery.top_n = top_n
|
|
216
|
+
|
|
217
|
+
console.print(f"[dim]config:[/dim] {config}")
|
|
218
|
+
console.print(f"[dim]out:[/dim] {out_dir}")
|
|
219
|
+
console.print(f"[dim]top-n:[/dim] {cfg.params.target_discovery.top_n}")
|
|
220
|
+
|
|
221
|
+
manifest = discover_pipeline.run(cfg, out_dir=out_dir)
|
|
222
|
+
|
|
223
|
+
failed = [s for s in manifest.stages if s.status == "failed"]
|
|
224
|
+
if failed:
|
|
225
|
+
console.print(
|
|
226
|
+
Panel(
|
|
227
|
+
"\n".join(f"[red]{s.name}[/red]: {s.error}" for s in failed),
|
|
228
|
+
title="Stage failures",
|
|
229
|
+
border_style="red",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
console.print(
|
|
235
|
+
Panel(
|
|
236
|
+
f"[green]Discovery complete.[/green]\n"
|
|
237
|
+
f"Manifest: {out_dir / 'run_manifest.jsonld'}\n"
|
|
238
|
+
f"Targets: {out_dir / 'targets' / 'candidates.parquet'}\n"
|
|
239
|
+
f"Epitopes: {out_dir / 'epitopes' / 'epitopes.parquet'}",
|
|
240
|
+
title="bindsight discover",
|
|
241
|
+
border_style="green",
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# design — GPU offload; produces designed binder PDBs
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
@main.command()
|
|
250
|
+
@click.argument(
|
|
251
|
+
"run_dir",
|
|
252
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
253
|
+
)
|
|
254
|
+
@click.option(
|
|
255
|
+
"--backend",
|
|
256
|
+
type=_backend_choice(),
|
|
257
|
+
default="colab",
|
|
258
|
+
show_default=True,
|
|
259
|
+
)
|
|
260
|
+
@click.option(
|
|
261
|
+
"--designer",
|
|
262
|
+
type=_designer_choice(),
|
|
263
|
+
default="rfdiff_mpnn",
|
|
264
|
+
show_default=True,
|
|
265
|
+
)
|
|
266
|
+
@click.option(
|
|
267
|
+
"--validator",
|
|
268
|
+
type=_validator_choice(),
|
|
269
|
+
default="boltz2",
|
|
270
|
+
show_default=True,
|
|
271
|
+
help="Validator run on each design (structure + affinity prediction).",
|
|
272
|
+
)
|
|
273
|
+
@click.option(
|
|
274
|
+
"--trajectories",
|
|
275
|
+
type=click.IntRange(min=1),
|
|
276
|
+
default=50,
|
|
277
|
+
show_default=True,
|
|
278
|
+
help=(
|
|
279
|
+
"Number of independent design trajectories per target. The default shown "
|
|
280
|
+
"here only applies when the run's config.yaml does not set "
|
|
281
|
+
"params.design.n_trajectories; when it does, that value wins unless you "
|
|
282
|
+
"pass this flag explicitly."
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
@click.option(
|
|
286
|
+
"--dry-run",
|
|
287
|
+
is_flag=True,
|
|
288
|
+
help="Estimate compute cost without launching jobs.",
|
|
289
|
+
)
|
|
290
|
+
def design(
|
|
291
|
+
run_dir: Path,
|
|
292
|
+
backend: str,
|
|
293
|
+
designer: str,
|
|
294
|
+
validator: str,
|
|
295
|
+
trajectories: int,
|
|
296
|
+
dry_run: bool,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Generate de novo binder backbones + sequences (offloaded to a GPU runner).
|
|
299
|
+
|
|
300
|
+
With ``--dry-run``, prints a per-target cost estimate without launching.
|
|
301
|
+
Otherwise: ``--backend colab`` writes a ready-to-run notebook per target;
|
|
302
|
+
the headless backends (``modal``/``local_docker``/``kaggle``/``mock``)
|
|
303
|
+
execute the design+validation job and pull results back into ``<run>/design``.
|
|
304
|
+
"""
|
|
305
|
+
from bindsight.cost import estimate_full_run
|
|
306
|
+
|
|
307
|
+
# A run directory carries the configuration it was produced under. Until now
|
|
308
|
+
# this command ignored it, so `params.design.n_trajectories: 10` in a config
|
|
309
|
+
# became the flag's default of 50 — five times the GPU cost the user asked
|
|
310
|
+
# for, printed in the cost panel as though it were their choice. An
|
|
311
|
+
# explicitly passed flag still wins.
|
|
312
|
+
designer, validator, trajectories = _design_defaults_from_run(
|
|
313
|
+
run_dir, designer=designer, validator=validator, trajectories=trajectories
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
_preflight_backend(backend, designer=designer, validator=validator)
|
|
317
|
+
|
|
318
|
+
epitopes_parquet = run_dir / "epitopes" / "epitopes.parquet"
|
|
319
|
+
n_targets = _count_top_targets(epitopes_parquet)
|
|
320
|
+
|
|
321
|
+
console.print(f"[dim]run-dir:[/dim] {run_dir}")
|
|
322
|
+
console.print(f"[dim]backend:[/dim] {backend}")
|
|
323
|
+
console.print(f"[dim]designer:[/dim] {designer}")
|
|
324
|
+
console.print(f"[dim]validator:[/dim] {validator}")
|
|
325
|
+
console.print(f"[dim]trajectories:[/dim] {trajectories}")
|
|
326
|
+
console.print(f"[dim]targets:[/dim] {n_targets if n_targets is not None else 'unknown'}")
|
|
327
|
+
|
|
328
|
+
if n_targets is None:
|
|
329
|
+
# No estimate rather than an estimate of nothing. Costing a guessed
|
|
330
|
+
# target count produces a plausible dollar figure for work whose size is
|
|
331
|
+
# not known, which is worse than saying so.
|
|
332
|
+
console.print(
|
|
333
|
+
"[yellow]No epitopes table, so the target count is unknown and the "
|
|
334
|
+
"cost estimate is skipped.[/yellow] Run `bindsight discover` first."
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
# gpu_type included, as `run` already does. Without it this quoted A100
|
|
338
|
+
# prices for a run configured for a T4 -- the estimate was for different
|
|
339
|
+
# hardware than the one the job would ask for.
|
|
340
|
+
d_cost, _v_cost, _c_cost = estimate_full_run(
|
|
341
|
+
backend=backend,
|
|
342
|
+
designer=designer,
|
|
343
|
+
validator=validator,
|
|
344
|
+
n_targets=n_targets,
|
|
345
|
+
n_trajectories=trajectories,
|
|
346
|
+
gpu_type=_gpu_type_from_run(run_dir),
|
|
347
|
+
)
|
|
348
|
+
_print_cost_panel(d_cost, label=f"design ({designer})")
|
|
349
|
+
|
|
350
|
+
if dry_run:
|
|
351
|
+
console.print(
|
|
352
|
+
Panel(
|
|
353
|
+
"[green]--dry-run:[/green] no jobs launched. Drop --dry-run to run.",
|
|
354
|
+
title="Dry run",
|
|
355
|
+
border_style="green",
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
if backend == "colab":
|
|
361
|
+
n = _write_design_notebooks(run_dir, designer=designer, trajectories=trajectories)
|
|
362
|
+
if n == 0:
|
|
363
|
+
# The same answer the headless backends give, for the same
|
|
364
|
+
# situation. This printed a green "Wrote 0 Colab notebook(s)" and a
|
|
365
|
+
# line telling the reader to open each of them, then exited 0 --
|
|
366
|
+
# one line after saying it could not even estimate the cost. It is
|
|
367
|
+
# the default backend, so it was the path most people met.
|
|
368
|
+
console.print(
|
|
369
|
+
Panel(
|
|
370
|
+
"[yellow]No targets with a structure to design against.[/yellow] "
|
|
371
|
+
"Run [bold]bindsight discover[/bold] first (and ensure AlphaFold "
|
|
372
|
+
"structures were fetched).",
|
|
373
|
+
title="design: nothing to do",
|
|
374
|
+
border_style="yellow",
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
sys.exit(2)
|
|
378
|
+
console.print(
|
|
379
|
+
Panel(
|
|
380
|
+
f"[green]Wrote {n} Colab notebook(s)[/green] to {run_dir / 'design'}.\n"
|
|
381
|
+
"Open each in Colab (GPU runtime), Run all, download the results "
|
|
382
|
+
"tarball into <run>/design/, then run [bold]bindsight validate[/bold].",
|
|
383
|
+
title="design: notebooks ready",
|
|
384
|
+
border_style="green",
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
_seed, _blo, _bhi = _design_spec_params_from_run(run_dir)
|
|
390
|
+
_top_k, _samples, _parallel = _validator_params_from_run(run_dir)
|
|
391
|
+
launched = _launch_design(
|
|
392
|
+
run_dir,
|
|
393
|
+
backend=backend,
|
|
394
|
+
designer=designer,
|
|
395
|
+
validator=validator,
|
|
396
|
+
trajectories=trajectories,
|
|
397
|
+
prescreen_top_k=_top_k,
|
|
398
|
+
diffusion_samples=_samples,
|
|
399
|
+
max_parallel_samples=_parallel,
|
|
400
|
+
)
|
|
401
|
+
if launched == 0:
|
|
402
|
+
console.print(
|
|
403
|
+
Panel(
|
|
404
|
+
"[yellow]No targets with a structure to design against.[/yellow] "
|
|
405
|
+
"Run [bold]bindsight discover[/bold] first (and ensure AlphaFold "
|
|
406
|
+
"structures were fetched).",
|
|
407
|
+
title="design: nothing to do",
|
|
408
|
+
border_style="yellow",
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
sys.exit(2)
|
|
412
|
+
provenance.record(
|
|
413
|
+
run_dir,
|
|
414
|
+
name="design",
|
|
415
|
+
tool=f"bindsight.design.{designer}",
|
|
416
|
+
outputs={
|
|
417
|
+
"design_metrics": run_dir / "design" / "metrics.jsonl",
|
|
418
|
+
"design_results": run_dir / "design" / "results.tar.gz",
|
|
419
|
+
},
|
|
420
|
+
params={
|
|
421
|
+
"designer": designer,
|
|
422
|
+
"validator": validator,
|
|
423
|
+
"backend": backend,
|
|
424
|
+
"n_trajectories": trajectories,
|
|
425
|
+
# ARCHITECTURE 5 promises a reviewer can reach "the trajectory seed
|
|
426
|
+
# and the resolved design parameters" from the manifest. They were
|
|
427
|
+
# not recorded here, so a run's own manifest could not answer the
|
|
428
|
+
# one question that makes it reproducible. `bindsight run` recorded
|
|
429
|
+
# them by dumping the whole config; this path did not.
|
|
430
|
+
"seed": _seed,
|
|
431
|
+
"binder_length_min": _blo,
|
|
432
|
+
"binder_length_max": _bhi,
|
|
433
|
+
},
|
|
434
|
+
notes=f"{launched} target(s) designed on the {backend} backend",
|
|
435
|
+
)
|
|
436
|
+
console.print(
|
|
437
|
+
Panel(
|
|
438
|
+
f"[green]Design complete for {launched} target(s).[/green]\n"
|
|
439
|
+
f"Results: {run_dir / 'design'}\n"
|
|
440
|
+
f"Next: [bold]bindsight validate {run_dir}[/bold]",
|
|
441
|
+
title="bindsight design",
|
|
442
|
+
border_style="green",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# validate — GPU; affinity + structure prediction on designed binders
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
@main.command()
|
|
451
|
+
@click.argument(
|
|
452
|
+
"run_dir",
|
|
453
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
454
|
+
)
|
|
455
|
+
@click.option(
|
|
456
|
+
"--backend",
|
|
457
|
+
type=_backend_choice(),
|
|
458
|
+
default="colab",
|
|
459
|
+
show_default=True,
|
|
460
|
+
)
|
|
461
|
+
@click.option(
|
|
462
|
+
"--validator",
|
|
463
|
+
type=_validator_choice(),
|
|
464
|
+
default="boltz2",
|
|
465
|
+
show_default=True,
|
|
466
|
+
)
|
|
467
|
+
@click.option(
|
|
468
|
+
"--revalidate",
|
|
469
|
+
is_flag=True,
|
|
470
|
+
help=(
|
|
471
|
+
"Actually run --validator against the existing designs on --backend, instead of "
|
|
472
|
+
"materialising the metrics the design step already produced. Use this to score the "
|
|
473
|
+
"same binders with a second validator without redesigning them."
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
def validate(run_dir: Path, backend: str, validator: str, revalidate: bool) -> None:
|
|
477
|
+
"""Validate designed binders by predicting structure and binding affinity.
|
|
478
|
+
|
|
479
|
+
By default this materialises the metrics the design job already produced,
|
|
480
|
+
because the headless backends run design and validation together. Pass
|
|
481
|
+
``--revalidate`` to dispatch ``--validator`` against the existing designs;
|
|
482
|
+
that is the path that makes cross-validator agreement possible.
|
|
483
|
+
"""
|
|
484
|
+
from bindsight.cost import estimate
|
|
485
|
+
|
|
486
|
+
if validator == "af2_ig":
|
|
487
|
+
console.print(
|
|
488
|
+
Panel(
|
|
489
|
+
"[bold red]AF2-IG depends on PyRosetta, which is free only for non-commercial use.[/bold red]\n"
|
|
490
|
+
"The AlphaFold2 parameters themselves are CC BY 4.0. See LICENSING.md § 3; "
|
|
491
|
+
"default to boltz2 for commercial work.",
|
|
492
|
+
title="License banner",
|
|
493
|
+
border_style="red",
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
design_dir = run_dir / "design"
|
|
498
|
+
n_designs = _count_designs(design_dir)
|
|
499
|
+
if n_designs is None:
|
|
500
|
+
# No designs on disk yet, so any per-design figure would be invented.
|
|
501
|
+
console.print(
|
|
502
|
+
f"[yellow]No designs found under {design_dir}[/yellow] — "
|
|
503
|
+
"cost is unknown until the design step has produced binders."
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
cost = estimate(backend=backend, stage="validate", plugin=validator, n_units=n_designs)
|
|
507
|
+
_print_cost_panel(cost, label=f"validate ({validator}, {n_designs} designs)")
|
|
508
|
+
|
|
509
|
+
if revalidate:
|
|
510
|
+
# Revalidation dispatches to the backend just as design does, so the same
|
|
511
|
+
# refusal applies: --validator chai1r --backend kaggle cannot work.
|
|
512
|
+
_preflight_backend(backend, validator=validator)
|
|
513
|
+
if backend == "colab":
|
|
514
|
+
console.print(
|
|
515
|
+
Panel(
|
|
516
|
+
"[yellow]--revalidate needs a headless backend[/yellow] "
|
|
517
|
+
"(modal / local_docker / kaggle / mock).\n"
|
|
518
|
+
"The colab runner cannot be launched from the CLI, so there is nothing "
|
|
519
|
+
"to dispatch to.",
|
|
520
|
+
title="validate: backend not dispatchable",
|
|
521
|
+
border_style="yellow",
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
sys.exit(2)
|
|
525
|
+
done = _launch_revalidate(run_dir, backend=backend, validator=validator)
|
|
526
|
+
if done == 0:
|
|
527
|
+
console.print(
|
|
528
|
+
Panel(
|
|
529
|
+
"[yellow]Nothing to revalidate.[/yellow] No per-target design tarballs "
|
|
530
|
+
f"under {run_dir / 'design' / '_targets'} — run "
|
|
531
|
+
"[bold]bindsight design[/bold] first.",
|
|
532
|
+
title="validate: nothing to do",
|
|
533
|
+
border_style="yellow",
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
sys.exit(2)
|
|
537
|
+
console.print(f"[green]Revalidated {done} target(s) with {validator}.[/green]")
|
|
538
|
+
|
|
539
|
+
# The design step (headless backends) runs design + validation together via
|
|
540
|
+
# the executor, writing per-design metrics into the design tarballs. Here we
|
|
541
|
+
# materialise those into validate/validated.parquet (+ per-binder dirs).
|
|
542
|
+
n = _finalize_validate(run_dir)
|
|
543
|
+
validated = run_dir / "validate" / "validated.parquet"
|
|
544
|
+
if n == 0:
|
|
545
|
+
# Nothing was validated, so nothing is recorded. provenance.record marks
|
|
546
|
+
# the stage completed unconditionally, so this command used to append a
|
|
547
|
+
# completed validate stage to run_manifest.jsonld and then print, on the
|
|
548
|
+
# same screen, that the GPU step still needed running. The manifest is
|
|
549
|
+
# what a reviewer reads and what the RO-Crate exports; it must not
|
|
550
|
+
# assert a stage the console is telling the user to go and perform.
|
|
551
|
+
console.print(
|
|
552
|
+
Panel(
|
|
553
|
+
"No design results to validate yet. Run [bold]bindsight design[/bold] on a\n"
|
|
554
|
+
"headless backend (modal/local_docker/kaggle), or for --backend colab open "
|
|
555
|
+
"the\ngenerated notebook (GPU), download the results tarball into "
|
|
556
|
+
"<run>/design/,\nthen re-run this command. See docs/colab-design-howto.md.",
|
|
557
|
+
title="validate: GPU step pending",
|
|
558
|
+
border_style="cyan",
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
sys.exit(0)
|
|
562
|
+
# Record the validator that produced these numbers, not the one the flag
|
|
563
|
+
# asked for. Without --revalidate no validator runs here, so the flag is a
|
|
564
|
+
# request the metrics may not answer to.
|
|
565
|
+
produced_by = _validators_that_produced(validated)
|
|
566
|
+
provenance.record(
|
|
567
|
+
run_dir,
|
|
568
|
+
name="validate",
|
|
569
|
+
tool=f"bindsight.validate.{'+'.join(produced_by) if produced_by else validator}",
|
|
570
|
+
outputs={"validated": validated},
|
|
571
|
+
params={
|
|
572
|
+
"validator_requested": validator,
|
|
573
|
+
"validator_recorded": produced_by,
|
|
574
|
+
"backend": backend,
|
|
575
|
+
"revalidate": revalidate,
|
|
576
|
+
},
|
|
577
|
+
notes=f"{n} design(s) materialised into validated.parquet",
|
|
578
|
+
)
|
|
579
|
+
# n == 0 returned above, before the stage was recorded, so reaching here
|
|
580
|
+
# means designs really were validated.
|
|
581
|
+
console.print(
|
|
582
|
+
Panel(
|
|
583
|
+
f"[green]Validated {n} design(s).[/green]\n"
|
|
584
|
+
f"Output: {validated}\nNext: [bold]bindsight rank {run_dir}[/bold]",
|
|
585
|
+
title="validate: ready",
|
|
586
|
+
border_style="green",
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# ---------------------------------------------------------------------------
|
|
592
|
+
# rank — multi-objective scoring
|
|
593
|
+
# ---------------------------------------------------------------------------
|
|
594
|
+
@main.command()
|
|
595
|
+
@click.argument(
|
|
596
|
+
"run_dir",
|
|
597
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
598
|
+
)
|
|
599
|
+
def rank(run_dir: Path) -> None:
|
|
600
|
+
"""Rank validated binders by composite score (affinity, iPTM, expression Δ)."""
|
|
601
|
+
from bindsight.rank import rank_run
|
|
602
|
+
|
|
603
|
+
# The run's own weights, not the defaults. This command called
|
|
604
|
+
# `rank_run(run_dir)` with no weights and then recorded `RankWeights()` in
|
|
605
|
+
# provenance, so a user who configured a weighting got an ordering produced
|
|
606
|
+
# by a different one -- and a manifest that said otherwise.
|
|
607
|
+
weights = _rank_weights(run_dir)
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
out = rank_run(run_dir, weights=weights)
|
|
611
|
+
except FileNotFoundError as e:
|
|
612
|
+
console.print(
|
|
613
|
+
Panel(
|
|
614
|
+
f"[yellow]{e}[/yellow]\n\n"
|
|
615
|
+
"Run [bold]bindsight validate <run_dir>[/bold] first, then this.",
|
|
616
|
+
title="rank: nothing to rank",
|
|
617
|
+
border_style="yellow",
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
sys.exit(2)
|
|
621
|
+
# The weights are what turn four metrics into one ordering, and they are
|
|
622
|
+
# configurable. Recording the ranking without them left the published order
|
|
623
|
+
# underdetermined: nothing in the manifest says which weighting produced it.
|
|
624
|
+
# They are read above from the run's config, so what is recorded here is what
|
|
625
|
+
# actually ran.
|
|
626
|
+
provenance.record(
|
|
627
|
+
run_dir,
|
|
628
|
+
name="rank",
|
|
629
|
+
tool="bindsight.rank",
|
|
630
|
+
inputs={"validated": run_dir / "validate" / "validated.parquet"},
|
|
631
|
+
outputs={"ranking": out},
|
|
632
|
+
params={"weights": weights.model_dump()},
|
|
633
|
+
)
|
|
634
|
+
console.print(
|
|
635
|
+
Panel(
|
|
636
|
+
f"[green]Ranking written.[/green]\n[bold]Output:[/bold] {out}",
|
|
637
|
+
title="bindsight rank",
|
|
638
|
+
border_style="green",
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
# report — self-contained HTML / served interface
|
|
645
|
+
# ---------------------------------------------------------------------------
|
|
646
|
+
@main.command()
|
|
647
|
+
@click.argument(
|
|
648
|
+
"run_dir",
|
|
649
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
650
|
+
)
|
|
651
|
+
@click.option(
|
|
652
|
+
"--format",
|
|
653
|
+
"fmt",
|
|
654
|
+
type=click.Choice(["html", "web"]),
|
|
655
|
+
default="html",
|
|
656
|
+
show_default=True,
|
|
657
|
+
)
|
|
658
|
+
@click.option(
|
|
659
|
+
"--include-binders/--no-include-binders",
|
|
660
|
+
default=False,
|
|
661
|
+
help=(
|
|
662
|
+
"Embed each ranked binder's designed sequence in the report. The ranked "
|
|
663
|
+
"binder table itself is always included when the design and rank stages "
|
|
664
|
+
"have run."
|
|
665
|
+
),
|
|
666
|
+
)
|
|
667
|
+
@click.option(
|
|
668
|
+
"--embed-structures/--no-embed-structures",
|
|
669
|
+
default=True,
|
|
670
|
+
show_default=True,
|
|
671
|
+
help=(
|
|
672
|
+
"Draw the predicted complexes in 3-D inside the report. The viewer and "
|
|
673
|
+
"the structures travel in the file, so it still opens with no server "
|
|
674
|
+
"and no network -- at roughly half a megabyte for the viewer and 150 kB "
|
|
675
|
+
"per complex, best-ranked first, up to a budget. Turn it off for a "
|
|
676
|
+
"small file that shows the numbers without the structures behind them."
|
|
677
|
+
),
|
|
678
|
+
)
|
|
679
|
+
def report(run_dir: Path, fmt: str, include_binders: bool, embed_structures: bool) -> None:
|
|
680
|
+
"""Render the run as a self-contained HTML report, or serve the interface.
|
|
681
|
+
|
|
682
|
+
HTML output is one self-contained file (CSS, plot, tables and the predicted
|
|
683
|
+
complexes in 3-D, all embedded) you can email or attach to a paper. `--format web` serves the same run locally
|
|
684
|
+
for interactive browsing; nothing is fetched from a network either way.
|
|
685
|
+
"""
|
|
686
|
+
if fmt == "html":
|
|
687
|
+
from bindsight.report import render_run
|
|
688
|
+
|
|
689
|
+
out_path = render_run(
|
|
690
|
+
run_dir, include_binders=include_binders, embed_structures=embed_structures
|
|
691
|
+
)
|
|
692
|
+
provenance.record(
|
|
693
|
+
run_dir,
|
|
694
|
+
name="report",
|
|
695
|
+
tool="bindsight.report",
|
|
696
|
+
outputs={"report_html": out_path},
|
|
697
|
+
params={
|
|
698
|
+
"format": fmt,
|
|
699
|
+
"include_binders": include_binders,
|
|
700
|
+
"embed_structures": embed_structures,
|
|
701
|
+
},
|
|
702
|
+
)
|
|
703
|
+
console.print(
|
|
704
|
+
Panel(
|
|
705
|
+
f"[green]Report rendered.[/green]\n[bold]Open:[/bold] {out_path}",
|
|
706
|
+
title="bindsight report",
|
|
707
|
+
border_style="green",
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
if fmt == "web":
|
|
713
|
+
try:
|
|
714
|
+
from bindsight.report.web.app import serve
|
|
715
|
+
except ImportError as exc: # pragma: no cover - exercised via the extra
|
|
716
|
+
console.print(
|
|
717
|
+
Panel(
|
|
718
|
+
"[yellow]The web interface is not installed.[/yellow]\n"
|
|
719
|
+
f"[dim]{exc}[/dim]\n"
|
|
720
|
+
# Rich reads an unescaped bracket as a style tag, which made
|
|
721
|
+
# this print `pip install -e "."` -- the base package, which
|
|
722
|
+
# is what the reader already had.
|
|
723
|
+
' [bold]pip install -e ".\\[report]"[/bold]',
|
|
724
|
+
title="Missing dependency",
|
|
725
|
+
border_style="yellow",
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
sys.exit(2)
|
|
729
|
+
|
|
730
|
+
console.print(
|
|
731
|
+
Panel(
|
|
732
|
+
f"[bold]http://127.0.0.1:8501[/bold]\n"
|
|
733
|
+
f"[dim]Serving runs from {run_dir.parent}. Ctrl-C to stop.[/dim]",
|
|
734
|
+
title="bindsight",
|
|
735
|
+
border_style="blue",
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
try:
|
|
739
|
+
# The root it just announced, not whatever ./runs resolves to from
|
|
740
|
+
# the shell's working directory.
|
|
741
|
+
serve(port=8501, open_browser=True, run_root=run_dir.parent)
|
|
742
|
+
except KeyboardInterrupt: # pragma: no cover - interactive
|
|
743
|
+
console.print("[dim]stopped[/dim]")
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# ---------------------------------------------------------------------------
|
|
748
|
+
# run — full pipeline
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
@main.command()
|
|
751
|
+
@click.argument(
|
|
752
|
+
"config",
|
|
753
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
754
|
+
)
|
|
755
|
+
@click.option(
|
|
756
|
+
"--out",
|
|
757
|
+
"out_dir",
|
|
758
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
759
|
+
required=True,
|
|
760
|
+
)
|
|
761
|
+
@click.option("--backend", type=_backend_choice())
|
|
762
|
+
@click.option("--designer", type=_designer_choice())
|
|
763
|
+
@click.option("--validator", type=_validator_choice())
|
|
764
|
+
@click.option("--cheap", is_flag=True, help="Use the --cheap profile (T4-friendly defaults).")
|
|
765
|
+
@click.option("--dry-run", is_flag=True, help="Print a cost estimate, don't execute.")
|
|
766
|
+
def run(
|
|
767
|
+
config: Path,
|
|
768
|
+
out_dir: Path,
|
|
769
|
+
backend: str | None,
|
|
770
|
+
designer: str | None,
|
|
771
|
+
validator: str | None,
|
|
772
|
+
cheap: bool,
|
|
773
|
+
dry_run: bool,
|
|
774
|
+
) -> None:
|
|
775
|
+
"""Run the full discover → design → validate → rank → report → export pipeline.
|
|
776
|
+
|
|
777
|
+
CPU stages (discover, rank, report, export) always execute. The GPU stages
|
|
778
|
+
(design, validate) **launch real work** when the configured backend is
|
|
779
|
+
headless (mock, modal, kaggle, local_docker) — this help used to say they
|
|
780
|
+
only reuse outputs from a previous session, which is true for ``colab`` and
|
|
781
|
+
false for every other backend, on a command that spends GPU quota — this command is the
|
|
782
|
+
single-entry-point version of running each stage in turn. To produce those
|
|
783
|
+
outputs, run ``bindsight design`` against a GPU backend first; ``--backend
|
|
784
|
+
kaggle`` is the verified free path.
|
|
785
|
+
"""
|
|
786
|
+
_setup_logging(verbose=False)
|
|
787
|
+
from bindsight.config import RunConfig
|
|
788
|
+
from bindsight.pipelines.full_run import run as run_full
|
|
789
|
+
|
|
790
|
+
cfg = RunConfig.from_yaml(config)
|
|
791
|
+
cfg.out_dir = out_dir
|
|
792
|
+
if backend:
|
|
793
|
+
cfg.backend = backend
|
|
794
|
+
if designer:
|
|
795
|
+
cfg.params.design.designer = designer
|
|
796
|
+
if validator:
|
|
797
|
+
cfg.params.validate_.validator = validator
|
|
798
|
+
if cheap:
|
|
799
|
+
_apply_cheap_profile(cfg)
|
|
800
|
+
|
|
801
|
+
# The same refusal `design` performs. This command launches the GPU half on
|
|
802
|
+
# a headless backend, so skipping the check meant `run` could spend quota
|
|
803
|
+
# discovering a designer/backend combination `design` would have refused
|
|
804
|
+
# locally and for free.
|
|
805
|
+
_preflight_backend(
|
|
806
|
+
cfg.backend,
|
|
807
|
+
designer=cfg.params.design.designer,
|
|
808
|
+
validator=cfg.params.validate_.validator,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
if dry_run:
|
|
812
|
+
from bindsight.cost import estimate_full_run
|
|
813
|
+
|
|
814
|
+
n_targets = cfg.params.target_discovery.top_n
|
|
815
|
+
n_traj = cfg.params.design.n_trajectories
|
|
816
|
+
_d, _v, c = estimate_full_run(
|
|
817
|
+
backend=cfg.backend,
|
|
818
|
+
designer=cfg.params.design.designer,
|
|
819
|
+
validator=cfg.params.validate_.validator,
|
|
820
|
+
n_targets=n_targets,
|
|
821
|
+
n_trajectories=n_traj,
|
|
822
|
+
gpu_type=cfg.params.design.gpu_type,
|
|
823
|
+
)
|
|
824
|
+
_print_cost_panel(c, label="full run (combined design + validate)")
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
result = run_full(cfg, out_dir=out_dir)
|
|
828
|
+
summary_lines = [
|
|
829
|
+
f"discover : {'OK' if result.discover_ok else 'partial / failed'}",
|
|
830
|
+
f"design : {'OK (artifacts present)' if result.design_ok else 'pending — run: bindsight design <run> --backend kaggle'}",
|
|
831
|
+
f"validate : {'OK (artifacts present)' if result.validate_ok else 'pending — needs design output'}",
|
|
832
|
+
f"rank : {'OK' if result.rank_ok else 'skipped (no validate output)'}",
|
|
833
|
+
f"report : {result.report_path}" if result.report_path else "report: not rendered",
|
|
834
|
+
f"crate : {result.crate_path}" if result.crate_path else "crate: not exported",
|
|
835
|
+
]
|
|
836
|
+
console.print(
|
|
837
|
+
Panel(
|
|
838
|
+
"\n".join(summary_lines),
|
|
839
|
+
title="bindsight run — summary",
|
|
840
|
+
border_style="green" if result.discover_ok else "yellow",
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ---------------------------------------------------------------------------
|
|
846
|
+
# export — RO-Crate
|
|
847
|
+
# ---------------------------------------------------------------------------
|
|
848
|
+
@main.command()
|
|
849
|
+
@click.argument(
|
|
850
|
+
"run_dir",
|
|
851
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
852
|
+
)
|
|
853
|
+
@click.option(
|
|
854
|
+
"--format",
|
|
855
|
+
"fmt",
|
|
856
|
+
type=click.Choice(["ro-crate"]),
|
|
857
|
+
default="ro-crate",
|
|
858
|
+
show_default=True,
|
|
859
|
+
)
|
|
860
|
+
@click.option(
|
|
861
|
+
"--out",
|
|
862
|
+
"out_path",
|
|
863
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
864
|
+
required=True,
|
|
865
|
+
)
|
|
866
|
+
def export(run_dir: Path, fmt: str, out_path: Path) -> None:
|
|
867
|
+
"""Export a finished run as an RO-Crate zip suitable for deposit."""
|
|
868
|
+
from bindsight.export import export_ro_crate
|
|
869
|
+
|
|
870
|
+
out = export_ro_crate(run_dir, out_path)
|
|
871
|
+
# The crate is NOT recorded as a digested output. A copy of this manifest is
|
|
872
|
+
# sealed inside the crate, so any digest written here describes a file that
|
|
873
|
+
# did not exist when that copy was taken: the crate's own manifest would
|
|
874
|
+
# assert a hash that is not the crate's, and a reviewer checking the deposit
|
|
875
|
+
# against it would find a mismatch and no way to tell which artifact was
|
|
876
|
+
# wrong. The crate's digest belongs outside the crate -- see the SHA256SUMS
|
|
877
|
+
# published beside the deposit.
|
|
878
|
+
provenance.record(
|
|
879
|
+
run_dir,
|
|
880
|
+
name="export",
|
|
881
|
+
tool="bindsight.export",
|
|
882
|
+
outputs={},
|
|
883
|
+
params={"format": fmt, "crate_path": out.name},
|
|
884
|
+
notes=(
|
|
885
|
+
"The crate is not listed as a digested output: it contains a copy of "
|
|
886
|
+
"this manifest, so it cannot also contain its own sha256. Publish the "
|
|
887
|
+
"crate's digest alongside the deposit (SHA256SUMS), not inside it."
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
console.print(
|
|
891
|
+
Panel(
|
|
892
|
+
f"[green]RO-Crate written.[/green]\n[bold]File:[/bold] {out}\n\n"
|
|
893
|
+
"Deposit it in a research data repository to make the run citable.",
|
|
894
|
+
title="bindsight export",
|
|
895
|
+
border_style="green",
|
|
896
|
+
)
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# ---------------------------------------------------------------------------
|
|
901
|
+
# benchmark — score rediscovery of held-out known antigens across runs
|
|
902
|
+
# ---------------------------------------------------------------------------
|
|
903
|
+
@main.command()
|
|
904
|
+
@click.argument(
|
|
905
|
+
"run_dirs",
|
|
906
|
+
nargs=-1,
|
|
907
|
+
required=True,
|
|
908
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
909
|
+
)
|
|
910
|
+
@click.option(
|
|
911
|
+
"--known-antigens",
|
|
912
|
+
"known_antigens",
|
|
913
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
914
|
+
default=_default_known_antigens(),
|
|
915
|
+
show_default=True,
|
|
916
|
+
help=(
|
|
917
|
+
"TSV of held-out known antigens (symbol, uniprot, tumor_type, ...). "
|
|
918
|
+
"The default ships with the source tree, not with the wheel: from a pip "
|
|
919
|
+
"install there is no benchmarks/ directory, so pass this explicitly."
|
|
920
|
+
),
|
|
921
|
+
)
|
|
922
|
+
@click.option(
|
|
923
|
+
"--out",
|
|
924
|
+
"out_path",
|
|
925
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
926
|
+
default=Path("benchmark_report.html"),
|
|
927
|
+
show_default=True,
|
|
928
|
+
help="Where to write the HTML benchmark report.",
|
|
929
|
+
)
|
|
930
|
+
@click.option(
|
|
931
|
+
"--k",
|
|
932
|
+
"ks",
|
|
933
|
+
multiple=True,
|
|
934
|
+
type=click.IntRange(min=1),
|
|
935
|
+
help=(
|
|
936
|
+
"Top-k cutoffs for recall@k (repeatable). Default: "
|
|
937
|
+
+ ", ".join(str(k) for k in DEFAULT_KS)
|
|
938
|
+
+ "."
|
|
939
|
+
),
|
|
940
|
+
)
|
|
941
|
+
def benchmark(
|
|
942
|
+
run_dirs: tuple[Path, ...],
|
|
943
|
+
known_antigens: Path,
|
|
944
|
+
out_path: Path,
|
|
945
|
+
ks: tuple[int, ...],
|
|
946
|
+
) -> None:
|
|
947
|
+
"""Benchmark how well runs rediscover the held-out known antigens.
|
|
948
|
+
|
|
949
|
+
Scores each RUN_DIR (a finished ``bindsight discover``/``run`` output) by the
|
|
950
|
+
rank of every known antigen in its candidate shortlist, computes recall@k,
|
|
951
|
+
and renders a side-by-side HTML report. The known set ships in
|
|
952
|
+
``benchmarks/known.tsv`` (see ``benchmarks/PROVENANCE.md``).
|
|
953
|
+
"""
|
|
954
|
+
from bindsight.benchmark import run_benchmark
|
|
955
|
+
|
|
956
|
+
# One source for the cutoffs. They were written as a literal here, again in
|
|
957
|
+
# the help text above, and a third time as DEFAULT_KS in the benchmark
|
|
958
|
+
# module -- three places to change, and a help string that could describe a
|
|
959
|
+
# default the command does not use.
|
|
960
|
+
cutoffs = tuple(ks) if ks else DEFAULT_KS
|
|
961
|
+
out, scores = run_benchmark(list(run_dirs), known_antigens, out_html=out_path, ks=cutoffs)
|
|
962
|
+
|
|
963
|
+
table = Table(title="rediscovery benchmark", show_lines=False, title_style="bold")
|
|
964
|
+
table.add_column("run", style="cyan", no_wrap=True)
|
|
965
|
+
table.add_column("found")
|
|
966
|
+
for k in cutoffs:
|
|
967
|
+
table.add_column(f"recall@{k}")
|
|
968
|
+
for s in scores:
|
|
969
|
+
table.add_row(
|
|
970
|
+
s.run_name,
|
|
971
|
+
f"{s.n_found}/{s.n_known}",
|
|
972
|
+
# `n/a`, not a crash. `score_run` leaves `recall_at` empty on
|
|
973
|
+
# purpose when there is nothing to compute a recall over -- an
|
|
974
|
+
# unreadable candidate table, or no on-indication antigen -- and
|
|
975
|
+
# this indexed into it, so the documented command exited 1 with a
|
|
976
|
+
# KeyError on exactly the runs it is most useful for. The HTML
|
|
977
|
+
# report at benchmark/core.py:321 already renders those as n/a.
|
|
978
|
+
*[f"{s.recall_at[k]:.0%}" if k in s.recall_at else "n/a" for k in cutoffs],
|
|
979
|
+
)
|
|
980
|
+
console.print(table)
|
|
981
|
+
console.print(
|
|
982
|
+
Panel(
|
|
983
|
+
f"[green]Benchmark written.[/green]\n[bold]Report:[/bold] {out}\n"
|
|
984
|
+
f"Known set: {known_antigens}",
|
|
985
|
+
title="bindsight benchmark",
|
|
986
|
+
border_style="green",
|
|
987
|
+
)
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
# ---------------------------------------------------------------------------
|
|
992
|
+
# ui — launch the local web interface
|
|
993
|
+
# ---------------------------------------------------------------------------
|
|
994
|
+
@main.command()
|
|
995
|
+
@click.option(
|
|
996
|
+
"--port",
|
|
997
|
+
type=click.IntRange(min=1, max=65535),
|
|
998
|
+
default=8501,
|
|
999
|
+
show_default=True,
|
|
1000
|
+
help="Port for the local web server.",
|
|
1001
|
+
)
|
|
1002
|
+
@click.option(
|
|
1003
|
+
"--no-browser",
|
|
1004
|
+
is_flag=True,
|
|
1005
|
+
help="Don't auto-open a browser tab; just print the URL.",
|
|
1006
|
+
)
|
|
1007
|
+
def ui(port: int, no_browser: bool) -> None:
|
|
1008
|
+
"""Launch the bindsight web interface in your browser.
|
|
1009
|
+
|
|
1010
|
+
Five sections: what the tool is and has shown, the evidence behind that, a
|
|
1011
|
+
demo, your own data, and the runs already on disk. Served locally; nothing
|
|
1012
|
+
leaves the machine and nothing is fetched from a network once installed --
|
|
1013
|
+
the stylesheet, the charts and the structure viewer all travel inside the
|
|
1014
|
+
package.
|
|
1015
|
+
"""
|
|
1016
|
+
try:
|
|
1017
|
+
from bindsight.report.web.app import serve
|
|
1018
|
+
except ImportError as exc: # pragma: no cover - exercised via the extra
|
|
1019
|
+
console.print(
|
|
1020
|
+
Panel(
|
|
1021
|
+
"[red]The web interface is not installed.[/red]\n"
|
|
1022
|
+
f"[dim]{exc}[/dim]\n\n"
|
|
1023
|
+
# Escaped: Rich would read `[report]` as a style tag and drop
|
|
1024
|
+
# it, printing a command that installs nothing new.
|
|
1025
|
+
'Install it with: [bold]pip install -e ".\\[report]"[/bold]',
|
|
1026
|
+
title="ui: missing dependency",
|
|
1027
|
+
border_style="red",
|
|
1028
|
+
)
|
|
1029
|
+
)
|
|
1030
|
+
sys.exit(2)
|
|
1031
|
+
|
|
1032
|
+
url = f"http://127.0.0.1:{port}"
|
|
1033
|
+
console.print(
|
|
1034
|
+
Panel(
|
|
1035
|
+
f"[bold]{url}[/bold]\n[dim]Ctrl-C to stop. Runs are read from ./runs.[/dim]",
|
|
1036
|
+
title="bindsight",
|
|
1037
|
+
border_style="blue",
|
|
1038
|
+
)
|
|
1039
|
+
)
|
|
1040
|
+
# Served in-process. The subprocess this replaced ran with ``check=False``
|
|
1041
|
+
# and its status was discarded, so a server that never started still left
|
|
1042
|
+
# the command printing "Launching bindsight UI" and exiting 0. uvicorn exits
|
|
1043
|
+
# non-zero on a port it cannot bind, and that status is no longer swallowed.
|
|
1044
|
+
try:
|
|
1045
|
+
serve(port=port, open_browser=not no_browser)
|
|
1046
|
+
except KeyboardInterrupt: # pragma: no cover - interactive
|
|
1047
|
+
console.print("[dim]stopped[/dim]")
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
# ---------------------------------------------------------------------------
|
|
1051
|
+
# verify-licenses — pure-Python, works today
|
|
1052
|
+
# ---------------------------------------------------------------------------
|
|
1053
|
+
@main.command(name="verify-licenses")
|
|
1054
|
+
@click.argument(
|
|
1055
|
+
"config",
|
|
1056
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
1057
|
+
required=False,
|
|
1058
|
+
)
|
|
1059
|
+
def verify_licenses(config: Path | None) -> None:
|
|
1060
|
+
"""Print the license summary for components used by bindsight.
|
|
1061
|
+
|
|
1062
|
+
With a config path, audits the components selected by that config and
|
|
1063
|
+
flags any non-commercial choices.
|
|
1064
|
+
"""
|
|
1065
|
+
# Single source of truth: (component, license, commercial?, role).
|
|
1066
|
+
# `commercial` is True when the component is usable in commercial work,
|
|
1067
|
+
# False when its terms forbid it, and None when the source states no
|
|
1068
|
+
# terms at all -- which is not a clearance and is not printed as one.
|
|
1069
|
+
components: list[tuple[str, str, bool | None, str]] = [
|
|
1070
|
+
("bindsight", "AGPL-3.0-or-later", True, "this package"),
|
|
1071
|
+
("pydeseq2", "MIT", True, "DEG analysis (default)"),
|
|
1072
|
+
("Open Targets", "CC0", True, "target evidence"),
|
|
1073
|
+
("GTEx (v8 public)", "Open", True, "tissue baselines"),
|
|
1074
|
+
("SURFY", "Not stated by source (article CC BY-NC-ND 4.0)", None, "surfaceome filter"),
|
|
1075
|
+
("SURFACE-Bind", "BSD-3", True, "targetable sites"),
|
|
1076
|
+
("AlphaFoldDB", "CC BY 4.0", True, "structures"),
|
|
1077
|
+
("RFdiffusion", "BSD-3", True, "default backbone designer"),
|
|
1078
|
+
("ProteinMPNN", "MIT", True, "sequence design"),
|
|
1079
|
+
("Boltz-2", "MIT (code+weights)", True, "default validator"),
|
|
1080
|
+
("Chai-1r", "Apache-2", True, "alt validator"),
|
|
1081
|
+
("BoltzGen", "MIT (code+weights)", True, "alt designer"),
|
|
1082
|
+
("BindCraft", "MIT", True, "premium designer"),
|
|
1083
|
+
("Snakemake", "MIT", True, "workflow"),
|
|
1084
|
+
(
|
|
1085
|
+
"AF2-IG (opt-in)",
|
|
1086
|
+
"MIT; AF2 params CC BY 4.0; PyRosetta NC",
|
|
1087
|
+
False,
|
|
1088
|
+
"alt validator (banner)",
|
|
1089
|
+
),
|
|
1090
|
+
]
|
|
1091
|
+
by_name = {c[0]: c for c in components}
|
|
1092
|
+
|
|
1093
|
+
def _commercial_cell(ok: bool | None) -> str:
|
|
1094
|
+
if ok is None:
|
|
1095
|
+
return "[yellow]verify[/yellow]"
|
|
1096
|
+
return "[green]yes[/green]" if ok else "[red]no[/red]"
|
|
1097
|
+
|
|
1098
|
+
def _render(title: str, rows: list[tuple[str, str, bool | None, str]]) -> None:
|
|
1099
|
+
t = Table(title=title, show_lines=False, title_style="bold")
|
|
1100
|
+
t.add_column("Component", style="cyan", no_wrap=True)
|
|
1101
|
+
t.add_column("License")
|
|
1102
|
+
t.add_column("Commercial?", style="bold")
|
|
1103
|
+
t.add_column("Role")
|
|
1104
|
+
for name, lic, ok, role in rows:
|
|
1105
|
+
t.add_row(name, lic, _commercial_cell(ok), role)
|
|
1106
|
+
console.print(t)
|
|
1107
|
+
|
|
1108
|
+
if config is None:
|
|
1109
|
+
_render("bindsight component licenses (default config)", components)
|
|
1110
|
+
else:
|
|
1111
|
+
from bindsight.config import RunConfig
|
|
1112
|
+
|
|
1113
|
+
cfg = RunConfig.from_yaml(config)
|
|
1114
|
+
designer = cfg.params.design.designer
|
|
1115
|
+
validator = cfg.params.validate_.validator
|
|
1116
|
+
backend = cfg.backend
|
|
1117
|
+
|
|
1118
|
+
# Map the config's plugin choices to the components they pull in.
|
|
1119
|
+
# `.get`, not `[...]`. Now that a third-party plugin can be selected,
|
|
1120
|
+
# a bare lookup would raise a KeyError naming nothing useful. A plugin
|
|
1121
|
+
# this table has never heard of has components, but not ones bindsight
|
|
1122
|
+
# can enumerate -- say that instead of crashing or claiming none.
|
|
1123
|
+
designer_components = {
|
|
1124
|
+
"rfdiff_mpnn": ["RFdiffusion", "ProteinMPNN"],
|
|
1125
|
+
"bindcraft": ["BindCraft"],
|
|
1126
|
+
"boltzgen": ["BoltzGen"],
|
|
1127
|
+
}.get(designer, [])
|
|
1128
|
+
validator_components = {
|
|
1129
|
+
"boltz2": ["Boltz-2"],
|
|
1130
|
+
"chai1r": ["Chai-1r"],
|
|
1131
|
+
"af2_ig": ["AF2-IG (opt-in)"],
|
|
1132
|
+
}.get(validator, [])
|
|
1133
|
+
unresolved = [
|
|
1134
|
+
name
|
|
1135
|
+
for name, found in ((designer, designer_components), (validator, validator_components))
|
|
1136
|
+
if not found
|
|
1137
|
+
]
|
|
1138
|
+
|
|
1139
|
+
# Core discovery components are always pulled in, regardless of config.
|
|
1140
|
+
core = [
|
|
1141
|
+
"bindsight",
|
|
1142
|
+
"pydeseq2",
|
|
1143
|
+
"Open Targets",
|
|
1144
|
+
"GTEx (v8 public)",
|
|
1145
|
+
"SURFY",
|
|
1146
|
+
"SURFACE-Bind",
|
|
1147
|
+
"AlphaFoldDB",
|
|
1148
|
+
]
|
|
1149
|
+
selected = [by_name[n] for n in core + designer_components + validator_components]
|
|
1150
|
+
|
|
1151
|
+
_render(
|
|
1152
|
+
f"Components selected by {Path(config).name} "
|
|
1153
|
+
f"(designer={designer}, validator={validator}, backend={backend})",
|
|
1154
|
+
selected,
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
if unresolved:
|
|
1158
|
+
# A green clearance covering a plugin whose licence was never looked
|
|
1159
|
+
# at is worse than no clearance: it is a legal statement about
|
|
1160
|
+
# software this table has never seen. Name it, and withhold the
|
|
1161
|
+
# verdict below.
|
|
1162
|
+
console.print(
|
|
1163
|
+
"\n[yellow bold]⚠ Not assessed:[/yellow bold] "
|
|
1164
|
+
f"{', '.join(unresolved)} — registered through an entry point, so "
|
|
1165
|
+
"bindsight does not know which components or licences it pulls in."
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
nc = [r for r in selected if r[2] is False]
|
|
1169
|
+
unstated = [r for r in selected if r[2] is None]
|
|
1170
|
+
if nc:
|
|
1171
|
+
names = ", ".join(r[0] for r in nc)
|
|
1172
|
+
console.print(
|
|
1173
|
+
f"\n[red bold]⚠ Non-commercial component(s) selected:[/red bold] {names}.\n"
|
|
1174
|
+
"[yellow]This configuration is NOT cleared for commercial use. Switch the "
|
|
1175
|
+
"offending stage (e.g. validator -> boltz2 or chai1r) for a fully "
|
|
1176
|
+
"commercial-friendly run.[/yellow]"
|
|
1177
|
+
)
|
|
1178
|
+
elif unresolved:
|
|
1179
|
+
console.print(
|
|
1180
|
+
"\n[yellow]Every component bindsight could identify is "
|
|
1181
|
+
"commercial-friendly. That is not a clearance for this "
|
|
1182
|
+
"configuration, because the plugin(s) above were not assessed."
|
|
1183
|
+
"[/yellow]"
|
|
1184
|
+
)
|
|
1185
|
+
elif unstated:
|
|
1186
|
+
# A source that states no terms is not a permissive source. The
|
|
1187
|
+
# table says "verify"; the summary must not say "all clear".
|
|
1188
|
+
names = ", ".join(r[0] for r in unstated)
|
|
1189
|
+
console.print(
|
|
1190
|
+
f"\n[yellow bold]⚠ Terms not stated by the source:[/yellow bold] {names}. "
|
|
1191
|
+
"[yellow]Every other component is commercial-friendly; see LICENSING.md "
|
|
1192
|
+
"before commercial use.[/yellow]"
|
|
1193
|
+
)
|
|
1194
|
+
else:
|
|
1195
|
+
console.print(
|
|
1196
|
+
"\n[green bold]✓ All components selected by this config are "
|
|
1197
|
+
"commercial-friendly.[/green bold]"
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
console.print(
|
|
1201
|
+
"\n[dim]See LICENSING.md for the full inventory and commercial-use guidance.[/dim]"
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
# ---------------------------------------------------------------------------
|
|
1206
|
+
# CLI helpers used by design + validate
|
|
1207
|
+
# ---------------------------------------------------------------------------
|
|
1208
|
+
def _count_top_targets(epitopes_parquet: Path) -> int | None:
|
|
1209
|
+
"""Count top-N targets from a discover-stage epitopes Parquet; None when unknown.
|
|
1210
|
+
|
|
1211
|
+
``None``, not 5. This returned a hard-coded 5 for a table that was missing or
|
|
1212
|
+
unreadable, and that invented number was printed as ``targets: 5`` and then
|
|
1213
|
+
fed to the cost estimate — so a run with no epitopes table quoted a GPU cost
|
|
1214
|
+
for work it had no targets to do, with nothing on screen to say the figure
|
|
1215
|
+
was made up. Its sibling ``_count_designs`` already says exactly this.
|
|
1216
|
+
"""
|
|
1217
|
+
if not epitopes_parquet.exists():
|
|
1218
|
+
LOG_CLI.warning("no epitopes table at %s; target count unknown", epitopes_parquet)
|
|
1219
|
+
return None
|
|
1220
|
+
try:
|
|
1221
|
+
import pandas as pd
|
|
1222
|
+
|
|
1223
|
+
return len(pd.read_parquet(epitopes_parquet))
|
|
1224
|
+
except Exception as exc:
|
|
1225
|
+
LOG_CLI.warning("could not read %s (%s); target count unknown", epitopes_parquet, exc)
|
|
1226
|
+
return None
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _count_designs(design_dir: Path) -> int | None:
|
|
1230
|
+
"""Count the designs actually present in ``<run>/design``; None when unknown.
|
|
1231
|
+
|
|
1232
|
+
The executor ships designs inside per-target tarballs, so the loose ``*.pdb``
|
|
1233
|
+
files a plain glob sees are only the ones a user unpacked by hand — both are
|
|
1234
|
+
counted, tarball members by name. ``None`` means nothing countable is there
|
|
1235
|
+
yet (the GPU step hasn't run), which the caller must report as unknown rather
|
|
1236
|
+
than quote a made-up number.
|
|
1237
|
+
"""
|
|
1238
|
+
import tarfile
|
|
1239
|
+
|
|
1240
|
+
if not design_dir.exists():
|
|
1241
|
+
return None
|
|
1242
|
+
n = len({p for p in design_dir.rglob("*.pdb") if "validate" not in p.parts})
|
|
1243
|
+
for archive in sorted(design_dir.rglob("*.tar.gz")):
|
|
1244
|
+
try:
|
|
1245
|
+
with tarfile.open(archive, "r:gz", encoding="utf-8") as tf:
|
|
1246
|
+
# Only the archive's own design/ members; the per-target tarballs
|
|
1247
|
+
# nested inside results.tar.gz are counted from their own files.
|
|
1248
|
+
n += sum(
|
|
1249
|
+
1
|
|
1250
|
+
for name in tf.getnames()
|
|
1251
|
+
if name.lower().endswith(".pdb") and Path(name).parts[:1] == ("design",)
|
|
1252
|
+
)
|
|
1253
|
+
except (tarfile.TarError, OSError) as e:
|
|
1254
|
+
LOG_CLI.warning("could not read %s: %s", archive, e)
|
|
1255
|
+
return n or None
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
#: The ``--cheap`` profile, as specified in ARCHITECTURE.md §10: "RFdiff+MPNN
|
|
1259
|
+
#: on T4, 10 trajectories, ESM-2 pre-screen". The flag was parsed and then
|
|
1260
|
+
#: silently ignored, so a user asking for the cheap profile got the expensive
|
|
1261
|
+
#: one and a cost estimate quoting A100 prices.
|
|
1262
|
+
CHEAP_DESIGNER = "rfdiff_mpnn"
|
|
1263
|
+
CHEAP_TRAJECTORIES = 10
|
|
1264
|
+
CHEAP_GPU_TYPE = "T4"
|
|
1265
|
+
CHEAP_PRESCREEN_TOP_K = 5
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _apply_cheap_profile(cfg: RunConfig) -> None:
|
|
1269
|
+
"""Apply the T4-friendly profile in place.
|
|
1270
|
+
|
|
1271
|
+
Applied before the ``--dry-run`` branch so the printed estimate describes
|
|
1272
|
+
the run that would actually happen.
|
|
1273
|
+
|
|
1274
|
+
Args:
|
|
1275
|
+
cfg: The run configuration to modify.
|
|
1276
|
+
"""
|
|
1277
|
+
design = cfg.params.design
|
|
1278
|
+
design.designer = CHEAP_DESIGNER
|
|
1279
|
+
design.n_trajectories = CHEAP_TRAJECTORIES
|
|
1280
|
+
design.gpu_type = CHEAP_GPU_TYPE
|
|
1281
|
+
design.prescreen_top_k = min(CHEAP_PRESCREEN_TOP_K, CHEAP_TRAJECTORIES)
|
|
1282
|
+
console.print(
|
|
1283
|
+
Panel(
|
|
1284
|
+
f"designer={CHEAP_DESIGNER} · trajectories={CHEAP_TRAJECTORIES} · "
|
|
1285
|
+
f"GPU={CHEAP_GPU_TYPE} · ESM-2 pre-screen keeps "
|
|
1286
|
+
f"{design.prescreen_top_k} designs per target",
|
|
1287
|
+
title="--cheap profile",
|
|
1288
|
+
border_style="cyan",
|
|
1289
|
+
)
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _preflight_backend(backend: str, **plugins: str) -> None:
|
|
1294
|
+
"""Refuse a backend/plugin combination the backend cannot run.
|
|
1295
|
+
|
|
1296
|
+
The CLI accepted any designer against any backend and found out remotely.
|
|
1297
|
+
``--designer bindcraft --backend kaggle`` built the same two-environment
|
|
1298
|
+
kernel every Kaggle job builds, because the kernel takes no designer
|
|
1299
|
+
argument, and failed only after roughly six minutes of environment building
|
|
1300
|
+
had been charged to a weekly GPU quota that does not refund.
|
|
1301
|
+
|
|
1302
|
+
A combination the backend has no environment for is refused here, in about a
|
|
1303
|
+
second, naming what it does provide. A combination the executor would *try*
|
|
1304
|
+
to bootstrap is allowed and announced, because untested is not the same
|
|
1305
|
+
statement as impossible and blocking it would remove a path that may work.
|
|
1306
|
+
|
|
1307
|
+
Args:
|
|
1308
|
+
backend: the runner the job would go to.
|
|
1309
|
+
**plugins: role name -> plugin name, e.g. ``designer="bindcraft"``.
|
|
1310
|
+
|
|
1311
|
+
Raises:
|
|
1312
|
+
SystemExit: with status 2 when any combination is unsupported.
|
|
1313
|
+
"""
|
|
1314
|
+
from bindsight.plugins import UNSUPPORTED, UNTESTED, plugin_support
|
|
1315
|
+
|
|
1316
|
+
refusals: list[str] = []
|
|
1317
|
+
for role, name in plugins.items():
|
|
1318
|
+
if not name:
|
|
1319
|
+
continue
|
|
1320
|
+
verdict, why = plugin_support(backend, name)
|
|
1321
|
+
if verdict == UNSUPPORTED:
|
|
1322
|
+
refusals.append(f"[bold]{role} {name}[/bold] — {why}")
|
|
1323
|
+
elif verdict == UNTESTED:
|
|
1324
|
+
console.print(
|
|
1325
|
+
f"[yellow]untested:[/yellow] {why}. Proceeding, but this path has "
|
|
1326
|
+
"not been demonstrated end to end."
|
|
1327
|
+
)
|
|
1328
|
+
if not refusals:
|
|
1329
|
+
return
|
|
1330
|
+
console.print(
|
|
1331
|
+
Panel(
|
|
1332
|
+
"\n\n".join(refusals)
|
|
1333
|
+
+ "\n\n[dim]Refused locally so no GPU quota is spent discovering it "
|
|
1334
|
+
"remotely.[/dim]",
|
|
1335
|
+
title=f"{backend}: unsupported combination",
|
|
1336
|
+
border_style="red",
|
|
1337
|
+
)
|
|
1338
|
+
)
|
|
1339
|
+
sys.exit(2)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _design_defaults_from_run(
|
|
1343
|
+
run_dir: Path, *, designer: str, validator: str, trajectories: int
|
|
1344
|
+
) -> tuple[str, str, int]:
|
|
1345
|
+
"""Fill unset design options from the configuration the run was produced under.
|
|
1346
|
+
|
|
1347
|
+
``bindsight design <run>`` takes a run directory, not a config, so it had no
|
|
1348
|
+
way to see what the user configured and used its own flag defaults instead.
|
|
1349
|
+
That silently overrode ``params.design.n_trajectories`` — a fivefold change
|
|
1350
|
+
in GPU cost, reported in the cost panel as if it had been requested.
|
|
1351
|
+
|
|
1352
|
+
Click knows whether a value came from the command line or from a default, so
|
|
1353
|
+
an explicit flag still wins and only unset options are filled in.
|
|
1354
|
+
|
|
1355
|
+
Args:
|
|
1356
|
+
run_dir: the run directory, which holds the effective ``config.yaml``.
|
|
1357
|
+
designer: the designer option as Click resolved it.
|
|
1358
|
+
validator: the validator option as Click resolved it.
|
|
1359
|
+
trajectories: the trajectory count as Click resolved it.
|
|
1360
|
+
|
|
1361
|
+
Returns:
|
|
1362
|
+
The three values, with defaults replaced by the run's configuration
|
|
1363
|
+
where one is available.
|
|
1364
|
+
"""
|
|
1365
|
+
import logging
|
|
1366
|
+
|
|
1367
|
+
log = logging.getLogger(__name__)
|
|
1368
|
+
cfg_path = run_dir / "config.yaml"
|
|
1369
|
+
if not cfg_path.is_file():
|
|
1370
|
+
return designer, validator, trajectories
|
|
1371
|
+
|
|
1372
|
+
try:
|
|
1373
|
+
import yaml
|
|
1374
|
+
|
|
1375
|
+
params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
|
|
1376
|
+
design_params = params.get("design") or {}
|
|
1377
|
+
validate_params = params.get("validate") or {}
|
|
1378
|
+
except Exception as e: # pragma: no cover - a malformed config is the user's
|
|
1379
|
+
log.warning("could not read %s (%s); using command-line defaults", cfg_path, e)
|
|
1380
|
+
return designer, validator, trajectories
|
|
1381
|
+
|
|
1382
|
+
ctx = click.get_current_context(silent=True)
|
|
1383
|
+
|
|
1384
|
+
def _from_default(name: str) -> bool:
|
|
1385
|
+
if ctx is None:
|
|
1386
|
+
return False
|
|
1387
|
+
return ctx.get_parameter_source(name) is click.core.ParameterSource.DEFAULT
|
|
1388
|
+
|
|
1389
|
+
if _from_default("designer") and design_params.get("designer"):
|
|
1390
|
+
designer = str(design_params["designer"])
|
|
1391
|
+
if _from_default("validator") and validate_params.get("validator"):
|
|
1392
|
+
validator = str(validate_params["validator"])
|
|
1393
|
+
if _from_default("trajectories") and design_params.get("n_trajectories"):
|
|
1394
|
+
trajectories = int(design_params["n_trajectories"])
|
|
1395
|
+
log.info("using n_trajectories=%d from %s", trajectories, cfg_path)
|
|
1396
|
+
|
|
1397
|
+
return designer, validator, trajectories
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _validator_params_from_run(run_dir: Path) -> tuple[int | None, int, int]:
|
|
1401
|
+
"""Validator settings this command has no flag for, read from the run's config.
|
|
1402
|
+
|
|
1403
|
+
``_design_defaults_from_run`` fills in options that *are* flags, deferring to
|
|
1404
|
+
an explicit one. These three are not flags, so the configuration is their
|
|
1405
|
+
only source — and this command was not reading them, which made them inert
|
|
1406
|
+
on the one path that runs design without ``bindsight run``.
|
|
1407
|
+
|
|
1408
|
+
``prescreen_top_k`` is the expensive one to lose: ``--cheap`` sets it, and
|
|
1409
|
+
dropping it validates every design instead of the configured few, paying the
|
|
1410
|
+
GPU cost the profile exists to avoid. It is the same defect this command
|
|
1411
|
+
already carries a fix for — a configured value honoured by ``bindsight run``
|
|
1412
|
+
and silently ignored here — one field further along.
|
|
1413
|
+
|
|
1414
|
+
Args:
|
|
1415
|
+
run_dir: the run directory holding the effective ``config.yaml``.
|
|
1416
|
+
|
|
1417
|
+
Returns:
|
|
1418
|
+
``(prescreen_top_k, diffusion_samples, max_parallel_samples)``, using the
|
|
1419
|
+
schema's own defaults when the file is absent or unreadable.
|
|
1420
|
+
"""
|
|
1421
|
+
import logging
|
|
1422
|
+
|
|
1423
|
+
log = logging.getLogger(__name__)
|
|
1424
|
+
cfg_path = run_dir / "config.yaml"
|
|
1425
|
+
if not cfg_path.is_file():
|
|
1426
|
+
return None, 1, 1
|
|
1427
|
+
try:
|
|
1428
|
+
import yaml
|
|
1429
|
+
|
|
1430
|
+
params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
|
|
1431
|
+
design_params = params.get("design") or {}
|
|
1432
|
+
validate_params = params.get("validate") or {}
|
|
1433
|
+
except Exception as e: # pragma: no cover - a malformed config is the user's
|
|
1434
|
+
log.warning("could not read %s (%s); using defaults", cfg_path, e)
|
|
1435
|
+
return None, 1, 1
|
|
1436
|
+
|
|
1437
|
+
top_k = design_params.get("prescreen_top_k")
|
|
1438
|
+
samples = int(validate_params.get("diffusion_samples") or 1)
|
|
1439
|
+
parallel = int(validate_params.get("max_parallel_samples") or 1)
|
|
1440
|
+
if top_k:
|
|
1441
|
+
log.info("using prescreen_top_k=%s from %s", top_k, cfg_path)
|
|
1442
|
+
if samples != 1:
|
|
1443
|
+
log.info("using diffusion_samples=%d from %s", samples, cfg_path)
|
|
1444
|
+
return (int(top_k) if top_k else None), samples, parallel
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _gpu_type_from_run(run_dir: Path) -> str | None:
|
|
1448
|
+
"""The GPU the run was configured for, or ``None`` when unrecorded.
|
|
1449
|
+
|
|
1450
|
+
``bindsight run`` already passes ``params.design.gpu_type`` to the cost
|
|
1451
|
+
estimate; ``design --dry-run`` did not, so it quoted A100 prices for a run
|
|
1452
|
+
configured for a T4 -- an estimate for different hardware than the job would
|
|
1453
|
+
ask for, printed with no indication that the two differ.
|
|
1454
|
+
"""
|
|
1455
|
+
cfg_path = run_dir / "config.yaml"
|
|
1456
|
+
if not cfg_path.is_file():
|
|
1457
|
+
return None
|
|
1458
|
+
try:
|
|
1459
|
+
import yaml
|
|
1460
|
+
|
|
1461
|
+
params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
|
|
1462
|
+
return (params.get("design") or {}).get("gpu_type")
|
|
1463
|
+
except Exception as e: # pragma: no cover - a malformed config is the user's
|
|
1464
|
+
LOG_CLI.warning("could not read %s (%s); pricing without a gpu_type", cfg_path, e)
|
|
1465
|
+
return None
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _design_spec_params_from_run(run_dir: Path) -> tuple[int, int, int]:
|
|
1469
|
+
"""Read the design parameters a run was configured with, for its spec.
|
|
1470
|
+
|
|
1471
|
+
Unlike :func:`_design_defaults_from_run`, none of these are command-line
|
|
1472
|
+
options, so there is no flag to defer to — the configuration is the only
|
|
1473
|
+
source. They were nonetheless not read at all: every job was built with
|
|
1474
|
+
``make_spec``'s own defaults, so a run whose ``config.yaml`` recorded
|
|
1475
|
+
``seed: 42`` shipped a spec carrying seed 0 to the GPU. That was verified by
|
|
1476
|
+
decoding the payload of a kernel launched from exactly such a config.
|
|
1477
|
+
|
|
1478
|
+
A silently ignored seed is worse than an inconvenient one. The seed is part
|
|
1479
|
+
of the cache key and part of the manifest, so the artifact claimed to
|
|
1480
|
+
describe a run that had not happened, and the run could never be reproduced
|
|
1481
|
+
from the configuration filed beside it.
|
|
1482
|
+
|
|
1483
|
+
Args:
|
|
1484
|
+
run_dir: the run directory, which holds the effective ``config.yaml``.
|
|
1485
|
+
|
|
1486
|
+
Returns:
|
|
1487
|
+
``(seed, binder_length_min, binder_length_max)``, falling back to the
|
|
1488
|
+
DesignSpec defaults wherever the configuration is silent or unreadable.
|
|
1489
|
+
"""
|
|
1490
|
+
import logging
|
|
1491
|
+
|
|
1492
|
+
from bindsight.design.protocol import DesignSpec
|
|
1493
|
+
|
|
1494
|
+
log = logging.getLogger(__name__)
|
|
1495
|
+
fields = DesignSpec.model_fields
|
|
1496
|
+
seed = int(fields["seed"].default)
|
|
1497
|
+
lo = int(fields["binder_length_min"].default)
|
|
1498
|
+
hi = int(fields["binder_length_max"].default)
|
|
1499
|
+
|
|
1500
|
+
cfg_path = run_dir / "config.yaml"
|
|
1501
|
+
if not cfg_path.is_file():
|
|
1502
|
+
return seed, lo, hi
|
|
1503
|
+
try:
|
|
1504
|
+
import yaml
|
|
1505
|
+
|
|
1506
|
+
params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
|
|
1507
|
+
design_params = params.get("design") or {}
|
|
1508
|
+
except Exception as e: # pragma: no cover - a malformed config is the user's
|
|
1509
|
+
log.warning("could not read %s (%s); using DesignSpec defaults", cfg_path, e)
|
|
1510
|
+
return seed, lo, hi
|
|
1511
|
+
|
|
1512
|
+
if design_params.get("seed") is not None:
|
|
1513
|
+
seed = int(design_params["seed"])
|
|
1514
|
+
if design_params.get("binder_length_min") is not None:
|
|
1515
|
+
lo = int(design_params["binder_length_min"])
|
|
1516
|
+
if design_params.get("binder_length_max") is not None:
|
|
1517
|
+
hi = int(design_params["binder_length_max"])
|
|
1518
|
+
log.info("design spec from %s: seed=%d binder_length=%d-%d", cfg_path, seed, lo, hi)
|
|
1519
|
+
return seed, lo, hi
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _working_tree_wheel(backend: str, run_dir: Path) -> Path | None:
|
|
1523
|
+
"""Build a wheel of this checkout for a remote backend to install.
|
|
1524
|
+
|
|
1525
|
+
Remote runners pip-install bindsight before doing anything, and with no
|
|
1526
|
+
wheel that means the repository's default branch — not the code you are
|
|
1527
|
+
running. A designer benchmark launched to validate a fix once spent an hour
|
|
1528
|
+
on a GPU executing the unfixed code for exactly this reason, and reported
|
|
1529
|
+
success.
|
|
1530
|
+
|
|
1531
|
+
Failure is not fatal: the runner falls back to a git install and warns that
|
|
1532
|
+
local changes will not run, so someone who installed bindsight from PyPI can
|
|
1533
|
+
still submit a job.
|
|
1534
|
+
|
|
1535
|
+
Args:
|
|
1536
|
+
backend: the runner name. Local backends install nothing, so they get None.
|
|
1537
|
+
run_dir: the run directory; the wheel is built into ``_wheel`` inside it.
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
Path to the built wheel, or None when it is unnecessary or unavailable.
|
|
1541
|
+
"""
|
|
1542
|
+
if backend in {"mock", "local_docker"}:
|
|
1543
|
+
return None
|
|
1544
|
+
from bindsight.runners.source_wheel import build_working_tree_wheel
|
|
1545
|
+
|
|
1546
|
+
return build_working_tree_wheel(run_dir / "_wheel")
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _target_artifact_stem(target: dict[str, Any]) -> str:
|
|
1550
|
+
"""Filename stem for one design job's artifacts, unique per epitope site.
|
|
1551
|
+
|
|
1552
|
+
Keyed on the accession alone, a receptor with two qualifying sites had its
|
|
1553
|
+
second job's tarball written over the first's — same name, different
|
|
1554
|
+
binders, no warning — and the reader then found one archive where two jobs
|
|
1555
|
+
had run. Discovery emits one epitopes row per site and each row becomes its
|
|
1556
|
+
own job, so the accession is not the unit of work.
|
|
1557
|
+
|
|
1558
|
+
Mirrors :func:`bindsight.runners.job_exec._binder_id_prefix` deliberately, so
|
|
1559
|
+
an archive's name and the binder ids inside it agree, and a whole-surface
|
|
1560
|
+
job keeps the bare accession it has always had.
|
|
1561
|
+
"""
|
|
1562
|
+
from bindsight.runners.job_exec import _binder_id_prefix
|
|
1563
|
+
|
|
1564
|
+
return _binder_id_prefix(
|
|
1565
|
+
{
|
|
1566
|
+
"target_uniprot": target.get("uniprot"),
|
|
1567
|
+
"epitope_residues": target.get("residues") or [],
|
|
1568
|
+
"epitope_chain": target.get("chain") or "A",
|
|
1569
|
+
}
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def _top_targets(run_dir: Path) -> list[dict[str, Any]]:
|
|
1574
|
+
"""Return top-N targets (uniprot, structure_path, chain, residues, ranges) to design.
|
|
1575
|
+
|
|
1576
|
+
Structure references are resolved to absolute paths here, which is the one
|
|
1577
|
+
place every design/validate consumer funnels through. New runs store them
|
|
1578
|
+
run-relative so the run stays portable; runs made before that change stored
|
|
1579
|
+
absolute cache paths. ``resolve_run_path`` accepts both.
|
|
1580
|
+
|
|
1581
|
+
``design_ranges`` (the extracellular ranges discovery annotated from UniProt
|
|
1582
|
+
topology) is carried through to the design job so binders are designed against
|
|
1583
|
+
the reachable part of the receptor. Targets without it are named on the console
|
|
1584
|
+
— designing against the full length, transmembrane helix and cytoplasmic tail
|
|
1585
|
+
included, is a real difference the user has to know about.
|
|
1586
|
+
"""
|
|
1587
|
+
import pandas as pd
|
|
1588
|
+
|
|
1589
|
+
from bindsight.io.paths import resolve_run_path
|
|
1590
|
+
|
|
1591
|
+
epitopes_parquet = run_dir / "epitopes" / "epitopes.parquet"
|
|
1592
|
+
if not epitopes_parquet.exists():
|
|
1593
|
+
return []
|
|
1594
|
+
df = pd.read_parquet(epitopes_parquet)
|
|
1595
|
+
targets: list[dict[str, Any]] = []
|
|
1596
|
+
no_ranges: list[str] = []
|
|
1597
|
+
for _, row in df.iterrows():
|
|
1598
|
+
uni = row.get("uniprot_id")
|
|
1599
|
+
resolved = resolve_run_path(run_dir, row.get("structure_path"))
|
|
1600
|
+
if resolved is None or not uni:
|
|
1601
|
+
continue
|
|
1602
|
+
struct = str(resolved)
|
|
1603
|
+
residues = row.get("residues")
|
|
1604
|
+
residues = list(residues) if residues is not None and len(residues) else []
|
|
1605
|
+
ranges = _design_ranges(row.get("design_ranges") if "design_ranges" in df.columns else None)
|
|
1606
|
+
if not ranges:
|
|
1607
|
+
no_ranges.append(str(uni))
|
|
1608
|
+
targets.append(
|
|
1609
|
+
{
|
|
1610
|
+
"uniprot": str(uni),
|
|
1611
|
+
"structure_path": struct,
|
|
1612
|
+
"chain": str(row.get("chain") or "A"),
|
|
1613
|
+
"residues": [int(r) for r in residues],
|
|
1614
|
+
"design_ranges": ranges,
|
|
1615
|
+
}
|
|
1616
|
+
)
|
|
1617
|
+
if no_ranges:
|
|
1618
|
+
console.print(
|
|
1619
|
+
f"[yellow]No extracellular ranges for {', '.join(sorted(set(no_ranges)))}[/yellow] — "
|
|
1620
|
+
"designing against the full-length chain (transmembrane and cytoplasmic "
|
|
1621
|
+
"regions included). Enable params.target_discovery.use_uniprot_topology "
|
|
1622
|
+
"in discover to restrict design to the extracellular domain."
|
|
1623
|
+
)
|
|
1624
|
+
return targets
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _design_ranges(raw: Any) -> list[tuple[int, int]]:
|
|
1628
|
+
"""Coerce an epitopes-table ``design_ranges`` cell to ``[(lo, hi), ...]``.
|
|
1629
|
+
|
|
1630
|
+
The column holds a nested array (or NaN/None when topology wasn't annotated),
|
|
1631
|
+
which is exactly the "we never determined the extracellular domain" case.
|
|
1632
|
+
"""
|
|
1633
|
+
if raw is None or not hasattr(raw, "__len__") or len(raw) == 0:
|
|
1634
|
+
return []
|
|
1635
|
+
ranges: list[tuple[int, int]] = []
|
|
1636
|
+
for r in raw:
|
|
1637
|
+
if r is None or len(r) != 2:
|
|
1638
|
+
continue
|
|
1639
|
+
ranges.append((int(r[0]), int(r[1])))
|
|
1640
|
+
return ranges
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _write_design_notebooks(run_dir: Path, *, designer: str, trajectories: int) -> int:
|
|
1644
|
+
"""Write one Colab design+validate notebook per top target. Returns count."""
|
|
1645
|
+
from bindsight.plugins import get_designer
|
|
1646
|
+
from bindsight.runners.notebook_content import write_design_notebook
|
|
1647
|
+
|
|
1648
|
+
targets = list(_top_targets(run_dir))
|
|
1649
|
+
if not targets:
|
|
1650
|
+
# No directory either. Creating an empty `design/` made the run look
|
|
1651
|
+
# like the stage had started, and the caller has nothing to write into
|
|
1652
|
+
# it.
|
|
1653
|
+
return 0
|
|
1654
|
+
|
|
1655
|
+
design_dir = run_dir / "design"
|
|
1656
|
+
design_dir.mkdir(parents=True, exist_ok=True)
|
|
1657
|
+
plugin = get_designer(designer)
|
|
1658
|
+
# The configured seed and binder-length bounds, read the same way the
|
|
1659
|
+
# launch path reads them. Omitting them here left make_spec's own defaults
|
|
1660
|
+
# embedded in the notebook -- on `colab`, which is the DEFAULT backend --
|
|
1661
|
+
# so a run configured with seed 42 shipped a notebook carrying seed 0.
|
|
1662
|
+
seed, binder_length_min, binder_length_max = _design_spec_params_from_run(run_dir)
|
|
1663
|
+
n = 0
|
|
1664
|
+
for t in targets:
|
|
1665
|
+
spec = plugin.make_spec(
|
|
1666
|
+
target_uniprot=t["uniprot"],
|
|
1667
|
+
target_structure_path=Path(t["structure_path"]),
|
|
1668
|
+
epitope_residues=t["residues"],
|
|
1669
|
+
epitope_chain=t["chain"],
|
|
1670
|
+
design_ranges=t["design_ranges"],
|
|
1671
|
+
n_trajectories=trajectories,
|
|
1672
|
+
seed=seed,
|
|
1673
|
+
binder_length_min=binder_length_min,
|
|
1674
|
+
binder_length_max=binder_length_max,
|
|
1675
|
+
)
|
|
1676
|
+
spec_dict = spec.model_dump()
|
|
1677
|
+
# Embed the target structure (converted to PDB) so the Colab notebook is
|
|
1678
|
+
# self-contained — RFdiffusion needs PDB; AlphaFold ships mmCIF.
|
|
1679
|
+
b64 = _structure_pdb_b64(Path(t["structure_path"]))
|
|
1680
|
+
if b64:
|
|
1681
|
+
spec_dict["target_structure_b64"] = b64
|
|
1682
|
+
spec_dict.setdefault("extra_params", {})["target_structure_name"] = "target.pdb"
|
|
1683
|
+
handle_id = f"{designer}_{t['uniprot']}"
|
|
1684
|
+
write_design_notebook(
|
|
1685
|
+
design_dir / f"{handle_id}.ipynb",
|
|
1686
|
+
handle_id=handle_id,
|
|
1687
|
+
designer=designer,
|
|
1688
|
+
gpu_type="T4",
|
|
1689
|
+
spec=spec_dict,
|
|
1690
|
+
)
|
|
1691
|
+
n += 1
|
|
1692
|
+
return n
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def _structure_pdb_b64(structure_path: Path) -> str | None:
|
|
1696
|
+
"""Return base64 PDB bytes for a structure (converting mmCIF → PDB)."""
|
|
1697
|
+
import base64
|
|
1698
|
+
import tempfile
|
|
1699
|
+
|
|
1700
|
+
if not structure_path.exists():
|
|
1701
|
+
return None
|
|
1702
|
+
if structure_path.suffix.lower() in {".cif", ".mmcif"}:
|
|
1703
|
+
from bindsight.runners.job_exec import _cif_to_pdb
|
|
1704
|
+
|
|
1705
|
+
tmp = Path(tempfile.mkdtemp()) / "target.pdb"
|
|
1706
|
+
try:
|
|
1707
|
+
_cif_to_pdb(structure_path, tmp)
|
|
1708
|
+
data = tmp.read_bytes()
|
|
1709
|
+
except Exception as e: # pragma: no cover - biopython parse edge cases
|
|
1710
|
+
LOG_CLI.warning("could not convert %s to PDB: %s", structure_path, e)
|
|
1711
|
+
return None
|
|
1712
|
+
return base64.b64encode(data).decode()
|
|
1713
|
+
return base64.b64encode(structure_path.read_bytes()).decode()
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _validators_that_produced(validated: Path) -> list[str]:
|
|
1717
|
+
"""Return the validators named in a validated table, as recorded per row.
|
|
1718
|
+
|
|
1719
|
+
``bindsight validate`` takes a ``--validator`` flag, but by default it does
|
|
1720
|
+
not run one: it materialises metrics the design job already produced, using
|
|
1721
|
+
whichever validator *that* job was given. Recording the flag in the manifest
|
|
1722
|
+
therefore described a validator that may never have run — passing
|
|
1723
|
+
``--validator chai1r`` to a Boltz-2 run filed Chai-1r's name against
|
|
1724
|
+
Boltz-2's numbers. The rows themselves carry the truth, so ask them.
|
|
1725
|
+
|
|
1726
|
+
Args:
|
|
1727
|
+
validated: path to ``validated.parquet``.
|
|
1728
|
+
|
|
1729
|
+
Returns:
|
|
1730
|
+
Sorted distinct validator names, or an empty list when the table is
|
|
1731
|
+
missing, empty, or carries no ``validator_name``.
|
|
1732
|
+
"""
|
|
1733
|
+
if not validated.is_file():
|
|
1734
|
+
return []
|
|
1735
|
+
try:
|
|
1736
|
+
import pandas as pd
|
|
1737
|
+
|
|
1738
|
+
df = pd.read_parquet(validated)
|
|
1739
|
+
except Exception as e: # a corrupt table must not sink the manifest
|
|
1740
|
+
LOG_CLI.warning("could not read %s (%s) to name the validator", validated, e)
|
|
1741
|
+
return []
|
|
1742
|
+
if "validator_name" not in df.columns:
|
|
1743
|
+
return []
|
|
1744
|
+
names = {str(v) for v in df["validator_name"].dropna().tolist() if str(v).strip()}
|
|
1745
|
+
return sorted(names)
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def _rank_weights(run_dir: Path) -> Any:
|
|
1749
|
+
"""Read the run's rank weights, falling back to the defaults.
|
|
1750
|
+
|
|
1751
|
+
Only the ``params.rank`` section is parsed, not the whole ``RunConfig`` --
|
|
1752
|
+
the same reason :func:`_validate_params` gives just below: validating the
|
|
1753
|
+
entire document would make an unrelated schema change silently revert an
|
|
1754
|
+
existing run to the default weighting, which is the class of quiet fallback
|
|
1755
|
+
these helpers exist to remove.
|
|
1756
|
+
|
|
1757
|
+
Args:
|
|
1758
|
+
run_dir: the run directory, which holds the effective ``config.yaml``.
|
|
1759
|
+
|
|
1760
|
+
Returns:
|
|
1761
|
+
The :class:`~bindsight.config.RankWeights` the run was configured with.
|
|
1762
|
+
A run with no config, or an unreadable one, gets the defaults -- and the
|
|
1763
|
+
unreadable case warns, because silently ranking by a different weighting
|
|
1764
|
+
than the one on disk is exactly the defect this fixes.
|
|
1765
|
+
"""
|
|
1766
|
+
from bindsight.config import RankWeights
|
|
1767
|
+
|
|
1768
|
+
cfg_path = run_dir / "config.yaml"
|
|
1769
|
+
if not cfg_path.is_file():
|
|
1770
|
+
return RankWeights()
|
|
1771
|
+
try:
|
|
1772
|
+
import yaml
|
|
1773
|
+
|
|
1774
|
+
params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
|
|
1775
|
+
return RankWeights(**((params.get("rank") or {}).get("weights") or {}))
|
|
1776
|
+
except Exception as e: # a malformed config must not silently reweight the run
|
|
1777
|
+
LOG_CLI.warning("could not read %s (%s); ranking with the default weights", cfg_path, e)
|
|
1778
|
+
return RankWeights()
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
def _validate_params(run_dir: Path) -> Any:
|
|
1782
|
+
"""Read the run's validation parameters, falling back to the defaults.
|
|
1783
|
+
|
|
1784
|
+
Args:
|
|
1785
|
+
run_dir: the run directory, which holds the effective ``config.yaml``.
|
|
1786
|
+
|
|
1787
|
+
Only the ``params.validate`` section is parsed, not the whole ``RunConfig``.
|
|
1788
|
+
Validating the entire document would make an unrelated schema change — a new
|
|
1789
|
+
required field, a renamed input — silently revert the thresholds to their
|
|
1790
|
+
defaults on every existing run, which is the same class of quiet fallback
|
|
1791
|
+
these helpers exist to remove. The values actually used are still validated,
|
|
1792
|
+
because the section is parsed into :class:`ValidateParams`.
|
|
1793
|
+
|
|
1794
|
+
Returns:
|
|
1795
|
+
The :class:`~bindsight.config.ValidateParams` the run was configured
|
|
1796
|
+
with. A malformed config must not cost the caller its metrics, so an
|
|
1797
|
+
unreadable file is a warning and the defaults are used.
|
|
1798
|
+
"""
|
|
1799
|
+
from bindsight.config import ValidateParams
|
|
1800
|
+
|
|
1801
|
+
cfg_path = run_dir / "config.yaml"
|
|
1802
|
+
if not cfg_path.is_file():
|
|
1803
|
+
return ValidateParams()
|
|
1804
|
+
try:
|
|
1805
|
+
import yaml
|
|
1806
|
+
|
|
1807
|
+
params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
|
|
1808
|
+
return ValidateParams(**(params.get("validate") or {}))
|
|
1809
|
+
except Exception as e: # a malformed config must not lose the metrics
|
|
1810
|
+
LOG_CLI.warning("could not read %s (%s); using default thresholds", cfg_path, e)
|
|
1811
|
+
return ValidateParams()
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
def _split_on_thresholds(df: Any, run_dir: Path, validate_dir: Path) -> Any:
|
|
1815
|
+
"""Honour ``apply_thresholds`` by setting failing designs aside, not deleting them.
|
|
1816
|
+
|
|
1817
|
+
The flag was declared in the config, shipped in an example YAML, and read by
|
|
1818
|
+
no code, so a user who asked for the quality bars to be enforced got a table
|
|
1819
|
+
in which every design still appeared. Its own comment claimed it changed what
|
|
1820
|
+
``bindsight validate`` records, while the annotation ran unconditionally —
|
|
1821
|
+
the flag had no behaviour to describe.
|
|
1822
|
+
|
|
1823
|
+
It is honoured now, and nothing is destroyed to do it. Rows that fail move to
|
|
1824
|
+
``excluded_by_thresholds.parquet`` beside the table they left, each carrying
|
|
1825
|
+
the reason it failed, and the count is printed. A design that vanishes with
|
|
1826
|
+
no recorded reason is precisely the invisible filter this pipeline exists not
|
|
1827
|
+
to have; a design moved to a named file with its reason attached is not one.
|
|
1828
|
+
|
|
1829
|
+
Designs whose metrics were never measured are kept. A bar a design was not
|
|
1830
|
+
scored against cannot be a bar it failed.
|
|
1831
|
+
|
|
1832
|
+
Args:
|
|
1833
|
+
df: the annotated validation table.
|
|
1834
|
+
run_dir: the run directory, for its configuration.
|
|
1835
|
+
validate_dir: where to write the excluded rows.
|
|
1836
|
+
|
|
1837
|
+
Returns:
|
|
1838
|
+
The table to keep — unchanged unless ``apply_thresholds`` is set.
|
|
1839
|
+
"""
|
|
1840
|
+
params = _validate_params(run_dir)
|
|
1841
|
+
if not getattr(params, "apply_thresholds", False) or df.empty:
|
|
1842
|
+
return df
|
|
1843
|
+
|
|
1844
|
+
failing = df["passes_thresholds"] == "fail"
|
|
1845
|
+
if not bool(failing.any()):
|
|
1846
|
+
return df
|
|
1847
|
+
|
|
1848
|
+
excluded = df[failing]
|
|
1849
|
+
excluded.to_parquet(validate_dir / "excluded_by_thresholds.parquet", index=False)
|
|
1850
|
+
console.print(
|
|
1851
|
+
f"[yellow]apply_thresholds:[/yellow] {len(excluded)} of {len(df)} designs "
|
|
1852
|
+
f"failed the configured bars and were written to "
|
|
1853
|
+
f"{validate_dir / 'excluded_by_thresholds.parquet'} with their reasons."
|
|
1854
|
+
)
|
|
1855
|
+
return df[~failing].reset_index(drop=True)
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
def _mark_thresholds(df: Any, run_dir: Path) -> Any:
|
|
1859
|
+
"""Annotate each validated design against the configured quality bars.
|
|
1860
|
+
|
|
1861
|
+
``iptm_threshold`` and ``pae_interaction_threshold`` were declared in the
|
|
1862
|
+
config, shipped in the example YAMLs, and read by nothing — so a user who
|
|
1863
|
+
tightened either was silently ignored. They are now applied, but only as
|
|
1864
|
+
annotation: every design stays in the table and carries ``passes_thresholds``
|
|
1865
|
+
plus a human-readable ``threshold_reason``. Nothing is dropped, because a
|
|
1866
|
+
design vanishing without a recorded reason is exactly the kind of invisible
|
|
1867
|
+
filter that makes a pipeline untrustworthy. Filtering is the reader's call.
|
|
1868
|
+
|
|
1869
|
+
A metric the validator did not produce cannot fail a bar it was never
|
|
1870
|
+
measured against: such a row is marked ``unassessed`` rather than passed.
|
|
1871
|
+
"""
|
|
1872
|
+
import pandas as pd
|
|
1873
|
+
|
|
1874
|
+
params = _validate_params(run_dir)
|
|
1875
|
+
|
|
1876
|
+
if df.empty:
|
|
1877
|
+
df["passes_thresholds"] = pd.Series(dtype="object")
|
|
1878
|
+
df["threshold_reason"] = pd.Series(dtype="object")
|
|
1879
|
+
return df
|
|
1880
|
+
|
|
1881
|
+
iptm = pd.to_numeric(df.get("iptm"), errors="coerce")
|
|
1882
|
+
pae = pd.to_numeric(df.get("pae_interaction"), errors="coerce")
|
|
1883
|
+
|
|
1884
|
+
verdicts: list[str | None] = []
|
|
1885
|
+
reasons: list[str] = []
|
|
1886
|
+
for i, p_ in zip(iptm, pae, strict=False):
|
|
1887
|
+
failed: list[str] = []
|
|
1888
|
+
unmeasured: list[str] = []
|
|
1889
|
+
if pd.isna(i):
|
|
1890
|
+
unmeasured.append("iptm")
|
|
1891
|
+
elif i < params.iptm_threshold:
|
|
1892
|
+
failed.append(f"iptm {i:.3f} < {params.iptm_threshold:.2f}")
|
|
1893
|
+
if pd.isna(p_):
|
|
1894
|
+
unmeasured.append("pae_interaction")
|
|
1895
|
+
elif p_ > params.pae_interaction_threshold:
|
|
1896
|
+
failed.append(f"pae_interaction {p_:.1f} > {params.pae_interaction_threshold:.1f}")
|
|
1897
|
+
if failed:
|
|
1898
|
+
verdicts.append("fail")
|
|
1899
|
+
reasons.append("; ".join(failed))
|
|
1900
|
+
elif unmeasured:
|
|
1901
|
+
verdicts.append("unassessed")
|
|
1902
|
+
reasons.append("not measured: " + ", ".join(unmeasured))
|
|
1903
|
+
else:
|
|
1904
|
+
verdicts.append("pass")
|
|
1905
|
+
reasons.append(
|
|
1906
|
+
f"iptm >= {params.iptm_threshold:.2f} and "
|
|
1907
|
+
f"pae_interaction <= {params.pae_interaction_threshold:.1f}"
|
|
1908
|
+
)
|
|
1909
|
+
df["passes_thresholds"] = verdicts
|
|
1910
|
+
df["threshold_reason"] = reasons
|
|
1911
|
+
return df
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
def _archive_carries_designs(archive: Path) -> bool:
|
|
1915
|
+
"""True when a results tarball still contains the designed sequences.
|
|
1916
|
+
|
|
1917
|
+
``_launch_revalidate`` replaces a target's tarball with whatever the run
|
|
1918
|
+
returns. That is right when the new archive carries the designs back
|
|
1919
|
+
alongside the fresh validator output, and destructive when it does not: the
|
|
1920
|
+
FASTA is the only record of what was designed, since the staged PDBs are
|
|
1921
|
+
byte-identical per backbone, and a second revalidation would then find
|
|
1922
|
+
nothing left to rescore.
|
|
1923
|
+
"""
|
|
1924
|
+
import tarfile
|
|
1925
|
+
|
|
1926
|
+
if not archive.is_file():
|
|
1927
|
+
return False
|
|
1928
|
+
try:
|
|
1929
|
+
with tarfile.open(archive, "r:gz", encoding="utf-8") as tf:
|
|
1930
|
+
return any(
|
|
1931
|
+
m.isfile() and m.name.startswith("design/") and m.name.endswith(".fasta")
|
|
1932
|
+
for m in tf.getmembers()
|
|
1933
|
+
)
|
|
1934
|
+
except (OSError, tarfile.TarError):
|
|
1935
|
+
return False
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def _launch_revalidate(run_dir: Path, *, backend: str, validator: str) -> int:
|
|
1939
|
+
"""Run ``validator`` against the binders a previous design step produced.
|
|
1940
|
+
|
|
1941
|
+
The designs travel to the GPU alongside the spec and no designer runs, so
|
|
1942
|
+
choosing a different validator costs one validation pass rather than a full
|
|
1943
|
+
redesign. This is what makes cross-validator agreement (Boltz-2 against
|
|
1944
|
+
Chai-1r or AF2 initial-guess on the *same* binders) reachable at all.
|
|
1945
|
+
|
|
1946
|
+
Returns the number of targets revalidated.
|
|
1947
|
+
"""
|
|
1948
|
+
import shutil
|
|
1949
|
+
import tarfile
|
|
1950
|
+
import tempfile
|
|
1951
|
+
|
|
1952
|
+
from bindsight.design._common import make_cache_key, submit_via_runner
|
|
1953
|
+
from bindsight.design.protocol import DesignSpec
|
|
1954
|
+
from bindsight.plugins import get_runner
|
|
1955
|
+
|
|
1956
|
+
targets = _top_targets(run_dir)
|
|
1957
|
+
if not targets:
|
|
1958
|
+
return 0
|
|
1959
|
+
design_dir = run_dir / "design"
|
|
1960
|
+
targets_dir = design_dir / "_targets"
|
|
1961
|
+
runner = get_runner(
|
|
1962
|
+
backend,
|
|
1963
|
+
designer="rfdiff_mpnn",
|
|
1964
|
+
n_units_per_target=1,
|
|
1965
|
+
bindsight_wheel=_working_tree_wheel(backend, run_dir),
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
metrics_lines: list[str] = []
|
|
1969
|
+
done = 0
|
|
1970
|
+
for t in targets:
|
|
1971
|
+
# Written under the site-specific stem since the per-site collision was
|
|
1972
|
+
# fixed. Runs made before that carry the bare accession, and they are
|
|
1973
|
+
# still readable: a naming fix must not orphan the archives it renames.
|
|
1974
|
+
tar_path = targets_dir / f"{_target_artifact_stem(t)}.tar.gz"
|
|
1975
|
+
if not tar_path.exists():
|
|
1976
|
+
legacy = targets_dir / f"{t['uniprot']}.tar.gz"
|
|
1977
|
+
if legacy.exists():
|
|
1978
|
+
tar_path = legacy
|
|
1979
|
+
else:
|
|
1980
|
+
LOG_CLI.warning("no design tarball for %s; skipping", t["uniprot"])
|
|
1981
|
+
continue
|
|
1982
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1983
|
+
staged = Path(tmp) / "design"
|
|
1984
|
+
staged.mkdir(parents=True, exist_ok=True)
|
|
1985
|
+
with tarfile.open(tar_path, "r:gz", encoding="utf-8") as tf:
|
|
1986
|
+
for m in tf.getmembers():
|
|
1987
|
+
if m.isfile() and m.name.startswith("design/"):
|
|
1988
|
+
src = tf.extractfile(m)
|
|
1989
|
+
if src is not None:
|
|
1990
|
+
(staged / Path(m.name).name).write_bytes(src.read())
|
|
1991
|
+
if not any(staged.glob("*.fasta")):
|
|
1992
|
+
LOG_CLI.warning("no designs inside %s; skipping", tar_path)
|
|
1993
|
+
continue
|
|
1994
|
+
spec = DesignSpec(
|
|
1995
|
+
target_uniprot=t["uniprot"],
|
|
1996
|
+
target_structure_path=str(t["structure_path"]),
|
|
1997
|
+
epitope_chain=t["chain"],
|
|
1998
|
+
epitope_residues=t["residues"],
|
|
1999
|
+
design_ranges=t["design_ranges"],
|
|
2000
|
+
n_trajectories=1,
|
|
2001
|
+
seed=0,
|
|
2002
|
+
extra_params={"mode": "validate_only", "validator": validator},
|
|
2003
|
+
)
|
|
2004
|
+
result = submit_via_runner(
|
|
2005
|
+
spec,
|
|
2006
|
+
runner,
|
|
2007
|
+
designer_name=f"revalidate:{validator}",
|
|
2008
|
+
designer_version="1",
|
|
2009
|
+
designer_commit_sha=None,
|
|
2010
|
+
cache_key=make_cache_key(spec, extra=("validate_only", validator)),
|
|
2011
|
+
payload_dir=staged,
|
|
2012
|
+
)
|
|
2013
|
+
# Never trade the designs for a validator's output. See
|
|
2014
|
+
# :func:`_archive_carries_designs`.
|
|
2015
|
+
if _archive_carries_designs(Path(result.results_archive_path)):
|
|
2016
|
+
shutil.copy2(result.results_archive_path, tar_path)
|
|
2017
|
+
else:
|
|
2018
|
+
LOG_CLI.error(
|
|
2019
|
+
"the revalidated archive for %s carries no designs, so copying it "
|
|
2020
|
+
"over %s would destroy the binders it was meant to rescore. Keeping "
|
|
2021
|
+
"the original; the new metrics are still recorded.",
|
|
2022
|
+
t["uniprot"],
|
|
2023
|
+
tar_path,
|
|
2024
|
+
)
|
|
2025
|
+
mpath = Path(result.metrics_jsonl_path)
|
|
2026
|
+
if mpath.exists():
|
|
2027
|
+
metrics_lines += [
|
|
2028
|
+
ln for ln in mpath.read_text(encoding="utf-8").splitlines() if ln.strip()
|
|
2029
|
+
]
|
|
2030
|
+
done += 1
|
|
2031
|
+
|
|
2032
|
+
if metrics_lines:
|
|
2033
|
+
(design_dir / "metrics.jsonl").write_text(
|
|
2034
|
+
"\n".join(metrics_lines) + "\n", encoding="utf-8", newline="\n"
|
|
2035
|
+
)
|
|
2036
|
+
return done
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
def _launch_design(
|
|
2040
|
+
run_dir: Path,
|
|
2041
|
+
*,
|
|
2042
|
+
backend: str,
|
|
2043
|
+
designer: str,
|
|
2044
|
+
validator: str,
|
|
2045
|
+
trajectories: int,
|
|
2046
|
+
prescreen_top_k: int | None = None,
|
|
2047
|
+
diffusion_samples: int = 1,
|
|
2048
|
+
max_parallel_samples: int = 1,
|
|
2049
|
+
) -> int:
|
|
2050
|
+
"""Run design+validation for each top target via a headless runner backend.
|
|
2051
|
+
|
|
2052
|
+
``prescreen_top_k`` is passed through to the executor, which applies the
|
|
2053
|
+
ESM-2 screen between design and validation — the only point at which
|
|
2054
|
+
dropping a design saves GPU time. ``None`` validates every design.
|
|
2055
|
+
"""
|
|
2056
|
+
import shutil
|
|
2057
|
+
|
|
2058
|
+
from bindsight.plugins import get_designer, get_runner
|
|
2059
|
+
|
|
2060
|
+
targets = _top_targets(run_dir)
|
|
2061
|
+
if not targets:
|
|
2062
|
+
return 0
|
|
2063
|
+
seed, binder_length_min, binder_length_max = _design_spec_params_from_run(run_dir)
|
|
2064
|
+
plugin = get_designer(designer)
|
|
2065
|
+
runner = get_runner(
|
|
2066
|
+
backend,
|
|
2067
|
+
designer=designer,
|
|
2068
|
+
n_units_per_target=trajectories,
|
|
2069
|
+
bindsight_wheel=_working_tree_wheel(backend, run_dir),
|
|
2070
|
+
)
|
|
2071
|
+
design_dir = run_dir / "design"
|
|
2072
|
+
targets_dir = design_dir / "_targets"
|
|
2073
|
+
targets_dir.mkdir(parents=True, exist_ok=True)
|
|
2074
|
+
|
|
2075
|
+
metrics_lines: list[str] = []
|
|
2076
|
+
launched = 0
|
|
2077
|
+
for t in targets:
|
|
2078
|
+
spec = plugin.make_spec(
|
|
2079
|
+
target_uniprot=t["uniprot"],
|
|
2080
|
+
target_structure_path=Path(t["structure_path"]),
|
|
2081
|
+
epitope_residues=t["residues"],
|
|
2082
|
+
epitope_chain=t["chain"],
|
|
2083
|
+
design_ranges=t["design_ranges"],
|
|
2084
|
+
n_trajectories=trajectories,
|
|
2085
|
+
seed=seed,
|
|
2086
|
+
binder_length_min=binder_length_min,
|
|
2087
|
+
binder_length_max=binder_length_max,
|
|
2088
|
+
)
|
|
2089
|
+
extra: dict[str, str | int | float | bool] = {
|
|
2090
|
+
**spec.extra_params,
|
|
2091
|
+
"validator": validator,
|
|
2092
|
+
}
|
|
2093
|
+
if prescreen_top_k:
|
|
2094
|
+
extra["prescreen_top_k"] = int(prescreen_top_k)
|
|
2095
|
+
# Only when asked for. Both default to 1, so a run that does not set
|
|
2096
|
+
# them produces the same spec — and the same cache key — as before.
|
|
2097
|
+
if diffusion_samples and int(diffusion_samples) != 1:
|
|
2098
|
+
extra["diffusion_samples"] = int(diffusion_samples)
|
|
2099
|
+
if max_parallel_samples and int(max_parallel_samples) != 1:
|
|
2100
|
+
extra["max_parallel_samples"] = int(max_parallel_samples)
|
|
2101
|
+
spec = spec.model_copy(update={"extra_params": extra})
|
|
2102
|
+
result = plugin.submit(spec, runner)
|
|
2103
|
+
shutil.copy2(
|
|
2104
|
+
result.results_archive_path, targets_dir / f"{_target_artifact_stem(t)}.tar.gz"
|
|
2105
|
+
)
|
|
2106
|
+
mpath = Path(result.metrics_jsonl_path)
|
|
2107
|
+
if mpath.exists():
|
|
2108
|
+
metrics_lines += [
|
|
2109
|
+
ln for ln in mpath.read_text(encoding="utf-8").splitlines() if ln.strip()
|
|
2110
|
+
]
|
|
2111
|
+
launched += 1
|
|
2112
|
+
|
|
2113
|
+
(design_dir / "metrics.jsonl").write_text(
|
|
2114
|
+
"\n".join(metrics_lines) + ("\n" if metrics_lines else ""), encoding="utf-8", newline="\n"
|
|
2115
|
+
)
|
|
2116
|
+
# A top-level results.tar.gz marks design completion for `bindsight run`.
|
|
2117
|
+
import tarfile
|
|
2118
|
+
|
|
2119
|
+
with tarfile.open(design_dir / "results.tar.gz", "w:gz", encoding="utf-8") as tf:
|
|
2120
|
+
tf.add(design_dir / "metrics.jsonl", arcname="metrics.jsonl")
|
|
2121
|
+
tf.add(targets_dir, arcname="_targets")
|
|
2122
|
+
return launched
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def _finalize_validate(run_dir: Path) -> int:
|
|
2126
|
+
"""Build validate/validated.parquet from design metrics; unpack per-binder dirs."""
|
|
2127
|
+
import tarfile
|
|
2128
|
+
|
|
2129
|
+
import pandas as pd
|
|
2130
|
+
|
|
2131
|
+
design_dir = run_dir / "design"
|
|
2132
|
+
validate_dir = run_dir / "validate"
|
|
2133
|
+
validate_dir.mkdir(parents=True, exist_ok=True)
|
|
2134
|
+
|
|
2135
|
+
# Unpack each per-target tarball's validate/<binder_id>/ into <run>/validate/.
|
|
2136
|
+
targets_dir = design_dir / "_targets"
|
|
2137
|
+
if targets_dir.exists():
|
|
2138
|
+
for tar_path in targets_dir.glob("*.tar.gz"):
|
|
2139
|
+
try:
|
|
2140
|
+
with tarfile.open(tar_path, "r:gz", encoding="utf-8") as tf:
|
|
2141
|
+
for m in tf.getmembers():
|
|
2142
|
+
if m.name.startswith("validate/"):
|
|
2143
|
+
tf.extract(m, validate_dir.parent, filter="data")
|
|
2144
|
+
except (tarfile.TarError, OSError) as e:
|
|
2145
|
+
LOG_CLI.warning("could not unpack %s: %s", tar_path, e)
|
|
2146
|
+
|
|
2147
|
+
metrics_path = design_dir / "metrics.jsonl"
|
|
2148
|
+
rows = (
|
|
2149
|
+
[
|
|
2150
|
+
json.loads(ln)
|
|
2151
|
+
for ln in metrics_path.read_text(encoding="utf-8").splitlines()
|
|
2152
|
+
if ln.strip()
|
|
2153
|
+
]
|
|
2154
|
+
if metrics_path.exists()
|
|
2155
|
+
else []
|
|
2156
|
+
)
|
|
2157
|
+
# Always write validated.parquet (with the expected schema even when empty)
|
|
2158
|
+
# so downstream rank / the Snakemake `validate` output always exists.
|
|
2159
|
+
cols = [
|
|
2160
|
+
"binder_id",
|
|
2161
|
+
"target_uniprot",
|
|
2162
|
+
"iptm",
|
|
2163
|
+
"pae_interaction",
|
|
2164
|
+
"affinity_pred_value",
|
|
2165
|
+
"affinity_probability_binary",
|
|
2166
|
+
"validator_name",
|
|
2167
|
+
"validator_version",
|
|
2168
|
+
]
|
|
2169
|
+
df = pd.DataFrame(rows) if rows else pd.DataFrame(columns=cols)
|
|
2170
|
+
df = _mark_thresholds(df, run_dir)
|
|
2171
|
+
df = _split_on_thresholds(df, run_dir, validate_dir)
|
|
2172
|
+
df.to_parquet(validate_dir / "validated.parquet", index=False)
|
|
2173
|
+
return len(df)
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def _print_cost_panel(cost: CostEstimate, label: str) -> None:
|
|
2177
|
+
"""Render a CostEstimate as a Rich panel with the user-relevant fields."""
|
|
2178
|
+
usd = cost.usd_estimate or 0.0
|
|
2179
|
+
dollars = "free" if usd == 0 else f"~${usd:.2f}"
|
|
2180
|
+
console.print(
|
|
2181
|
+
Panel(
|
|
2182
|
+
f"[bold]{label}[/bold]\n"
|
|
2183
|
+
f"backend {cost.backend}\n"
|
|
2184
|
+
f"GPU {cost.gpu_type}\n"
|
|
2185
|
+
f"GPU-hours {cost.gpu_hours:.2f}\n"
|
|
2186
|
+
f"USD {dollars}\n"
|
|
2187
|
+
f"queue ~{cost.queue_minutes_estimate or 0:.0f} min · "
|
|
2188
|
+
f"{cost.notes or ''}",
|
|
2189
|
+
title="Cost estimate",
|
|
2190
|
+
border_style="cyan",
|
|
2191
|
+
)
|
|
2192
|
+
)
|
|
2193
|
+
|
|
2194
|
+
|
|
2195
|
+
# ---------------------------------------------------------------------------
|
|
2196
|
+
# demo — one-command end-to-end against the shipped tiny example
|
|
2197
|
+
# ---------------------------------------------------------------------------
|
|
2198
|
+
@main.command()
|
|
2199
|
+
@click.option(
|
|
2200
|
+
"--out",
|
|
2201
|
+
"out_dir",
|
|
2202
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
2203
|
+
default=Path("runs/demo"),
|
|
2204
|
+
show_default=True,
|
|
2205
|
+
help="Where to write the demo output.",
|
|
2206
|
+
)
|
|
2207
|
+
@click.option(
|
|
2208
|
+
"--no-report",
|
|
2209
|
+
is_flag=True,
|
|
2210
|
+
help="Skip rendering the HTML report at the end.",
|
|
2211
|
+
)
|
|
2212
|
+
def demo(out_dir: Path, no_report: bool) -> None:
|
|
2213
|
+
"""Run the full discovery half on a real TCGA breast-cancer cohort.
|
|
2214
|
+
|
|
2215
|
+
Auto-downloads an authentic TCGA-BRCA tumor-vs-adjacent-normal RNA-seq
|
|
2216
|
+
cohort (STAR - Counts, 20 tumour vs 20 normal) from NIH/GDC on first run and
|
|
2217
|
+
runs real DESeq2 over it. Needs network the first time (cohort + SURFY
|
|
2218
|
+
downloaded, then cached); takes a few minutes on real data. Produces an HTML
|
|
2219
|
+
report.
|
|
2220
|
+
|
|
2221
|
+
Which antigens surface is the run's result, not a scripted one: a 20-vs-20
|
|
2222
|
+
subsample can land either side of the significance cutoffs. See
|
|
2223
|
+
benchmarks/study/RESULTS.md for what the full cohorts show.
|
|
2224
|
+
"""
|
|
2225
|
+
# This help text once promised the demo rediscovered ERBB2 (HER2) and EGFR
|
|
2226
|
+
# as top antibody-tractable surface antigens. The project's own rediscovery
|
|
2227
|
+
# study measures ERBB2 in an unstratified breast cohort (matched pairs, no
|
|
2228
|
+
# biomarker selection) at log2 fold
|
|
2229
|
+
# change 0.92 -- below the 1.0 floor, so it does not clear the significance
|
|
2230
|
+
# rule -- and the demo config's own comment notes that EGFR is often lower in
|
|
2231
|
+
# bulk tumour than in normal breast epithelium. Naming an expected outcome in
|
|
2232
|
+
# --help turns whatever the run produces into either a confirmation or an
|
|
2233
|
+
# apparent malfunction, and one of those readings would be wrong.
|
|
2234
|
+
from bindsight.config import RunConfig
|
|
2235
|
+
from bindsight.pipelines import discover as discover_pipeline
|
|
2236
|
+
|
|
2237
|
+
_setup_logging(verbose=False)
|
|
2238
|
+
|
|
2239
|
+
# Resolve the bundled config relative to the package install root.
|
|
2240
|
+
repo_root = Path(__file__).parent.parent
|
|
2241
|
+
cfg_path = repo_root / "examples" / "demo" / "config.yaml"
|
|
2242
|
+
if not cfg_path.exists():
|
|
2243
|
+
# Installed wheels put it in shared-data under sys.prefix (pyproject's
|
|
2244
|
+
# [tool.hatch.build.targets.wheel.shared-data]). Without this candidate
|
|
2245
|
+
# `bindsight demo` could only ever run from a source checkout, which is
|
|
2246
|
+
# the one situation the one-button demo is not for.
|
|
2247
|
+
cfg_path = Path(sys.prefix) / "bindsight_demo" / "config.yaml"
|
|
2248
|
+
if not cfg_path.exists():
|
|
2249
|
+
# Editable installs: try CWD as a fallback.
|
|
2250
|
+
cfg_path = Path("examples/demo/config.yaml")
|
|
2251
|
+
if not cfg_path.exists():
|
|
2252
|
+
console.print(
|
|
2253
|
+
Panel(
|
|
2254
|
+
"[red]Could not find examples/demo/config.yaml.[/red] "
|
|
2255
|
+
"Re-install with `pip install -e .` from the repo root.",
|
|
2256
|
+
title="demo failed",
|
|
2257
|
+
border_style="red",
|
|
2258
|
+
)
|
|
2259
|
+
)
|
|
2260
|
+
sys.exit(2)
|
|
2261
|
+
|
|
2262
|
+
console.print(
|
|
2263
|
+
Panel(
|
|
2264
|
+
"[bold]bindsight demo[/bold]\n\n"
|
|
2265
|
+
f"config: {cfg_path}\nout: {out_dir}\n\n"
|
|
2266
|
+
"Real TCGA-BRCA tumor-vs-adjacent-normal cohort (NIH/GDC). The\n"
|
|
2267
|
+
"pipeline discovers antibody-tractable cell-surface antigens that are\n"
|
|
2268
|
+
"over-expressed in tumor (known targets such as ERBB2/HER2 appear when\n"
|
|
2269
|
+
"their signal is present), with full provenance. First run downloads\n"
|
|
2270
|
+
"the cohort + SURFY and enriches via Open Targets (cached afterwards)\n"
|
|
2271
|
+
"and runs real DESeq2 — a few minutes, not seconds.",
|
|
2272
|
+
title="Demo run",
|
|
2273
|
+
border_style="cyan",
|
|
2274
|
+
)
|
|
2275
|
+
)
|
|
2276
|
+
|
|
2277
|
+
cfg = RunConfig.from_yaml(cfg_path)
|
|
2278
|
+
cfg.out_dir = out_dir
|
|
2279
|
+
# Cache the auto-downloaded cohort under the OS user-cache dir (like the
|
|
2280
|
+
# SURFY/AlphaFold/Open Targets caches) so the one-button demo works from any
|
|
2281
|
+
# working directory and is downloaded only once.
|
|
2282
|
+
from bindsight.io.paths import cache_dir
|
|
2283
|
+
|
|
2284
|
+
cohort_dir = cache_dir("gdc") / "tcga_brca"
|
|
2285
|
+
cfg.inputs.counts = cohort_dir / "counts.tsv.gz"
|
|
2286
|
+
cfg.inputs.design = cohort_dir / "design.tsv"
|
|
2287
|
+
|
|
2288
|
+
manifest = discover_pipeline.run(cfg, out_dir=out_dir)
|
|
2289
|
+
failed = [s for s in manifest.stages if s.status == "failed"]
|
|
2290
|
+
if failed:
|
|
2291
|
+
console.print(
|
|
2292
|
+
Panel(
|
|
2293
|
+
"\n".join(f"[red]{s.name}[/red]: {s.error}" for s in failed),
|
|
2294
|
+
title="Demo stage failures",
|
|
2295
|
+
border_style="red",
|
|
2296
|
+
)
|
|
2297
|
+
)
|
|
2298
|
+
sys.exit(1)
|
|
2299
|
+
|
|
2300
|
+
# Render the report unless asked not to.
|
|
2301
|
+
report_path: Path | None = None
|
|
2302
|
+
if not no_report:
|
|
2303
|
+
from bindsight.report import render_run
|
|
2304
|
+
|
|
2305
|
+
report_path = render_run(out_dir)
|
|
2306
|
+
|
|
2307
|
+
console.print(
|
|
2308
|
+
Panel(
|
|
2309
|
+
f"[green]Demo complete![/green]\n\n"
|
|
2310
|
+
f"Manifest: {out_dir / 'run_manifest.jsonld'}\n"
|
|
2311
|
+
f"Targets: {out_dir / 'targets' / 'candidates.parquet'}\n"
|
|
2312
|
+
f"Epitopes: {out_dir / 'epitopes' / 'epitopes.parquet'}"
|
|
2313
|
+
+ (f"\nReport HTML: {report_path}" if report_path else "")
|
|
2314
|
+
+ "\n\nNext steps:\n"
|
|
2315
|
+
" 1. Open the HTML report in a browser to see the results.\n"
|
|
2316
|
+
" 2. Inspect the Parquet outputs with pandas / DuckDB.\n"
|
|
2317
|
+
" 3. Read docs/how-to-use.md to swap in your own RNA-seq cohort.\n"
|
|
2318
|
+
" 4. See docs/what-is-bindsight.md for the full pitch.",
|
|
2319
|
+
title="bindsight demo",
|
|
2320
|
+
border_style="green",
|
|
2321
|
+
)
|
|
2322
|
+
)
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
# ---------------------------------------------------------------------------
|
|
2326
|
+
# doctor — diagnose the local install (cache state, vendored data, env vars)
|
|
2327
|
+
# ---------------------------------------------------------------------------
|
|
2328
|
+
@main.command()
|
|
2329
|
+
def doctor() -> None:
|
|
2330
|
+
"""Diagnose the local install: Python, deps, cache state, vendored data."""
|
|
2331
|
+
import os
|
|
2332
|
+
import platform
|
|
2333
|
+
|
|
2334
|
+
from bindsight.io.paths import cache_dir
|
|
2335
|
+
|
|
2336
|
+
table = Table(title="bindsight doctor", show_lines=False, title_style="bold")
|
|
2337
|
+
table.add_column("Check", style="cyan", no_wrap=True)
|
|
2338
|
+
table.add_column("Status")
|
|
2339
|
+
table.add_column("Detail", overflow="fold")
|
|
2340
|
+
|
|
2341
|
+
def _row(name: str, ok: bool, detail: str = "") -> None:
|
|
2342
|
+
status = "[green]ok[/green]" if ok else "[yellow]warn[/yellow]"
|
|
2343
|
+
table.add_row(name, status, detail)
|
|
2344
|
+
|
|
2345
|
+
# Runtime
|
|
2346
|
+
# Not a bare `>= 3.11`. `requires-python` has no ceiling, so a newer
|
|
2347
|
+
# interpreter installs cleanly and this row said "ok" about a version CI
|
|
2348
|
+
# has never run -- the first thing `doctor` tells a newcomer about their
|
|
2349
|
+
# setup, and not true of it. Supported means tested.
|
|
2350
|
+
_row(
|
|
2351
|
+
"python",
|
|
2352
|
+
_PYTHON_SUPPORT_STATUS(),
|
|
2353
|
+
platform.python_version() + _python_support_note(),
|
|
2354
|
+
)
|
|
2355
|
+
_row("platform", True, platform.platform())
|
|
2356
|
+
_row("bindsight", True, __version__)
|
|
2357
|
+
|
|
2358
|
+
# Optional deps that the discovery half needs
|
|
2359
|
+
for dep in ("pydeseq2", "pandas", "pyarrow", "requests", "tenacity"):
|
|
2360
|
+
try:
|
|
2361
|
+
v = pkg_version(dep)
|
|
2362
|
+
_row(f"dep: {dep}", True, v)
|
|
2363
|
+
except PackageNotFoundError:
|
|
2364
|
+
_row(
|
|
2365
|
+
f"dep: {dep}",
|
|
2366
|
+
False,
|
|
2367
|
+
'not installed; run: pip install -e ".\\[discover]"',
|
|
2368
|
+
)
|
|
2369
|
+
|
|
2370
|
+
# Cache state
|
|
2371
|
+
base = cache_dir()
|
|
2372
|
+
_row("cache root", base.exists(), str(base))
|
|
2373
|
+
# Report the surfaceome list discovery will *actually* use, not just whether
|
|
2374
|
+
# a refreshed network cache exists. The full list is vendored with the
|
|
2375
|
+
# package (2,886 accessions) and needs no network, so a missing cache is
|
|
2376
|
+
# normal, not a degradation.
|
|
2377
|
+
from bindsight.surfaceome.surfy import load_vendored_surfy
|
|
2378
|
+
|
|
2379
|
+
surfy_cache = base / "surfy" / "surfy_v1.uniprot.txt"
|
|
2380
|
+
vendored = load_vendored_surfy()
|
|
2381
|
+
if surfy_cache.exists():
|
|
2382
|
+
n_cached = sum(
|
|
2383
|
+
1
|
|
2384
|
+
for ln in surfy_cache.read_text(encoding="utf-8").splitlines()
|
|
2385
|
+
if ln.strip() and not ln.lstrip().startswith("#")
|
|
2386
|
+
)
|
|
2387
|
+
_row("SURFY surfaceome", n_cached > 0, f"{n_cached} accessions (refreshed cache)")
|
|
2388
|
+
elif vendored is not None:
|
|
2389
|
+
_row(
|
|
2390
|
+
"SURFY surfaceome",
|
|
2391
|
+
True,
|
|
2392
|
+
f"{len(vendored)} accessions (vendored list — no network needed)",
|
|
2393
|
+
)
|
|
2394
|
+
else:
|
|
2395
|
+
_row(
|
|
2396
|
+
"SURFY surfaceome",
|
|
2397
|
+
False,
|
|
2398
|
+
"vendored list missing — reinstall bindsight (only a 10-protein fallback remains)",
|
|
2399
|
+
)
|
|
2400
|
+
afdb_cache = base / "alphafolddb"
|
|
2401
|
+
n_afdb = len(list(afdb_cache.glob("*.cif"))) if afdb_cache.exists() else 0
|
|
2402
|
+
_row("AlphaFoldDB cache", afdb_cache.exists(), f"{n_afdb} mmCIF files cached")
|
|
2403
|
+
ot_cache = base / "opentargets"
|
|
2404
|
+
n_ot = len(list(ot_cache.glob("*.json"))) if ot_cache.exists() else 0
|
|
2405
|
+
_row("Open Targets cache", ot_cache.exists(), f"{n_ot} cached responses")
|
|
2406
|
+
|
|
2407
|
+
# Vendored data (SURFACE-Bind)
|
|
2408
|
+
sb_env = os.environ.get("BINDSIGHT_SURFACE_BIND_DATA")
|
|
2409
|
+
sb_default = Path("data/surface_bind")
|
|
2410
|
+
if sb_env:
|
|
2411
|
+
sb_path = Path(sb_env)
|
|
2412
|
+
_row(
|
|
2413
|
+
"SURFACE-Bind data",
|
|
2414
|
+
sb_path.exists(),
|
|
2415
|
+
f"optional — env BINDSIGHT_SURFACE_BIND_DATA={sb_path}",
|
|
2416
|
+
)
|
|
2417
|
+
else:
|
|
2418
|
+
_row(
|
|
2419
|
+
"SURFACE-Bind data",
|
|
2420
|
+
(sb_default / "sites").exists() if sb_default.exists() else False,
|
|
2421
|
+
"optional — data/surface_bind/sites; without it design targets the "
|
|
2422
|
+
"whole surface. See data/surface_bind/README.md",
|
|
2423
|
+
)
|
|
2424
|
+
|
|
2425
|
+
console.print(table)
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
if __name__ == "__main__": # pragma: no cover
|
|
2429
|
+
main()
|