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,489 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Builder for the self-contained Kaggle kernel that runs the design+validation job.
4
+
5
+ Kaggle's *default* free GPU is a **Tesla P100 (sm_60)**, which current PyTorch
6
+ cannot drive at all: it ships no Pascal kernels, so CUDA reports available and
7
+ the first real launch dies hours into a run. This kernel therefore pins
8
+ ``machine_shape`` to a **Tesla T4 (sm_75)** and checks compute capability before
9
+ doing any work.
10
+
11
+ Even on a T4, the preinstalled stack
12
+ (py3.12 / torch 2.10+cu128) supports neither that image's CUDA nor RFdiffusion's
13
+ legacy requirements. RFdiffusion (the only designer that fits 16 GB) needs an old
14
+ py3.9 / torch-1.12 environment, and that environment is mutually exclusive with
15
+ the modern Boltz-2 validator (torch ≥2.2 / py ≥3.10). So the kernel builds **two
16
+ micromamba environments** on the T4 and runs the existing executor
17
+ (:mod:`bindsight.runners.job_exec`) across them:
18
+
19
+ - ``se3`` (py3.9, torch 1.12.1+cu113 — supports sm_60): RFdiffusion + ProteinMPNN.
20
+ - ``boltz`` (py3.11, torch 2.2.2+cu118 — still ships sm_60): Boltz-2 + bindsight.
21
+
22
+ ``job_exec`` runs under ``boltz`` and invokes the design tools with the ``se3``
23
+ interpreter via ``BINDSIGHT_DESIGN_PYTHON`` (a wrapper that does ``micromamba run
24
+ -p .../se3``), so there is still exactly one executor — only the per-tool
25
+ interpreter differs. The design spec + target structure are embedded as base64
26
+ (no Kaggle dataset needed); the results tarball is written to
27
+ ``/kaggle/working/<handle_id>.tar.gz`` for :meth:`KaggleRunner.fetch`.
28
+
29
+ The pinned upstream revisions come from :mod:`bindsight.runners.tools` so the
30
+ kernel can never drift from the Colab/Modal/local paths.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import Any
36
+
37
+ from bindsight.runners import tools
38
+
39
+ # Channels-free pip recipe for RFdiffusion's legacy env. RFdiffusion's own
40
+ # env/SE3nv.yml pins pytorch=1.9/cudatoolkit=11.1 via conda channels whose ancient
41
+ # builds are unreliable to solve today; the cu113 pip wheels below are the
42
+ # community-proven equivalent and (unlike Kaggle's default cu128 torch) include the
43
+ # P100's sm_60 kernels. SE3-Transformer's own requirements (e3nn==0.3.3, dllogger,
44
+ # …) come from RFdiffusion/env/SE3Transformer/requirements.txt at install time.
45
+ # cu113 is the newest CUDA wheel line for torch 1.12.1; the dgl cu113 wheels at the
46
+ # find-links below are therefore built against this exact torch. Package name is
47
+ # plain ``dgl`` with a ``+cu113`` local version (the old ``dgl-cu113`` name is gone).
48
+ _SE3_TORCH = "torch==1.12.1+cu113"
49
+ _SE3_TORCH_INDEX = "https://download.pytorch.org/whl/cu113"
50
+ _SE3_DGL = "dgl==1.0.2+cu113"
51
+ _SE3_DGL_FIND = "https://data.dgl.ai/wheels/cu113/repo.html"
52
+ _SE3_EXTRA_PIP = [
53
+ "hydra-core==1.3.2",
54
+ "pyrsistent",
55
+ "omegaconf",
56
+ "icecream",
57
+ "scipy",
58
+ "numpy<1.24",
59
+ "opt_einsum",
60
+ "opt_einsum_fx",
61
+ "e3nn==0.3.3",
62
+ ]
63
+ # Boltz-2 env: a torch that still ships sm_60 (Kaggle's default cu128 does not).
64
+ _BOLTZ_TORCH = "torch==2.2.2"
65
+ _BOLTZ_TORCH_INDEX = "https://download.pytorch.org/whl/cu118"
66
+
67
+
68
+ _BINDSIGHT_REPO = "https://github.com/mikhaeelatefrizk/bindsight.git"
69
+
70
+
71
+ def build_kernel_script(
72
+ *,
73
+ handle_id: str,
74
+ payload: dict[str, str],
75
+ bindsight_ref: str | None = None,
76
+ n_trajectories_note: int | None = None,
77
+ bindsight_wheel_b64: str | None = None,
78
+ bindsight_wheel_name: str = "bindsight-0.0.0-py3-none-any.whl",
79
+ mode: str = "design_and_validate",
80
+ ) -> str:
81
+ """Return the full Python source of the Kaggle kernel.
82
+
83
+ Args:
84
+ handle_id: stable id; the results tarball is ``<handle_id>.tar.gz``.
85
+ payload: ``{filename: base64}`` for the spec dir (``spec.json`` + the
86
+ target structure file the spec references).
87
+ bindsight_ref: optional git ref (branch/tag/sha) to install bindsight from;
88
+ defaults to the repository's default branch. Used to run a feature
89
+ branch before it is merged.
90
+ n_trajectories_note: optional, embedded only as a log breadcrumb.
91
+ bindsight_wheel_b64: optional base64 of a wheel built from the working
92
+ tree. When given the kernel installs it instead of pip-installing
93
+ from git, which is the only way an unpushed change reaches the GPU.
94
+ Without it a run silently exercises whatever the ref resolves to.
95
+ bindsight_wheel_name: the wheel's filename, which must be a valid PEP 427
96
+ name. pip parses the distribution, version and compatibility tags out
97
+ of it and rejects anything else outright: writing the payload to
98
+ ``bindsight-embedded.whl`` failed a run with "Invalid wheel filename
99
+ (wrong number of parts)" after the environment build had finished.
100
+ mode: ``design_and_validate`` or ``validate_only``. The latter skips the
101
+ RFdiffusion clone, its checkpoint download and the entire se3
102
+ environment, none of which a re-scoring job touches.
103
+ """
104
+ git_url = f"git+{_BINDSIGHT_REPO}" + (f"@{bindsight_ref}" if bindsight_ref else "")
105
+ # A wheel built from the working tree beats any git ref: it is the code that
106
+ # launched the run, rather than whatever the named branch points at by the
107
+ # time pip resolves it. Without one, an unpushed fix is invisible to the GPU.
108
+ install_source = "embedded wheel" if bindsight_wheel_b64 else git_url
109
+ # Emit Python *literals* with repr(), not json.dumps() — JSON's null/true/false
110
+ # are not valid Python identifiers (an early bug embedded ``= null``).
111
+ consts = {
112
+ "HANDLE_ID": handle_id,
113
+ "PAYLOAD": payload,
114
+ "N_TRAJ_NOTE": n_trajectories_note,
115
+ "RFDIFF_REPO": tools.RFDIFF_REPO,
116
+ "RFDIFF_COMMIT": tools.RFDIFF_COMMIT,
117
+ "RFDIFF_WEIGHTS": tools.RFDIFF_WEIGHTS,
118
+ "RFDIFF_WEIGHT_SHA256": tools.RFDIFF_WEIGHT_SHA256,
119
+ "MPNN_REPO": tools.PROTEINMPNN_REPO,
120
+ "MPNN_COMMIT": tools.PROTEINMPNN_COMMIT,
121
+ "BOLTZ_PIP": tools.BOLTZ_PIP,
122
+ "SE3_TORCH": _SE3_TORCH,
123
+ "SE3_TORCH_INDEX": _SE3_TORCH_INDEX,
124
+ "SE3_DGL": _SE3_DGL,
125
+ "SE3_DGL_FIND": _SE3_DGL_FIND,
126
+ "SE3_EXTRA_PIP": _SE3_EXTRA_PIP,
127
+ "BOLTZ_TORCH": _BOLTZ_TORCH,
128
+ "BOLTZ_TORCH_INDEX": _BOLTZ_TORCH_INDEX,
129
+ "BINDSIGHT_GIT": git_url,
130
+ "BINDSIGHT_WHEEL_B64": bindsight_wheel_b64 or "",
131
+ "BINDSIGHT_WHEEL_NAME": bindsight_wheel_name,
132
+ "INSTALL_SOURCE": install_source,
133
+ "MIN_CC": MIN_COMPUTE_CAPABILITY,
134
+ "MODE": mode,
135
+ }
136
+ header_lines = ["# Auto-generated by bindsight KaggleRunner — split-env design+validation."]
137
+ header_lines += [f"{name} = {value!r}" for name, value in consts.items()]
138
+ return "\n".join(header_lines) + "\n\n" + _BODY
139
+
140
+
141
+ #: Kaggle accelerator to request. ``enable_gpu`` alone gets Kaggle's default,
142
+ #: which is a P100 — and Kaggle's own CLI documentation now warns that the P100
143
+ #: is unusable for GPU compute with the default image, because current PyTorch
144
+ #: builds ship no Pascal (sm_60) kernels. The failure mode is nasty:
145
+ #: ``torch.cuda.is_available()`` returns True and the first real kernel launch
146
+ #: dies with ``cudaErrorNoKernelImageForDevice``. The T4 (sm_75) is also what
147
+ #: current JAX supports, which is what makes BindCraft and AF2 initial-guess
148
+ #: reachable at all.
149
+ KAGGLE_ACCELERATOR = "NvidiaTeslaT4"
150
+
151
+ #: Compute capability the kernel requires. Turing is 7.5. Anything lower is a
152
+ #: P100 or older and cannot run the pinned stack; the kernel fails fast rather
153
+ #: than burning quota to discover it mid-run.
154
+ MIN_COMPUTE_CAPABILITY = (7, 5)
155
+
156
+ #: The same accelerator under the name :mod:`bindsight.cost` knows it by.
157
+ #:
158
+ #: These two constants have to agree. When the accelerator was pinned to a T4,
159
+ #: ``KaggleRunner`` kept defaulting to ``"P100"``, so every Kaggle runtime and
160
+ #: cost estimate was computed against a card the run would never be given —
161
+ #: a 3.0x slowdown factor applied where 4.0x was correct, quietly under-quoting
162
+ #: every estimate by a quarter. Deriving the default from here removes the
163
+ #: opportunity to make that mistake twice.
164
+ KAGGLE_COST_GPU = "T4"
165
+
166
+
167
+ def build_kernel_metadata(
168
+ *, username: str, slug: str, accelerator: str = KAGGLE_ACCELERATOR
169
+ ) -> dict[str, Any]:
170
+ """kernel-metadata.json for a GPU + internet script kernel (no dataset sources).
171
+
172
+ ``machine_shape`` pins which accelerator Kaggle attaches. Without it Kaggle
173
+ picks its default, so the hardware a published result was produced on would
174
+ not be under version control. See :data:`KAGGLE_ACCELERATOR`.
175
+ """
176
+ return {
177
+ "id": f"{username}/{slug}",
178
+ "title": slug,
179
+ "code_file": "kernel.py",
180
+ "language": "python",
181
+ "kernel_type": "script",
182
+ "is_private": True,
183
+ "enable_gpu": True,
184
+ "machine_shape": accelerator,
185
+ "enable_internet": True,
186
+ "dataset_sources": [],
187
+ "competition_sources": [],
188
+ "kernel_sources": [],
189
+ }
190
+
191
+
192
+ # The static kernel body. References the variables defined in the generated header
193
+ # above; kept brace-safe (no .format) so the embedded shell/Python is verbatim.
194
+ _BODY = r'''
195
+ import base64, gzip, hashlib, json, os, pathlib, subprocess, sys, threading, time
196
+
197
+ MR = "/opt/mamba" # MAMBA_ROOT_PREFIX (off the /kaggle/working output volume)
198
+ MM = "/opt/micromamba/bin/micromamba"
199
+ TOOLS = "/opt/tools"
200
+ SE3 = f"{MR}/envs/se3"
201
+ BOLTZ = f"{MR}/envs/boltz"
202
+ # dgl's C lib dlopen()s CUDA 11's libcusparse.so.11 etc. via the system loader, so
203
+ # those libs must be on LD_LIBRARY_PATH. We install conda cudatoolkit=11.3 into the
204
+ # se3 env (puts them in $SE3/lib) and also keep torch's bundled libs (torch/lib).
205
+ SE3_LD = f"{SE3}/lib:{SE3}/lib/python3.9/site-packages/torch/lib"
206
+ os.environ["MAMBA_ROOT_PREFIX"] = MR
207
+
208
+
209
+ def sh(cmd, env=None):
210
+ """Run a shell command, streaming output; raise on non-zero exit."""
211
+ print(f"\n+ {cmd}", flush=True)
212
+ t0 = time.time()
213
+ r = subprocess.run(cmd, shell=True, env={**os.environ, **(env or {})})
214
+ print(f" ({time.time()-t0:.0f}s, exit={r.returncode})", flush=True)
215
+ if r.returncode != 0:
216
+ raise SystemExit(f"step failed: {cmd}")
217
+
218
+
219
+ def disk_report(label):
220
+ """Print free space on every volume that matters.
221
+
222
+ Kaggle documents 20 GB of persisted /kaggle/working and does not publish the
223
+ size of the scratch area, while this build needs roughly 60 GB for two torch
224
+ stacks plus model weights. Rather than assume it fits, every stage records
225
+ what was actually available, so the constraint is a published number in the
226
+ run log instead of a guess in a README.
227
+ """
228
+ import shutil
229
+
230
+ parts = []
231
+ for path in ("/", "/kaggle/working", "/kaggle/temp", "/opt", "/tmp"):
232
+ try:
233
+ u = shutil.disk_usage(path)
234
+ except OSError:
235
+ continue
236
+ parts.append(f"{path} {u.free / 2**30:.1f}/{u.total / 2**30:.1f} GiB free")
237
+ print(f"[disk @ {label}] " + " | ".join(parts), flush=True)
238
+
239
+
240
+ _VRAM_PEAK = [0]
241
+
242
+
243
+ def _vram_now():
244
+ """Device-wide VRAM in use, in MiB, or None if it cannot be read."""
245
+ out = subprocess.run(
246
+ ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
247
+ capture_output=True, text=True,
248
+ )
249
+ if out.returncode != 0 or not out.stdout.strip():
250
+ return None
251
+ try:
252
+ return int(out.stdout.strip().splitlines()[0])
253
+ except ValueError:
254
+ return None
255
+
256
+
257
+ def _vram_sampler():
258
+ """Record the high-water mark of VRAM for the whole kernel.
259
+
260
+ Sampling only between stages measured nothing: the work runs in subprocesses
261
+ under two separate micromamba environments, and by the time a stage boundary
262
+ is reached every one of them has exited and released its memory. A whole
263
+ 3h49m design run reported 0 MiB at every stage while plainly using the GPU,
264
+ which made the "every memory claim is a measurement" promise false.
265
+
266
+ Device-wide sampling from a background thread is what actually observes it,
267
+ and it does so regardless of which environment or subprocess holds the
268
+ allocation — which is the reason not to use torch's own counter here.
269
+ """
270
+ while True:
271
+ v = _vram_now()
272
+ if v is not None and v > _VRAM_PEAK[0]:
273
+ _VRAM_PEAK[0] = v
274
+ time.sleep(5)
275
+
276
+
277
+ threading.Thread(target=_vram_sampler, daemon=True).start()
278
+
279
+
280
+ def gpu_report(label):
281
+ """Print current and peak VRAM, so every memory claim is a measurement."""
282
+ out = subprocess.run(
283
+ ["nvidia-smi", "--query-gpu=name,memory.used,memory.total",
284
+ "--format=csv,noheader,nounits"],
285
+ capture_output=True, text=True,
286
+ )
287
+ if out.returncode == 0 and out.stdout.strip():
288
+ print(
289
+ f"[gpu @ {label}] {out.stdout.strip()} | peak so far {_VRAM_PEAK[0]} MiB",
290
+ flush=True,
291
+ )
292
+
293
+
294
+ def step(msg):
295
+ print(f"\n========== {msg} ==========", flush=True)
296
+ disk_report(msg)
297
+ gpu_report(msg)
298
+
299
+
300
+ step("GPU check")
301
+ sh("nvidia-smi --query-gpu=name,memory.total,compute_cap,driver_version "
302
+ "--format=csv,noheader || true")
303
+
304
+ # Fail fast on the wrong accelerator. On a P100 the default Kaggle image reports
305
+ # torch.cuda.is_available() == True and then dies on the first kernel launch with
306
+ # cudaErrorNoKernelImageForDevice, hours into the run. Checking compute capability
307
+ # up front costs a second and turns that into an immediate, legible failure.
308
+ _cc = subprocess.run(
309
+ ["nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader"],
310
+ capture_output=True, text=True,
311
+ )
312
+ _cc_text = (_cc.stdout or "").strip().splitlines()[0].strip() if _cc.stdout.strip() else ""
313
+ if _cc_text:
314
+ _major, _, _minor = _cc_text.partition(".")
315
+ _have = (int(_major), int(_minor or 0))
316
+ print(f"compute capability {_have[0]}.{_have[1]} (need >= {MIN_CC[0]}.{MIN_CC[1]})", flush=True)
317
+ if _have < tuple(MIN_CC):
318
+ raise SystemExit(
319
+ f"this kernel needs compute capability >= {MIN_CC[0]}.{MIN_CC[1]} "
320
+ f"but got {_have[0]}.{_have[1]}. Kaggle attached the wrong accelerator: set "
321
+ "machine_shape to NvidiaTeslaT4 in kernel-metadata.json. The default P100 "
322
+ "(6.0) reports CUDA as available and then fails on the first kernel launch."
323
+ )
324
+ else:
325
+ print("WARNING: could not read compute capability; continuing unguarded", flush=True)
326
+
327
+ step("install micromamba")
328
+ pathlib.Path("/opt/micromamba/bin").mkdir(parents=True, exist_ok=True)
329
+ sh("curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest "
330
+ "| tar -xj -C /opt/micromamba bin/micromamba")
331
+ sh(f"{MM} --version")
332
+
333
+ # Everything RFdiffusion needs — the clone, ~480 MB of checkpoints, and the
334
+ # whole se3 environment — is design-only. A validate-only job re-scores
335
+ # binders that already exist, so building it there spent roughly six minutes
336
+ # and several gigabytes of a weekly quota on tools the job never invokes.
337
+ if MODE != "validate_only":
338
+ step("clone RFdiffusion + ProteinMPNN, fetch weights")
339
+ pathlib.Path(TOOLS).mkdir(parents=True, exist_ok=True)
340
+ if not pathlib.Path(f"{TOOLS}/RFdiffusion").exists():
341
+ sh(f"git clone -q {RFDIFF_REPO} {TOOLS}/RFdiffusion")
342
+ sh(f"git -C {TOOLS}/RFdiffusion checkout -q {RFDIFF_COMMIT}")
343
+ pathlib.Path(f"{TOOLS}/RFdiffusion/models").mkdir(parents=True, exist_ok=True)
344
+ for name, url in RFDIFF_WEIGHTS.items():
345
+ dst = f"{TOOLS}/RFdiffusion/models/{name}"
346
+ if not pathlib.Path(dst).exists():
347
+ sh(f"wget -q '{url}' -O '{dst}'")
348
+ # Hash every checkpoint and print it. Until now these ~480 MB of model
349
+ # parameters arrived over plain HTTP, were loaded and executed, and nothing
350
+ # checked that what landed was what was asked for. A truncated transfer
351
+ # produces a checkpoint that may still load and quietly design differently.
352
+ # Printing the digest unconditionally is also how a pin gets established:
353
+ # the first run publishes it into this log.
354
+ _h = hashlib.sha256()
355
+ with open(dst, "rb") as _fh:
356
+ for _chunk in iter(lambda: _fh.read(1 << 20), b""):
357
+ _h.update(_chunk)
358
+ _digest = _h.hexdigest()
359
+ print(f"checkpoint {name}: {pathlib.Path(dst).stat().st_size} bytes sha256={_digest}", flush=True)
360
+ _want = RFDIFF_WEIGHT_SHA256.get(name)
361
+ if _want and _digest != _want:
362
+ raise SystemExit(
363
+ f"{name}: sha256 {_digest} does not match the pinned {_want}. "
364
+ "Refusing to design against a checkpoint that is not the one this "
365
+ "result would claim."
366
+ )
367
+ if not pathlib.Path(f"{TOOLS}/ProteinMPNN").exists():
368
+ sh(f"git clone -q {MPNN_REPO} {TOOLS}/ProteinMPNN")
369
+ sh(f"git -C {TOOLS}/ProteinMPNN checkout -q {MPNN_COMMIT}")
370
+
371
+ step("build se3 env (RFdiffusion: py3.9 / torch1.12+cu113 — covers sm_75)")
372
+ sh(f"{MM} create -y -p {SE3} -c conda-forge python=3.9 pip")
373
+ # CUDA 11.3 runtime libs (libcusparse.so.11 …) that dgl's C library dlopen()s.
374
+ sh(f"{MM} install -y -p {SE3} -c conda-forge cudatoolkit=11.3")
375
+ sh(f"{MM} run -p {SE3} pip install -q --no-input {SE3_TORCH} --extra-index-url {SE3_TORCH_INDEX}")
376
+ sh(f"{MM} run -p {SE3} pip install -q --no-input {SE3_DGL} -f {SE3_DGL_FIND}")
377
+ sh(f"{MM} run -p {SE3} pip install -q --no-input " + " ".join(f"'{p}'" for p in SE3_EXTRA_PIP))
378
+ # SE3-Transformer's own requirements (e3nn pin, dllogger, …), then the packages.
379
+ sh(f"{MM} run -p {SE3} pip install -q --no-input -r {TOOLS}/RFdiffusion/env/SE3Transformer/requirements.txt")
380
+ sh(f"{MM} run -p {SE3} pip install -q --no-input --no-deps {TOOLS}/RFdiffusion/env/SE3Transformer")
381
+ sh(f"{MM} run -p {SE3} pip install -q --no-input --no-deps -e {TOOLS}/RFdiffusion")
382
+
383
+ step("write se3 interpreter wrapper (sets LD_LIBRARY_PATH for dgl's CUDA libs)")
384
+ # Invoke the se3 python directly (not via `micromamba run`) so the explicit
385
+ # LD_LIBRARY_PATH reliably reaches the loader when dgl dlopen()s its CUDA deps.
386
+ wrapper = "/opt/se3_python.sh"
387
+ pathlib.Path(wrapper).write_text(
388
+ "#!/bin/bash\n"
389
+ f"export LD_LIBRARY_PATH={SE3_LD}:$LD_LIBRARY_PATH\n"
390
+ "export DGLBACKEND=pytorch\n"
391
+ f"exec {SE3}/bin/python \"$@\"\n"
392
+ )
393
+ os.chmod(wrapper, 0o755)
394
+
395
+ # Sanity: RFdiffusion importable + CUDA usable on the pinned T4 — via the wrapper, so the
396
+ # import-time dgl CUDA-lib load is exercised exactly as the real run will do it.
397
+ sh(f"{wrapper} -c \""
398
+ "import torch, dgl, e3nn; "
399
+ "print('se3 torch', torch.__version__, 'dgl', dgl.__version__, 'cuda', torch.cuda.is_available(), "
400
+ "torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'NO-CUDA'); "
401
+ "x=torch.zeros(4,device='cuda'); print('se3 cuda op ok', (x+1).sum().item())\"")
402
+
403
+ else:
404
+ step("skip the se3 environment (validate_only: no designer runs)")
405
+ # There is no se3 interpreter to point at, so the name stays bound to None
406
+ # rather than going undefined: everything the design branch defines is read
407
+ # again further down, and a name that exists in only one branch turns a
408
+ # skipped step into a NameError at the end of a paid job.
409
+ wrapper = None
410
+ print("MODE=validate_only: RFdiffusion is not cloned, its checkpoints are "
411
+ "not fetched, and the se3 environment is not built.", flush=True)
412
+
413
+ step("build boltz env (Boltz-2 + bindsight: py3.11 / torch2.2+cu118 — covers sm_75)")
414
+ sh(f"{MM} create -y -p {BOLTZ} -c conda-forge python=3.11 pip")
415
+ sh(f"{MM} run -p {BOLTZ} pip install -q --no-input {BOLTZ_TORCH} --index-url {BOLTZ_TORCH_INDEX}")
416
+ sh(f"{MM} run -p {BOLTZ} pip install -q --no-input '{BOLTZ_PIP}' biopython")
417
+ # What the validator actually resolved to, recorded in the run log. The install
418
+ # above is quiet, so no run before this one recorded the version of the tool
419
+ # that produced its confidence numbers — the pin said what was asked for, and
420
+ # nothing said what arrived. A pin is a request; this is the receipt.
421
+ sh(f"{MM} run -p {BOLTZ} python -c \""
422
+ "from importlib.metadata import version; "
423
+ "print('boltz version', version('boltz'))\"")
424
+ print("bindsight install source:", INSTALL_SOURCE, flush=True)
425
+ if BINDSIGHT_WHEEL_B64:
426
+ # Install the exact tree that launched this run. Pinning a branch name would
427
+ # still leave the GPU installing whatever that branch points at now, and an
428
+ # unpushed commit would be invisible entirely.
429
+ # The filename matters: pip reads the distribution, version and
430
+ # compatibility tags out of it, and rejects a name it cannot parse.
431
+ _whl = f"/tmp/{BINDSIGHT_WHEEL_NAME}"
432
+ pathlib.Path(_whl).write_bytes(base64.b64decode(BINDSIGHT_WHEEL_B64))
433
+ print(" wheel bytes:", pathlib.Path(_whl).stat().st_size, flush=True)
434
+ sh(f"{MM} run -p {BOLTZ} pip install -q --no-input '{_whl}'")
435
+ else:
436
+ sh(f"{MM} run -p {BOLTZ} pip install -q --no-input {BINDSIGHT_GIT}")
437
+ sh(f"{MM} run -p {BOLTZ} python -c \""
438
+ "import bindsight; print('bindsight', bindsight.__version__, 'from', bindsight.__file__)\"")
439
+ sh(f"{MM} run -p {BOLTZ} python -c \""
440
+ "import torch; print('boltz torch', torch.__version__, 'cuda', torch.cuda.is_available(), "
441
+ "torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'NO-CUDA'); "
442
+ "x=torch.zeros(4,device='cuda'); print('boltz cuda op ok', (x+1).sum().item())\"")
443
+
444
+ step("patch Boltz-2 precision for non-bf16 GPUs (T4 is sm_75; bf16 needs sm_80+)")
445
+ # Boltz-2 hardcodes precision="bf16-mixed" (main.py). bfloat16 needs compute
446
+ # capability 8.0+, and no free-tier GPU has it — the T4 this kernel pins is 7.5 —
447
+ # so force full fp32, which is what the boltz1 path already uses and is
448
+ # numerically safe. This patch stays necessary for as long as the free tier is
449
+ # pre-Ampere.
450
+ import glob as _glob # noqa: E402
451
+
452
+ _bm = _glob.glob(f"{BOLTZ}/lib/python*/site-packages/boltz/main.py")[0]
453
+ _txt = pathlib.Path(_bm).read_text()
454
+ _patched = _txt.replace('precision=32 if model == "boltz1" else "bf16-mixed"', "precision=32")
455
+ assert _patched != _txt, "could not find Boltz-2 precision line to patch"
456
+ pathlib.Path(_bm).write_text(_patched)
457
+ print("patched", _bm, "-> precision=32")
458
+
459
+ step("materialise spec + target structure (embedded base64)")
460
+ spec_dir = pathlib.Path("/tmp/spec"); spec_dir.mkdir(parents=True, exist_ok=True)
461
+ for name, b64 in PAYLOAD.items():
462
+ # The payload is gzipped: structures are text and shrink to about a fifth,
463
+ # which is what keeps the script under Kaggle's size limit once a
464
+ # working-tree wheel is embedded alongside them.
465
+ dest = spec_dir / name
466
+ if not dest.resolve().is_relative_to(spec_dir.resolve()):
467
+ raise SystemExit(f"payload entry escapes the spec directory: {name}")
468
+ dest.parent.mkdir(parents=True, exist_ok=True)
469
+ dest.write_bytes(gzip.decompress(base64.b64decode(b64)))
470
+ print(" wrote", name, dest.stat().st_size, "bytes")
471
+ print(" spec:", (spec_dir / "spec.json").read_text()[:400])
472
+
473
+ step("run job_exec (RFdiffusion -> ProteinMPNN under se3; Boltz-2 + orchestration under boltz)")
474
+ out_tmp = f"/tmp/{HANDLE_ID}.tar.gz"
475
+ job_env = {"BINDSIGHT_TOOLS_ROOT": TOOLS}
476
+ if wrapper is not None:
477
+ # Left unset for validate_only, where bindsight falls back to the boltz
478
+ # environment's own interpreter. Exporting a path that was never built
479
+ # would dress a step this mode deliberately skips as an exec failure.
480
+ job_env["BINDSIGHT_DESIGN_PYTHON"] = wrapper
481
+ sh(f"{MM} run -p {BOLTZ} python -m bindsight.runners.job_exec "
482
+ f"{spec_dir}/spec.json {out_tmp}", env=job_env)
483
+
484
+ step("stage tarball to /kaggle/working (keep the output volume lean)")
485
+ pathlib.Path("/kaggle/working").mkdir(parents=True, exist_ok=True)
486
+ sh(f"cp {out_tmp} /kaggle/working/{HANDLE_ID}.tar.gz")
487
+ sz = pathlib.Path(f"/kaggle/working/{HANDLE_ID}.tar.gz").stat().st_size
488
+ print(f"\nDONE -> /kaggle/working/{HANDLE_ID}.tar.gz ({sz/1e6:.2f} MB)")
489
+ '''
@@ -0,0 +1,154 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Local-GPU runner.
4
+
5
+ For users with a local NVIDIA GPU. Two modes, selected by the ``native`` flag
6
+ (or the ``BINDSIGHT_LOCAL_NATIVE`` env var):
7
+
8
+ - **native** — runs :mod:`bindsight.runners.job_exec` in the current Python env
9
+ (a CUDA box with bindsight + the design tools installed). Simplest for an
10
+ already-provisioned workstation; also the mode CPU tests exercise (with
11
+ ``job_exec`` monkeypatched).
12
+ - **docker** — ``docker run --gpus all <image> python -m bindsight.runners.job_exec``
13
+ against the pinned bindsight image, mounting the spec + results dirs. No local
14
+ Python deps beyond Docker.
15
+
16
+ Both call the same executor, so the design+validation pipeline is identical to
17
+ the Modal/Kaggle paths.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ import subprocess
25
+ import sys
26
+ import uuid
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+
30
+ from bindsight.cost import estimate
31
+ from bindsight.runners.protocol import CostEstimate, JobHandle, JobStatus
32
+
33
+ LOG = logging.getLogger(__name__)
34
+
35
+ # Track launched processes by handle id (JobHandle is frozen, so we can't stash
36
+ # the Popen on it). Maps handle id -> (Popen, tarball Path).
37
+ _PROCS: dict[str, tuple[subprocess.Popen[bytes], Path]] = {}
38
+
39
+
40
+ class LocalDockerRunner:
41
+ """Run design+validation on a local GPU, natively or via Docker."""
42
+
43
+ name = "local_docker"
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ designer: str = "rfdiff_mpnn",
49
+ n_units_per_target: int = 50,
50
+ gpu_type: str = "A100-40GB",
51
+ image: str = "ghcr.io/mikhaeelatefrizk/bindsight:dev",
52
+ native: bool | None = None,
53
+ ) -> None:
54
+ self.designer = designer
55
+ self.n_units_per_target = n_units_per_target
56
+ self.gpu_type = gpu_type
57
+ self.image = image
58
+ self.native = (
59
+ native
60
+ if native is not None
61
+ else os.environ.get("BINDSIGHT_LOCAL_NATIVE", "").lower() in {"1", "true", "yes"}
62
+ )
63
+
64
+ def estimate_cost(self, spec_size: int) -> CostEstimate:
65
+ """Estimate cost (always $0 — your hardware)."""
66
+ return estimate(
67
+ backend=self.name,
68
+ stage="design",
69
+ plugin=self.designer,
70
+ n_units=spec_size,
71
+ gpu_type=self.gpu_type,
72
+ )
73
+
74
+ def submit(self, spec_path: Path, *, results_dir: Path) -> JobHandle:
75
+ """Launch the executor (native subprocess or docker run); return a handle."""
76
+ results_dir.mkdir(parents=True, exist_ok=True)
77
+ handle_id = str(uuid.uuid4())
78
+ tarball = results_dir / f"{handle_id}.tar.gz"
79
+
80
+ if self.native:
81
+ cmd = [
82
+ sys.executable,
83
+ "-m",
84
+ "bindsight.runners.job_exec",
85
+ str(spec_path),
86
+ str(tarball),
87
+ ]
88
+ else:
89
+ spec_dir = spec_path.parent.resolve()
90
+ cmd = [
91
+ "docker",
92
+ "run",
93
+ "--rm",
94
+ "--gpus",
95
+ "all",
96
+ "-v",
97
+ f"{spec_dir}:/spec",
98
+ "-v",
99
+ f"{results_dir.resolve()}:/results",
100
+ self.image,
101
+ "python",
102
+ "-m",
103
+ "bindsight.runners.job_exec",
104
+ f"/spec/{spec_path.name}",
105
+ f"/results/{handle_id}.tar.gz",
106
+ ]
107
+
108
+ LOG.info(
109
+ "local_docker submit (%s): %s", "native" if self.native else "docker", " ".join(cmd)
110
+ )
111
+ try:
112
+ proc = subprocess.Popen(cmd)
113
+ except FileNotFoundError as e:
114
+ raise RuntimeError(
115
+ "docker not found — install Docker, or use native mode "
116
+ "(BINDSIGHT_LOCAL_NATIVE=1) on a machine with bindsight + the design tools."
117
+ if not self.native
118
+ else f"failed to launch executor: {e}"
119
+ ) from e
120
+ _PROCS[handle_id] = (proc, tarball)
121
+ return JobHandle(
122
+ backend=self.name,
123
+ id=handle_id,
124
+ submitted_at=datetime.now(UTC).isoformat(timespec="seconds"),
125
+ results_dir=str(results_dir),
126
+ tarball=str(tarball),
127
+ )
128
+
129
+ def poll(self, handle: JobHandle) -> JobStatus:
130
+ """Report queued/running/succeeded/failed from the tracked process."""
131
+ entry = _PROCS.get(handle.id)
132
+ tarball = Path(entry[1]) if entry else Path(getattr(handle, "tarball", ""))
133
+ if entry is None:
134
+ state = "succeeded" if tarball.exists() else "failed"
135
+ return JobStatus(handle=handle, state=state, log_tail=None)
136
+ proc, _ = entry
137
+ rc = proc.poll()
138
+ if rc is None:
139
+ return JobStatus(handle=handle, state="running", progress=None)
140
+ state = "succeeded" if rc == 0 and tarball.exists() else "failed"
141
+ return JobStatus(handle=handle, state=state, log_tail=f"exit code {rc}")
142
+
143
+ def fetch(self, handle: JobHandle) -> Path:
144
+ """Block until the job finishes; return the results tarball path."""
145
+ entry = _PROCS.get(handle.id)
146
+ tarball = Path(entry[1]) if entry else Path(getattr(handle, "tarball", ""))
147
+ if entry is not None:
148
+ proc, _ = entry
149
+ rc = proc.wait()
150
+ if rc != 0:
151
+ raise RuntimeError(f"local_docker job {handle.id} failed (exit {rc})")
152
+ if not tarball.exists():
153
+ raise RuntimeError(f"local_docker job {handle.id} produced no results at {tarball}")
154
+ return tarball