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,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