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