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,899 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Three-way designer benchmark: RFdiffusion+ProteinMPNN vs BindCraft vs BoltzGen.
4
+
5
+ Runs each designer over the *same* target set with the *same* validator (Boltz-2
6
+ by default) on a chosen GPU backend, then tabulates per-designer binder quality
7
+ (ipTM, PAE-interaction, predicted affinity, success rate). The comparison the
8
+ v0.2 validation paper promises.
9
+
10
+ The real three-way comparison needs a GPU — RFdiffusion, BindCraft and BoltzGen
11
+ do not run on CPU — so this module is **CPU-tested with the mock backend** and
12
+ **runs for real on ``--backend modal|local_docker|kaggle``**. With the mock
13
+ backend the numbers are clearly labelled synthetic (CI/orchestration only) and
14
+ are never written as if they were real results.
15
+
16
+ Reuses the production plugin stack verbatim: :mod:`bindsight.plugins` to resolve
17
+ designers/validators/runners, each designer's ``make_spec``/``submit``, and
18
+ :mod:`bindsight.cost` for the GPU-cost estimate — so a green mock run is a
19
+ faithful dry-run of the real GPU job.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import statistics
27
+ import tempfile
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from bindsight import __version__
34
+ from bindsight import cost as cost_mod
35
+ from bindsight.benchmark.statistics import cluster_bootstrap_interval, wilson_interval
36
+ from bindsight.plugins import get_designer, get_runner, runner_accepts
37
+
38
+ LOG = logging.getLogger(__name__)
39
+
40
+ DEFAULT_DESIGNERS = ("rfdiff_mpnn", "bindcraft", "boltzgen")
41
+ DEFAULT_IPTM_SUCCESS = 0.65
42
+
43
+ #: Withdrawal notice attached to every rendering of success@0.65.
44
+ #:
45
+ #: The threshold had no citation, so the project measured what it is worth
46
+ #: (``benchmarks/calibration``). The answer is that it does not currently
47
+ #: separate a design from a shuffle of itself, which makes the rate above a
48
+ #: measurement of the pipeline rather than of the designs. The number is not
49
+ #: deleted — it is what Boltz-2 returned, and deleting it would hide the
50
+ #: finding rather than state it — but it must not travel without this.
51
+ #:
52
+ #: One string, referenced by every surface that renders the rate, so the caveat
53
+ #: cannot survive in one place and go stale in another. A test fails if a
54
+ #: document states the rate without it.
55
+ #: The designer-benchmark summary schema. Named because two writers emit it —
56
+ #: this module and benchmarks/designer_benchmark/score_run.py — and a bumped
57
+ #: version in one would silently disagree with the other.
58
+ DESIGNER_BENCHMARK_SCHEMA = "bindsight-designer-benchmark/1"
59
+
60
+ IPTM_CALIBRATION_CAVEAT = (
61
+ "> **This success rate is withdrawn as a measure of design quality.** "
62
+ "A paired control folded each of these twenty designs in one job alongside "
63
+ "a shuffle of its own sequence — same length, same amino-acid composition, "
64
+ "same target, same validator, same card, same session. Under a seeded "
65
+ "validator averaging five diffusion draws per binder, the **shuffles pass "
66
+ "more often than the designs**: 50% of shuffles clear 0.65 against 30% of "
67
+ "designs. The paired difference is −0.043 (95% CI −0.142 to +0.054, exact "
68
+ "sign-flip p = 0.40), and 9 of 20 designs beat their own shuffle where 10 "
69
+ "is chance.\n"
70
+ ">\n"
71
+ "> Two causes were found in the pipeline and fixed — the validator and the "
72
+ "designer were both invoked without a seed, so every number in this table "
73
+ "is a single unseeded draw of a sequence that was itself an unseeded draw. "
74
+ "Refolding these twenty sequences moves ipTM by a median of 0.172, against "
75
+ "a measured per-draw spread of 0.139. But the fixes did not rescue the "
76
+ "result: five times the sampling effort moved it slightly further against "
77
+ "the designs, and 85% of the remaining spread is real pair-to-pair "
78
+ "variation that no amount of resampling reduces.\n"
79
+ ">\n"
80
+ "> The numbers in this table are real Boltz-2 outputs. What is withdrawn is "
81
+ "the claim that the rate measures the designs. See "
82
+ "`benchmarks/calibration/README.md`.\n"
83
+ )
84
+
85
+ # Minimal valid PDB so the mock/dry-run path has a structure to ship without a
86
+ # network fetch. Real GPU runs pass a true AlphaFold/PDB target structure.
87
+ _PLACEHOLDER_PDB = (
88
+ "ATOM 1 CA GLY A 1 0.000 0.000 0.000 1.00 0.00 C\n"
89
+ "ATOM 2 CA SER A 2 3.800 0.000 0.000 1.00 0.00 C\n"
90
+ "ATOM 3 CA HIS A 3 7.600 0.000 0.000 1.00 0.00 C\n"
91
+ "END\n"
92
+ )
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Target:
97
+ """One antigen to design binders against."""
98
+
99
+ uniprot: str
100
+ symbol: str
101
+ epitope_residues: list[int] = field(default_factory=list)
102
+ epitope_chain: str = "A"
103
+
104
+
105
+ # Default target set: the held-out known antigens (benchmarks/known.tsv). Epitope
106
+ # residues are left empty (whole-target design) until SURFACE-Bind epitope
107
+ # prediction lands in v0.2 — which is honest and valid per DesignSpec.
108
+ DEFAULT_TARGETS = [
109
+ Target("P04626", "ERBB2"),
110
+ Target("P00533", "EGFR"),
111
+ Target("Q13421", "MSLN"),
112
+ Target("P20138", "CD33"),
113
+ Target("P26951", "IL3RA"),
114
+ ]
115
+
116
+
117
+ @dataclass
118
+ class DesignerScore:
119
+ """Aggregate binder-quality metrics for one designer over the target set."""
120
+
121
+ designer: str
122
+ n_targets: int = 0
123
+ n_designs: int = 0
124
+ mean_iptm: float | None = None
125
+ median_iptm: float | None = None
126
+ mean_pae_interaction: float | None = None
127
+ mean_affinity: float | None = None
128
+ success_rate: float | None = None # fraction of designs with ipTM >= threshold
129
+ # The numerator and a Wilson interval for it. Two runs of the same target
130
+ # differing only in seed returned 2/20 and 6/20 - 10% and 30% - which a
131
+ # Fisher exact test cannot separate (p = 0.24). A bare percentage from
132
+ # twenty designs claims a precision the sample does not carry, so the
133
+ # interval travels with it.
134
+ n_success: int | None = None
135
+ # The denominator the rate was actually computed over: designs the validator
136
+ # returned a confidence for. It can be smaller than n_designs, because a
137
+ # design Boltz-2 skipped has no ipTM to compare against the threshold. The
138
+ # cell used to print n_success/n_designs beside a percentage computed over
139
+ # this, so with any unfolded design the fraction and the percentage
140
+ # disagreed — 8/20 rendered beside a rate of 8/16.
141
+ n_scored: int | None = None
142
+ # The reported interval, clustered over backbones. See _success_intervals.
143
+ success_ci_low: float | None = None
144
+ success_ci_high: float | None = None
145
+ success_ci_method: str | None = None
146
+ # The same interval computed as if every design were independent, kept so a
147
+ # reader can see how much the clustering costs rather than taking it on
148
+ # trust. Never the reported figure.
149
+ success_ci_independent_low: float | None = None
150
+ success_ci_independent_high: float | None = None
151
+ # Backbone-level view: how many RFdiffusion trajectories yielded any design
152
+ # over the bar. This is the number of independent attempts that worked.
153
+ n_backbones: int | None = None
154
+ n_backbones_with_success: int | None = None
155
+ per_target: list[dict[str, Any]] = field(default_factory=list)
156
+ # Results tarballs this designer produced, so the binder artifacts can be
157
+ # staged after the run. Excluded from the serialised summary: a local scratch
158
+ # path is not a published result.
159
+ archives: list[Path] = field(default_factory=list)
160
+ #: Estimated by :mod:`bindsight.cost` before the run, not measured.
161
+ #: :attr:`wall_seconds` is the measurement; the table says which is which.
162
+ cost_usd: float | None = None
163
+ gpu_hours: float | None = None
164
+ #: Measured: this designer's own wall-clock for its whole pass.
165
+ wall_seconds: float | None = None
166
+ error: str | None = None
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Structure resolution
171
+ # ---------------------------------------------------------------------------
172
+ #: Where prepared target structures live by default. Leaving this unset used to
173
+ #: mean "write a placeholder", which is catastrophic on a real backend.
174
+ DEFAULT_STRUCTURES_DIR = Path("data/target_structures")
175
+
176
+
177
+ def _resolve_structure(
178
+ target: Target,
179
+ structures_dir: Path | None,
180
+ scratch: Path,
181
+ *,
182
+ allow_placeholder: bool = False,
183
+ ) -> Path:
184
+ """Return a real target structure, or fail.
185
+
186
+ The placeholder is a three-residue stub. It exists so the harness can run
187
+ offline on ``--backend mock``, and it is worthless anywhere else: RFdiffusion
188
+ will happily design binders against three residues, Boltz-2 will score them,
189
+ and the run will report ipTM figures for a target that was never present.
190
+ That is the most expensive kind of silent failure this project can produce —
191
+ it costs GPU hours and yields numbers that look real.
192
+
193
+ So ``allow_placeholder`` is False by default and only the mock backend passes
194
+ True. A real backend with no resolvable structure raises.
195
+
196
+ Resolution order: the structures directory, then AlphaFoldDB. The directory
197
+ is consulted even when the caller passed none, because the default location
198
+ is where ``prepare_erbb2_target.py`` writes.
199
+
200
+ Raises:
201
+ FileNotFoundError: On a real backend when no structure could be resolved.
202
+ """
203
+ search_dir = structures_dir if structures_dir is not None else DEFAULT_STRUCTURES_DIR
204
+ for ext in (".cif", ".pdb", ".mmcif"):
205
+ cand = Path(search_dir) / f"{target.uniprot}{ext}"
206
+ if cand.exists():
207
+ LOG.info("%s: using %s", target.symbol, cand)
208
+ return cand
209
+
210
+ try:
211
+ from bindsight.structures.alphafolddb import AlphaFoldDBClient
212
+
213
+ fetched = AlphaFoldDBClient().fetch(target.uniprot)
214
+ if fetched is not None:
215
+ LOG.info("%s: using AlphaFold model %s", target.symbol, fetched)
216
+ return Path(fetched)
217
+ except Exception as e: # pragma: no cover - network edge cases
218
+ LOG.warning("AlphaFold fetch failed for %s: %s", target.uniprot, e)
219
+
220
+ if not allow_placeholder:
221
+ raise FileNotFoundError(
222
+ f"no structure for {target.symbol} ({target.uniprot}). Looked in "
223
+ f"{search_dir}/ and AlphaFoldDB. A real backend will not fall back to "
224
+ "the three-residue placeholder: designing against it burns GPU hours "
225
+ "and produces ipTM figures for a target that was never there. Prepare "
226
+ "the structure first, e.g. "
227
+ "`python benchmarks/designer_benchmark/prepare_erbb2_target.py`."
228
+ )
229
+
230
+ LOG.warning(
231
+ "%s: no structure found; writing the three-residue placeholder. This is "
232
+ "only meaningful on the mock backend.",
233
+ target.symbol,
234
+ )
235
+ placeholder = scratch / f"{target.uniprot}.pdb"
236
+ placeholder.write_text(_PLACEHOLDER_PDB, encoding="utf-8", newline="\n")
237
+ return placeholder
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Metrics parsing
242
+ # ---------------------------------------------------------------------------
243
+ def _read_metrics(metrics_jsonl: Path) -> list[dict[str, Any]]:
244
+ if not metrics_jsonl.exists():
245
+ # Said out loud. An absent metrics file scores the run as zero designs,
246
+ # which is the same published shape as a run that produced none — and
247
+ # this function already goes to some length for the lesser case, naming
248
+ # every unparseable *line* below. A missing file loses every line.
249
+ LOG.warning(
250
+ "%s: no metrics file, so this run scores as zero designs. That is not "
251
+ "the same as a run that designed nothing: nothing was read.",
252
+ metrics_jsonl,
253
+ )
254
+ return []
255
+ rows = []
256
+ skipped: list[int] = []
257
+ for lineno, line in enumerate(metrics_jsonl.read_text(encoding="utf-8").splitlines(), start=1):
258
+ line = line.strip()
259
+ if not line:
260
+ continue
261
+ try:
262
+ rows.append(json.loads(line))
263
+ except json.JSONDecodeError:
264
+ # Named, not swallowed. Every dropped line changes n_designs, the
265
+ # mean ipTM and the success rate, and the old ``continue`` left no
266
+ # trace of it anywhere: a truncated file scored as a smaller run.
267
+ skipped.append(lineno)
268
+ if skipped:
269
+ LOG.warning(
270
+ "%s: %d unparseable line(s) skipped (line %s); n_designs and every "
271
+ "rate below are computed over the %d that parsed",
272
+ metrics_jsonl,
273
+ len(skipped),
274
+ ", ".join(str(n) for n in skipped[:10]),
275
+ len(rows),
276
+ )
277
+ return rows
278
+
279
+
280
+ def _floats(rows: list[dict[str, Any]], key: str) -> list[float]:
281
+ out = []
282
+ for r in rows:
283
+ v = r.get(key)
284
+ if isinstance(v, (int, float)):
285
+ out.append(float(v))
286
+ return out
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Run one designer over the target set
291
+ # ---------------------------------------------------------------------------
292
+ def run_one_designer(
293
+ designer_name: str,
294
+ targets: list[Target],
295
+ *,
296
+ backend: str,
297
+ validator: str,
298
+ n_trajectories: int,
299
+ seed: int,
300
+ structures_dir: Path | None,
301
+ scratch: Path,
302
+ bindsight_wheel: Path | None = None,
303
+ ) -> DesignerScore:
304
+ """Design + validate binders for every target with one designer; aggregate."""
305
+ score = DesignerScore(designer=designer_name, n_targets=len(targets))
306
+ # Wall-clock for this designer's whole pass. The table's GPU-hours column was
307
+ # the cost model's forecast, printed beside measured ipTMs with no marking,
308
+ # and the run it described recorded nothing to check it against.
309
+ started = time.monotonic()
310
+ try:
311
+ designer = get_designer(designer_name)
312
+ # get_runner forwards only the kwargs a given runner accepts, so this is
313
+ # inert for backends that install nothing.
314
+ runner = get_runner(backend, bindsight_wheel=bindsight_wheel)
315
+ except Exception as e:
316
+ score.error = f"plugin load failed: {e!r}"
317
+ return score
318
+
319
+ all_iptm: list[float] = []
320
+ all_pae: list[float] = []
321
+ all_aff: list[float] = []
322
+ outcomes_by_backbone: dict[str, list[bool]] = {}
323
+ archives: list[Path] = []
324
+ n_designs = 0
325
+
326
+ for target in targets:
327
+ try:
328
+ struct = _resolve_structure(
329
+ target, structures_dir, scratch, allow_placeholder=(backend == "mock")
330
+ )
331
+ except FileNotFoundError as e:
332
+ # Abort the whole designer: submitting the remaining targets would
333
+ # spend GPU time on a run whose summary is already incomplete.
334
+ LOG.error("%s", e)
335
+ score.error = str(e)
336
+ return score
337
+ spec = designer.make_spec(
338
+ target_uniprot=target.uniprot,
339
+ target_structure_path=struct,
340
+ epitope_residues=target.epitope_residues,
341
+ epitope_chain=target.epitope_chain,
342
+ n_trajectories=n_trajectories,
343
+ seed=seed,
344
+ )
345
+ # Pin the validator so the remote executor validates identically across
346
+ # designers (job_exec reads extra_params['validator']).
347
+ spec = spec.model_copy(
348
+ update={"extra_params": {**spec.extra_params, "validator": validator}}
349
+ )
350
+ try:
351
+ result = designer.submit(spec, runner)
352
+ except Exception as e:
353
+ LOG.warning("%s/%s submit failed: %s", designer_name, target.symbol, e)
354
+ score.per_target.append({"symbol": target.symbol, "error": repr(e)})
355
+ continue
356
+
357
+ archives.append(Path(result.results_archive_path))
358
+ rows = _read_metrics(Path(result.metrics_jsonl_path))
359
+ iptm = _floats(rows, "iptm")
360
+ pae = _floats(rows, "pae_interaction")
361
+ aff = _floats(rows, "affinity_pred_value")
362
+ all_iptm += iptm
363
+ all_pae += pae
364
+ all_aff += aff
365
+ for row in rows:
366
+ value = row.get("iptm")
367
+ if value is None:
368
+ continue
369
+ key = _backbone_of(str(row.get("binder_id", "")))
370
+ outcomes_by_backbone.setdefault(key, []).append(float(value) >= DEFAULT_IPTM_SUCCESS)
371
+ n_designs += len(rows)
372
+ score.per_target.append(
373
+ {
374
+ "symbol": target.symbol,
375
+ "uniprot": target.uniprot,
376
+ "n_designs": len(rows),
377
+ "mean_iptm": round(statistics.fmean(iptm), 4) if iptm else None,
378
+ "mean_affinity": round(statistics.fmean(aff), 4) if aff else None,
379
+ }
380
+ )
381
+
382
+ score.n_designs = n_designs
383
+ if all_iptm:
384
+ score.mean_iptm = round(statistics.fmean(all_iptm), 4)
385
+ score.median_iptm = round(statistics.median(all_iptm), 4)
386
+ n_ok = sum(1 for v in all_iptm if v >= DEFAULT_IPTM_SUCCESS)
387
+ score.success_rate = round(n_ok / len(all_iptm), 4)
388
+ score.n_success = n_ok
389
+ score.n_scored = len(all_iptm)
390
+ for field, value in _success_intervals(outcomes_by_backbone).items():
391
+ setattr(score, field, value)
392
+ if all_pae:
393
+ score.mean_pae_interaction = round(statistics.fmean(all_pae), 4)
394
+ if all_aff:
395
+ score.mean_affinity = round(statistics.fmean(all_aff), 4)
396
+
397
+ # Cost estimate for the real GPU job (zero on mock).
398
+ try:
399
+ _d, _v, combined = cost_mod.estimate_full_run(
400
+ backend=backend,
401
+ designer=designer_name,
402
+ validator=validator,
403
+ n_targets=len(targets),
404
+ n_trajectories=n_trajectories,
405
+ )
406
+ score.cost_usd = combined.usd_estimate
407
+ score.gpu_hours = combined.gpu_hours
408
+ except Exception as e: # pragma: no cover - unknown plugin/backend
409
+ LOG.warning("cost estimate failed for %s: %s", designer_name, e)
410
+ score.wall_seconds = round(time.monotonic() - started, 1)
411
+
412
+ score.archives = archives
413
+ return score
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Orchestration + reporting
418
+ # ---------------------------------------------------------------------------
419
+ def stage_binder_artifacts(archives: list[Path], out_dir: Path) -> dict[str, int]:
420
+ """Replace ``out_dir/binders/`` with the artifacts these archives contain.
421
+
422
+ A benchmark that rewrites ``results.json`` but leaves the previous run's
423
+ structures in place produces a directory whose metrics and whose molecules
424
+ describe different runs. The Real-results page renders those structures, so
425
+ the mismatch would be published as one coherent result. Staging is therefore
426
+ destructive by design: superseded ``.fasta`` and ``_complex.cif`` files are
427
+ removed before the new ones are written.
428
+
429
+ Files a run does not produce — developability descriptors, embedding
430
+ coordinates — are left alone, because they are derived by separate scripts
431
+ from whatever binders are present and are regenerated by running those.
432
+
433
+ Args:
434
+ archives: results tarballs from the run, in target order.
435
+ out_dir: the benchmark directory holding ``binders/``.
436
+
437
+ Returns:
438
+ Counts of what was removed and written.
439
+ """
440
+ import tarfile
441
+
442
+ binders = Path(out_dir) / "binders"
443
+ binders.mkdir(parents=True, exist_ok=True)
444
+
445
+ removed = 0
446
+ for stale in list(binders.glob("*.fasta")) + list(binders.glob("*_complex.cif")):
447
+ stale.unlink()
448
+ removed += 1
449
+
450
+ written = 0
451
+ metrics_lines: list[str] = []
452
+ for archive in archives:
453
+ if not Path(archive).is_file():
454
+ LOG.warning("results archive missing, cannot stage: %s", archive)
455
+ continue
456
+ with tarfile.open(archive, "r:gz", encoding="utf-8") as tf:
457
+ for member in tf.getmembers():
458
+ if not member.isfile():
459
+ continue
460
+ name = Path(member.name).name
461
+ src = tf.extractfile(member)
462
+ if src is None:
463
+ continue
464
+ if member.name.startswith("design/") and name.endswith(".fasta"):
465
+ (binders / name).write_bytes(src.read())
466
+ written += 1
467
+ elif member.name.startswith("validate/") and name.endswith(".cif"):
468
+ # Boltz-2 writes the predicted complex under validate/<binder_id>/.
469
+ binder_id = Path(member.name).parts[1]
470
+ (binders / f"{binder_id}_complex.cif").write_bytes(src.read())
471
+ written += 1
472
+ elif name == "metrics.jsonl":
473
+ metrics_lines += [ln for ln in src.read().decode().splitlines() if ln.strip()]
474
+
475
+ if metrics_lines:
476
+ (binders / "metrics.jsonl").write_text(
477
+ "\n".join(metrics_lines) + "\n", encoding="utf-8", newline="\n"
478
+ )
479
+ LOG.info("staged %d binder artifact(s); removed %d superseded", written, removed)
480
+ return {"written": written, "removed": removed}
481
+
482
+
483
+ def run_designer_benchmark(
484
+ *,
485
+ out_dir: Path,
486
+ backend: str,
487
+ designers: tuple[str, ...] = DEFAULT_DESIGNERS,
488
+ validator: str = "boltz2",
489
+ targets: list[Target] | None = None,
490
+ n_trajectories: int = 50,
491
+ seed: int = 42,
492
+ structures_dir: Path | None = None,
493
+ bindsight_wheel: Path | None = None,
494
+ ) -> dict[str, Any]:
495
+ """Run every designer over the target set and write results + a summary table.
496
+
497
+ Writes ``results.json`` and ``RESULTS.md`` under ``out_dir``. On ``mock`` the
498
+ summary is clearly marked synthetic.
499
+
500
+ Args:
501
+ out_dir: directory to write ``results.json``, ``RESULTS.md`` and staged
502
+ binder artifacts into.
503
+ backend: runner backend to submit design jobs to (``kaggle``, ``modal``,
504
+ ``local_docker``, ``mock``).
505
+ designers: designer plugins to score, one arm each.
506
+ validator: validator plugin used to score every design.
507
+ targets: targets to design against; ``DEFAULT_TARGETS`` when omitted.
508
+ n_trajectories: backbones per target, per designer.
509
+ seed: seed forwarded to each designer.
510
+ structures_dir: directory of prepared target structures. A real backend
511
+ errors rather than falling back to the placeholder.
512
+ bindsight_wheel: a wheel built from the working tree, embedded in the
513
+ remote job so the GPU runs this code. Without it a remote backend
514
+ pip-installs bindsight from git and silently exercises the default
515
+ branch, which is how a benchmark launched to validate a fix came
516
+ back having run the unfixed code.
517
+
518
+ Returns:
519
+ The summary dict that was written to ``results.json``.
520
+ """
521
+ import datetime as _dt
522
+
523
+ out_dir = Path(out_dir)
524
+ out_dir.mkdir(parents=True, exist_ok=True)
525
+ targets = targets or DEFAULT_TARGETS
526
+
527
+ scores: list[DesignerScore] = []
528
+ with tempfile.TemporaryDirectory(prefix="bindsight_designerbench_") as tmp:
529
+ scratch = Path(tmp)
530
+ for name in designers:
531
+ LOG.info("=== designer benchmark: %s (%d targets) ===", name, len(targets))
532
+ scores.append(
533
+ run_one_designer(
534
+ name,
535
+ targets,
536
+ backend=backend,
537
+ validator=validator,
538
+ n_trajectories=n_trajectories,
539
+ seed=seed,
540
+ bindsight_wheel=bindsight_wheel,
541
+ structures_dir=structures_dir,
542
+ scratch=scratch,
543
+ )
544
+ )
545
+
546
+ is_mock = backend == "mock"
547
+ # Never stage mock output into the committed binder directory: the whole
548
+ # point of that directory is that it holds real predicted complexes.
549
+ if not is_mock:
550
+ archives = [a for sc in scores for a in getattr(sc, "archives", [])]
551
+ if archives:
552
+ stage_binder_artifacts(archives, out_dir)
553
+ summary = {
554
+ "schema": DESIGNER_BENCHMARK_SCHEMA,
555
+ "generated_utc": _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds"),
556
+ "bindsight_version": __version__,
557
+ "backend": backend,
558
+ "validator": validator,
559
+ "n_trajectories": n_trajectories,
560
+ # The parameter that decides which draw this is. It was the only input
561
+ # the summary did not record — backend, validator, trajectory count,
562
+ # version and wheel were all here — so a run could name everything about
563
+ # itself except the thing that would let someone reproduce it.
564
+ "seed": seed,
565
+ "is_mock": is_mock,
566
+ # Which bindsight actually ran. A result that cannot name its own code
567
+ # is not reproducible, and "the default branch at some past moment" is
568
+ # not a name.
569
+ "bindsight_source": _bindsight_source(backend, bindsight_wheel, is_mock=is_mock),
570
+ "targets": [t.symbol for t in targets],
571
+ "designers": [_score_dict(s) for s in scores],
572
+ }
573
+ if _would_erase_a_real_result(out_dir, summary):
574
+ salvage = out_dir / "results.failed.json"
575
+ salvage.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8", newline="\n")
576
+ LOG.error(
577
+ "this run produced no designs, and %s records a run that did. Refusing "
578
+ "to overwrite it; the failed run is saved to %s. Fix the cause and "
579
+ "re-run, or delete the committed result deliberately if it is genuinely "
580
+ "superseded.",
581
+ out_dir / "results.json",
582
+ salvage,
583
+ )
584
+ return summary
585
+
586
+ (out_dir / "results.json").write_text(
587
+ json.dumps(summary, indent=2) + "\n", encoding="utf-8", newline="\n"
588
+ )
589
+ (out_dir / "RESULTS.md").write_text(_render_md(summary), encoding="utf-8", newline="\n")
590
+ LOG.info("designer benchmark complete; wrote %s", out_dir)
591
+ return summary
592
+
593
+
594
+ def _design_count(summary: dict[str, Any]) -> int:
595
+ """Total designs across every arm of a benchmark summary."""
596
+ return sum(int(d.get("n_designs") or 0) for d in summary.get("designers", []))
597
+
598
+
599
+ def _would_erase_a_real_result(out_dir: Path, summary: dict[str, Any]) -> bool:
600
+ """True when writing ``summary`` would replace a real result with an empty one.
601
+
602
+ A benchmark that fails after the GPU work is done still reaches the write,
603
+ and an empty summary is a perfectly well-formed one. That is not
604
+ hypothetical: a corrected ERBB2 re-run completed on the T4, produced its
605
+ tarball, and then the harness hit an encoding error writing the kernel log
606
+ and recorded zero designs — which promptly overwrote twenty committed
607
+ binders' worth of measurements with dashes, while ``binders/`` still held
608
+ the structures those measurements described.
609
+
610
+ Refusing the write is the conservative direction. The two ways to lose data
611
+ here are not symmetric: a stale-but-real result is visibly stale and can be
612
+ replaced deliberately, whereas an overwritten one is gone.
613
+
614
+ Args:
615
+ out_dir: the benchmark directory.
616
+ summary: the summary this run would write.
617
+
618
+ Returns:
619
+ True if the existing ``results.json`` records designs and this one does not.
620
+ """
621
+ if _design_count(summary) > 0:
622
+ return False
623
+ existing = out_dir / "results.json"
624
+ if not existing.is_file():
625
+ return False
626
+ try:
627
+ prior = json.loads(existing.read_text(encoding="utf-8"))
628
+ except (OSError, json.JSONDecodeError):
629
+ # An unreadable prior result is not evidence worth protecting.
630
+ return False
631
+ return _design_count(prior) > 0
632
+
633
+
634
+ def _score_dict(s: DesignerScore) -> dict[str, Any]:
635
+ return {
636
+ "designer": s.designer,
637
+ "n_targets": s.n_targets,
638
+ "n_designs": s.n_designs,
639
+ "n_scored": s.n_scored,
640
+ # The denominator the success rate was actually computed over.
641
+ # `_success_cell` reads it and discloses any gap from n_designs --
642
+ # but it was never written here, so the fallback always fired and the
643
+ # cell printed "8/20 = 50%", a rate over 16 beside a denominator of
644
+ # 20. The two numbers in one cell did not describe the same thing.
645
+ "mean_iptm": s.mean_iptm,
646
+ "median_iptm": s.median_iptm,
647
+ "mean_pae_interaction": s.mean_pae_interaction,
648
+ "mean_affinity": s.mean_affinity,
649
+ "success_rate": s.success_rate,
650
+ "n_success": s.n_success,
651
+ "success_ci_low": s.success_ci_low,
652
+ "success_ci_high": s.success_ci_high,
653
+ "success_ci_method": s.success_ci_method,
654
+ "success_ci_independent_low": s.success_ci_independent_low,
655
+ "success_ci_independent_high": s.success_ci_independent_high,
656
+ "n_backbones": s.n_backbones,
657
+ "n_backbones_with_success": s.n_backbones_with_success,
658
+ "cost_usd": s.cost_usd,
659
+ "gpu_hours": s.gpu_hours,
660
+ "wall_seconds": s.wall_seconds,
661
+ "per_target": s.per_target,
662
+ "error": s.error,
663
+ }
664
+
665
+
666
+ def _backbone_of(binder_id: str) -> str:
667
+ """The trajectory a design came from.
668
+
669
+ Ids are ``<target>_<backbone>_seq<i>``, so several designs share a backbone:
670
+ ProteinMPNN produces multiple sequences per RFdiffusion trajectory. A
671
+ designer whose ids carry no ``_seq`` suffix gets one cluster per design,
672
+ which is the right degradation — nothing is assumed to be correlated that
673
+ is not known to be.
674
+ """
675
+ return binder_id.rsplit("_seq", 1)[0]
676
+
677
+
678
+ def _success_intervals(outcomes: dict[str, list[bool]]) -> dict[str, Any]:
679
+ """Interval for the success rate, clustered over backbones.
680
+
681
+ Designs are not independent trials. Ten RFdiffusion trajectories times two
682
+ ProteinMPNN sequences is twenty designs but ten attempts, and success
683
+ clusters hard: on the committed ERBB2 run five backbones yielded nothing,
684
+ three yielded two, and within-trajectory ipTM correlates at about 0.55.
685
+ A binomial interval over the designs therefore claims more precision than
686
+ the run contains — it gave (22%, 61%) where clustering gives (15%, 70%).
687
+
688
+ This is the same error the study half was already fixed for, where one
689
+ antigen appearing in several cohorts is one piece of evidence rather than
690
+ several; :func:`cluster_bootstrap_interval` exists for it and was simply
691
+ never called from here.
692
+
693
+ Args:
694
+ outcomes: backbone id -> whether each of its designs cleared the bar.
695
+
696
+ Returns:
697
+ Fields to merge into the score: the clustered interval, the
698
+ independence-assuming one for contrast, and the backbone-level counts.
699
+ """
700
+ flat = [ok for v in outcomes.values() for ok in v]
701
+ n_ok = sum(flat)
702
+ independent = wilson_interval(n_ok, len(flat))
703
+ out: dict[str, Any] = {
704
+ "n_backbones": len(outcomes),
705
+ "n_backbones_with_success": sum(1 for v in outcomes.values() if any(v)),
706
+ "success_ci_independent_low": round(independent.low, 4),
707
+ "success_ci_independent_high": round(independent.high, 4),
708
+ }
709
+ try:
710
+ clustered = cluster_bootstrap_interval(outcomes, n_boot=10_000, seed=0)
711
+ except ValueError: # no clusters with outcomes; nothing to report
712
+ return out
713
+ out["success_ci_low"] = round(clustered.low, 4)
714
+ out["success_ci_high"] = round(clustered.high, 4)
715
+ out["success_ci_method"] = clustered.method
716
+ return out
717
+
718
+
719
+ def _bindsight_source(backend: str, wheel: Path | None, *, is_mock: bool) -> str:
720
+ """Which bindsight actually ran, as opposed to which one was offered.
721
+
722
+ This said "working-tree wheel <name>" whenever a wheel had been *built*,
723
+ and building one is not the same as the backend taking it.
724
+ ``get_runner`` forwards only the kwargs a runner's constructor declares, and
725
+ ``ModalRunner`` declares no ``bindsight_wheel`` — so a Modal benchmark built
726
+ the wheel, dropped it, installed bindsight from the repository's default
727
+ branch, and published the wheel's name as its provenance. That is the exact
728
+ failure the wheel was introduced to prevent, reported as though prevented.
729
+
730
+ Args:
731
+ backend: the runner name the results came from.
732
+ wheel: the wheel that was built, if any.
733
+ is_mock: whether the mock backend produced these numbers.
734
+
735
+ Returns:
736
+ A sentence naming the code that ran.
737
+ """
738
+ if is_mock:
739
+ return "mock backend: nothing is installed and no GPU runs"
740
+ if wheel is None:
741
+ return "pip install from the repository default branch"
742
+ if not runner_accepts(backend, "bindsight_wheel"):
743
+ return (
744
+ f"pip install from the repository default branch — a working-tree wheel "
745
+ f"({Path(wheel).name}) was built but backend {backend!r} cannot ship one, "
746
+ "so these results are not attributable to the tree that launched them"
747
+ )
748
+ return f"working-tree wheel {Path(wheel).name}"
749
+
750
+
751
+ def _gpu_hours_cell(designer: dict[str, Any]) -> str:
752
+ """GPU-hours, marked as measured or forecast.
753
+
754
+ The column used to print ``bindsight.cost``'s forecast unmarked, in a row
755
+ whose other numbers are all measurements. A reader had no way to tell that
756
+ 0.722 was predicted before the run rather than observed during it.
757
+ """
758
+ wall = designer.get("wall_seconds")
759
+ if wall:
760
+ return f"{float(wall) / 3600:.3g} (measured)"
761
+ estimate = designer.get("gpu_hours")
762
+ if estimate is None:
763
+ return "—"
764
+ return f"{float(estimate):.3g} (est.)"
765
+
766
+
767
+ def _success_cell(d: dict[str, Any]) -> str:
768
+ """Render success@0.65 with its interval, never as a bare percentage.
769
+
770
+ The interval is clustered over backbones, because designs sharing a
771
+ trajectory are not independent trials. A binomial interval over the designs
772
+ reads narrower than the run earns.
773
+ """
774
+ rate = d.get("success_rate")
775
+ if rate is None:
776
+ return "—"
777
+ low, high = d.get("success_ci_low"), d.get("success_ci_high")
778
+ n_ok = d.get("n_success")
779
+ # The denominator the rate was computed over, not the number of designs
780
+ # submitted. Older summaries carry no n_scored; for those the two were
781
+ # equal or the discrepancy is unrecoverable, and n_designs is the honest
782
+ # fallback.
783
+ n_designs = d.get("n_designs")
784
+ n = d.get("n_scored") or n_designs
785
+ if low is None or high is None or n_ok is None or not n:
786
+ return f"{rate:.0%}"
787
+ cell = f"{n_ok}/{n} = {rate:.0%} ({low:.0%}–{high:.0%})"
788
+ if n_designs and n != n_designs:
789
+ # Silently narrowing a denominator is itself a claim. Say how many
790
+ # designs the validator never scored, so the rate is read as the
791
+ # conditional quantity it is.
792
+ cell += f" of {n_designs} designed; {n_designs - n} unscored"
793
+ return cell
794
+
795
+
796
+ def _backbone_cell(d: dict[str, Any]) -> str:
797
+ """Backbones that yielded any design over the bar — the independent attempts.
798
+
799
+ Deliberately a bare fraction. Rendering it as a percentage put "50%" in a
800
+ table whose withdrawn predecessor reported 50% success@0.65, which is the
801
+ figure this benchmark exists to have replaced; a reader skimming the row
802
+ would have had every reason to think the old number had survived.
803
+ """
804
+ hit, total = d.get("n_backbones_with_success"), d.get("n_backbones")
805
+ if hit is None or not total:
806
+ return "—"
807
+ return f"{hit}/{total}"
808
+
809
+
810
+ def _render_md(summary: dict[str, Any]) -> str:
811
+ lines: list[str] = []
812
+ a = lines.append
813
+ a("# bindsight designer benchmark — results\n")
814
+ if summary["is_mock"]:
815
+ a(
816
+ "> **⚠ MOCK BACKEND — synthetic numbers for CI/orchestration only.** "
817
+ "These are NOT real GPU results. Re-run with `--backend modal` (or "
818
+ "`local_docker`/`kaggle`) on a GPU to produce real metrics.\n"
819
+ )
820
+ a(f"- Generated: `{summary['generated_utc']}` · bindsight `{summary['bindsight_version']}`")
821
+ a(
822
+ f"- Backend: `{summary['backend']}` · validator: `{summary['validator']}` · "
823
+ f"trajectories/target: {summary['n_trajectories']}"
824
+ )
825
+ # Absent, not zero. A run predating the seed record must not render as
826
+ # though it had been seeded with 0, which is a real and different run.
827
+ a(
828
+ "- Seed: "
829
+ + (
830
+ f"`{summary['seed']}`"
831
+ if summary.get("seed") is not None
832
+ else "**unrecorded** — this run predates the benchmark recording "
833
+ "its seed, and its designer and validator both ran unseeded"
834
+ )
835
+ )
836
+ a(f"- Targets: {', '.join(summary['targets'])}")
837
+ if summary.get("gpu"):
838
+ a(f"- GPU: `{summary['gpu']}`")
839
+ if summary.get("bindsight_source"):
840
+ # Which bindsight actually ran. Remote backends pip-install it, so the
841
+ # code that produced a result is not necessarily the code that submitted
842
+ # the job — a benchmark launched to validate a fix once ran the unfixed
843
+ # code because the kernel installed the repository's default branch.
844
+ a(f"- Code: {summary['bindsight_source']}")
845
+ if not summary["is_mock"]:
846
+ a(
847
+ "\n> **The target chain was held fixed.** ProteinMPNN is invoked with\n"
848
+ "> `--pdb_path_chains` so it redesigns the binder only. A superseded run\n"
849
+ "> omitted that flag, rewrote the target as well, and therefore optimised\n"
850
+ "> its designs against a partly-invented surface before scoring them\n"
851
+ "> against the native one; its figures are withdrawn. Every design here\n"
852
+ "> carries a target chain byte-identical to the prepared structure.\n"
853
+ ">\n"
854
+ "> That is checked against the artifacts, not asserted: "
855
+ "`tests/test_target_chain_artifact.py` reads every committed "
856
+ "`*_complex.cif` and requires exactly one chain to equal the native "
857
+ "target in `target/P04626_domain_IV.pdb`."
858
+ )
859
+ a("")
860
+
861
+ a(
862
+ "| designer | designs | mean ipTM | median ipTM | mean PAE-int | mean affinity | "
863
+ "success@0.65 | backbones hit | est. cost (USD) | GPU-h |"
864
+ )
865
+ a("|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|")
866
+ for d in summary["designers"]:
867
+
868
+ def fmt(v: Any, pct: bool = False) -> str:
869
+ if v is None:
870
+ return "—"
871
+ return f"{v:.0%}" if pct else f"{v:.3g}"
872
+
873
+ a(
874
+ f"| {d['designer']} | {d['n_designs']} | {fmt(d['mean_iptm'])} | "
875
+ f"{fmt(d['median_iptm'])} | {fmt(d['mean_pae_interaction'])} | "
876
+ f"{fmt(d['mean_affinity'])} | {_success_cell(d)} | {_backbone_cell(d)} | "
877
+ f"{fmt(d['cost_usd'])} | {_gpu_hours_cell(d)} |"
878
+ )
879
+ a("")
880
+ a(
881
+ "**ipTM** / **PAE-interaction** / **affinity** are the validator's "
882
+ "(Boltz-2) interface-confidence and predicted-affinity outputs; "
883
+ "**success@0.65** is the fraction of designs with ipTM ≥ 0.65, with a 95% "
884
+ "interval **bootstrapped over backbones**, not over designs: ProteinMPNN "
885
+ "produces several sequences per RFdiffusion trajectory, so the designs are "
886
+ "not independent trials and a binomial interval over them reads narrower "
887
+ "than the run earns. **backbones hit** is the fraction of trajectories that "
888
+ "yielded any design over the bar, which is the count of independent attempts "
889
+ "that worked. Read the interval, not the point: two runs of the same target "
890
+ "differing only in seed returned 2/20 and 6/20, which a Fisher exact test "
891
+ "cannot separate. Cost is the "
892
+ "`bindsight.cost` estimate for the run on the chosen backend, and so is "
893
+ "any GPU-hours figure marked `(est.)` — both are forecasts made before "
894
+ "the run rather than measurements of it. A figure marked `(measured)` is "
895
+ "this designer's own wall-clock. The committed run predates that "
896
+ "measurement, which is why its GPU-hours are an estimate.\n"
897
+ )
898
+ a(IPTM_CALIBRATION_CAVEAT)
899
+ return "\n".join(lines)