bindsight 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Shared designer plumbing: ship the spec+structure to a runner, stage results.
|
|
4
|
+
|
|
5
|
+
All designer plugins (RFdiffusion+ProteinMPNN, BindCraft, BoltzGen) submit the
|
|
6
|
+
same way — write the ``DesignSpec`` JSON and the target structure into one dir,
|
|
7
|
+
launch the chosen :class:`~bindsight.runners.protocol.GPURunner`, block on
|
|
8
|
+
``fetch``, then stage the tarball and extract ``metrics.jsonl``. The only
|
|
9
|
+
difference is which designer the remote executor dispatches to (recorded in
|
|
10
|
+
``extra_params['designer']``). This module is that shared body.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import shutil
|
|
17
|
+
import tarfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from bindsight.design.protocol import DesignResult, DesignSpec
|
|
21
|
+
from bindsight.runners.protocol import GPURunner, JobHandle
|
|
22
|
+
|
|
23
|
+
LOG = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _code_identity(runner: object) -> str:
|
|
27
|
+
"""Identify the bindsight that will actually execute on the runner.
|
|
28
|
+
|
|
29
|
+
A remote runner pip-installs bindsight before running anything, so the code
|
|
30
|
+
that produces a result is not necessarily the code that submitted it. When
|
|
31
|
+
the runner carries a working-tree wheel, that wheel's content hash is the
|
|
32
|
+
exact answer. When it carries only a git ref, the honest answer is the ref
|
|
33
|
+
name, which is weaker: a branch resolves to different commits over time.
|
|
34
|
+
With neither, the local package version is the best available.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
runner: the GPU runner about to receive the job.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A short string identifying the code, for folding into the cache key.
|
|
41
|
+
"""
|
|
42
|
+
import hashlib
|
|
43
|
+
|
|
44
|
+
wheel = getattr(runner, "bindsight_wheel", None)
|
|
45
|
+
if wheel is not None:
|
|
46
|
+
try:
|
|
47
|
+
digest = hashlib.sha256(Path(wheel).read_bytes()).hexdigest()[:16]
|
|
48
|
+
return f"wheel:{digest}"
|
|
49
|
+
except OSError:
|
|
50
|
+
return f"wheel:{Path(wheel).name}"
|
|
51
|
+
ref = getattr(runner, "bindsight_ref", None)
|
|
52
|
+
if ref:
|
|
53
|
+
return f"ref:{ref}"
|
|
54
|
+
from bindsight import __version__
|
|
55
|
+
|
|
56
|
+
return f"version:{__version__}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _with_backend(cache_key: str, backend: str, code: str = "") -> str:
|
|
60
|
+
"""Fold the backend, and the code that will run, into a cache key.
|
|
61
|
+
|
|
62
|
+
Two runs that differ only in where they executed are not the same work: one
|
|
63
|
+
may be synthetic. Mixing them is the difference between a real result and a
|
|
64
|
+
mock's canned numbers wearing a real result's label.
|
|
65
|
+
|
|
66
|
+
The same applies to which bindsight runs. A corrected designer benchmark was
|
|
67
|
+
submitted against a branch whose fixes existed only locally, so the GPU
|
|
68
|
+
pip-installed the default branch and produced pre-fix output. Had that run
|
|
69
|
+
succeeded and been cached, a later run of the fixed code would have been
|
|
70
|
+
handed the unfixed result under the same key.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
cache_key: the spec-derived key.
|
|
74
|
+
backend: the runner's name.
|
|
75
|
+
code: identity of the bindsight that will execute, from
|
|
76
|
+
:func:`_code_identity`. Empty reproduces the previous key exactly,
|
|
77
|
+
which keeps existing cache entries addressable.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The folded key.
|
|
81
|
+
"""
|
|
82
|
+
import hashlib
|
|
83
|
+
|
|
84
|
+
bits = f"{cache_key}|backend={backend}"
|
|
85
|
+
if code:
|
|
86
|
+
bits += f"|code={code}"
|
|
87
|
+
return hashlib.sha256(bits.encode()).hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _with_payload(cache_key: str, payload_dir: Path) -> str:
|
|
91
|
+
"""Fold a shipped ``design/`` directory into a cache key.
|
|
92
|
+
|
|
93
|
+
The spec-derived key covers what a *designer* would be told to produce. For
|
|
94
|
+
a ``validate_only`` job nothing is produced: the binders travel in the
|
|
95
|
+
payload, and the spec is identical no matter which ones. Two calibration
|
|
96
|
+
sets — twenty scrambles, and the same twenty plus their originals — hashed
|
|
97
|
+
to the same key against the same target, so the second would have been
|
|
98
|
+
served the first's twenty rows and read as the answer to a question it never
|
|
99
|
+
asked. That is the same failure the validator fold in :func:`make_cache_key`
|
|
100
|
+
exists to prevent, one level further in.
|
|
101
|
+
|
|
102
|
+
Both the relative path and the content of every shipped file are covered:
|
|
103
|
+
renaming a binder changes which row is which downstream. Files are ordered
|
|
104
|
+
by their POSIX relative path so the key is the same on every platform.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
cache_key: the key so far.
|
|
108
|
+
payload_dir: the local directory shipped as ``design/``.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The folded key.
|
|
112
|
+
"""
|
|
113
|
+
import hashlib
|
|
114
|
+
|
|
115
|
+
digest = hashlib.sha256()
|
|
116
|
+
# Sorted by the POSIX relative path *string*, not by the Path objects.
|
|
117
|
+
# `PurePath.__lt__` compares the parts tuple, and case-insensitively on
|
|
118
|
+
# Windows, so an identical payload hashed in a different order depending on
|
|
119
|
+
# the operating system: {"a.txt", "a/b.txt", "B.txt"} came out
|
|
120
|
+
# ["B.txt", "a/b.txt", "a.txt"] on Linux and ["a/b.txt", "a.txt", "B.txt"]
|
|
121
|
+
# on Windows. The key is meant to be the identity of the content, and an
|
|
122
|
+
# identity that changes with the machine is not one -- a rerun on CI could
|
|
123
|
+
# not reuse a local result, and two keys that differed said the payloads did.
|
|
124
|
+
relative = sorted(
|
|
125
|
+
(q.relative_to(payload_dir).as_posix(), q) for q in payload_dir.rglob("*") if q.is_file()
|
|
126
|
+
)
|
|
127
|
+
for rel, path in relative:
|
|
128
|
+
digest.update(rel.encode())
|
|
129
|
+
digest.update(b"\0")
|
|
130
|
+
digest.update(hashlib.sha256(path.read_bytes()).digest())
|
|
131
|
+
return hashlib.sha256(f"{cache_key}|payload={digest.hexdigest()}".encode()).hexdigest()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _record_handle(handle_path: Path, handle: object) -> None:
|
|
135
|
+
"""Persist a launched job's handle, without ever failing the job to do it.
|
|
136
|
+
|
|
137
|
+
Recording happens *after* the remote job is launched, which makes this the
|
|
138
|
+
one place where raising would destroy exactly what it exists to protect: the
|
|
139
|
+
run would abort with a kernel already consuming quota and no record of how
|
|
140
|
+
to reach it. So every failure here is a warning. A runner whose handle
|
|
141
|
+
cannot be serialised simply loses reattachability, which is where the tool
|
|
142
|
+
stood before this existed.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
handle_path: where to write the record.
|
|
146
|
+
handle: the handle returned by ``runner.submit``.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
handle_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
dump = getattr(handle, "model_dump_json", None)
|
|
151
|
+
if dump is None:
|
|
152
|
+
LOG.warning(
|
|
153
|
+
"runner returned a %s rather than a JobHandle, so this job cannot "
|
|
154
|
+
"be reattached to if the client dies",
|
|
155
|
+
type(handle).__name__,
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
handle_path.write_text(dump(indent=2), encoding="utf-8", newline="\n")
|
|
159
|
+
except (OSError, TypeError, ValueError) as e:
|
|
160
|
+
LOG.warning(
|
|
161
|
+
"could not record the job handle at %s (%s); a crashed wait will not "
|
|
162
|
+
"be able to reattach to this job",
|
|
163
|
+
handle_path,
|
|
164
|
+
e,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _recorded_handle(handle_path: Path, runner: GPURunner) -> JobHandle | None:
|
|
169
|
+
"""Return a still-live job recorded by an earlier client, if there is one.
|
|
170
|
+
|
|
171
|
+
A remote job outlives the process that launched it. When the client dies
|
|
172
|
+
mid-wait — a dropped connection, a closed laptop, a killed terminal — the
|
|
173
|
+
kernel keeps running and keeps spending a fixed weekly GPU quota, but
|
|
174
|
+
nothing is left that knows how to collect its output. Resubmitting is the
|
|
175
|
+
obvious response and the wrong one: it pays for the same work twice and
|
|
176
|
+
orphans the first job for good. This is the recovery path, and it is why
|
|
177
|
+
:func:`submit_via_runner` records the handle before it starts waiting.
|
|
178
|
+
|
|
179
|
+
A job that ended badly is not reattachable, so its record is cleared and the
|
|
180
|
+
caller submits afresh. But a job whose state cannot be *determined* is
|
|
181
|
+
reattached anyway: the cost of waiting on a job that turns out to be dead is
|
|
182
|
+
one more poll, while the cost of resubmitting a job that is actually alive is
|
|
183
|
+
hours of quota. :meth:`fetch` retries through transient failures, so the
|
|
184
|
+
reattached wait recovers on its own once the network does.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
handle_path: where the handle was recorded.
|
|
188
|
+
runner: the runner to ask about the job's state.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The handle to reattach to, or None if the caller should submit.
|
|
192
|
+
"""
|
|
193
|
+
if not handle_path.is_file():
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
handle = JobHandle.model_validate_json(handle_path.read_text(encoding="utf-8"))
|
|
197
|
+
except (OSError, ValueError) as e:
|
|
198
|
+
LOG.warning("ignoring unreadable job record %s (%s)", handle_path, e)
|
|
199
|
+
return None
|
|
200
|
+
try:
|
|
201
|
+
state = runner.poll(handle).state
|
|
202
|
+
except Exception as e:
|
|
203
|
+
LOG.warning(
|
|
204
|
+
"could not determine the state of recorded job %s (%s); reattaching "
|
|
205
|
+
"rather than resubmitting, because a duplicate submission would "
|
|
206
|
+
"spend GPU quota on work that may already be running.",
|
|
207
|
+
handle.id,
|
|
208
|
+
e,
|
|
209
|
+
)
|
|
210
|
+
return handle
|
|
211
|
+
if state in {"queued", "running", "succeeded"}:
|
|
212
|
+
LOG.info(
|
|
213
|
+
"reattaching to %s job %s (state=%s) instead of resubmitting",
|
|
214
|
+
handle.backend,
|
|
215
|
+
handle.id,
|
|
216
|
+
state,
|
|
217
|
+
)
|
|
218
|
+
return handle
|
|
219
|
+
LOG.info("recorded job %s ended in state %s; submitting a new one", handle.id, state)
|
|
220
|
+
handle_path.unlink(missing_ok=True)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def submit_via_runner(
|
|
225
|
+
spec: DesignSpec,
|
|
226
|
+
runner: GPURunner,
|
|
227
|
+
*,
|
|
228
|
+
designer_name: str,
|
|
229
|
+
designer_version: str,
|
|
230
|
+
designer_commit_sha: str | None,
|
|
231
|
+
cache_key: str,
|
|
232
|
+
payload_dir: Path | None = None,
|
|
233
|
+
) -> DesignResult:
|
|
234
|
+
"""Ship spec + structure to ``runner``, block, stage results, return DesignResult.
|
|
235
|
+
|
|
236
|
+
``payload_dir`` is an optional local ``design/`` directory shipped alongside
|
|
237
|
+
the spec. Validation-only jobs use it to send binders that already exist, so
|
|
238
|
+
a different validator can be run against them without redesigning.
|
|
239
|
+
"""
|
|
240
|
+
# The backend belongs in the key. Without it, a run on --backend mock and a
|
|
241
|
+
# run on a real GPU share a cache entry, so a later real run silently returns
|
|
242
|
+
# the mock's synthetic tarball and reports it as a genuine result. The
|
|
243
|
+
# designer's commit is already folded in by the caller; the runner is not.
|
|
244
|
+
cache_key = _with_backend(cache_key, getattr(runner, "name", "unknown"), _code_identity(runner))
|
|
245
|
+
payload = Path(payload_dir) if payload_dir is not None else None
|
|
246
|
+
if payload is not None and payload.is_dir():
|
|
247
|
+
# Folded here rather than by the caller: this is the function the
|
|
248
|
+
# payload is handed to, so keying it cannot be forgotten by whoever
|
|
249
|
+
# ships one next. It must precede spec_dir, which is named after the key.
|
|
250
|
+
cache_key = _with_payload(cache_key, payload)
|
|
251
|
+
else:
|
|
252
|
+
payload = None
|
|
253
|
+
spec_dir = Path(f"_bindsight_spec_{cache_key[:8]}")
|
|
254
|
+
spec_dir.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
if payload is not None:
|
|
256
|
+
shutil.copytree(payload, spec_dir / "design", dirs_exist_ok=True)
|
|
257
|
+
|
|
258
|
+
# Ship the target structure next to the spec (it is not embedded in the
|
|
259
|
+
# spec). Record the filename in extra_params so the executor can find it.
|
|
260
|
+
structure_src = Path(spec.target_structure_path)
|
|
261
|
+
target_name = "target" + (structure_src.suffix or ".pdb")
|
|
262
|
+
if not structure_src.is_file():
|
|
263
|
+
# The spec records `target_structure_name` unconditionally, so a missing
|
|
264
|
+
# structure was not shipped and the spec still told the executor to find
|
|
265
|
+
# it. `job_exec._locate_structure` raises when it cannot -- but only
|
|
266
|
+
# after the GPU session has been allocated and paid for, and its own
|
|
267
|
+
# comment gives the reason to fail early rather than late. The same
|
|
268
|
+
# argument applies one level further out, where the file is at hand.
|
|
269
|
+
#
|
|
270
|
+
# `make_cache_key` makes it worse: a structure it cannot read hashes as
|
|
271
|
+
# the empty string, so two jobs against two different missing structures
|
|
272
|
+
# share a key and the second would be served the first's designs.
|
|
273
|
+
shutil.rmtree(spec_dir, ignore_errors=True)
|
|
274
|
+
raise FileNotFoundError(
|
|
275
|
+
f"target structure not found: {structure_src}. The job ships this "
|
|
276
|
+
"file beside the spec, so it must exist before submitting. Re-run "
|
|
277
|
+
"the discover stage, or correct target_structure_path."
|
|
278
|
+
)
|
|
279
|
+
shutil.copy2(structure_src, spec_dir / target_name)
|
|
280
|
+
spec_to_send = spec.model_copy(
|
|
281
|
+
update={"extra_params": {**spec.extra_params, "target_structure_name": target_name}}
|
|
282
|
+
)
|
|
283
|
+
spec_path = spec_dir / "spec.json"
|
|
284
|
+
spec_path.write_text(spec_to_send.model_dump_json(indent=2), encoding="utf-8", newline="\n")
|
|
285
|
+
|
|
286
|
+
# Idempotency (ARCHITECTURE.md 4.4): a completed unit of work is identified
|
|
287
|
+
# by its cache key, so a rerun with identical inputs must not pay for the GPU
|
|
288
|
+
# again. The key was previously computed and then used only as a directory
|
|
289
|
+
# name, so every rerun resubmitted. Check it before submitting, and record
|
|
290
|
+
# the hit or miss so the manifest can show which units were reused.
|
|
291
|
+
results_dir = Path("./runs/_design") / cache_key
|
|
292
|
+
staged_archive = results_dir / "results.tar.gz"
|
|
293
|
+
metrics_path = results_dir / "metrics.jsonl"
|
|
294
|
+
# A non-empty file is not a readable archive. A download interrupted partway
|
|
295
|
+
# leaves bytes on disk, and size alone called that a hit -- permanently, on
|
|
296
|
+
# every rerun, because nothing here ever re-submits once the file exists.
|
|
297
|
+
# `extract_member` then swallows the TarError as a warning and returns
|
|
298
|
+
# without writing anything, so the result pointed at a metrics file that was
|
|
299
|
+
# never created while reporting cache_status="hit". The cache poisoned
|
|
300
|
+
# itself and stayed poisoned.
|
|
301
|
+
cache_hit = staged_archive.exists() and staged_archive.stat().st_size > 0
|
|
302
|
+
if cache_hit and not _archive_is_readable(staged_archive):
|
|
303
|
+
LOG.warning(
|
|
304
|
+
"cached archive %s is present but unreadable; treating as a miss and re-running",
|
|
305
|
+
staged_archive,
|
|
306
|
+
)
|
|
307
|
+
cache_hit = False
|
|
308
|
+
if cache_hit:
|
|
309
|
+
LOG.info("cache hit for %s; skipping submit (%s)", cache_key[:8], staged_archive)
|
|
310
|
+
if not metrics_path.exists():
|
|
311
|
+
extract_member(staged_archive, "metrics.jsonl", metrics_path)
|
|
312
|
+
if not metrics_path.exists():
|
|
313
|
+
# A readable archive that does not carry metrics.jsonl is not a
|
|
314
|
+
# usable result either, and returning its path would hand the caller
|
|
315
|
+
# a file that is not there.
|
|
316
|
+
LOG.warning(
|
|
317
|
+
"cached archive %s carries no metrics.jsonl; treating as a miss", staged_archive
|
|
318
|
+
)
|
|
319
|
+
cache_hit = False
|
|
320
|
+
if cache_hit:
|
|
321
|
+
shutil.rmtree(spec_dir, ignore_errors=True)
|
|
322
|
+
return DesignResult(
|
|
323
|
+
spec=spec,
|
|
324
|
+
results_archive_path=str(staged_archive),
|
|
325
|
+
metrics_jsonl_path=str(metrics_path),
|
|
326
|
+
designer_name=designer_name,
|
|
327
|
+
designer_version=designer_version,
|
|
328
|
+
designer_commit_sha=designer_commit_sha,
|
|
329
|
+
weights_sha256=None,
|
|
330
|
+
cache_key=cache_key,
|
|
331
|
+
cache_status="hit",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Record the handle before waiting on it. A remote job outlives its client,
|
|
335
|
+
# so without this a crashed wait leaves a running kernel that nothing can
|
|
336
|
+
# collect, and the rerun pays for the same GPU hours again. See
|
|
337
|
+
# :func:`_recorded_handle`.
|
|
338
|
+
handle_path = results_dir / "handle.json"
|
|
339
|
+
try:
|
|
340
|
+
handle = _recorded_handle(handle_path, runner)
|
|
341
|
+
if handle is None:
|
|
342
|
+
handle = runner.submit(spec_path, results_dir=Path("./runs/_design"))
|
|
343
|
+
_record_handle(handle_path, handle)
|
|
344
|
+
archive_path = runner.fetch(handle)
|
|
345
|
+
finally:
|
|
346
|
+
shutil.rmtree(spec_dir, ignore_errors=True)
|
|
347
|
+
|
|
348
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
if archive_path.resolve() != staged_archive.resolve():
|
|
350
|
+
shutil.copy2(archive_path, staged_archive)
|
|
351
|
+
extract_member(staged_archive, "metrics.jsonl", metrics_path)
|
|
352
|
+
|
|
353
|
+
return DesignResult(
|
|
354
|
+
spec=spec,
|
|
355
|
+
results_archive_path=str(staged_archive),
|
|
356
|
+
metrics_jsonl_path=str(metrics_path),
|
|
357
|
+
designer_name=designer_name,
|
|
358
|
+
designer_version=designer_version,
|
|
359
|
+
designer_commit_sha=designer_commit_sha,
|
|
360
|
+
weights_sha256=None, # multi-GB checkpoints; verified on download
|
|
361
|
+
cache_key=cache_key,
|
|
362
|
+
cache_status="miss",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _archive_is_readable(tar_path: Path) -> bool:
|
|
367
|
+
"""Whether a staged results archive can actually be opened and listed.
|
|
368
|
+
|
|
369
|
+
Existence and a non-zero size do not make a tarball readable: an interrupted
|
|
370
|
+
transfer leaves plausible-looking bytes. Reading the member list is the
|
|
371
|
+
cheapest check that touches the gzip stream and the tar index, which is
|
|
372
|
+
where a truncated file fails.
|
|
373
|
+
"""
|
|
374
|
+
try:
|
|
375
|
+
with tarfile.open(tar_path, "r:gz", encoding="utf-8") as tf:
|
|
376
|
+
return bool(tf.getnames())
|
|
377
|
+
except (tarfile.TarError, OSError, EOFError) as e:
|
|
378
|
+
LOG.warning("cached archive %s is not readable: %s", tar_path, e)
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def extract_member(tar_path: Path, member: str, dest: Path) -> None:
|
|
383
|
+
"""Extract a single named member from a .tar.gz to ``dest`` (best-effort)."""
|
|
384
|
+
if not tar_path.exists():
|
|
385
|
+
return
|
|
386
|
+
try:
|
|
387
|
+
with tarfile.open(tar_path, "r:gz", encoding="utf-8") as tf:
|
|
388
|
+
names = {Path(n).name: n for n in tf.getnames()}
|
|
389
|
+
if member in names:
|
|
390
|
+
src = tf.extractfile(names[member])
|
|
391
|
+
if src is not None:
|
|
392
|
+
dest.write_bytes(src.read())
|
|
393
|
+
except (tarfile.TarError, OSError) as e:
|
|
394
|
+
LOG.warning("could not extract %s from %s: %s", member, tar_path, e)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
#: ``extra_params`` entries that change the result, and so belong in the key.
|
|
398
|
+
#:
|
|
399
|
+
#: Deliberately not the whole dict. ``target_structure_name`` is added *after*
|
|
400
|
+
#: the key is computed, so hashing everything would make the key depend on
|
|
401
|
+
#: bookkeeping rather than on the work. These are the entries the remote
|
|
402
|
+
#: executor actually acts on: which validator scores the designs, and whether an
|
|
403
|
+
#: ESM-2 prescreen drops some before they are scored at all.
|
|
404
|
+
#: ``extra_params`` keys that change what a job produces, and so belong in its key.
|
|
405
|
+
#:
|
|
406
|
+
#: ``diffusion_samples`` is here because it changes the reported number, not
|
|
407
|
+
#: just its precision: the validator averages over that many draws, so one draw
|
|
408
|
+
#: and five are different measurements of the same design.
|
|
409
|
+
#:
|
|
410
|
+
#: ``max_parallel_samples`` looks like a performance knob and is not one. The
|
|
411
|
+
#: sampler draws noise shaped by the batch, so the same seed consumes the RNG
|
|
412
|
+
#: stream differently at batch 1 and batch 5 and yields different structures.
|
|
413
|
+
#: Two runs differing only in it are reproducible individually and not
|
|
414
|
+
#: comparable to each other.
|
|
415
|
+
#:
|
|
416
|
+
#: ``mode`` is here because a validate-only job runs no designer at all.
|
|
417
|
+
_RESULT_AFFECTING_PARAMS = (
|
|
418
|
+
"validator",
|
|
419
|
+
# ``designer`` selects which designer executes -- ``job_exec`` reads it
|
|
420
|
+
# straight out of ``extra_params`` -- and was not in this tuple. Two jobs
|
|
421
|
+
# differing only in it shared a key. That they happened not to collide was
|
|
422
|
+
# incidental: the three adapters pass distinct pinned commits through
|
|
423
|
+
# ``extra=``, so the keys differed for a reason unrelated to the field that
|
|
424
|
+
# chose the tool. ``designer_version`` was absent too, and nothing else
|
|
425
|
+
# carries it, so bumping a designer's version without bumping its pinned
|
|
426
|
+
# commit produced an identical key and recorded the new version against the
|
|
427
|
+
# old cached numbers.
|
|
428
|
+
#
|
|
429
|
+
# The docstring below already narrates this exact failure happening once,
|
|
430
|
+
# for ``validator``.
|
|
431
|
+
"designer",
|
|
432
|
+
"designer_version",
|
|
433
|
+
"prescreen_top_k",
|
|
434
|
+
"diffusion_samples",
|
|
435
|
+
"max_parallel_samples",
|
|
436
|
+
"mode",
|
|
437
|
+
# Read by ``job_exec`` at the BoltzGen call site and absent from this tuple,
|
|
438
|
+
# so two BoltzGen jobs differing only in protocol -- a different design task
|
|
439
|
+
# entirely -- shared a key, and the second silently returned the first's
|
|
440
|
+
# designs. ``use_kernels`` selects a different compute path for the same
|
|
441
|
+
# sampler and is not guaranteed bit-identical to the reference one.
|
|
442
|
+
#
|
|
443
|
+
# This is the third time an entry the executor acts on was missing from a
|
|
444
|
+
# hand-kept list; ``tests/test_cache_key_scope.py`` now derives the set the
|
|
445
|
+
# executor actually reads and fails when one is added without a decision.
|
|
446
|
+
"boltzgen_protocol",
|
|
447
|
+
"boltzgen_use_kernels",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
#: ``extra_params`` keys the executor reads that deliberately stay out of the key.
|
|
451
|
+
#:
|
|
452
|
+
#: Each needs a reason, because the default for anything the executor acts on is
|
|
453
|
+
#: to be in the key.
|
|
454
|
+
_CACHE_KEY_EXEMPT_PARAMS = {
|
|
455
|
+
# Assigned after the key is computed, and names the file rather than its
|
|
456
|
+
# content -- the content is already hashed into the key as ``structure_digest``.
|
|
457
|
+
# Including it would make the key depend on bookkeeping.
|
|
458
|
+
"target_structure_name",
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def make_cache_key(spec: DesignSpec, *, extra: tuple[str, ...] = ()) -> str:
|
|
463
|
+
"""SHA-256 over the deterministic inputs to a design job.
|
|
464
|
+
|
|
465
|
+
Covers the target (accession *and* structure content), the epitope, the
|
|
466
|
+
design ranges, the binder length bounds, the trajectory count, the seed, the
|
|
467
|
+
result-affecting ``extra_params``, and whatever the caller adds via ``extra``
|
|
468
|
+
(the designer's pinned commit and resolved parameters). Two jobs sharing a
|
|
469
|
+
key are the same work.
|
|
470
|
+
|
|
471
|
+
The validator belongs here for the same reason the backend does. The remote
|
|
472
|
+
executor runs whichever validator ``extra_params`` names, so without it a run
|
|
473
|
+
validated by Boltz-2 and a run validated by Chai-1r shared a cache entry, and
|
|
474
|
+
the second silently returned the first's numbers under the other validator's
|
|
475
|
+
name — a wrong answer that looks entirely plausible.
|
|
476
|
+
"""
|
|
477
|
+
import hashlib
|
|
478
|
+
|
|
479
|
+
structure = Path(spec.target_structure_path)
|
|
480
|
+
structure_digest = ""
|
|
481
|
+
if structure.is_file():
|
|
482
|
+
# A new AlphaFold model for the same accession is different work. Without
|
|
483
|
+
# the content in the key, a rerun would reuse a result computed against
|
|
484
|
+
# the superseded structure.
|
|
485
|
+
structure_digest = hashlib.sha256(structure.read_bytes()).hexdigest()
|
|
486
|
+
bits = "|".join(
|
|
487
|
+
[
|
|
488
|
+
spec.target_uniprot,
|
|
489
|
+
structure_digest,
|
|
490
|
+
spec.epitope_chain,
|
|
491
|
+
",".join(str(r) for r in sorted(spec.epitope_residues)),
|
|
492
|
+
",".join(f"{lo}-{hi}" for lo, hi in spec.design_ranges),
|
|
493
|
+
str(spec.binder_length_min),
|
|
494
|
+
str(spec.binder_length_max),
|
|
495
|
+
str(spec.n_trajectories),
|
|
496
|
+
str(spec.seed),
|
|
497
|
+
*(f"{k}={spec.extra_params.get(k)}" for k in _RESULT_AFFECTING_PARAMS),
|
|
498
|
+
*extra,
|
|
499
|
+
]
|
|
500
|
+
)
|
|
501
|
+
return hashlib.sha256(bits.encode()).hexdigest()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""BindCraft designer plugin (premium GPU path).
|
|
4
|
+
|
|
5
|
+
BindCraft (`Pacesa et al., Nature 2025 <https://www.nature.com/articles/s41586-025-09429-6>`_,
|
|
6
|
+
MIT) is a one-shot AF2-based binder design pipeline with reported ~10-100%
|
|
7
|
+
experimental success rates. Trade-off vs. RFdiff+MPNN: needs ≥32 GB VRAM, so
|
|
8
|
+
free Colab T4 (16 GB) doesn't fit; you need Colab Pro+ A100 or paid Modal.
|
|
9
|
+
|
|
10
|
+
The GPU work runs in :mod:`bindsight.runners.job_exec`; this module owns the
|
|
11
|
+
plugin entry point and the spec it builds.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from bindsight.design._common import make_cache_key, submit_via_runner
|
|
20
|
+
from bindsight.design.protocol import DesignResult, DesignSpec
|
|
21
|
+
from bindsight.runners.protocol import GPURunner
|
|
22
|
+
from bindsight.runners.tools import BINDCRAFT_COMMIT
|
|
23
|
+
|
|
24
|
+
LOG = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BindCraftDesigner:
|
|
28
|
+
"""Plugin: BindCraft one-shot binder design (≥32 GB VRAM)."""
|
|
29
|
+
|
|
30
|
+
name = "bindcraft"
|
|
31
|
+
# The adapter's own version, not the upstream tool's. Pinned upstream
|
|
32
|
+
# revisions live in the spec's extra_params and in the cache key; this
|
|
33
|
+
# number moves only when this adapter's behaviour does.
|
|
34
|
+
version = "0.1.0"
|
|
35
|
+
license_notice = "BindCraft: MIT. Commercial-OK. Requires ≥32 GB VRAM."
|
|
36
|
+
|
|
37
|
+
def make_spec(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
target_uniprot: str,
|
|
41
|
+
target_structure_path: Path,
|
|
42
|
+
epitope_residues: list[int],
|
|
43
|
+
epitope_chain: str = "A",
|
|
44
|
+
design_ranges: list[tuple[int, int]] | None = None,
|
|
45
|
+
n_trajectories: int = 50,
|
|
46
|
+
seed: int = 0,
|
|
47
|
+
binder_length_min: int = 50,
|
|
48
|
+
binder_length_max: int = 100,
|
|
49
|
+
) -> DesignSpec:
|
|
50
|
+
"""Build a DesignSpec carrying the BindCraft parameters."""
|
|
51
|
+
return DesignSpec(
|
|
52
|
+
target_uniprot=target_uniprot,
|
|
53
|
+
target_structure_path=str(target_structure_path),
|
|
54
|
+
epitope_chain=epitope_chain,
|
|
55
|
+
epitope_residues=epitope_residues,
|
|
56
|
+
design_ranges=list(design_ranges or []),
|
|
57
|
+
n_trajectories=n_trajectories,
|
|
58
|
+
seed=seed,
|
|
59
|
+
binder_length_min=binder_length_min,
|
|
60
|
+
binder_length_max=binder_length_max,
|
|
61
|
+
extra_params={
|
|
62
|
+
"designer": self.name,
|
|
63
|
+
"designer_version": self.version,
|
|
64
|
+
"bindcraft_commit": BINDCRAFT_COMMIT,
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def submit(self, spec: DesignSpec, runner: GPURunner) -> DesignResult:
|
|
69
|
+
"""Ship the spec+structure to the runner and stage the results."""
|
|
70
|
+
cache_key = make_cache_key(spec, extra=(BINDCRAFT_COMMIT,))
|
|
71
|
+
return submit_via_runner(
|
|
72
|
+
spec,
|
|
73
|
+
runner,
|
|
74
|
+
designer_name=self.name,
|
|
75
|
+
designer_version=self.version,
|
|
76
|
+
designer_commit_sha=BINDCRAFT_COMMIT,
|
|
77
|
+
cache_key=cache_key,
|
|
78
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""BoltzGen designer plugin (newest, watch-list for v0.2 default).
|
|
4
|
+
|
|
5
|
+
`BoltzGen <https://github.com/HannesStark/boltzgen>`_ (Nov 2025, MIT for both
|
|
6
|
+
code and weights) is a "universal binder design" framework from the same
|
|
7
|
+
author as Boltz-2. Permissive license + same family as our default validator
|
|
8
|
+
makes it the strongest v0.2 candidate to replace RFdiff+MPNN as the default
|
|
9
|
+
designer once benchmarked.
|
|
10
|
+
|
|
11
|
+
The GPU work runs in :mod:`bindsight.runners.job_exec`; this module owns the
|
|
12
|
+
plugin entry point and the spec it builds.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from bindsight.design._common import make_cache_key, submit_via_runner
|
|
21
|
+
from bindsight.design.protocol import DesignResult, DesignSpec
|
|
22
|
+
from bindsight.runners.protocol import GPURunner
|
|
23
|
+
from bindsight.runners.tools import BOLTZGEN_COMMIT
|
|
24
|
+
|
|
25
|
+
LOG = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BoltzGenDesigner:
|
|
29
|
+
"""Plugin: BoltzGen universal binder design."""
|
|
30
|
+
|
|
31
|
+
name = "boltzgen"
|
|
32
|
+
# The adapter's own version, not the upstream tool's. Pinned upstream
|
|
33
|
+
# revisions live in the spec's extra_params and in the cache key; this
|
|
34
|
+
# number moves only when this adapter's behaviour does.
|
|
35
|
+
version = "0.1.0"
|
|
36
|
+
license_notice = "BoltzGen: MIT (code + weights). Commercial-OK."
|
|
37
|
+
|
|
38
|
+
def make_spec(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
target_uniprot: str,
|
|
42
|
+
target_structure_path: Path,
|
|
43
|
+
epitope_residues: list[int],
|
|
44
|
+
epitope_chain: str = "A",
|
|
45
|
+
design_ranges: list[tuple[int, int]] | None = None,
|
|
46
|
+
n_trajectories: int = 50,
|
|
47
|
+
seed: int = 0,
|
|
48
|
+
binder_length_min: int = 50,
|
|
49
|
+
binder_length_max: int = 100,
|
|
50
|
+
) -> DesignSpec:
|
|
51
|
+
"""Build a DesignSpec carrying the BoltzGen parameters."""
|
|
52
|
+
return DesignSpec(
|
|
53
|
+
target_uniprot=target_uniprot,
|
|
54
|
+
target_structure_path=str(target_structure_path),
|
|
55
|
+
epitope_chain=epitope_chain,
|
|
56
|
+
epitope_residues=epitope_residues,
|
|
57
|
+
design_ranges=list(design_ranges or []),
|
|
58
|
+
n_trajectories=n_trajectories,
|
|
59
|
+
seed=seed,
|
|
60
|
+
binder_length_min=binder_length_min,
|
|
61
|
+
binder_length_max=binder_length_max,
|
|
62
|
+
extra_params={
|
|
63
|
+
"designer": self.name,
|
|
64
|
+
"designer_version": self.version,
|
|
65
|
+
"boltzgen_commit": BOLTZGEN_COMMIT,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def submit(self, spec: DesignSpec, runner: GPURunner) -> DesignResult:
|
|
70
|
+
"""Ship the spec+structure to the runner and stage the results."""
|
|
71
|
+
cache_key = make_cache_key(spec, extra=(BOLTZGEN_COMMIT,))
|
|
72
|
+
return submit_via_runner(
|
|
73
|
+
spec,
|
|
74
|
+
runner,
|
|
75
|
+
designer_name=self.name,
|
|
76
|
+
designer_version=self.version,
|
|
77
|
+
designer_commit_sha=BOLTZGEN_COMMIT,
|
|
78
|
+
cache_key=cache_key,
|
|
79
|
+
)
|