bindsight 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
@@ -0,0 +1,1110 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Headless design+validation executor — the real GPU work, runnable anywhere.
4
+
5
+ This module is what actually runs RFdiffusion → ProteinMPNN → Boltz-2 (and the
6
+ BindCraft / BoltzGen / Chai-1 / AF2-IG alternatives) end-to-end on a CUDA
7
+ machine. It is invoked identically by:
8
+
9
+ - the **local/Docker runner** (`python -m bindsight.runners.job_exec spec.json out.tar.gz`),
10
+ - the **Modal runner** (the same function, executed inside a GPU container), and
11
+ - the **Kaggle runner** (the same module, run inside a pushed kernel).
12
+
13
+ It reads a :class:`bindsight.design.protocol.DesignSpec` JSON plus the
14
+ co-located target structure, runs the chosen designer + validator as
15
+ subprocesses (commands from :mod:`bindsight.runners.tools` — one source of
16
+ truth shared with the Colab notebook), and writes a ``results.tar.gz`` laid out
17
+ so the local orchestrator consumes it directly:
18
+
19
+ <work>/metrics.jsonl # one ValidationResult per line
20
+ <work>/validate/<binder_id>/confidence_*.json + affinity_*.json
21
+ <work>/design/<binder_id>.pdb + .fasta
22
+
23
+ No GPU libraries are imported at module load (torch/boltz live only inside the
24
+ subprocesses), so ``import bindsight`` stays clean on a CPU box. ``_run`` is the
25
+ single subprocess seam, monkeypatched in tests to exercise the dispatch + output
26
+ assembly without a GPU.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import os
34
+ import subprocess
35
+ import sys
36
+ import tarfile
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from bindsight.runners import tools
42
+
43
+ LOG = logging.getLogger(__name__)
44
+
45
+
46
+ @dataclass
47
+ class Design:
48
+ """One designed binder: id, sequence, and backbone PDB path."""
49
+
50
+ binder_id: str
51
+ sequence: str
52
+ pdb_path: Path
53
+
54
+
55
+ #: Wall clock for one external tool invocation, in seconds. Six hours is longer
56
+ #: than any single RFdiffusion / ProteinMPNN / Boltz-2 step this project has
57
+ #: measured, and shorter than a free Kaggle session -- so it bounds a hang
58
+ #: without cutting real work short. It exists because this is the one seam
59
+ #: through which those tools run on rented or quota-limited hardware, and it had
60
+ #: no timeout at all: a tool that stopped making progress burned the entire
61
+ #: session with nothing to stop it and nothing in the log to say so.
62
+ #:
63
+ #: Override with ``BINDSIGHT_TOOL_TIMEOUT`` for a genuinely longer job.
64
+ _TOOL_TIMEOUT_S = float(os.environ.get("BINDSIGHT_TOOL_TIMEOUT", 6 * 60 * 60))
65
+
66
+
67
+ def _run(cmd: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
68
+ """Run a subprocess, capturing output; raise on non-zero exit.
69
+
70
+ The single subprocess seam — monkeypatched in tests so the dispatch +
71
+ output-assembly logic is exercised without a GPU.
72
+ """
73
+ LOG.info("exec: %s", " ".join(cmd))
74
+ try:
75
+ proc = subprocess.run(
76
+ cmd,
77
+ cwd=cwd,
78
+ capture_output=True,
79
+ text=True,
80
+ check=False,
81
+ timeout=_TOOL_TIMEOUT_S,
82
+ )
83
+ except subprocess.TimeoutExpired as exc:
84
+ LOG.error("command exceeded %.0f s and was killed: %s", _TOOL_TIMEOUT_S, " ".join(cmd))
85
+ raise RuntimeError(
86
+ f"{cmd[0]} exceeded the {_TOOL_TIMEOUT_S:.0f}s tool timeout and was killed. "
87
+ "Raise BINDSIGHT_TOOL_TIMEOUT if the job genuinely needs longer."
88
+ ) from exc
89
+ if proc.returncode != 0:
90
+ LOG.error(
91
+ "command failed (%d): %s\n%s", proc.returncode, " ".join(cmd), proc.stderr[-2000:]
92
+ )
93
+ raise RuntimeError(f"{cmd[0]} failed with code {proc.returncode}")
94
+ return proc
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Tool setup (idempotent; no-ops when an image already ships the tools)
99
+ # ---------------------------------------------------------------------------
100
+ def _git_clone(repo: str, commit: str, dest: Path, *, submodules: bool = False) -> Path:
101
+ """Clone ``repo`` at ``commit`` into ``dest``, optionally with submodules.
102
+
103
+ ``submodules`` matters for dl_binder_design: its ``af2_initial_guess``
104
+ predict.py imports ``silent_tools`` at module scope, and that is a git
105
+ submodule. A plain clone leaves the directory empty, so the AF2
106
+ initial-guess validator fails on import rather than at a legible point.
107
+ """
108
+ if dest.exists():
109
+ return dest
110
+ _run(["git", "clone", "--quiet", repo, str(dest)])
111
+ _run(["git", "checkout", "--quiet", commit], cwd=dest)
112
+ if submodules:
113
+ _run(["git", "submodule", "update", "--init", "--recursive", "--quiet"], cwd=dest)
114
+ return dest
115
+
116
+
117
+ def _verify_checkpoint(path: Path, expected_sha256: str | None) -> str:
118
+ """Hash a downloaded checkpoint, log it, and fail on a pinned mismatch.
119
+
120
+ Always hashing, rather than only when a pin exists, is what makes pinning
121
+ possible at all: the first run publishes the digest into its own log, and
122
+ that is where the pinned value comes from. Inventing one locally would
123
+ certify whatever this machine downloaded.
124
+
125
+ Args:
126
+ path: the checkpoint on disk.
127
+ expected_sha256: the pinned digest, or None if none is established.
128
+
129
+ Returns:
130
+ The hex digest of the file.
131
+
132
+ Raises:
133
+ RuntimeError: if a pinned digest is present and does not match.
134
+ """
135
+ import hashlib
136
+
137
+ if not path.is_file():
138
+ # wget exiting 0 without producing a file is a silent failure. Before
139
+ # this check the run continued to RFdiffusion, which failed later with a
140
+ # message about the model rather than about the download.
141
+ raise RuntimeError(
142
+ f"{path.name} is missing after the download step. The fetch reported "
143
+ f"no error but produced no file at {path}. Re-run with network access, "
144
+ "or place the checkpoint there yourself."
145
+ )
146
+
147
+ h = hashlib.sha256()
148
+ with open(path, "rb") as fh:
149
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
150
+ h.update(chunk)
151
+ digest = h.hexdigest()
152
+ size = path.stat().st_size
153
+ LOG.info("checkpoint %s: %d bytes sha256=%s", path.name, size, digest)
154
+ if expected_sha256 and digest != expected_sha256:
155
+ raise RuntimeError(
156
+ f"{path.name}: sha256 {digest} does not match the pinned "
157
+ f"{expected_sha256}. Refusing to design against a checkpoint that is "
158
+ "not the one this result claims. Delete the file and re-fetch; if the "
159
+ "mismatch persists, upstream has republished and the pin needs review."
160
+ )
161
+ return digest
162
+
163
+
164
+ def _ensure_rfdiff_mpnn(tools_root: Path) -> tuple[Path, Path]:
165
+ rfdiff_dir = tools_root / "RFdiffusion"
166
+ fresh = not rfdiff_dir.exists()
167
+ rfdiff = _git_clone(tools.RFDIFF_REPO, tools.RFDIFF_COMMIT, rfdiff_dir)
168
+ weights = rfdiff / "models"
169
+ weights.mkdir(parents=True, exist_ok=True)
170
+ for name, url in tools.RFDIFF_WEIGHTS.items():
171
+ dst = weights / name
172
+ if not dst.exists():
173
+ _run(["wget", "-q", url, "-O", str(dst)])
174
+ _verify_checkpoint(dst, tools.RFDIFF_WEIGHT_SHA256.get(name))
175
+ if fresh:
176
+ # Install RFdiffusion's SE3-Transformer deps so the executor is
177
+ # self-sufficient on a *bare* GPU (headless Kaggle / a fresh box), not
178
+ # only inside the prebuilt Docker image. Mirrors the proven Colab
179
+ # install cell; no-ops when the image already ships an installed tree.
180
+ req = rfdiff / "env" / "SE3Transformer" / "requirements.txt"
181
+ if req.exists():
182
+ _run([sys.executable, "-m", "pip", "install", "-q", "-r", str(req)])
183
+ _run([sys.executable, "-m", "pip", "install", "-q", "-e", str(rfdiff)])
184
+ mpnn = _git_clone(tools.PROTEINMPNN_REPO, tools.PROTEINMPNN_COMMIT, tools_root / "ProteinMPNN")
185
+ return rfdiff, mpnn
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Identity + target sequence (shared by every designer and validator)
190
+ # ---------------------------------------------------------------------------
191
+ def _binder_id_prefix(spec: dict[str, Any]) -> str:
192
+ """Per-**site** namespace for binder ids.
193
+
194
+ RFdiffusion writes every trajectory under a fixed ``binder_*`` prefix, so
195
+ without a namespace two different jobs both produce ``binder_0_seq0``. That
196
+ collides in ``validated.parquet`` and, worse, makes one job's
197
+ ``validate/<binder_id>/`` overwrite another's on disk. ``binder_id`` is the
198
+ key the provenance chain is walked by, so it has to be unique across the
199
+ whole run.
200
+
201
+ The accession alone was not enough. Discovery emits **one epitopes row per
202
+ qualifying targetable site**, and ``_top_targets`` turns each row into its
203
+ own design job — so two sites of one receptor are two genuinely different
204
+ jobs, against different residues, producing different binders, and both
205
+ named ``P04626_binder_0_seq0``. The concatenated metrics then carry two
206
+ rows with one id and different ipTMs, and whichever job finished last owns
207
+ the directory. The cross-target case was fixed here; the cross-site case is
208
+ the same collision one level in.
209
+
210
+ The epitope is therefore part of the namespace, digested rather than spelled
211
+ out because a residue list does not fit in a filename. Sorted first, so the
212
+ same site always gives the same id however the residues were ordered.
213
+
214
+ A spec with **no** epitope residues keeps the bare accession. That is not a
215
+ special case for its own sake: whole-surface design is one namespace per
216
+ target by definition, and it keeps the ids of every already-published
217
+ artifact exactly as they are.
218
+ """
219
+ import hashlib
220
+
221
+ raw = str(spec.get("target_uniprot") or "").strip()
222
+ safe = "".join(ch if (ch.isalnum() or ch in "-_") else "_" for ch in raw)
223
+ prefix = safe or "target"
224
+
225
+ residues = spec.get("epitope_residues") or []
226
+ if not residues:
227
+ return prefix
228
+ chain = str(spec.get("epitope_chain") or "")
229
+ token = f"{chain}:{','.join(str(int(r)) for r in sorted(residues))}"
230
+ return f"{prefix}_s{hashlib.sha256(token.encode()).hexdigest()[:8]}"
231
+
232
+
233
+ def _target_sequence_for_design(spec: dict[str, Any], work: Path, chain: str) -> str:
234
+ """The target sequence the designer actually saw: the kept ranges only.
235
+
236
+ ``design_ranges`` carries the extracellular region discovery annotated from
237
+ UniProt topology, and the designer is given only that. Validating against the
238
+ full-length chain instead would score the binder against a surface it was
239
+ never designed for, so every validator must use this same sequence.
240
+ """
241
+ target_pdb = work / "target.pdb"
242
+ ranges = _target_ranges(spec, target_pdb, chain)
243
+ return "".join(
244
+ aa
245
+ for resi, aa in tools.chain_residues_from_pdb(target_pdb, chain)
246
+ if any(lo <= resi <= hi for lo, hi in ranges)
247
+ )
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Designers
252
+ # ---------------------------------------------------------------------------
253
+ def _design_rfdiff_mpnn(spec: dict[str, Any], work: Path, tools_root: Path) -> list[Design]:
254
+ rfdiff_dir, mpnn_dir = _ensure_rfdiff_mpnn(tools_root)
255
+ target_pdb = work / "target.pdb"
256
+ chain = spec.get("epitope_chain", "A")
257
+ residues = [int(r) for r in spec.get("epitope_residues", [])]
258
+ ranges = _target_ranges(spec, target_pdb, chain)
259
+ # The sequence RFdiffusion is given — the kept segments only, which is what
260
+ # the output backbone's target chain will carry, and what the validator
261
+ # must later score against.
262
+ target_seq = _target_sequence_for_design(spec, work, chain)
263
+
264
+ rfdiff_out = work / "rfdiff_out"
265
+ rfdiff_out.mkdir(parents=True, exist_ok=True)
266
+ cmd = tools.build_rfdiff_cmd(
267
+ rfdiff_dir=rfdiff_dir,
268
+ input_pdb=target_pdb,
269
+ output_prefix=rfdiff_out / "binder",
270
+ num_designs=int(spec.get("n_trajectories", 5)),
271
+ hotspot=tools.build_hotspot_str(chain, residues),
272
+ contig=tools.build_ranges_contig_str(
273
+ chain,
274
+ ranges,
275
+ int(spec.get("binder_length_min", 50)),
276
+ int(spec.get("binder_length_max", 100)),
277
+ ),
278
+ # The first stochastic stage, and until now the only one the configured
279
+ # seed never reached. A reproducibly folded sequence is no use when the
280
+ # backbone that produced it was an unseeded draw.
281
+ seed=int(spec.get("seed", 0)),
282
+ )
283
+ if not residues:
284
+ # whole-target design: drop the empty ppi.hotspot_res token
285
+ cmd = [a for a in cmd if not a.startswith("ppi.hotspot_res=")]
286
+ _run(cmd, cwd=rfdiff_dir)
287
+
288
+ designs: list[Design] = []
289
+ design_dir = work / "design"
290
+ design_dir.mkdir(parents=True, exist_ok=True)
291
+ for backbone in sorted(rfdiff_out.glob("binder_*.pdb")):
292
+ mpnn_out = work / "mpnn_out" / backbone.stem
293
+ mpnn_out.mkdir(parents=True, exist_ok=True)
294
+ # Design the binder chain only; the target is context ProteinMPNN must
295
+ # not rewrite (its default is to design every chain).
296
+ binder_chain = tools.binder_chain_from_backbone(backbone, target_sequence=target_seq)
297
+ _run(
298
+ tools.build_mpnn_cmd(
299
+ mpnn_dir=mpnn_dir,
300
+ pdb_path=backbone,
301
+ out_folder=mpnn_out,
302
+ designed_chains=[binder_chain],
303
+ seed=int(spec.get("seed", 0)),
304
+ )
305
+ )
306
+ fasta = next((mpnn_out / "seqs").glob("*.fa"), None)
307
+ if fasta is None:
308
+ continue
309
+ seqs = tools.mpnn_design_sequences(fasta)
310
+ for i, seq in enumerate(seqs):
311
+ binder_id = f"{_binder_id_prefix(spec)}_{backbone.stem}_seq{i}"
312
+ pdb_copy = design_dir / f"{binder_id}.pdb"
313
+ # Write the design, not the backbone it came from. Copying the
314
+ # backbone verbatim gave every sequence from a trajectory a
315
+ # byte-identical file under a different name, each carrying the
316
+ # residues diffusion emitted rather than the ones ProteinMPNN chose.
317
+ tools.write_designed_backbone(backbone, pdb_copy, chain=binder_chain, sequence=seq)
318
+ (design_dir / f"{binder_id}.fasta").write_text(
319
+ f">{binder_id}\n{seq}\n", encoding="utf-8", newline="\n"
320
+ )
321
+ designs.append(Design(binder_id=binder_id, sequence=seq, pdb_path=pdb_copy))
322
+ return designs
323
+
324
+
325
+ def _design_boltzgen(spec: dict[str, Any], work: Path, tools_root: Path) -> list[Design]:
326
+ """Run BoltzGen against the target and collect its ranked designs.
327
+
328
+ BoltzGen is driven by a design-specification YAML rather than command-line
329
+ hotspot flags, and it resolves file references relative to that YAML, so the
330
+ spec and the target structure are written into one directory together.
331
+ """
332
+ import yaml
333
+
334
+ _git_clone(tools.BOLTZGEN_REPO, tools.BOLTZGEN_COMMIT, tools_root / "boltzgen")
335
+ out = work / "boltzgen_out"
336
+ out.mkdir(parents=True, exist_ok=True)
337
+ chain = spec.get("epitope_chain", "A")
338
+ residues = [int(r) for r in spec.get("epitope_residues", [])]
339
+
340
+ spec_dir = work / "boltzgen_spec"
341
+ spec_dir.mkdir(parents=True, exist_ok=True)
342
+ target = _target_structure_for_design(spec, work, chain)
343
+ staged_target = spec_dir / "target.pdb"
344
+ staged_target.write_bytes(target.read_bytes())
345
+
346
+ # BoltzGen indexes residues by their canonical 1-based position in the chain,
347
+ # not by author numbering. Converting is the difference between binding the
348
+ # intended epitope and binding an arbitrary stretch of the receptor.
349
+ binding = tools.label_indices_for_residues(staged_target, chain, residues)
350
+ if residues and not binding:
351
+ LOG.warning(
352
+ "none of the %d epitope residue(s) are present in the target chain; "
353
+ "BoltzGen will design against the whole surface",
354
+ len(residues),
355
+ )
356
+
357
+ extra = spec.get("extra_params", {}) or {}
358
+ design_spec = spec_dir / "design_spec.yaml"
359
+ design_spec.write_text(
360
+ yaml.safe_dump(
361
+ tools.build_boltzgen_spec(
362
+ target_file=staged_target.name,
363
+ target_chain=chain,
364
+ binding_indices=binding,
365
+ binder_length_min=int(spec.get("binder_length_min", 50)),
366
+ binder_length_max=int(spec.get("binder_length_max", 100)),
367
+ ),
368
+ sort_keys=False,
369
+ ),
370
+ encoding="utf-8",
371
+ newline="\n",
372
+ )
373
+
374
+ n = int(spec.get("n_trajectories", 5))
375
+ _run(
376
+ tools.build_boltzgen_cmd(
377
+ design_spec=design_spec,
378
+ out_dir=out,
379
+ num_designs=n,
380
+ protocol=str(extra.get("boltzgen_protocol", "protein-anything")),
381
+ # Triton kernels need compute capability 8.0 or newer. Every free-tier
382
+ # GPU is older, and BoltzGen's "auto" default would try to enable them.
383
+ use_kernels=str(extra.get("boltzgen_use_kernels", "auto")),
384
+ budget=n,
385
+ diffusion_batch_size=1 if n < 10 else None,
386
+ )
387
+ )
388
+ return _collect_designs_from_dir(out, work, prefix="boltzgen", spec=spec)
389
+
390
+
391
+ def _design_bindcraft(spec: dict[str, Any], work: Path, tools_root: Path) -> list[Design]:
392
+ bindcraft = _git_clone(tools.BINDCRAFT_REPO, tools.BINDCRAFT_COMMIT, tools_root / "BindCraft")
393
+ out = work / "bindcraft_out"
394
+ out.mkdir(parents=True, exist_ok=True)
395
+ settings = work / "bindcraft_settings.json"
396
+ settings.write_text(
397
+ json.dumps(
398
+ {
399
+ "design_path": str(out),
400
+ "binder_name": spec.get("target_uniprot", "binder"),
401
+ "starting_pdb": str(
402
+ _target_structure_for_design(spec, work, spec.get("epitope_chain", "A"))
403
+ ),
404
+ "chains": spec.get("epitope_chain", "A"),
405
+ "target_hotspot_residues": ",".join(
406
+ str(r) for r in spec.get("epitope_residues", [])
407
+ ),
408
+ "lengths": [
409
+ int(spec.get("binder_length_min", 50)),
410
+ int(spec.get("binder_length_max", 100)),
411
+ ],
412
+ "number_of_final_designs": int(spec.get("n_trajectories", 5)),
413
+ }
414
+ ),
415
+ encoding="utf-8",
416
+ newline="\n",
417
+ )
418
+ _run(
419
+ tools.build_bindcraft_cmd(
420
+ bindcraft_dir=bindcraft,
421
+ settings_json=settings,
422
+ filters_json=bindcraft / "settings_filters" / "default_filters.json",
423
+ advanced_json=bindcraft / "settings_advanced" / "default_4stage_multimer.json",
424
+ ),
425
+ cwd=bindcraft,
426
+ )
427
+ return _collect_designs_from_dir(out, work, prefix="bindcraft", spec=spec)
428
+
429
+
430
+ _DESIGNERS = {
431
+ "rfdiff_mpnn": _design_rfdiff_mpnn,
432
+ "boltzgen": _design_boltzgen,
433
+ "bindcraft": _design_bindcraft,
434
+ }
435
+
436
+
437
+ #: Designers whose pinned upstream offers no way to set the random seed, with
438
+ #: the evidence for each. ``spec["seed"]`` is folded into the design cache key,
439
+ #: which is a promise that the same seed means the same work; for these two that
440
+ #: promise cannot be kept, and saying so here is the difference between a
441
+ #: documented limitation and a silent one.
442
+ #:
443
+ #: Checked against the pinned commits, not from memory:
444
+ #:
445
+ #: * **BindCraft** ``b971db42``: ``bindcraft.py`` line 91 draws its own seed with
446
+ #: ``np.random.randint(0, high=999999, ...)`` on every trajectory. There is no
447
+ #: ``--seed`` flag, and no seed field in ``settings_advanced/`` — and because
448
+ #: bindsight runs it as a subprocess, seeding numpy in this process cannot
449
+ #: reach it.
450
+ #: * **BoltzGen** ``a3149cf1``: ``src/boltzgen/cli/boltzgen.py`` contains no
451
+ #: occurrence of "seed" among its 30-odd options, and neither do the
452
+ #: ``design``/``fold``/``inverse_fold`` configs that ``--config`` overrides.
453
+ #:
454
+ #: Neither backend has been executed end to end (the README's runner table is the
455
+ #: authority), so no published number rests on this. It is the reproducibility
456
+ #: claim that has to stay accurate, not a result.
457
+ UNSEEDABLE_DESIGNERS = {
458
+ "bindcraft": (
459
+ "BindCraft draws its own seed per trajectory (bindcraft.py:91 at commit "
460
+ "b971db42) and exposes no flag or settings field to fix it"
461
+ ),
462
+ "boltzgen": (
463
+ "the boltzgen CLI at commit a3149cf1 has no --seed option and no seed "
464
+ "field in the configs --config overrides"
465
+ ),
466
+ }
467
+
468
+
469
+ def warn_if_unseeded(designer: str, seed: int) -> str | None:
470
+ """Warn, once per job, that ``seed`` will not reach ``designer``.
471
+
472
+ Returns the reason when the designer is unseedable, so callers that record
473
+ run metadata can carry it too; ``None`` when the seed is honoured.
474
+ """
475
+ reason = UNSEEDABLE_DESIGNERS.get(designer)
476
+ if reason is None:
477
+ return None
478
+ LOG.warning(
479
+ "designer %s ignores the configured seed (%s): %s. This run is not "
480
+ "reproducible from its seed; re-running it will produce different "
481
+ "binders even with an identical spec.",
482
+ designer,
483
+ seed,
484
+ reason,
485
+ )
486
+ return reason
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # Validators
491
+ # ---------------------------------------------------------------------------
492
+ def _validate_boltz2(
493
+ spec: dict[str, Any], designs: list[Design], work: Path
494
+ ) -> list[dict[str, Any]]:
495
+ target_seq = _target_sequence_for_design(spec, work, spec.get("epitope_chain", "A"))
496
+ boltz_root = work / "boltz_out"
497
+ validate_root = work / "validate"
498
+ metrics: list[dict[str, Any]] = []
499
+ for d in designs:
500
+ # predict_affinity=False: Boltz-2 affinity prediction is ligand-only
501
+ # ("Chain B is not a ligand!"), and bindsight designs *protein* binders —
502
+ # requesting it makes Boltz-2 skip the whole input. ipTM + PAE-interaction
503
+ # from the protein–protein structure prediction are the binder-quality
504
+ # metrics we want; affinity_pred_value stays None for protein binders.
505
+ yaml_spec = tools.build_boltz_yaml(
506
+ target_id="T",
507
+ target_sequence=target_seq,
508
+ binder_id=d.binder_id,
509
+ binder_sequence=d.sequence,
510
+ predict_affinity=False,
511
+ )
512
+ from bindsight.validate.boltz2 import write_boltz_yaml
513
+
514
+ yaml_path = boltz_root / f"{d.binder_id}.yaml"
515
+ write_boltz_yaml(yaml_spec, yaml_path)
516
+ out_dir = boltz_root / d.binder_id
517
+ # The configured seed reaches the designer at the top of this module and
518
+ # stopped there, so the stochastic half of the pipeline — the diffusion
519
+ # model that produces every published confidence number — ran unseeded.
520
+ # Derived per binder rather than shared: one seed across every design
521
+ # would correlate their draws, and each binder's number is read on its
522
+ # own. Stable across reruns because it comes from the id, not from
523
+ # enumeration order.
524
+ proc = _run(
525
+ tools.build_boltz_cmd(
526
+ yaml_path=yaml_path,
527
+ out_dir=out_dir,
528
+ seed=_binder_seed(int(spec.get("seed", 0)), d.binder_id),
529
+ diffusion_samples=int(spec.get("extra_params", {}).get("diffusion_samples", 1)),
530
+ max_parallel_samples=int(
531
+ spec.get("extra_params", {}).get("max_parallel_samples", 1)
532
+ ),
533
+ )
534
+ )
535
+ result = tools.parse_boltz_output(
536
+ output_dir=out_dir,
537
+ binder_id=d.binder_id,
538
+ target_uniprot=str(spec.get("target_uniprot", "")),
539
+ )
540
+ # Fill PAE-interaction from the predicted PAE matrix (mean inter-chain PAE)
541
+ # when Boltz's confidence JSON didn't carry it. Token order is target then
542
+ # binder (per the YAML), so the chain lengths split the matrix.
543
+ if result.pae_interaction is None:
544
+ pae_i = _boltz_pae_interaction(
545
+ out_dir, target_len=len(target_seq), binder_len=len(d.sequence)
546
+ )
547
+ if pae_i is not None:
548
+ result = result.model_copy(update={"pae_interaction": pae_i})
549
+ if result.iptm is None:
550
+ # Boltz-2 exits 0 even when it *skips* a bad input, so surface its own
551
+ # output here rather than recording a silent null.
552
+ LOG.warning(
553
+ "boltz2 produced no confidence for %s (no confidence_*.json under %s).\n"
554
+ "boltz stdout tail:\n%s\nboltz stderr tail:\n%s",
555
+ d.binder_id,
556
+ out_dir,
557
+ (proc.stdout or "")[-2000:],
558
+ (proc.stderr or "")[-2000:],
559
+ )
560
+ _stage_validate_outputs(out_dir, validate_root / d.binder_id)
561
+ metrics.append(result.model_dump())
562
+ return metrics
563
+
564
+
565
+ def _boltz_pae_interaction(out_dir: Path, *, target_len: int, binder_len: int) -> float | None:
566
+ """Mean inter-chain PAE (Å) from a Boltz-2 ``pae_*.npz``, or None if unavailable.
567
+
568
+ The PAE matrix is over all residue tokens in chain order (target, then binder),
569
+ so the off-diagonal blocks [target × binder] and [binder × target] are the
570
+ interface PAE — lower means a more confident interface.
571
+
572
+ Averaged over every diffusion draw, for the same reason ipTM is: Boltz ranks
573
+ its samples best-first, so taking whichever ``pae_*.npz`` turned up first
574
+ reported the most confident draw rather than a representative one, and that
575
+ number would improve with every extra sample bought.
576
+ """
577
+ import numpy as np
578
+
579
+ values: list[float] = []
580
+ for npz in sorted(Path(out_dir).rglob("pae_*.npz")):
581
+ try:
582
+ data = np.load(npz)
583
+ pae = data["pae"] if "pae" in data.files else data[data.files[0]]
584
+ n = target_len + binder_len
585
+ if pae.ndim != 2 or pae.shape != (n, n):
586
+ continue
587
+ t = target_len
588
+ inter = np.concatenate([pae[:t, t:].ravel(), pae[t:, :t].ravel()])
589
+ if inter.size:
590
+ values.append(float(inter.mean()))
591
+ except Exception as e: # a malformed npz must not abort the job
592
+ LOG.warning("failed to compute PAE-interaction from %s: %s", npz, e)
593
+ return sum(values) / len(values) if values else None
594
+
595
+
596
+ def _validate_chai1r(
597
+ spec: dict[str, Any], designs: list[Design], work: Path
598
+ ) -> list[dict[str, Any]]:
599
+ target_seq = _target_sequence_for_design(spec, work, spec.get("epitope_chain", "A"))
600
+ chai_root = work / "chai_out"
601
+ metrics: list[dict[str, Any]] = []
602
+ for d in designs:
603
+ fasta = chai_root / f"{d.binder_id}.fasta"
604
+ fasta.parent.mkdir(parents=True, exist_ok=True)
605
+ fasta.write_text(
606
+ f">protein|T\n{target_seq}\n>protein|{d.binder_id}\n{d.sequence}\n",
607
+ encoding="utf-8",
608
+ newline="\n",
609
+ )
610
+ out_dir = chai_root / d.binder_id
611
+ _run(
612
+ tools.build_chai_cmd(
613
+ fasta_path=fasta,
614
+ out_dir=out_dir,
615
+ # Per binder, from the run's seed — the same derivation the
616
+ # Boltz-2 path uses, so the two validators are reproducible in
617
+ # the same way rather than one of them by accident.
618
+ seed=_binder_seed(int(spec.get("seed", 0)), d.binder_id),
619
+ )
620
+ )
621
+ result = tools.parse_chai_output(
622
+ out_dir, binder_id=d.binder_id, target_uniprot=str(spec.get("target_uniprot", ""))
623
+ )
624
+ metrics.append(result.model_dump())
625
+ return metrics
626
+
627
+
628
+ def _validate_af2_ig(
629
+ spec: dict[str, Any], designs: list[Design], work: Path
630
+ ) -> list[dict[str, Any]]:
631
+ tools_root = work / "_tools"
632
+ dl = _git_clone(
633
+ tools.DL_BINDER_DESIGN_REPO,
634
+ tools.DL_BINDER_DESIGN_COMMIT,
635
+ tools_root / "dl_binder_design",
636
+ # predict.py does `from silent_tools import silent_tools` at import time,
637
+ # and silent_tools is a submodule.
638
+ submodules=True,
639
+ )
640
+ af2_root = work / "af2_out"
641
+ metrics: list[dict[str, Any]] = []
642
+ for d in designs:
643
+ out_dir = af2_root / d.binder_id
644
+ out_dir.mkdir(parents=True, exist_ok=True)
645
+ _run(
646
+ tools.build_af2ig_cmd(
647
+ dl_binder_design_dir=dl, silent_or_pdb=d.pdb_path, out_dir=out_dir
648
+ )
649
+ )
650
+ result = tools.parse_af2ig_output(
651
+ out_dir / "af2_scores.sc",
652
+ binder_id=d.binder_id,
653
+ target_uniprot=str(spec.get("target_uniprot", "")),
654
+ )
655
+ metrics.append(result.model_dump())
656
+ return metrics
657
+
658
+
659
+ _VALIDATORS = {
660
+ "boltz2": _validate_boltz2,
661
+ "chai1r": _validate_chai1r,
662
+ "af2_ig": _validate_af2_ig,
663
+ }
664
+
665
+
666
+ # ---------------------------------------------------------------------------
667
+ # Helpers
668
+ # ---------------------------------------------------------------------------
669
+ def _target_ranges(spec: dict[str, Any], target_pdb: Path, chain: str) -> list[tuple[int, int]]:
670
+ """Target residue ranges to present to the designer, clipped to the structure.
671
+
672
+ ``spec['design_ranges']`` carries the extracellular ranges discovery annotated
673
+ from UniProt topology. Absent (or empty) means no topology was available, and
674
+ the whole chain — transmembrane helix and cytoplasmic tail included — becomes
675
+ the design surface; that is recorded loudly rather than assumed.
676
+
677
+ Args:
678
+ spec: The design spec dict.
679
+ target_pdb: The materialised target structure.
680
+ chain: The target chain id.
681
+
682
+ Returns:
683
+ Inclusive ``(lo, hi)`` ranges in the structure's numbering.
684
+
685
+ Raises:
686
+ ValueError: When ``design_ranges`` is given but overlaps no modelled residue.
687
+ """
688
+ lo, hi = _chain_span(target_pdb, chain)
689
+ raw = spec.get("design_ranges") or []
690
+ if not raw:
691
+ LOG.warning(
692
+ "no design_ranges in the spec: designing against the full length of chain %s "
693
+ "(%d-%d), including any transmembrane and cytoplasmic regions",
694
+ chain,
695
+ lo,
696
+ hi,
697
+ )
698
+ return [(lo, hi)]
699
+ clipped = [
700
+ (max(int(r[0]), lo), min(int(r[1]), hi)) for r in raw if int(r[0]) <= hi and int(r[1]) >= lo
701
+ ]
702
+ if not clipped:
703
+ raise ValueError(
704
+ f"design_ranges {[list(r) for r in raw]} do not overlap chain {chain} "
705
+ f"residues {lo}-{hi} of {target_pdb}"
706
+ )
707
+ LOG.info(
708
+ "designing against %d extracellular range(s) of chain %s: %s", len(clipped), chain, clipped
709
+ )
710
+ return clipped
711
+
712
+
713
+ def _target_structure_for_design(spec: dict[str, Any], work: Path, chain: str) -> Path:
714
+ """Target structure trimmed to the spec's ``design_ranges``.
715
+
716
+ The one-shot designers take a structure rather than a contig, so the ranges
717
+ are applied by writing a trimmed copy. Returns ``target.pdb`` unchanged when
718
+ the ranges already span the whole chain.
719
+ """
720
+ target_pdb = work / "target.pdb"
721
+ ranges = _target_ranges(spec, target_pdb, chain)
722
+ if ranges == [_chain_span(target_pdb, chain)]:
723
+ return target_pdb
724
+ kept: list[str] = []
725
+ for line in target_pdb.read_text(encoding="utf-8").splitlines():
726
+ if line.startswith(("ATOM", "HETATM")) and line[21] == chain:
727
+ try:
728
+ resi = int(line[22:26])
729
+ except ValueError:
730
+ continue
731
+ if not any(lo <= resi <= hi for lo, hi in ranges):
732
+ continue
733
+ kept.append(line)
734
+ trimmed = work / "target_design_region.pdb"
735
+ trimmed.write_text("\n".join(kept) + "\n", encoding="utf-8", newline="\n")
736
+ return trimmed
737
+
738
+
739
+ def _chain_span(pdb_path: Path, chain: str) -> tuple[int, int]:
740
+ """Return (min, max) residue number for a chain in a PDB (default 1, 9999)."""
741
+ nums: list[int] = []
742
+ if pdb_path.exists():
743
+ for line in pdb_path.read_text(encoding="utf-8").splitlines():
744
+ if line.startswith(("ATOM", "HETATM")) and line[21] == chain:
745
+ try:
746
+ nums.append(int(line[22:26]))
747
+ except ValueError:
748
+ continue
749
+ return (min(nums), max(nums)) if nums else (1, 9999)
750
+
751
+
752
+ def _binder_seed(run_seed: int, binder_id: str) -> int:
753
+ """A per-binder RNG seed, derived from the run's seed and the binder's id.
754
+
755
+ Two properties matter. It must be **stable**: the same run seed and the same
756
+ binder must give the same draw on a rerun, which rules out anything derived
757
+ from enumeration order or from a counter. And it must **differ per binder**:
758
+ handing every design the same seed would make their draws share a random
759
+ state, and these numbers are read one design at a time.
760
+
761
+ Args:
762
+ run_seed: the spec's seed.
763
+ binder_id: the design's identifier.
764
+
765
+ Returns:
766
+ A non-negative seed inside the 32-bit range Lightning accepts.
767
+ """
768
+ import hashlib
769
+
770
+ digest = hashlib.sha256(f"{run_seed}:{binder_id}".encode()).digest()
771
+ return int.from_bytes(digest[:4], "big")
772
+
773
+
774
+ def load_existing_designs(work: Path) -> list[Design]:
775
+ """Reconstruct designs from a staged ``<work>/design/`` directory.
776
+
777
+ Validation-only jobs ship the designs a previous run produced rather than
778
+ generating new ones, so a user can point a different validator at the same
779
+ binders instead of paying to redesign them. Each design is a ``<id>.fasta``
780
+ with its ``<id>.pdb`` backbone alongside; a FASTA with no backbone is
781
+ skipped and named, because a validator that needs the structure would
782
+ otherwise fail obscurely.
783
+ """
784
+ design_dir = work / "design"
785
+ designs: list[Design] = []
786
+ if not design_dir.is_dir():
787
+ return designs
788
+ for fasta in sorted(design_dir.glob("*.fasta")):
789
+ binder_id = fasta.stem
790
+ seq = "".join(
791
+ ln.strip()
792
+ for ln in fasta.read_text(encoding="utf-8").splitlines()
793
+ if not ln.startswith(">")
794
+ )
795
+ pdb = design_dir / f"{binder_id}.pdb"
796
+ if not seq:
797
+ LOG.warning("skipping %s: no sequence", fasta.name)
798
+ continue
799
+ if not pdb.exists():
800
+ LOG.warning("skipping %s: no backbone PDB alongside it", fasta.name)
801
+ continue
802
+ designs.append(Design(binder_id=binder_id, sequence=seq, pdb_path=pdb))
803
+ LOG.info("loaded %d existing design(s) from %s", len(designs), design_dir)
804
+ return designs
805
+
806
+
807
+ def _collect_designs_from_dir(
808
+ out: Path, work: Path, *, prefix: str, spec: dict[str, Any] | None = None
809
+ ) -> list[Design]:
810
+ """Collect (pdb + sequence) designs a one-shot designer wrote to ``out``.
811
+
812
+ ``spec`` supplies the per-target namespace; see :func:`_binder_id_prefix`.
813
+ """
814
+ design_dir = work / "design"
815
+ design_dir.mkdir(parents=True, exist_ok=True)
816
+ target = _binder_id_prefix(spec or {})
817
+ designs: list[Design] = []
818
+ for i, structure in enumerate(_designer_output_structures(out)):
819
+ binder_id = f"{target}_{prefix}_{i}"
820
+ pdb_copy = design_dir / f"{binder_id}.pdb"
821
+ if structure.suffix.lower() in {".cif", ".mmcif"}:
822
+ # BoltzGen writes mmCIF; the rest of the pipeline expects PDB, so
823
+ # normalise here rather than at every downstream reader.
824
+ try:
825
+ _cif_to_pdb(structure, pdb_copy)
826
+ except Exception as e:
827
+ LOG.warning("could not convert %s to PDB: %s", structure, e)
828
+ continue
829
+ else:
830
+ pdb_copy.write_bytes(structure.read_bytes())
831
+ seq = tools.chain_sequence_from_pdb(pdb_copy, _last_chain(pdb_copy))
832
+ if not seq:
833
+ LOG.warning("no chain sequence recovered from %s; skipping", structure)
834
+ pdb_copy.unlink(missing_ok=True)
835
+ continue
836
+ (design_dir / f"{binder_id}.fasta").write_text(
837
+ f">{binder_id}\n{seq}\n", encoding="utf-8", newline="\n"
838
+ )
839
+ designs.append(Design(binder_id=binder_id, sequence=seq, pdb_path=pdb_copy))
840
+ return designs
841
+
842
+
843
+ def _designer_output_structures(out: Path) -> list[Path]:
844
+ """Structures a one-shot designer produced, most-refined stage first.
845
+
846
+ BoltzGen lays its output out in stages and only the last one is filtered
847
+ and ranked, so globbing the whole output root would mix ranked final
848
+ designs with unfiltered intermediates. The preference order below follows
849
+ the upstream pipeline; anything unrecognised falls back to a plain
850
+ recursive search so a different designer still collects.
851
+ """
852
+ preferred = (
853
+ "final_ranked_designs",
854
+ "intermediate_designs_inverse_folded/refold_design_cif",
855
+ "intermediate_designs_inverse_folded",
856
+ "intermediate_designs",
857
+ )
858
+ for rel in preferred:
859
+ sub = out / Path(rel)
860
+ if sub.is_dir():
861
+ found = sorted(p for p in sub.rglob("*") if p.suffix.lower() in {".cif", ".pdb"})
862
+ if found:
863
+ return found
864
+ return sorted(p for p in out.rglob("*") if p.suffix.lower() in {".cif", ".pdb"})
865
+
866
+
867
+ def _last_chain(pdb_path: Path) -> str:
868
+ chains = [
869
+ line[21]
870
+ for line in pdb_path.read_text(encoding="utf-8").splitlines()
871
+ if line.startswith("ATOM")
872
+ ]
873
+ return chains[-1] if chains else "A"
874
+
875
+
876
+ def _stage_validate_outputs(src_dir: Path, dst_dir: Path) -> None:
877
+ """Copy Boltz outputs into <work>/validate/<binder_id>/ for the results tarball.
878
+
879
+ Retains not just the confidence/affinity JSONs but the **predicted complex
880
+ structure** (``*_model_0.cif``/``.pdb``) and the PAE / pLDDT arrays — the real,
881
+ inspectable folded binder–target complex and its per-residue confidence. (The
882
+ earlier version kept only the JSONs, so the structures were lost.)
883
+ """
884
+ dst_dir.mkdir(parents=True, exist_ok=True)
885
+ patterns = (
886
+ "confidence_*.json",
887
+ "affinity_*.json",
888
+ "*_model_0.cif",
889
+ "*_model_0.pdb",
890
+ "pae_*.npz",
891
+ "plddt_*.npz",
892
+ )
893
+ for pattern in patterns:
894
+ for path in src_dir.rglob(pattern):
895
+ (dst_dir / path.name).write_bytes(path.read_bytes())
896
+
897
+
898
+ def _apply_prescreen(
899
+ spec: dict[str, Any], designs: list[Design]
900
+ ) -> tuple[list[Design], str | None]:
901
+ """Screen designs before validation, if the spec asks for it.
902
+
903
+ Args:
904
+ spec: The design spec; ``extra_params['prescreen_top_k']`` opts in.
905
+ designs: Designs produced by the designer, in design order.
906
+
907
+ Returns:
908
+ ``(designs_to_validate, note)`` where ``note`` is a provenance-ready
909
+ summary, or ``None`` when no screen was requested.
910
+ """
911
+ raw = spec.get("extra_params", {}).get("prescreen_top_k")
912
+ if raw in (None, "", 0, "0"):
913
+ return designs, None
914
+ try:
915
+ top_k = int(raw)
916
+ except (TypeError, ValueError):
917
+ LOG.warning("ignoring non-integer prescreen_top_k=%r", raw)
918
+ return designs, None
919
+
920
+ from bindsight.design.prescreen import prescreen_report, select_representative
921
+
922
+ result = select_representative([d.sequence for d in designs], top_k)
923
+ note = prescreen_report(result, [d.binder_id for d in designs])
924
+ LOG.info("%s", note)
925
+ if not result.applied:
926
+ return designs, note
927
+ return [designs[i] for i in result.kept], note
928
+
929
+
930
+ def run_job(spec: dict[str, Any], work_dir: Path, *, tarball: Path | None = None) -> Path:
931
+ """Run design + validation for one spec; write the results tarball.
932
+
933
+ Args:
934
+ spec: a ``DesignSpec.model_dump()`` dict (with the target structure
935
+ materialised next to it, see :func:`materialise_target`).
936
+ work_dir: scratch dir; outputs are assembled here then tarred.
937
+ tarball: output ``.tar.gz`` path (default ``<work_dir>.tar.gz``).
938
+
939
+ Returns:
940
+ Path to the results tarball.
941
+ """
942
+ work_dir = Path(work_dir)
943
+ work_dir.mkdir(parents=True, exist_ok=True)
944
+ # A pre-provisioned image/notebook can install the design tools once and
945
+ # point here via BINDSIGHT_TOOLS_ROOT so we reuse them instead of re-cloning.
946
+ tools_root = Path(os.environ.get("BINDSIGHT_TOOLS_ROOT") or (work_dir / "_tools"))
947
+
948
+ designer = str(spec.get("extra_params", {}).get("designer") or "rfdiff_mpnn")
949
+ validator = str(spec.get("extra_params", {}).get("validator") or "boltz2")
950
+ mode = str(spec.get("extra_params", {}).get("mode") or "design_and_validate")
951
+ if mode not in {"design_and_validate", "validate_only"}:
952
+ raise ValueError(f"unknown mode: {mode}")
953
+ if mode == "design_and_validate" and designer not in _DESIGNERS:
954
+ raise ValueError(f"unknown designer: {designer}")
955
+ if validator not in _VALIDATORS:
956
+ raise ValueError(f"unknown validator: {validator}")
957
+
958
+ LOG.info(
959
+ "job: mode=%s designer=%s validator=%s target=%s",
960
+ mode,
961
+ designer if mode == "design_and_validate" else "-",
962
+ validator,
963
+ spec.get("target_uniprot"),
964
+ )
965
+ prescreen_note: str | None = None
966
+ if mode == "validate_only":
967
+ # Re-validating binders that already exist: no designer runs, so the
968
+ # pre-screen is skipped too (it exists to cut GPU spend before
969
+ # validation, and here the caller has explicitly chosen what to validate).
970
+ designs = load_existing_designs(work_dir)
971
+ if not designs:
972
+ raise ValueError(
973
+ f"validate_only: no staged designs under {work_dir / 'design'}; "
974
+ "run `bindsight design` first, or ship the design directory with the spec"
975
+ )
976
+ else:
977
+ warn_if_unseeded(designer, int(spec.get("seed", 0)))
978
+ designs = _DESIGNERS[designer](spec, work_dir, tools_root)
979
+ LOG.info("designer produced %d designs", len(designs))
980
+ if not designs:
981
+ # The validate_only branch already refuses an empty set. This one
982
+ # did not, so a designer that wrote nothing — a bad contig, an OOM,
983
+ # a tool exiting 0 after failing — produced an empty metrics.jsonl,
984
+ # a valid tarball and a zero exit, and the caller recorded a
985
+ # completed design stage. A run that designed nothing is not a run
986
+ # that found nothing worth keeping.
987
+ raise ValueError(
988
+ f"{designer} produced no designs. The job cannot be reported as "
989
+ "completed: an empty result here is a failed designer, not a "
990
+ "negative finding. Check the designer's output above for the "
991
+ "cause (contig, hotspots, or a tool that exited 0 without "
992
+ "writing)."
993
+ )
994
+
995
+ # ESM-2 pre-screen, between design and validation — the only point where
996
+ # dropping a design actually saves GPU time. Off unless prescreen_top_k is
997
+ # set, and it keeps everything if the optional `embed` extra is absent.
998
+ designs, prescreen_note = _apply_prescreen(spec, designs)
999
+
1000
+ metrics = _VALIDATORS[validator](spec, designs, work_dir)
1001
+ # Written unconditionally. A missing file used to mean either "the screen
1002
+ # ran and had nothing to report" or "the screen was never reached", and a
1003
+ # reader could not tell which.
1004
+ (work_dir / "prescreen.txt").write_text(
1005
+ (prescreen_note or "pre-screen not requested (no prescreen_top_k)") + "\n",
1006
+ encoding="utf-8",
1007
+ newline="\n",
1008
+ )
1009
+
1010
+ tools.write_metrics_jsonl(metrics, work_dir / "metrics.jsonl")
1011
+
1012
+ out_tar = Path(tarball) if tarball else work_dir.with_suffix(".tar.gz")
1013
+ out_tar.parent.mkdir(parents=True, exist_ok=True)
1014
+ with tarfile.open(out_tar, "w:gz", encoding="utf-8") as tf:
1015
+ # ``prescreen.txt`` is in this list because it is the only record that
1016
+ # the ESM-2 screen did not run. The screen fails open by design -- losing
1017
+ # an already-successful GPU run would be worse -- but ``prescreen_top_k``
1018
+ # is part of the cache key, so a job whose embedding died is cached under
1019
+ # a key asserting it kept the top k, and the next run with a working
1020
+ # embedder is served the unscreened set. Without this file leaving the
1021
+ # host, nothing anywhere said which of the two happened.
1022
+ for sub in ("design", "validate", "metrics.jsonl", "prescreen.txt"):
1023
+ p = work_dir / sub
1024
+ if p.exists():
1025
+ tf.add(p, arcname=sub)
1026
+ LOG.info("wrote %s", out_tar)
1027
+ return out_tar
1028
+
1029
+
1030
+ def materialise_target(spec: dict[str, Any], spec_dir: Path, work_dir: Path) -> None:
1031
+ """Copy the target structure referenced by the spec into ``work_dir/target.pdb``.
1032
+
1033
+ The designer's ``submit`` writes the structure next to the spec JSON and
1034
+ records its filename in ``extra_params['target_structure_name']``; this
1035
+ reconstructs it on the remote/work side (the structure is never embedded in
1036
+ the spec).
1037
+ """
1038
+ name = spec.get("extra_params", {}).get("target_structure_name")
1039
+ src = (spec_dir / name) if name else Path(spec.get("target_structure_path", ""))
1040
+ work_dir.mkdir(parents=True, exist_ok=True)
1041
+ dst = work_dir / "target.pdb"
1042
+ if not src or not Path(src).exists():
1043
+ # Raise rather than warn-and-continue. This logged that the designer
1044
+ # "will fail without it" and then let the job run anyway, so the GPU
1045
+ # session was spent reaching an unrelated-looking error somewhere
1046
+ # downstream instead of stopping here, where the cause is known.
1047
+ raise FileNotFoundError(
1048
+ f"target structure not found: {src!r}. It is resolved from "
1049
+ "extra_params['target_structure_name'] relative to the spec "
1050
+ "directory, then from target_structure_path. Re-run "
1051
+ "`bindsight design` so the structure ships with the spec, or place "
1052
+ "the file at that path before submitting."
1053
+ )
1054
+ src = Path(src)
1055
+ if src.suffix.lower() in {".cif", ".mmcif"}:
1056
+ _cif_to_pdb(src, dst)
1057
+ else:
1058
+ dst.write_bytes(src.read_bytes())
1059
+
1060
+
1061
+ def materialise_designs(spec_dir: Path, work_dir: Path) -> int:
1062
+ """Copy a ``design/`` directory shipped next to the spec into ``work_dir``.
1063
+
1064
+ Validation-only jobs carry the binders to validate alongside the spec, the
1065
+ same way ``materialise_target`` carries the receptor. Returns how many
1066
+ design files were staged.
1067
+ """
1068
+ src = Path(spec_dir) / "design"
1069
+ if not src.is_dir():
1070
+ return 0
1071
+ dst = Path(work_dir) / "design"
1072
+ dst.mkdir(parents=True, exist_ok=True)
1073
+ n = 0
1074
+ for f in sorted(src.iterdir()):
1075
+ if f.is_file():
1076
+ (dst / f.name).write_bytes(f.read_bytes())
1077
+ n += 1
1078
+ LOG.info("staged %d design file(s) from %s", n, src)
1079
+ return n
1080
+
1081
+
1082
+ def _cif_to_pdb(cif_path: Path, pdb_path: Path) -> None:
1083
+ """Convert an mmCIF (e.g. AlphaFoldDB) to PDB; RFdiffusion needs PDB input."""
1084
+ from Bio.PDB import PDBIO, MMCIFParser # lazy: biopython only on the GPU side
1085
+
1086
+ parser = MMCIFParser(QUIET=True)
1087
+ structure = parser.get_structure("target", str(cif_path))
1088
+ io = PDBIO()
1089
+ io.set_structure(structure)
1090
+ io.save(str(pdb_path))
1091
+
1092
+
1093
+ def main(argv: list[str] | None = None) -> int:
1094
+ """CLI entry: ``python -m bindsight.runners.job_exec <spec.json> <out.tar.gz>``."""
1095
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
1096
+ args = list(sys.argv[1:] if argv is None else argv)
1097
+ if len(args) < 2:
1098
+ print("usage: job_exec <spec.json> <out.tar.gz>", file=sys.stderr)
1099
+ return 2
1100
+ spec_path, out_tar = Path(args[0]), Path(args[1])
1101
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
1102
+ work_dir = out_tar.parent / (out_tar.stem.replace(".tar", "") + "_work")
1103
+ materialise_target(spec, spec_path.parent, work_dir)
1104
+ materialise_designs(spec_path.parent, work_dir)
1105
+ run_job(spec, work_dir, tarball=out_tar)
1106
+ return 0
1107
+
1108
+
1109
+ if __name__ == "__main__": # pragma: no cover
1110
+ raise SystemExit(main())