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
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Single source of truth for the design/validation tools.
|
|
4
|
+
|
|
5
|
+
Pure, CPU-importable command-builders + output-parsers shared by BOTH the
|
|
6
|
+
generated Colab/Modal notebook (:mod:`bindsight.runners.notebook_content`) and
|
|
7
|
+
the real headless executor (:mod:`bindsight.runners.job_exec`). Keeping the
|
|
8
|
+
exact CLI invocations and pinned revisions in one place means the notebook and
|
|
9
|
+
the runners can never drift.
|
|
10
|
+
|
|
11
|
+
Nothing here imports torch / boltz / GPU libraries — the builders just return
|
|
12
|
+
``list[str]`` argv that the executor runs as subprocesses on a CUDA box. The
|
|
13
|
+
parsers read the tools' output files (NumPy is lazy-imported only where needed).
|
|
14
|
+
|
|
15
|
+
Pinned upstream revisions (real HEAD SHAs, resolved via ``git ls-remote``):
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import statistics
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from bindsight.validate.boltz2 import (
|
|
29
|
+
PINNED_BOLTZ2_VERSION,
|
|
30
|
+
build_boltz_yaml,
|
|
31
|
+
parse_boltz_output,
|
|
32
|
+
)
|
|
33
|
+
from bindsight.validate.protocol import (
|
|
34
|
+
UNRECORDED_VERSION,
|
|
35
|
+
ValidationResult,
|
|
36
|
+
installed_version,
|
|
37
|
+
note_unmeasured,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
LOG = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _design_python() -> str:
|
|
44
|
+
"""Interpreter for the design subprocesses (RFdiffusion / ProteinMPNN).
|
|
45
|
+
|
|
46
|
+
Defaults to ``python`` on PATH. Hosts that run the *designer* in a different
|
|
47
|
+
Python env than the orchestrator (e.g. the Kaggle split-env build, where
|
|
48
|
+
RFdiffusion needs a legacy py3.9/torch1.12 env while ``job_exec`` itself runs
|
|
49
|
+
under the modern Boltz-2 env) point this at that env's interpreter via
|
|
50
|
+
``BINDSIGHT_DESIGN_PYTHON``.
|
|
51
|
+
"""
|
|
52
|
+
return os.environ.get("BINDSIGHT_DESIGN_PYTHON") or "python"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _boltz_bin() -> str:
|
|
56
|
+
"""Executable for the Boltz-2 validator (``boltz`` on PATH by default).
|
|
57
|
+
|
|
58
|
+
Overridable via ``BINDSIGHT_BOLTZ_BIN`` for split-env hosts where Boltz-2
|
|
59
|
+
lives in its own environment.
|
|
60
|
+
"""
|
|
61
|
+
return os.environ.get("BINDSIGHT_BOLTZ_BIN") or "boltz"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Pinned upstream tool revisions + weights (real, verifiable).
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
RFDIFF_REPO = "https://github.com/RosettaCommons/RFdiffusion"
|
|
68
|
+
RFDIFF_COMMIT = "2d0c003df46b9db41d119321f15403dec3716cd9"
|
|
69
|
+
# IPD's public weight mirror (see the RFdiffusion README). HTTPS, not HTTP:
|
|
70
|
+
# these are ~480 MB of model parameters that get loaded and executed, and the
|
|
71
|
+
# comment that used to sit here claimed they were "verified on download in the
|
|
72
|
+
# executor when a hash is supplied" while no verification code existed at either
|
|
73
|
+
# download site. Plain HTTP with no integrity check is how a truncated transfer
|
|
74
|
+
# becomes a checkpoint that loads and quietly produces different structures.
|
|
75
|
+
RFDIFF_WEIGHTS: dict[str, str] = {
|
|
76
|
+
"Base_ckpt.pt": "https://files.ipd.uw.edu/pub/RFdiffusion/6f5902ac237024bdd0c176cb93063dc4/Base_ckpt.pt",
|
|
77
|
+
"Complex_base_ckpt.pt": "https://files.ipd.uw.edu/pub/RFdiffusion/e29311f6f1bf1af907f9ef9f44b8328b/Complex_base_ckpt.pt",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#: sha256 of each checkpoint, or None where one has not been established yet.
|
|
81
|
+
#:
|
|
82
|
+
#: Both download sites now hash what they fetched and log it, and refuse to
|
|
83
|
+
#: proceed when a hash here disagrees. Pinning is therefore a two-step process
|
|
84
|
+
#: on purpose: run once, read the hash out of the run log, paste it here. A
|
|
85
|
+
#: hash invented locally would only certify whatever this machine happened to
|
|
86
|
+
#: download, which is the thing being checked.
|
|
87
|
+
#:
|
|
88
|
+
#: The 32-hex path segment in each URL is IPD's own content digest, so a change
|
|
89
|
+
#: upstream arrives as a new URL rather than as different bytes at the same one.
|
|
90
|
+
#: That makes a silent substitution unlikely; it does not make a truncated or
|
|
91
|
+
#: corrupted transfer detectable, which is what these hashes are for.
|
|
92
|
+
RFDIFF_WEIGHT_SHA256: dict[str, str | None] = {
|
|
93
|
+
"Base_ckpt.pt": None,
|
|
94
|
+
"Complex_base_ckpt.pt": None,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
PROTEINMPNN_REPO = "https://github.com/dauparas/ProteinMPNN"
|
|
98
|
+
PROTEINMPNN_COMMIT = "8907e6671bfbfc92303b5f79c4b5e6ce47cdef57"
|
|
99
|
+
|
|
100
|
+
#: Pinned exactly, like every other external tool here.
|
|
101
|
+
#:
|
|
102
|
+
#: This was ``boltz>=2.0,<3.0`` — a range — which made Boltz-2 the one tool in
|
|
103
|
+
#: this file free to change between runs. It is also the tool that produces
|
|
104
|
+
#: every confidence number the project publishes, so two runs a month apart
|
|
105
|
+
#: could report ipTM from two different models under one label. The artifacts
|
|
106
|
+
#: could not have told them apart either: ``validator_version`` was a hardcoded
|
|
107
|
+
#: ``"2.0.1"`` written into every metrics row whatever was installed, so the one
|
|
108
|
+
#: field that looked like it recorded this was answering from a constant. It is
|
|
109
|
+
#: read from the environment now. RFdiffusion, ProteinMPNN, BindCraft and
|
|
110
|
+
#: BoltzGen are all pinned to a commit; there was no argument for exempting the
|
|
111
|
+
#: validator, only an oversight.
|
|
112
|
+
#:
|
|
113
|
+
#: 2.0.3 is the version the precision audit in ``benchmarks/calibration``
|
|
114
|
+
#: examined line by line (wheel sha256
|
|
115
|
+
#: 5851dd10c7819d4c011534a4e5c9cc495d95e42e0e1336617a1e9e78d6c4cf12). Raising
|
|
116
|
+
#: this pin invalidates that audit, and — because this file ships inside the
|
|
117
|
+
#: wheel whose hash is folded into the cache key — correctly invalidates every
|
|
118
|
+
#: cached result produced under the old one.
|
|
119
|
+
BOLTZ_PIP = f"boltz=={PINNED_BOLTZ2_VERSION}"
|
|
120
|
+
|
|
121
|
+
BINDCRAFT_REPO = "https://github.com/martinpacesa/BindCraft"
|
|
122
|
+
BINDCRAFT_COMMIT = "b971db42ba6e091afab63ccb30ae02215150a990"
|
|
123
|
+
|
|
124
|
+
BOLTZGEN_REPO = "https://github.com/HannesStark/boltzgen"
|
|
125
|
+
BOLTZGEN_COMMIT = "a3149cf18eeb58648d1abbb27539bd73f746cdda"
|
|
126
|
+
|
|
127
|
+
CHAI_PIP = "chai_lab>=0.6"
|
|
128
|
+
CHAI_REPO = "https://github.com/chaidiscovery/chai-lab"
|
|
129
|
+
CHAI_COMMIT = "c544fb183e865c4950909444db860a9d50604f66"
|
|
130
|
+
|
|
131
|
+
# AF2 initial-guess (Bennett/Baker dl_binder_design). Depends on PyRosetta, which
|
|
132
|
+
# is free only for non-commercial use; the AF2 parameters themselves are CC BY 4.0.
|
|
133
|
+
DL_BINDER_DESIGN_REPO = "https://github.com/nrbennet/dl_binder_design"
|
|
134
|
+
DL_BINDER_DESIGN_COMMIT = "cafa3853ac94dceb1b908c8d9e6954d71749871a"
|
|
135
|
+
|
|
136
|
+
_AA3TO1 = {
|
|
137
|
+
"ALA": "A",
|
|
138
|
+
"ARG": "R",
|
|
139
|
+
"ASN": "N",
|
|
140
|
+
"ASP": "D",
|
|
141
|
+
"CYS": "C",
|
|
142
|
+
"GLN": "Q",
|
|
143
|
+
"GLU": "E",
|
|
144
|
+
"GLY": "G",
|
|
145
|
+
"HIS": "H",
|
|
146
|
+
"ILE": "I",
|
|
147
|
+
"LEU": "L",
|
|
148
|
+
"LYS": "K",
|
|
149
|
+
"MET": "M",
|
|
150
|
+
"PHE": "F",
|
|
151
|
+
"PRO": "P",
|
|
152
|
+
"SER": "S",
|
|
153
|
+
"THR": "T",
|
|
154
|
+
"TRP": "W",
|
|
155
|
+
"TYR": "Y",
|
|
156
|
+
"VAL": "V",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Pure string helpers (highest-risk — exhaustively unit-tested)
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
def build_hotspot_str(chain: str, residues: list[int]) -> str:
|
|
164
|
+
"""RFdiffusion ``ppi.hotspot_res`` token, e.g. ``[A30,A31,A45]``."""
|
|
165
|
+
return "[" + ",".join(f"{chain}{r}" for r in residues) + "]"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_contig_str(
|
|
169
|
+
target_chain: str,
|
|
170
|
+
target_lo: int,
|
|
171
|
+
target_hi: int,
|
|
172
|
+
binder_len_min: int,
|
|
173
|
+
binder_len_max: int,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""RFdiffusion ``contigmap.contigs`` token.
|
|
176
|
+
|
|
177
|
+
``[A<lo>-<hi>/0 <bmin>-<bmax>]`` = keep target chain residues lo–hi, insert a
|
|
178
|
+
chain break (``/0``), then diffuse a new binder of length bmin–bmax. The span
|
|
179
|
+
is derived from the actual structure (not hard-coded ``A1-9999``).
|
|
180
|
+
"""
|
|
181
|
+
return build_ranges_contig_str(
|
|
182
|
+
target_chain, [(target_lo, target_hi)], binder_len_min, binder_len_max
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_ranges_contig_str(
|
|
187
|
+
target_chain: str,
|
|
188
|
+
ranges: Sequence[tuple[int, int]],
|
|
189
|
+
binder_len_min: int,
|
|
190
|
+
binder_len_max: int,
|
|
191
|
+
) -> str:
|
|
192
|
+
"""RFdiffusion contig keeping only ``ranges`` of the target chain.
|
|
193
|
+
|
|
194
|
+
Segments of the same chain are joined by ``/`` and the residues between them
|
|
195
|
+
are dropped, so a single-pass receptor can be presented by its extracellular
|
|
196
|
+
ranges alone — ``[A23-652/A700-720/0 50-100]``. Without this the diffusion
|
|
197
|
+
model sees the transmembrane helix and the cytoplasmic tail as part of the
|
|
198
|
+
binding surface, which no extracellular binder can reach.
|
|
199
|
+
"""
|
|
200
|
+
if not ranges:
|
|
201
|
+
raise ValueError("at least one target range is required to build a contig")
|
|
202
|
+
segments = "/".join(f"{target_chain}{lo}-{hi}" for lo, hi in ranges)
|
|
203
|
+
return f"[{segments}/0 {binder_len_min}-{binder_len_max}]"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Command builders (return argv lists; no shell)
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
#: What a configured seed of 0 becomes for ProteinMPNN.
|
|
210
|
+
#:
|
|
211
|
+
#: Any non-zero constant would do; this one is fixed so that a run seeded 0 is
|
|
212
|
+
#: reproducible rather than merely non-random. It must never itself be 0.
|
|
213
|
+
_MPNN_SEED_FOR_ZERO = 20260912
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
#: Bound on the run seed before it is turned into a design-index block.
|
|
217
|
+
#:
|
|
218
|
+
#: ``design_startnum`` becomes ``seed * num_designs``, and that index is handed
|
|
219
|
+
#: to ``torch.manual_seed``, which requires a value below 2**64. Reducing the
|
|
220
|
+
#: seed first keeps the product small for any seed a user might write. Two run
|
|
221
|
+
#: seeds congruent modulo this share a block; with a million blocks that is not
|
|
222
|
+
#: a collision anyone meets by accident.
|
|
223
|
+
_SEED_BLOCKS = 1_000_000
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_rfdiff_cmd(
|
|
227
|
+
*,
|
|
228
|
+
rfdiff_dir: Path,
|
|
229
|
+
input_pdb: Path,
|
|
230
|
+
output_prefix: Path,
|
|
231
|
+
num_designs: int,
|
|
232
|
+
hotspot: str,
|
|
233
|
+
contig: str,
|
|
234
|
+
seed: int | None = None,
|
|
235
|
+
) -> list[str]:
|
|
236
|
+
"""RFdiffusion ``scripts/run_inference.py`` argv for binder backbone design.
|
|
237
|
+
|
|
238
|
+
RFdiffusion is a diffusion model and it is the *first* stochastic stage: the
|
|
239
|
+
backbones it draws determine the sequences ProteinMPNN writes and therefore
|
|
240
|
+
every confidence number downstream. At the pinned commit it seeds
|
|
241
|
+
``torch``/``numpy``/``random`` only inside ``if conf.inference.deterministic``,
|
|
242
|
+
and ``config/inference/base.yaml`` ships ``deterministic: False``. This
|
|
243
|
+
builder passed neither that flag nor anything else, so the backbone
|
|
244
|
+
generator ran unseeded — the same defect as the unseeded validator, one
|
|
245
|
+
stage upstream, and the stage that defeats the fix applied downstream: a
|
|
246
|
+
reproducibly folded sequence is no use when the sequence itself is not
|
|
247
|
+
reproducible.
|
|
248
|
+
|
|
249
|
+
**How the seed is injected.** There is no ``inference.seed``. Upstream does::
|
|
250
|
+
|
|
251
|
+
if conf.inference.deterministic:
|
|
252
|
+
make_deterministic() # seed 0, once
|
|
253
|
+
for i_des in range(design_startnum, design_startnum + num_designs):
|
|
254
|
+
if conf.inference.deterministic:
|
|
255
|
+
make_deterministic(i_des) # seed = the design's index
|
|
256
|
+
|
|
257
|
+
so the design index *is* the seed, and ``design_startnum`` chooses it. The
|
|
258
|
+
run's seed therefore selects a block of ``num_designs`` consecutive indices.
|
|
259
|
+
It is multiplied by ``num_designs`` rather than used directly so that
|
|
260
|
+
adjacent seeds get **disjoint** blocks: seeds 42 and 43 with ten designs
|
|
261
|
+
would otherwise share nine of their ten indices and produce nine identical
|
|
262
|
+
backbones while claiming to be different runs.
|
|
263
|
+
|
|
264
|
+
``design_startnum`` does not leak into the results: the executor names
|
|
265
|
+
designs by enumeration order, not by RFdiffusion's file numbering.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
rfdiff_dir: the RFdiffusion checkout.
|
|
269
|
+
input_pdb: the target structure.
|
|
270
|
+
output_prefix: where RFdiffusion writes its backbones.
|
|
271
|
+
num_designs: trajectories to draw.
|
|
272
|
+
hotspot: ``ppi.hotspot_res`` token.
|
|
273
|
+
contig: ``contigmap.contigs`` token.
|
|
274
|
+
seed: the run's seed. ``None`` reproduces the previous unseeded
|
|
275
|
+
behaviour, which is left reachable only so the argv this project
|
|
276
|
+
already published can still be built.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
The argv list.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ValueError: If ``seed`` is negative.
|
|
283
|
+
"""
|
|
284
|
+
cmd = [
|
|
285
|
+
_design_python(),
|
|
286
|
+
str(Path(rfdiff_dir) / "scripts" / "run_inference.py"),
|
|
287
|
+
f"inference.input_pdb={input_pdb}",
|
|
288
|
+
f"inference.output_prefix={output_prefix}",
|
|
289
|
+
f"inference.num_designs={num_designs}",
|
|
290
|
+
f"ppi.hotspot_res={hotspot}",
|
|
291
|
+
f"contigmap.contigs={contig}",
|
|
292
|
+
]
|
|
293
|
+
if seed is not None:
|
|
294
|
+
if seed < 0:
|
|
295
|
+
raise ValueError(f"seed must not be negative; got {seed}")
|
|
296
|
+
cmd += [
|
|
297
|
+
"inference.deterministic=True",
|
|
298
|
+
f"inference.design_startnum={(seed % _SEED_BLOCKS) * num_designs}",
|
|
299
|
+
]
|
|
300
|
+
return cmd
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_mpnn_cmd(
|
|
304
|
+
*,
|
|
305
|
+
mpnn_dir: Path,
|
|
306
|
+
pdb_path: Path,
|
|
307
|
+
out_folder: Path,
|
|
308
|
+
designed_chains: Sequence[str],
|
|
309
|
+
num_seq_per_target: int = 2,
|
|
310
|
+
sampling_temp: float = 0.1,
|
|
311
|
+
seed: int = 0,
|
|
312
|
+
) -> list[str]:
|
|
313
|
+
"""ProteinMPNN ``protein_mpnn_run.py`` argv for sequence design on a backbone.
|
|
314
|
+
|
|
315
|
+
``designed_chains`` is mandatory: ProteinMPNN designs *every* chain when
|
|
316
|
+
``--pdb_path_chains`` is absent, so on a binder–target complex it would
|
|
317
|
+
redesign the target too and the binder would be optimised against a surface
|
|
318
|
+
the model partly invented. Chains left out of ``--pdb_path_chains`` are kept
|
|
319
|
+
as fixed context, which is the conditioning binder design needs — whole
|
|
320
|
+
chains are held, so per-position ``--fixed_positions_jsonl`` is not required,
|
|
321
|
+
and ``--chain_id_jsonl`` only applies to the folder-of-PDBs input mode.
|
|
322
|
+
"""
|
|
323
|
+
if not designed_chains:
|
|
324
|
+
raise ValueError("designed_chains must name at least one chain to design")
|
|
325
|
+
return [
|
|
326
|
+
_design_python(),
|
|
327
|
+
str(Path(mpnn_dir) / "protein_mpnn_run.py"),
|
|
328
|
+
"--pdb_path",
|
|
329
|
+
str(pdb_path),
|
|
330
|
+
"--pdb_path_chains",
|
|
331
|
+
" ".join(designed_chains),
|
|
332
|
+
"--out_folder",
|
|
333
|
+
str(out_folder),
|
|
334
|
+
"--num_seq_per_target",
|
|
335
|
+
str(num_seq_per_target),
|
|
336
|
+
"--sampling_temp",
|
|
337
|
+
str(sampling_temp),
|
|
338
|
+
"--seed",
|
|
339
|
+
# Never the literal 0. ProteinMPNN documents it as a sentinel — its
|
|
340
|
+
# argparse help reads "If set to 0 then a random seed will be picked",
|
|
341
|
+
# and the code is `if args.seed: seed = args.seed else: seed =
|
|
342
|
+
# np.random.randint(...)`. So the one value that reads as "no offset,
|
|
343
|
+
# plain default" is the value that silently turns seeding off, and it is
|
|
344
|
+
# the default of this parameter and of DesignSpec.seed on several paths.
|
|
345
|
+
# Only the sentinel is substituted, so every non-zero seed keeps the
|
|
346
|
+
# exact value it has always had and no reproducible run moves.
|
|
347
|
+
str(seed or _MPNN_SEED_FOR_ZERO),
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def build_boltz_cmd(
|
|
352
|
+
*,
|
|
353
|
+
yaml_path: Path,
|
|
354
|
+
out_dir: Path,
|
|
355
|
+
use_msa_server: bool = True,
|
|
356
|
+
seed: int | None = None,
|
|
357
|
+
diffusion_samples: int = 1,
|
|
358
|
+
max_parallel_samples: int = 1,
|
|
359
|
+
) -> list[str]:
|
|
360
|
+
"""Boltz-2 ``boltz predict`` argv for structure + affinity prediction.
|
|
361
|
+
|
|
362
|
+
Boltz-2 generates structures by diffusion. Its ``--seed`` defaults to
|
|
363
|
+
``None``, which its own help text spells "no seeding", and
|
|
364
|
+
``--diffusion_samples`` defaults to 1. Passing neither — which this builder
|
|
365
|
+
did — makes every reported ipTM a single unseeded draw from a stochastic
|
|
366
|
+
model, and the project's calibration run measured what that costs: refolding
|
|
367
|
+
the same twenty sequences moved ipTM by a median of 0.172 and a
|
|
368
|
+
maximum of 0.573, flipped 4 of 20 verdicts at the 0.65
|
|
369
|
+
threshold, and left the two runs correlated at Spearman 0.065. A number that does not survive its own
|
|
370
|
+
rerun cannot support a per-design claim.
|
|
371
|
+
|
|
372
|
+
The seed makes a run repeatable. It does not make one draw representative —
|
|
373
|
+
only ``diffusion_samples`` does that, at linear GPU cost — so the two are
|
|
374
|
+
separate knobs and the caller chooses what to spend.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
yaml_path: the Boltz-2 spec to predict.
|
|
378
|
+
out_dir: where Boltz-2 writes predictions.
|
|
379
|
+
use_msa_server: use the hosted MSA server.
|
|
380
|
+
seed: RNG seed. ``None`` leaves Boltz-2 unseeded, which is only
|
|
381
|
+
appropriate when drawing deliberately independent samples.
|
|
382
|
+
diffusion_samples: how many structures to draw per input.
|
|
383
|
+
max_parallel_samples: how many of those to diffuse at once.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
The argv list.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
ValueError: If either count is not positive.
|
|
390
|
+
"""
|
|
391
|
+
if diffusion_samples < 1:
|
|
392
|
+
raise ValueError(f"diffusion_samples must be at least 1; got {diffusion_samples}")
|
|
393
|
+
if max_parallel_samples < 1:
|
|
394
|
+
raise ValueError(f"max_parallel_samples must be at least 1; got {max_parallel_samples}")
|
|
395
|
+
cmd = [_boltz_bin(), "predict", str(yaml_path), "--out_dir", str(out_dir)]
|
|
396
|
+
if use_msa_server:
|
|
397
|
+
cmd.append("--use_msa_server")
|
|
398
|
+
if seed is not None:
|
|
399
|
+
cmd += ["--seed", str(seed)]
|
|
400
|
+
if diffusion_samples != 1:
|
|
401
|
+
cmd += ["--diffusion_samples", str(diffusion_samples)]
|
|
402
|
+
# Always explicit, and sequential by default. Boltz-2's own default is
|
|
403
|
+
# 5 — its ``--max_parallel_samples`` help text says "Default is None"
|
|
404
|
+
# while the click option says ``default=5`` — so asking for five draws
|
|
405
|
+
# silently diffuses all five at once. The instrumented run measured a
|
|
406
|
+
# peak of 10,917 MiB of the T4's 15,360 with a single draw of a
|
|
407
|
+
# ~230-token complex, leaving about 4 GB of headroom, and this project
|
|
408
|
+
# targets free-tier cards. Sequential trades wall-clock for not losing
|
|
409
|
+
# the whole job to an out-of-memory kill hours in; a caller with the
|
|
410
|
+
# VRAM can raise it.
|
|
411
|
+
cmd += ["--max_parallel_samples", str(max_parallel_samples)]
|
|
412
|
+
return cmd
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def build_chai_cmd(*, fasta_path: Path, out_dir: Path, seed: int | None = None) -> list[str]:
|
|
416
|
+
"""Chai-1 ``chai-lab fold`` argv (structure + confidence prediction).
|
|
417
|
+
|
|
418
|
+
Chai-1 diffuses like Boltz-2 and is unseeded by the same default: at the
|
|
419
|
+
pinned commit ``run_inference`` takes ``seed: int | None = None`` and
|
|
420
|
+
``num_diffn_samples: int = 5``. This builder passed no seed, so the
|
|
421
|
+
validator was nondeterministic exactly as Boltz-2 was before it was fixed.
|
|
422
|
+
|
|
423
|
+
No published number comes from here — ``plugin_support`` marks chai1r
|
|
424
|
+
unsupported on every bundled backend, because it needs bfloat16 and the free
|
|
425
|
+
tiers pin pre-Ampere cards — so the defect is latent. It is fixed anyway,
|
|
426
|
+
for the same reason the hardcoded ``validator_version`` beside it was: a
|
|
427
|
+
plugin that has not run yet is the one place a defect can sit unnoticed
|
|
428
|
+
until it is producing results.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
fasta_path: the complex to fold.
|
|
432
|
+
out_dir: where Chai-1 writes its predictions.
|
|
433
|
+
seed: RNG seed. ``None`` leaves Chai-1 unseeded.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
The argv list.
|
|
437
|
+
|
|
438
|
+
Raises:
|
|
439
|
+
ValueError: If ``seed`` is negative.
|
|
440
|
+
"""
|
|
441
|
+
cmd = ["chai-lab", "fold", str(fasta_path), str(out_dir)]
|
|
442
|
+
if seed is not None:
|
|
443
|
+
if seed < 0:
|
|
444
|
+
raise ValueError(f"seed must not be negative; got {seed}")
|
|
445
|
+
cmd += ["--seed", str(seed)]
|
|
446
|
+
return cmd
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def build_bindcraft_cmd(
|
|
450
|
+
*, bindcraft_dir: Path, settings_json: Path, filters_json: Path, advanced_json: Path
|
|
451
|
+
) -> list[str]:
|
|
452
|
+
"""BindCraft ``bindcraft.py`` argv (one-shot AF2-based binder design)."""
|
|
453
|
+
return [
|
|
454
|
+
"python",
|
|
455
|
+
str(Path(bindcraft_dir) / "bindcraft.py"),
|
|
456
|
+
"--settings",
|
|
457
|
+
str(settings_json),
|
|
458
|
+
"--filters",
|
|
459
|
+
str(filters_json),
|
|
460
|
+
"--advanced",
|
|
461
|
+
str(advanced_json),
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
#: BoltzGen protocols, from the upstream README. ``protein-anything`` is the one
|
|
466
|
+
#: that designs proteins against protein targets, which is what bindsight does.
|
|
467
|
+
BOLTZGEN_PROTOCOLS = (
|
|
468
|
+
"protein-anything",
|
|
469
|
+
"peptide-anything",
|
|
470
|
+
"protein-small_molecule",
|
|
471
|
+
"nanobody-anything",
|
|
472
|
+
"antibody-anything",
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def build_boltzgen_spec(
|
|
477
|
+
*,
|
|
478
|
+
target_file: str,
|
|
479
|
+
target_chain: str,
|
|
480
|
+
binding_indices: list[int],
|
|
481
|
+
binder_length_min: int,
|
|
482
|
+
binder_length_max: int,
|
|
483
|
+
binder_chain: str = "B",
|
|
484
|
+
) -> dict[str, Any]:
|
|
485
|
+
"""Build a BoltzGen design-specification mapping.
|
|
486
|
+
|
|
487
|
+
BoltzGen is driven by a design-spec YAML listing ``entities``: the protein to
|
|
488
|
+
design (given as a length range, which BoltzGen samples) and the target,
|
|
489
|
+
pulled from a structure file with an optional binding-site restriction.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
target_file: Structure filename **relative to the YAML's own directory** —
|
|
493
|
+
BoltzGen resolves file references relative to the spec, not the cwd.
|
|
494
|
+
target_chain: Chain in that file to use as the target.
|
|
495
|
+
binding_indices: Residues the binder should bind, as **1-based ordinals
|
|
496
|
+
within the chain as it appears in the file** (BoltzGen's canonical
|
|
497
|
+
``label_seq_id`` convention), not author numbering. Empty means bind
|
|
498
|
+
anywhere, which is BoltzGen's documented default.
|
|
499
|
+
binder_length_min: Shortest binder to sample.
|
|
500
|
+
binder_length_max: Longest binder to sample.
|
|
501
|
+
binder_chain: Chain id for the designed protein.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
A mapping ready to serialise as the design-spec YAML.
|
|
505
|
+
"""
|
|
506
|
+
target: dict[str, Any] = {
|
|
507
|
+
"path": target_file,
|
|
508
|
+
"include": [{"chain": {"id": target_chain}}],
|
|
509
|
+
}
|
|
510
|
+
if binding_indices:
|
|
511
|
+
target["binding_types"] = [
|
|
512
|
+
{
|
|
513
|
+
"chain": {
|
|
514
|
+
"id": target_chain,
|
|
515
|
+
"binding": ",".join(str(i) for i in sorted(set(binding_indices))),
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
]
|
|
519
|
+
return {
|
|
520
|
+
"entities": [
|
|
521
|
+
{
|
|
522
|
+
"protein": {
|
|
523
|
+
"id": binder_chain,
|
|
524
|
+
"sequence": f"{int(binder_length_min)}..{int(binder_length_max)}",
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
{"file": target},
|
|
528
|
+
]
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def build_boltzgen_cmd(
|
|
533
|
+
*,
|
|
534
|
+
design_spec: Path,
|
|
535
|
+
out_dir: Path,
|
|
536
|
+
num_designs: int,
|
|
537
|
+
protocol: str = "protein-anything",
|
|
538
|
+
use_kernels: str = "auto",
|
|
539
|
+
budget: int | None = None,
|
|
540
|
+
devices: int | None = None,
|
|
541
|
+
diffusion_batch_size: int | None = None,
|
|
542
|
+
) -> list[str]:
|
|
543
|
+
"""BoltzGen ``boltzgen run`` argv.
|
|
544
|
+
|
|
545
|
+
The previous form of this function built ``boltzgen design --target ...
|
|
546
|
+
--out_dir ... --hotspots ...``. Upstream has no ``design`` subcommand and
|
|
547
|
+
none of those flags, so that command could never have executed; this is the
|
|
548
|
+
real interface (``boltzgen run <spec.yaml> --output <dir>``), taken from the
|
|
549
|
+
README at the pinned commit.
|
|
550
|
+
|
|
551
|
+
``use_kernels`` matters on free hardware: BoltzGen's Triton kernels need
|
|
552
|
+
compute capability 8.0 or newer, and its ``auto`` default enables them on
|
|
553
|
+
exactly that test. A T4 is 7.5 and a P100 is 6.0, so both need ``"false"``.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
design_spec: Path to the design-specification YAML.
|
|
557
|
+
out_dir: Output directory for pipeline results.
|
|
558
|
+
num_designs: Total designs to generate before filtering.
|
|
559
|
+
protocol: One of :data:`BOLTZGEN_PROTOCOLS`.
|
|
560
|
+
use_kernels: ``"auto"``, ``"true"`` or ``"false"``.
|
|
561
|
+
budget: Size of the final diversity-optimised set. Defaults to
|
|
562
|
+
``num_designs`` so a small run keeps everything it generated.
|
|
563
|
+
devices: Number of devices; ``None`` lets BoltzGen use all available.
|
|
564
|
+
diffusion_batch_size: Diffusion samples per trunk run. Small runs want 1,
|
|
565
|
+
since a large batch shares one sampled binder length across the batch.
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
The argv list.
|
|
569
|
+
|
|
570
|
+
Raises:
|
|
571
|
+
ValueError: On an unknown protocol or an invalid ``use_kernels`` value.
|
|
572
|
+
"""
|
|
573
|
+
if protocol not in BOLTZGEN_PROTOCOLS:
|
|
574
|
+
raise ValueError(f"unknown boltzgen protocol {protocol!r}; one of {BOLTZGEN_PROTOCOLS}")
|
|
575
|
+
if use_kernels not in {"auto", "true", "false"}:
|
|
576
|
+
raise ValueError(f"use_kernels must be auto/true/false, got {use_kernels!r}")
|
|
577
|
+
cmd = [
|
|
578
|
+
"boltzgen",
|
|
579
|
+
"run",
|
|
580
|
+
str(design_spec),
|
|
581
|
+
"--output",
|
|
582
|
+
str(out_dir),
|
|
583
|
+
"--protocol",
|
|
584
|
+
protocol,
|
|
585
|
+
"--num_designs",
|
|
586
|
+
str(int(num_designs)),
|
|
587
|
+
"--use_kernels",
|
|
588
|
+
use_kernels,
|
|
589
|
+
"--budget",
|
|
590
|
+
str(int(budget if budget is not None else num_designs)),
|
|
591
|
+
]
|
|
592
|
+
if devices is not None:
|
|
593
|
+
cmd += ["--devices", str(int(devices))]
|
|
594
|
+
if diffusion_batch_size is not None:
|
|
595
|
+
cmd += ["--diffusion_batch_size", str(int(diffusion_batch_size))]
|
|
596
|
+
return cmd
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def label_indices_for_residues(pdb_path: Path, chain: str, residues: list[int]) -> list[int]:
|
|
600
|
+
"""Map author residue numbers to 1-based ordinals within the chain.
|
|
601
|
+
|
|
602
|
+
BoltzGen indexes residues by their canonical position in the chain, starting
|
|
603
|
+
at 1, and its README is explicit that author numbering is *not* what it
|
|
604
|
+
reads. bindsight carries epitope residues in author numbering (UniProt
|
|
605
|
+
positions, preserved through the trimmed target), so they must be converted
|
|
606
|
+
or the binding site silently lands on the wrong residues.
|
|
607
|
+
|
|
608
|
+
Residues absent from the structure are dropped rather than guessed.
|
|
609
|
+
"""
|
|
610
|
+
order = {resi: i + 1 for i, (resi, _aa) in enumerate(chain_residues_from_pdb(pdb_path, chain))}
|
|
611
|
+
return [order[r] for r in residues if r in order]
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def build_af2ig_cmd(*, dl_binder_design_dir: Path, silent_or_pdb: Path, out_dir: Path) -> list[str]:
|
|
615
|
+
"""AF2 initial-guess (dl_binder_design) argv. PyRosetta: non-commercial use only."""
|
|
616
|
+
return [
|
|
617
|
+
"python",
|
|
618
|
+
str(Path(dl_binder_design_dir) / "af2_initial_guess" / "predict.py"),
|
|
619
|
+
"-pdbdir",
|
|
620
|
+
str(silent_or_pdb),
|
|
621
|
+
"-outpdbdir",
|
|
622
|
+
str(out_dir),
|
|
623
|
+
"-scorefilename",
|
|
624
|
+
str(Path(out_dir) / "af2_scores.sc"),
|
|
625
|
+
]
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# ---------------------------------------------------------------------------
|
|
629
|
+
# Parsers
|
|
630
|
+
# ---------------------------------------------------------------------------
|
|
631
|
+
def chain_residues_from_pdb(pdb_path: Path, chain: str = "A") -> list[tuple[int, str]]:
|
|
632
|
+
"""Extract ``(residue number, 1-letter code)`` for a chain (CA atoms, in order)."""
|
|
633
|
+
residues: list[tuple[int, str]] = []
|
|
634
|
+
seen: set[int] = set()
|
|
635
|
+
for line in Path(pdb_path).read_text(encoding="utf-8").splitlines():
|
|
636
|
+
if line.startswith("ATOM") and line[12:16].strip() == "CA":
|
|
637
|
+
if line[21] != chain:
|
|
638
|
+
continue
|
|
639
|
+
resi = int(line[22:26])
|
|
640
|
+
if resi not in seen:
|
|
641
|
+
seen.add(resi)
|
|
642
|
+
residues.append((resi, _AA3TO1.get(line[17:20].strip(), "X")))
|
|
643
|
+
return residues
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
_AA1TO3: dict[str, str] = {one: three for three, one in _AA3TO1.items()}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def write_designed_backbone(backbone: Path, dest: Path, *, chain: str, sequence: str) -> None:
|
|
650
|
+
"""Copy a backbone, rewriting one chain's residues to the designed sequence.
|
|
651
|
+
|
|
652
|
+
Every ProteinMPNN sequence for a trajectory used to be staged as a
|
|
653
|
+
byte-identical copy of the same RFdiffusion backbone. Twenty designs
|
|
654
|
+
produced twenty files with ten distinct contents, each named for a design
|
|
655
|
+
whose sequence it did not carry: the residues in it were the ones diffusion
|
|
656
|
+
happened to emit, and the designed sequence lived only in the sibling FASTA.
|
|
657
|
+
A reviewer opening ``..._seq0.pdb`` and ``..._seq1.pdb`` found the same file
|
|
658
|
+
twice.
|
|
659
|
+
|
|
660
|
+
Rewriting the residue names is a complete fix here rather than an
|
|
661
|
+
approximation, because RFdiffusion emits backbone atoms only — N, CA, C and
|
|
662
|
+
O, verified on the committed output — so there are no side chains to be left
|
|
663
|
+
inconsistent with the new identities. The coordinates are the design's
|
|
664
|
+
backbone and the residue names are now the design's sequence.
|
|
665
|
+
|
|
666
|
+
Only the binder chain is touched. The target chain is the native structure
|
|
667
|
+
the binder was designed against, and rewriting it is exactly the defect the
|
|
668
|
+
``--pdb_path_chains`` fix exists to prevent.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
backbone: the RFdiffusion output to copy.
|
|
672
|
+
dest: where to write the design.
|
|
673
|
+
chain: the binder chain id to rewrite.
|
|
674
|
+
sequence: the designed one-letter sequence for that chain.
|
|
675
|
+
|
|
676
|
+
Raises:
|
|
677
|
+
ValueError: If the chain is absent, or its residue count does not match
|
|
678
|
+
the sequence. A mismatch means the file would describe a molecule
|
|
679
|
+
that was never scored, which is worse than the duplicate it replaces.
|
|
680
|
+
"""
|
|
681
|
+
lines = backbone.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
682
|
+
|
|
683
|
+
order: list[str] = []
|
|
684
|
+
for line in lines:
|
|
685
|
+
if line.startswith(("ATOM", "HETATM")) and line[21:22] == chain:
|
|
686
|
+
residue = line[22:27]
|
|
687
|
+
if not order or order[-1] != residue:
|
|
688
|
+
order.append(residue)
|
|
689
|
+
if not order:
|
|
690
|
+
raise ValueError(f"{backbone.name}: chain {chain!r} has no residues to rewrite")
|
|
691
|
+
if len(order) != len(sequence):
|
|
692
|
+
raise ValueError(
|
|
693
|
+
f"{backbone.name}: chain {chain!r} has {len(order)} residues but the "
|
|
694
|
+
f"designed sequence is {len(sequence)}; refusing to write a structure "
|
|
695
|
+
"that does not describe the design it is named for"
|
|
696
|
+
)
|
|
697
|
+
position = {residue: index for index, residue in enumerate(order)}
|
|
698
|
+
|
|
699
|
+
out: list[str] = []
|
|
700
|
+
for line in lines:
|
|
701
|
+
if line.startswith(("ATOM", "HETATM")) and line[21:22] == chain:
|
|
702
|
+
three = _AA1TO3.get(sequence[position[line[22:27]]].upper(), "UNK")
|
|
703
|
+
line = line[:17] + f"{three:<3}" + line[20:]
|
|
704
|
+
out.append(line)
|
|
705
|
+
dest.write_text("".join(out), encoding="utf-8", newline="\n")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def chain_sequence_from_pdb(pdb_path: Path, chain: str = "A") -> str:
|
|
709
|
+
"""Extract a chain's 1-letter sequence from a PDB (CA atoms, in order)."""
|
|
710
|
+
return "".join(aa for _, aa in chain_residues_from_pdb(pdb_path, chain))
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def chain_sequences_from_cif(cif_path: Path) -> dict[str, str]:
|
|
714
|
+
"""Every chain's 1-letter sequence from an mmCIF, keyed by label_asym_id.
|
|
715
|
+
|
|
716
|
+
Validator output is mmCIF, so checking what a predicted complex actually
|
|
717
|
+
contains needs this rather than :func:`chain_sequence_from_pdb`. The check
|
|
718
|
+
it exists for is whether a designed complex still carries the native target:
|
|
719
|
+
ProteinMPNN will rewrite every chain it is given, and the difference between
|
|
720
|
+
a real result and a design scored against a surface it invented is exactly
|
|
721
|
+
one command-line flag.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
cif_path: an mmCIF file with an ``_atom_site`` loop.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
chain id -> sequence, residues ordered by ``label_seq_id``. Empty when
|
|
728
|
+
the file carries no atom records.
|
|
729
|
+
"""
|
|
730
|
+
from Bio.PDB.MMCIF2Dict import MMCIF2Dict
|
|
731
|
+
|
|
732
|
+
data = MMCIF2Dict(str(cif_path))
|
|
733
|
+
chains = data.get("_atom_site.label_asym_id")
|
|
734
|
+
comps = data.get("_atom_site.label_comp_id")
|
|
735
|
+
seq_ids = data.get("_atom_site.label_seq_id")
|
|
736
|
+
if not chains or not comps or not seq_ids:
|
|
737
|
+
return {}
|
|
738
|
+
by_chain: dict[str, dict[int, str]] = {}
|
|
739
|
+
for chain, comp, seq_id in zip(chains, comps, seq_ids, strict=False):
|
|
740
|
+
try:
|
|
741
|
+
index = int(seq_id)
|
|
742
|
+
except (TypeError, ValueError): # heteroatoms carry "." here
|
|
743
|
+
continue
|
|
744
|
+
by_chain.setdefault(str(chain), {})[index] = _AA3TO1.get(str(comp).upper(), "X")
|
|
745
|
+
return {
|
|
746
|
+
chain: "".join(residues[i] for i in sorted(residues))
|
|
747
|
+
for chain, residues in sorted(by_chain.items())
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def pdb_chain_ids(pdb_path: Path) -> list[str]:
|
|
752
|
+
"""Chain ids present in a PDB, in first-appearance order (CA atoms)."""
|
|
753
|
+
chains: list[str] = []
|
|
754
|
+
for line in Path(pdb_path).read_text(encoding="utf-8").splitlines():
|
|
755
|
+
if line.startswith("ATOM") and line[12:16].strip() == "CA":
|
|
756
|
+
ch = line[21]
|
|
757
|
+
if ch not in chains:
|
|
758
|
+
chains.append(ch)
|
|
759
|
+
return chains
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _target_likeness(seq: str, target_sequence: str, k: int = 6) -> float:
|
|
763
|
+
"""Fraction of ``seq``'s k-mers that also occur in ``target_sequence`` (0–1).
|
|
764
|
+
|
|
765
|
+
A k-mer profile rather than string equality, so a target chain that RFdiffusion
|
|
766
|
+
trimmed or renumbered still scores ~1 while a diffused (poly-glycine) binder
|
|
767
|
+
scores ~0.
|
|
768
|
+
"""
|
|
769
|
+
if not seq or not target_sequence:
|
|
770
|
+
return 0.0
|
|
771
|
+
if len(seq) < k:
|
|
772
|
+
return 1.0 if seq in target_sequence else 0.0
|
|
773
|
+
kmers = [seq[i : i + k] for i in range(len(seq) - k + 1)]
|
|
774
|
+
return sum(1 for km in kmers if km in target_sequence) / len(kmers)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
#: k-mer identity above which a backbone chain is considered part of the target.
|
|
778
|
+
_TARGET_LIKENESS_CUTOFF = 0.5
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def binder_chain_from_backbone(backbone_pdb: Path, *, target_sequence: str) -> str:
|
|
782
|
+
"""Chain id of the diffused binder in an RFdiffusion binder-design backbone.
|
|
783
|
+
|
|
784
|
+
RFdiffusion assigns output chain letters from the contig, so the binder's
|
|
785
|
+
letter is not the input structure's and must not be hard-coded. The target
|
|
786
|
+
block keeps its native residue identities while the diffused binder does not,
|
|
787
|
+
so the binder is identified as the chain that does not match the target.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
backbone_pdb: An RFdiffusion output backbone (target + binder complex).
|
|
791
|
+
target_sequence: The target sequence RFdiffusion was given — the
|
|
792
|
+
concatenation of the contig's kept target segments.
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
The binder's chain id.
|
|
796
|
+
|
|
797
|
+
Raises:
|
|
798
|
+
ValueError: When exactly one non-target chain cannot be identified.
|
|
799
|
+
Guessing here would hand ProteinMPNN the wrong chain to hold fixed,
|
|
800
|
+
which silently redesigns the antigen.
|
|
801
|
+
"""
|
|
802
|
+
chains = pdb_chain_ids(backbone_pdb)
|
|
803
|
+
scores = {
|
|
804
|
+
ch: _target_likeness(chain_sequence_from_pdb(backbone_pdb, ch), target_sequence)
|
|
805
|
+
for ch in chains
|
|
806
|
+
}
|
|
807
|
+
binders = [ch for ch, s in scores.items() if s < _TARGET_LIKENESS_CUTOFF]
|
|
808
|
+
targets = [ch for ch, s in scores.items() if s >= _TARGET_LIKENESS_CUTOFF]
|
|
809
|
+
if len(binders) != 1 or not targets:
|
|
810
|
+
raise ValueError(
|
|
811
|
+
f"cannot identify the binder chain in {backbone_pdb}: "
|
|
812
|
+
f"target-likeness per chain = { {c: round(s, 3) for c, s in scores.items()} }; "
|
|
813
|
+
"refusing to run ProteinMPNN without knowing which chain to hold fixed"
|
|
814
|
+
)
|
|
815
|
+
return binders[0]
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def parse_mpnn_fasta(fasta_path: Path) -> list[tuple[str, str]]:
|
|
819
|
+
"""Parse a ProteinMPNN output FASTA into ``[(header, sequence), ...]``.
|
|
820
|
+
|
|
821
|
+
ProteinMPNN writes the input/native sequence as the FIRST record (header
|
|
822
|
+
contains ``score=...`` for the original), then one record per sampled design
|
|
823
|
+
(header contains ``sample=...``). Sequences are returned verbatim; callers
|
|
824
|
+
that want only designs should skip the first (native) record. This parses by
|
|
825
|
+
record, not by line index, so it is robust to the multi-line layout.
|
|
826
|
+
"""
|
|
827
|
+
records: list[tuple[str, str]] = []
|
|
828
|
+
header: str | None = None
|
|
829
|
+
chunks: list[str] = []
|
|
830
|
+
for line in Path(fasta_path).read_text(encoding="utf-8").splitlines():
|
|
831
|
+
if line.startswith(">"):
|
|
832
|
+
if header is not None:
|
|
833
|
+
records.append((header, "".join(chunks)))
|
|
834
|
+
header, chunks = line[1:].strip(), []
|
|
835
|
+
elif header is not None:
|
|
836
|
+
chunks.append(line.strip())
|
|
837
|
+
if header is not None:
|
|
838
|
+
records.append((header, "".join(chunks)))
|
|
839
|
+
return records
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def mpnn_design_sequences(fasta_path: Path) -> list[str]:
|
|
843
|
+
"""Return only the *designed* binder sequences from a ProteinMPNN FASTA.
|
|
844
|
+
|
|
845
|
+
Skips the first (native/input) record. ProteinMPNN can emit multi-chain
|
|
846
|
+
sequences joined by ``/``; for binder design the binder is the last chain,
|
|
847
|
+
so we take the final ``/``-segment of each design.
|
|
848
|
+
"""
|
|
849
|
+
records = parse_mpnn_fasta(fasta_path)
|
|
850
|
+
designs = records[1:] if len(records) > 1 else records
|
|
851
|
+
return [seq.split("/")[-1] for _, seq in designs if seq]
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def parse_chai_output(output_dir: Path, *, binder_id: str, target_uniprot: str) -> ValidationResult:
|
|
855
|
+
"""Parse Chai-1 output scores into a ValidationResult (lazy NumPy import)."""
|
|
856
|
+
iptm = ptm = None
|
|
857
|
+
iptm_samples: list[float] = []
|
|
858
|
+
ptm_samples: list[float] = []
|
|
859
|
+
# Every draw, not the first. chai-lab writes one scores npz per diffusion
|
|
860
|
+
# sample — scores.model_idx_0.npz through model_idx_4.npz at its default of
|
|
861
|
+
# five — and this took whichever the directory walk yielded first. That is
|
|
862
|
+
# the same defect found and fixed in the Boltz-2 parser: a number that
|
|
863
|
+
# improves as more samples are bought, with no change to the design being
|
|
864
|
+
# scored. The mean estimates the centre of the distribution the model is
|
|
865
|
+
# sampling from; one draw estimates nothing.
|
|
866
|
+
for npz in sorted(Path(output_dir).rglob("scores*.npz")):
|
|
867
|
+
try:
|
|
868
|
+
import numpy as np
|
|
869
|
+
|
|
870
|
+
data = np.load(npz)
|
|
871
|
+
value = _to_float(data["iptm"]) if "iptm" in data else None
|
|
872
|
+
if value is not None:
|
|
873
|
+
iptm_samples.append(value)
|
|
874
|
+
value = _to_float(data["ptm"]) if "ptm" in data else None
|
|
875
|
+
if value is not None:
|
|
876
|
+
ptm_samples.append(value)
|
|
877
|
+
except Exception as e: # pragma: no cover - depends on chai output shape
|
|
878
|
+
LOG.warning("failed to parse chai npz %s: %s", npz, e)
|
|
879
|
+
if iptm_samples:
|
|
880
|
+
iptm = statistics.fmean(iptm_samples)
|
|
881
|
+
if ptm_samples:
|
|
882
|
+
ptm = statistics.fmean(ptm_samples)
|
|
883
|
+
result = ValidationResult(
|
|
884
|
+
binder_id=binder_id,
|
|
885
|
+
target_uniprot=target_uniprot,
|
|
886
|
+
iptm=iptm,
|
|
887
|
+
iptm_n_samples=len(iptm_samples) or None,
|
|
888
|
+
iptm_sd=statistics.stdev(iptm_samples) if len(iptm_samples) > 1 else None,
|
|
889
|
+
ptm=ptm,
|
|
890
|
+
pae_interaction=None,
|
|
891
|
+
# Chai-1 predicts no affinity. pTM is a structure-confidence score and
|
|
892
|
+
# belongs in ``ptm``; writing it here would make the ranker weight
|
|
893
|
+
# confidence a second time as though it were an orthogonal signal.
|
|
894
|
+
affinity_pred_value=None,
|
|
895
|
+
validator_name="chai1r",
|
|
896
|
+
# chai_lab is a pip distribution, so the installed version is
|
|
897
|
+
# readable. CHAI_PIP is still a range, which is why it is read
|
|
898
|
+
# rather than assumed.
|
|
899
|
+
validator_version=installed_version("chai_lab") or UNRECORDED_VERSION,
|
|
900
|
+
notes=f"parsed chai scores={len(iptm_samples)} sample(s)",
|
|
901
|
+
)
|
|
902
|
+
return note_unmeasured(
|
|
903
|
+
result,
|
|
904
|
+
reason=f"no scores*.npz under {output_dir} yielded an iptm or ptm value",
|
|
905
|
+
log=LOG,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def parse_af2ig_output(
|
|
910
|
+
score_file: Path, *, binder_id: str, target_uniprot: str
|
|
911
|
+
) -> ValidationResult:
|
|
912
|
+
"""Parse an AF2 initial-guess ``.sc`` score table into a ValidationResult."""
|
|
913
|
+
pae_interaction = plddt = None
|
|
914
|
+
sc = Path(score_file)
|
|
915
|
+
# Each way the file can fail to yield a metric, named. Returning None for
|
|
916
|
+
# all three without a reason made a missing file, a header-only file and a
|
|
917
|
+
# file with different column names indistinguishable from a real low score.
|
|
918
|
+
if not sc.exists():
|
|
919
|
+
reason = f"score file {sc} does not exist"
|
|
920
|
+
else:
|
|
921
|
+
lines = [ln.split() for ln in sc.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
922
|
+
if len(lines) < 2:
|
|
923
|
+
reason = f"score file {sc} has {len(lines)} line(s); expected a header and a row"
|
|
924
|
+
else:
|
|
925
|
+
header, row = lines[0], lines[1]
|
|
926
|
+
cols = dict(zip(header, row, strict=False))
|
|
927
|
+
pae_interaction = _to_float(cols.get("pae_interaction"))
|
|
928
|
+
plddt = _to_float(cols.get("plddt_binder"))
|
|
929
|
+
reason = (
|
|
930
|
+
f"score file {sc} has no readable pae_interaction or plddt_binder "
|
|
931
|
+
f"column (columns: {', '.join(header) or 'none'})"
|
|
932
|
+
)
|
|
933
|
+
result = ValidationResult(
|
|
934
|
+
binder_id=binder_id,
|
|
935
|
+
target_uniprot=target_uniprot,
|
|
936
|
+
iptm=None,
|
|
937
|
+
plddt_binder=plddt,
|
|
938
|
+
pae_interaction=pae_interaction,
|
|
939
|
+
# AF2 initial-guess predicts no affinity. ``plddt_binder`` is a 0-100
|
|
940
|
+
# confidence score, not a binding constant.
|
|
941
|
+
affinity_pred_value=None,
|
|
942
|
+
validator_name="af2_ig",
|
|
943
|
+
# Cloned from git at a pinned SHA rather than pip-installed, so
|
|
944
|
+
# there is no distribution to interrogate. The SHA is the better
|
|
945
|
+
# record anyway: it is content-addressed, so a clone either yields
|
|
946
|
+
# exactly that tree or fails, and request and receipt coincide.
|
|
947
|
+
validator_version=f"dl_binder_design@{DL_BINDER_DESIGN_COMMIT}",
|
|
948
|
+
notes="AF2 initial-guess (PyRosetta: non-commercial use only)",
|
|
949
|
+
)
|
|
950
|
+
return note_unmeasured(result, reason=reason, log=LOG)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _to_float(v: object) -> float | None:
|
|
954
|
+
if v is None:
|
|
955
|
+
return None
|
|
956
|
+
try:
|
|
957
|
+
return float(v) # type: ignore[arg-type]
|
|
958
|
+
except (TypeError, ValueError):
|
|
959
|
+
return None
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
# Re-export the canonical Boltz YAML builder + JSON parser so there is exactly
|
|
963
|
+
# one of each in the codebase.
|
|
964
|
+
__all__ = [
|
|
965
|
+
"binder_chain_from_backbone",
|
|
966
|
+
"build_af2ig_cmd",
|
|
967
|
+
"build_bindcraft_cmd",
|
|
968
|
+
"build_boltz_cmd",
|
|
969
|
+
"build_boltz_yaml",
|
|
970
|
+
"build_boltzgen_cmd",
|
|
971
|
+
"build_boltzgen_spec",
|
|
972
|
+
"build_chai_cmd",
|
|
973
|
+
"build_contig_str",
|
|
974
|
+
"build_hotspot_str",
|
|
975
|
+
"build_mpnn_cmd",
|
|
976
|
+
"build_ranges_contig_str",
|
|
977
|
+
"build_rfdiff_cmd",
|
|
978
|
+
"chain_residues_from_pdb",
|
|
979
|
+
"chain_sequence_from_pdb",
|
|
980
|
+
"label_indices_for_residues",
|
|
981
|
+
"mpnn_design_sequences",
|
|
982
|
+
"parse_af2ig_output",
|
|
983
|
+
"parse_boltz_output",
|
|
984
|
+
"parse_chai_output",
|
|
985
|
+
"parse_mpnn_fasta",
|
|
986
|
+
"pdb_chain_ids",
|
|
987
|
+
]
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def write_metrics_jsonl(metrics: list[dict[str, object]], path: Path) -> Path:
|
|
991
|
+
"""Write per-design metrics as JSONL (one ValidationResult-shaped row/line)."""
|
|
992
|
+
path = Path(path)
|
|
993
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
994
|
+
with path.open("w", encoding="utf-8") as f:
|
|
995
|
+
for m in metrics:
|
|
996
|
+
f.write(json.dumps(m) + "\n")
|
|
997
|
+
return path
|