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
bindsight/cli.py ADDED
@@ -0,0 +1,2429 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """bindsight command-line interface.
4
+
5
+ A Click app exposing the pipeline stages as subcommands: ``discover`` (CPU
6
+ genomics → target shortlist), ``design`` / ``validate`` (GPU binder design +
7
+ structure/affinity prediction, dispatched to a runner backend), ``rank``,
8
+ ``report``, ``export``, ``benchmark`` (rediscovery scoring against the held-out
9
+ known-antigen set), plus ``run`` (full pipeline), ``demo``, ``ui``, ``doctor``
10
+ and ``verify-licenses``.
11
+
12
+ GPU stages require a CUDA backend (``--backend modal|local_docker|kaggle`` for
13
+ headless execution, or ``--backend colab`` to generate a notebook). The CPU
14
+ stages run anywhere; ``--backend mock`` exercises the full chain in CI.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import sys
22
+ from importlib.metadata import PackageNotFoundError
23
+ from importlib.metadata import version as pkg_version
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ import click
28
+ from rich.console import Console
29
+ from rich.logging import RichHandler
30
+ from rich.panel import Panel
31
+ from rich.table import Table
32
+
33
+ from bindsight import __version__
34
+
35
+ if TYPE_CHECKING: # pragma: no cover - typing only; runtime imports stay lazy
36
+ from bindsight.config import RunConfig
37
+ from bindsight.runners.protocol import CostEstimate
38
+
39
+
40
+ def _force_utf8_io() -> None:
41
+ """Reconfigure stdout/stderr to UTF-8.
42
+
43
+ Rich uses Unicode box-drawing characters for panels (┃ ━ etc.) and the
44
+ pipeline log messages contain ≥, ×, → and other non-cp1252 glyphs. The
45
+ default Windows console is cp1252 and will raise UnicodeEncodeError when
46
+ asked to write those. ``sys.stdout.reconfigure(encoding='utf-8')`` is the
47
+ one-line fix that works on Python 3.7+ on all platforms.
48
+ """
49
+ import contextlib
50
+
51
+ for stream_name in ("stdout", "stderr"):
52
+ stream = getattr(sys, stream_name, None)
53
+ if stream is not None and hasattr(stream, "reconfigure"):
54
+ # Tests sometimes wrap sys.stdout in a non-reconfigurable buffer;
55
+ # tolerate that and fall back to silently dropping bad chars.
56
+ with contextlib.suppress(Exception):
57
+ stream.reconfigure(encoding="utf-8", errors="replace")
58
+
59
+
60
+ _force_utf8_io()
61
+
62
+ LOG_CLI = logging.getLogger(__name__)
63
+
64
+
65
+ def _default_known_antigens() -> Path:
66
+ """The shipped known-antigen set, resolved from the source tree if present.
67
+
68
+ The default was the bare relative path ``benchmarks/known.tsv``, which only
69
+ exists when the command is run from a repository checkout — and Click checks
70
+ ``exists=True`` against it, so from a pip install the option failed before
71
+ the command ran, naming a path the user has no reason to expect.
72
+ """
73
+ candidate = Path(__file__).resolve().parent.parent / "benchmarks" / "known.tsv"
74
+ return candidate if candidate.is_file() else Path("benchmarks/known.tsv")
75
+
76
+
77
+ # From `defaults`, not `core`: core imports pandas, which lives in the
78
+ # `discover` extra, and this line runs for every `bindsight` invocation.
79
+ from bindsight.benchmark.defaults import DEFAULT_KS # noqa: E402
80
+ from bindsight.provenance import append as provenance # noqa: E402
81
+
82
+ # Rich console; legacy-windows mode off so box-drawing chars work after the
83
+ # UTF-8 reconfigure above.
84
+ console = Console(force_terminal=True, legacy_windows=False, soft_wrap=False)
85
+
86
+
87
+ def _setup_logging(verbose: bool) -> None:
88
+ logging.basicConfig(
89
+ level=logging.DEBUG if verbose else logging.INFO,
90
+ format="%(message)s",
91
+ datefmt="[%X]",
92
+ handlers=[RichHandler(console=console, rich_tracebacks=True, show_path=False)],
93
+ )
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Top-level group
98
+ # ---------------------------------------------------------------------------
99
+ #: Python versions the CI matrix actually runs, as (major, minor).
100
+ #:
101
+ #: Kept beside the check rather than read from `pyproject.toml` classifiers,
102
+ #: which are a publishing hint rather than a statement about what was executed.
103
+ #: `tests/test_counted_self_claims.py` asserts this matches the workflow matrix.
104
+ TESTED_PYTHONS: tuple[tuple[int, int], ...] = ((3, 11), (3, 12), (3, 13))
105
+
106
+
107
+ def _PYTHON_SUPPORT_STATUS() -> bool:
108
+ """True when this interpreter is one the CI matrix runs.
109
+
110
+ Below the floor is a hard no. Above the ceiling is also not "ok": nothing
111
+ has run the suite there, and saying otherwise is the check reporting a
112
+ result it does not have.
113
+ """
114
+ return sys.version_info[:2] in TESTED_PYTHONS
115
+
116
+
117
+ def _python_support_note() -> str:
118
+ """What to add beside the version when it is not one that was tested."""
119
+ if _PYTHON_SUPPORT_STATUS():
120
+ return ""
121
+ lowest = ".".join(str(n) for n in min(TESTED_PYTHONS))
122
+ highest = ".".join(str(n) for n in max(TESTED_PYTHONS))
123
+ if sys.version_info[:2] < min(TESTED_PYTHONS):
124
+ return f" — below the minimum; bindsight needs at least {lowest}"
125
+ return f" — untested: CI runs {lowest}–{highest}, so nothing has exercised this one"
126
+
127
+
128
+ def _designer_choice() -> click.Choice[str]:
129
+ """The designers this installation can run, for `--designer`.
130
+
131
+ These were three `click.Choice` literals repeated across `design`, `run` and
132
+ the benchmark, each of which rejected a designer registered through the
133
+ `bindsight.designers` entry-point group before the command body ran -- so
134
+ `--designer my_designer`, the command `docs/use-cases.md` tells a method
135
+ developer to run, could not work no matter what they registered.
136
+ """
137
+ from bindsight.plugins import all_designers
138
+
139
+ return click.Choice(sorted(all_designers()))
140
+
141
+
142
+ def _validator_choice() -> click.Choice[str]:
143
+ """The validators this installation can run, for `--validator`."""
144
+ from bindsight.plugins import all_validators
145
+
146
+ return click.Choice(sorted(all_validators()))
147
+
148
+
149
+ def _backend_choice() -> click.Choice[str]:
150
+ """The runner backends this installation can run, for `--backend`."""
151
+ from bindsight.plugins import all_runners
152
+
153
+ return click.Choice(sorted(all_runners()))
154
+
155
+
156
+ @click.group(
157
+ name="bindsight",
158
+ context_settings={"help_option_names": ["-h", "--help"]},
159
+ )
160
+ @click.version_option(__version__, prog_name="bindsight")
161
+ def main() -> None:
162
+ """Bridge from RNA-seq to de novo protein binder design.
163
+
164
+ See https://github.com/mikhaeelatefrizk/bindsight for the manual.
165
+ """
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # discover — CPU only; produces targets.parquet + epitopes.parquet
170
+ # ---------------------------------------------------------------------------
171
+ @main.command()
172
+ @click.argument(
173
+ "config",
174
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
175
+ )
176
+ @click.option(
177
+ "--out",
178
+ "out_dir",
179
+ type=click.Path(file_okay=False, path_type=Path),
180
+ required=True,
181
+ help="Output directory for this run (will be created).",
182
+ )
183
+ @click.option(
184
+ "--top-n",
185
+ # Ranged, because this value is assigned straight onto the pydantic model
186
+ # (``cfg.params.target_discovery.top_n = top_n``) and assignment does not
187
+ # re-validate. ``--top-n 0`` therefore reached the pipeline and produced an
188
+ # empty shortlist that looked like a finding.
189
+ type=click.IntRange(min=1),
190
+ default=None,
191
+ help="Override params.target_discovery.top_n in the config.",
192
+ )
193
+ @click.option(
194
+ "-v",
195
+ "--verbose",
196
+ is_flag=True,
197
+ help="Verbose (DEBUG) logging.",
198
+ )
199
+ def discover(config: Path, out_dir: Path, top_n: int | None, verbose: bool) -> None:
200
+ """Discover surface antigen targets from RNA-seq counts.
201
+
202
+ Runs the discovery half of the pipeline (CPU only):
203
+ DEG → surfaceome filter → Open Targets enrichment → AlphaFoldDB pull.
204
+ (SURFACE-Bind targetable-site lookup is wired in: it reads a vendored data
205
+ tree — there is no public API — and focuses design on real sites when
206
+ present, else targets the whole surface.)
207
+ """
208
+ _setup_logging(verbose)
209
+ from bindsight.config import RunConfig
210
+ from bindsight.pipelines import discover as discover_pipeline
211
+
212
+ cfg = RunConfig.from_yaml(config)
213
+ cfg.out_dir = out_dir
214
+ if top_n is not None:
215
+ cfg.params.target_discovery.top_n = top_n
216
+
217
+ console.print(f"[dim]config:[/dim] {config}")
218
+ console.print(f"[dim]out:[/dim] {out_dir}")
219
+ console.print(f"[dim]top-n:[/dim] {cfg.params.target_discovery.top_n}")
220
+
221
+ manifest = discover_pipeline.run(cfg, out_dir=out_dir)
222
+
223
+ failed = [s for s in manifest.stages if s.status == "failed"]
224
+ if failed:
225
+ console.print(
226
+ Panel(
227
+ "\n".join(f"[red]{s.name}[/red]: {s.error}" for s in failed),
228
+ title="Stage failures",
229
+ border_style="red",
230
+ )
231
+ )
232
+ sys.exit(1)
233
+
234
+ console.print(
235
+ Panel(
236
+ f"[green]Discovery complete.[/green]\n"
237
+ f"Manifest: {out_dir / 'run_manifest.jsonld'}\n"
238
+ f"Targets: {out_dir / 'targets' / 'candidates.parquet'}\n"
239
+ f"Epitopes: {out_dir / 'epitopes' / 'epitopes.parquet'}",
240
+ title="bindsight discover",
241
+ border_style="green",
242
+ )
243
+ )
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # design — GPU offload; produces designed binder PDBs
248
+ # ---------------------------------------------------------------------------
249
+ @main.command()
250
+ @click.argument(
251
+ "run_dir",
252
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
253
+ )
254
+ @click.option(
255
+ "--backend",
256
+ type=_backend_choice(),
257
+ default="colab",
258
+ show_default=True,
259
+ )
260
+ @click.option(
261
+ "--designer",
262
+ type=_designer_choice(),
263
+ default="rfdiff_mpnn",
264
+ show_default=True,
265
+ )
266
+ @click.option(
267
+ "--validator",
268
+ type=_validator_choice(),
269
+ default="boltz2",
270
+ show_default=True,
271
+ help="Validator run on each design (structure + affinity prediction).",
272
+ )
273
+ @click.option(
274
+ "--trajectories",
275
+ type=click.IntRange(min=1),
276
+ default=50,
277
+ show_default=True,
278
+ help=(
279
+ "Number of independent design trajectories per target. The default shown "
280
+ "here only applies when the run's config.yaml does not set "
281
+ "params.design.n_trajectories; when it does, that value wins unless you "
282
+ "pass this flag explicitly."
283
+ ),
284
+ )
285
+ @click.option(
286
+ "--dry-run",
287
+ is_flag=True,
288
+ help="Estimate compute cost without launching jobs.",
289
+ )
290
+ def design(
291
+ run_dir: Path,
292
+ backend: str,
293
+ designer: str,
294
+ validator: str,
295
+ trajectories: int,
296
+ dry_run: bool,
297
+ ) -> None:
298
+ """Generate de novo binder backbones + sequences (offloaded to a GPU runner).
299
+
300
+ With ``--dry-run``, prints a per-target cost estimate without launching.
301
+ Otherwise: ``--backend colab`` writes a ready-to-run notebook per target;
302
+ the headless backends (``modal``/``local_docker``/``kaggle``/``mock``)
303
+ execute the design+validation job and pull results back into ``<run>/design``.
304
+ """
305
+ from bindsight.cost import estimate_full_run
306
+
307
+ # A run directory carries the configuration it was produced under. Until now
308
+ # this command ignored it, so `params.design.n_trajectories: 10` in a config
309
+ # became the flag's default of 50 — five times the GPU cost the user asked
310
+ # for, printed in the cost panel as though it were their choice. An
311
+ # explicitly passed flag still wins.
312
+ designer, validator, trajectories = _design_defaults_from_run(
313
+ run_dir, designer=designer, validator=validator, trajectories=trajectories
314
+ )
315
+
316
+ _preflight_backend(backend, designer=designer, validator=validator)
317
+
318
+ epitopes_parquet = run_dir / "epitopes" / "epitopes.parquet"
319
+ n_targets = _count_top_targets(epitopes_parquet)
320
+
321
+ console.print(f"[dim]run-dir:[/dim] {run_dir}")
322
+ console.print(f"[dim]backend:[/dim] {backend}")
323
+ console.print(f"[dim]designer:[/dim] {designer}")
324
+ console.print(f"[dim]validator:[/dim] {validator}")
325
+ console.print(f"[dim]trajectories:[/dim] {trajectories}")
326
+ console.print(f"[dim]targets:[/dim] {n_targets if n_targets is not None else 'unknown'}")
327
+
328
+ if n_targets is None:
329
+ # No estimate rather than an estimate of nothing. Costing a guessed
330
+ # target count produces a plausible dollar figure for work whose size is
331
+ # not known, which is worse than saying so.
332
+ console.print(
333
+ "[yellow]No epitopes table, so the target count is unknown and the "
334
+ "cost estimate is skipped.[/yellow] Run `bindsight discover` first."
335
+ )
336
+ else:
337
+ # gpu_type included, as `run` already does. Without it this quoted A100
338
+ # prices for a run configured for a T4 -- the estimate was for different
339
+ # hardware than the one the job would ask for.
340
+ d_cost, _v_cost, _c_cost = estimate_full_run(
341
+ backend=backend,
342
+ designer=designer,
343
+ validator=validator,
344
+ n_targets=n_targets,
345
+ n_trajectories=trajectories,
346
+ gpu_type=_gpu_type_from_run(run_dir),
347
+ )
348
+ _print_cost_panel(d_cost, label=f"design ({designer})")
349
+
350
+ if dry_run:
351
+ console.print(
352
+ Panel(
353
+ "[green]--dry-run:[/green] no jobs launched. Drop --dry-run to run.",
354
+ title="Dry run",
355
+ border_style="green",
356
+ )
357
+ )
358
+ return
359
+
360
+ if backend == "colab":
361
+ n = _write_design_notebooks(run_dir, designer=designer, trajectories=trajectories)
362
+ if n == 0:
363
+ # The same answer the headless backends give, for the same
364
+ # situation. This printed a green "Wrote 0 Colab notebook(s)" and a
365
+ # line telling the reader to open each of them, then exited 0 --
366
+ # one line after saying it could not even estimate the cost. It is
367
+ # the default backend, so it was the path most people met.
368
+ console.print(
369
+ Panel(
370
+ "[yellow]No targets with a structure to design against.[/yellow] "
371
+ "Run [bold]bindsight discover[/bold] first (and ensure AlphaFold "
372
+ "structures were fetched).",
373
+ title="design: nothing to do",
374
+ border_style="yellow",
375
+ )
376
+ )
377
+ sys.exit(2)
378
+ console.print(
379
+ Panel(
380
+ f"[green]Wrote {n} Colab notebook(s)[/green] to {run_dir / 'design'}.\n"
381
+ "Open each in Colab (GPU runtime), Run all, download the results "
382
+ "tarball into <run>/design/, then run [bold]bindsight validate[/bold].",
383
+ title="design: notebooks ready",
384
+ border_style="green",
385
+ )
386
+ )
387
+ return
388
+
389
+ _seed, _blo, _bhi = _design_spec_params_from_run(run_dir)
390
+ _top_k, _samples, _parallel = _validator_params_from_run(run_dir)
391
+ launched = _launch_design(
392
+ run_dir,
393
+ backend=backend,
394
+ designer=designer,
395
+ validator=validator,
396
+ trajectories=trajectories,
397
+ prescreen_top_k=_top_k,
398
+ diffusion_samples=_samples,
399
+ max_parallel_samples=_parallel,
400
+ )
401
+ if launched == 0:
402
+ console.print(
403
+ Panel(
404
+ "[yellow]No targets with a structure to design against.[/yellow] "
405
+ "Run [bold]bindsight discover[/bold] first (and ensure AlphaFold "
406
+ "structures were fetched).",
407
+ title="design: nothing to do",
408
+ border_style="yellow",
409
+ )
410
+ )
411
+ sys.exit(2)
412
+ provenance.record(
413
+ run_dir,
414
+ name="design",
415
+ tool=f"bindsight.design.{designer}",
416
+ outputs={
417
+ "design_metrics": run_dir / "design" / "metrics.jsonl",
418
+ "design_results": run_dir / "design" / "results.tar.gz",
419
+ },
420
+ params={
421
+ "designer": designer,
422
+ "validator": validator,
423
+ "backend": backend,
424
+ "n_trajectories": trajectories,
425
+ # ARCHITECTURE 5 promises a reviewer can reach "the trajectory seed
426
+ # and the resolved design parameters" from the manifest. They were
427
+ # not recorded here, so a run's own manifest could not answer the
428
+ # one question that makes it reproducible. `bindsight run` recorded
429
+ # them by dumping the whole config; this path did not.
430
+ "seed": _seed,
431
+ "binder_length_min": _blo,
432
+ "binder_length_max": _bhi,
433
+ },
434
+ notes=f"{launched} target(s) designed on the {backend} backend",
435
+ )
436
+ console.print(
437
+ Panel(
438
+ f"[green]Design complete for {launched} target(s).[/green]\n"
439
+ f"Results: {run_dir / 'design'}\n"
440
+ f"Next: [bold]bindsight validate {run_dir}[/bold]",
441
+ title="bindsight design",
442
+ border_style="green",
443
+ )
444
+ )
445
+
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # validate — GPU; affinity + structure prediction on designed binders
449
+ # ---------------------------------------------------------------------------
450
+ @main.command()
451
+ @click.argument(
452
+ "run_dir",
453
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
454
+ )
455
+ @click.option(
456
+ "--backend",
457
+ type=_backend_choice(),
458
+ default="colab",
459
+ show_default=True,
460
+ )
461
+ @click.option(
462
+ "--validator",
463
+ type=_validator_choice(),
464
+ default="boltz2",
465
+ show_default=True,
466
+ )
467
+ @click.option(
468
+ "--revalidate",
469
+ is_flag=True,
470
+ help=(
471
+ "Actually run --validator against the existing designs on --backend, instead of "
472
+ "materialising the metrics the design step already produced. Use this to score the "
473
+ "same binders with a second validator without redesigning them."
474
+ ),
475
+ )
476
+ def validate(run_dir: Path, backend: str, validator: str, revalidate: bool) -> None:
477
+ """Validate designed binders by predicting structure and binding affinity.
478
+
479
+ By default this materialises the metrics the design job already produced,
480
+ because the headless backends run design and validation together. Pass
481
+ ``--revalidate`` to dispatch ``--validator`` against the existing designs;
482
+ that is the path that makes cross-validator agreement possible.
483
+ """
484
+ from bindsight.cost import estimate
485
+
486
+ if validator == "af2_ig":
487
+ console.print(
488
+ Panel(
489
+ "[bold red]AF2-IG depends on PyRosetta, which is free only for non-commercial use.[/bold red]\n"
490
+ "The AlphaFold2 parameters themselves are CC BY 4.0. See LICENSING.md § 3; "
491
+ "default to boltz2 for commercial work.",
492
+ title="License banner",
493
+ border_style="red",
494
+ )
495
+ )
496
+
497
+ design_dir = run_dir / "design"
498
+ n_designs = _count_designs(design_dir)
499
+ if n_designs is None:
500
+ # No designs on disk yet, so any per-design figure would be invented.
501
+ console.print(
502
+ f"[yellow]No designs found under {design_dir}[/yellow] — "
503
+ "cost is unknown until the design step has produced binders."
504
+ )
505
+ else:
506
+ cost = estimate(backend=backend, stage="validate", plugin=validator, n_units=n_designs)
507
+ _print_cost_panel(cost, label=f"validate ({validator}, {n_designs} designs)")
508
+
509
+ if revalidate:
510
+ # Revalidation dispatches to the backend just as design does, so the same
511
+ # refusal applies: --validator chai1r --backend kaggle cannot work.
512
+ _preflight_backend(backend, validator=validator)
513
+ if backend == "colab":
514
+ console.print(
515
+ Panel(
516
+ "[yellow]--revalidate needs a headless backend[/yellow] "
517
+ "(modal / local_docker / kaggle / mock).\n"
518
+ "The colab runner cannot be launched from the CLI, so there is nothing "
519
+ "to dispatch to.",
520
+ title="validate: backend not dispatchable",
521
+ border_style="yellow",
522
+ )
523
+ )
524
+ sys.exit(2)
525
+ done = _launch_revalidate(run_dir, backend=backend, validator=validator)
526
+ if done == 0:
527
+ console.print(
528
+ Panel(
529
+ "[yellow]Nothing to revalidate.[/yellow] No per-target design tarballs "
530
+ f"under {run_dir / 'design' / '_targets'} — run "
531
+ "[bold]bindsight design[/bold] first.",
532
+ title="validate: nothing to do",
533
+ border_style="yellow",
534
+ )
535
+ )
536
+ sys.exit(2)
537
+ console.print(f"[green]Revalidated {done} target(s) with {validator}.[/green]")
538
+
539
+ # The design step (headless backends) runs design + validation together via
540
+ # the executor, writing per-design metrics into the design tarballs. Here we
541
+ # materialise those into validate/validated.parquet (+ per-binder dirs).
542
+ n = _finalize_validate(run_dir)
543
+ validated = run_dir / "validate" / "validated.parquet"
544
+ if n == 0:
545
+ # Nothing was validated, so nothing is recorded. provenance.record marks
546
+ # the stage completed unconditionally, so this command used to append a
547
+ # completed validate stage to run_manifest.jsonld and then print, on the
548
+ # same screen, that the GPU step still needed running. The manifest is
549
+ # what a reviewer reads and what the RO-Crate exports; it must not
550
+ # assert a stage the console is telling the user to go and perform.
551
+ console.print(
552
+ Panel(
553
+ "No design results to validate yet. Run [bold]bindsight design[/bold] on a\n"
554
+ "headless backend (modal/local_docker/kaggle), or for --backend colab open "
555
+ "the\ngenerated notebook (GPU), download the results tarball into "
556
+ "<run>/design/,\nthen re-run this command. See docs/colab-design-howto.md.",
557
+ title="validate: GPU step pending",
558
+ border_style="cyan",
559
+ )
560
+ )
561
+ sys.exit(0)
562
+ # Record the validator that produced these numbers, not the one the flag
563
+ # asked for. Without --revalidate no validator runs here, so the flag is a
564
+ # request the metrics may not answer to.
565
+ produced_by = _validators_that_produced(validated)
566
+ provenance.record(
567
+ run_dir,
568
+ name="validate",
569
+ tool=f"bindsight.validate.{'+'.join(produced_by) if produced_by else validator}",
570
+ outputs={"validated": validated},
571
+ params={
572
+ "validator_requested": validator,
573
+ "validator_recorded": produced_by,
574
+ "backend": backend,
575
+ "revalidate": revalidate,
576
+ },
577
+ notes=f"{n} design(s) materialised into validated.parquet",
578
+ )
579
+ # n == 0 returned above, before the stage was recorded, so reaching here
580
+ # means designs really were validated.
581
+ console.print(
582
+ Panel(
583
+ f"[green]Validated {n} design(s).[/green]\n"
584
+ f"Output: {validated}\nNext: [bold]bindsight rank {run_dir}[/bold]",
585
+ title="validate: ready",
586
+ border_style="green",
587
+ )
588
+ )
589
+
590
+
591
+ # ---------------------------------------------------------------------------
592
+ # rank — multi-objective scoring
593
+ # ---------------------------------------------------------------------------
594
+ @main.command()
595
+ @click.argument(
596
+ "run_dir",
597
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
598
+ )
599
+ def rank(run_dir: Path) -> None:
600
+ """Rank validated binders by composite score (affinity, iPTM, expression Δ)."""
601
+ from bindsight.rank import rank_run
602
+
603
+ # The run's own weights, not the defaults. This command called
604
+ # `rank_run(run_dir)` with no weights and then recorded `RankWeights()` in
605
+ # provenance, so a user who configured a weighting got an ordering produced
606
+ # by a different one -- and a manifest that said otherwise.
607
+ weights = _rank_weights(run_dir)
608
+
609
+ try:
610
+ out = rank_run(run_dir, weights=weights)
611
+ except FileNotFoundError as e:
612
+ console.print(
613
+ Panel(
614
+ f"[yellow]{e}[/yellow]\n\n"
615
+ "Run [bold]bindsight validate <run_dir>[/bold] first, then this.",
616
+ title="rank: nothing to rank",
617
+ border_style="yellow",
618
+ )
619
+ )
620
+ sys.exit(2)
621
+ # The weights are what turn four metrics into one ordering, and they are
622
+ # configurable. Recording the ranking without them left the published order
623
+ # underdetermined: nothing in the manifest says which weighting produced it.
624
+ # They are read above from the run's config, so what is recorded here is what
625
+ # actually ran.
626
+ provenance.record(
627
+ run_dir,
628
+ name="rank",
629
+ tool="bindsight.rank",
630
+ inputs={"validated": run_dir / "validate" / "validated.parquet"},
631
+ outputs={"ranking": out},
632
+ params={"weights": weights.model_dump()},
633
+ )
634
+ console.print(
635
+ Panel(
636
+ f"[green]Ranking written.[/green]\n[bold]Output:[/bold] {out}",
637
+ title="bindsight rank",
638
+ border_style="green",
639
+ )
640
+ )
641
+
642
+
643
+ # ---------------------------------------------------------------------------
644
+ # report — self-contained HTML / served interface
645
+ # ---------------------------------------------------------------------------
646
+ @main.command()
647
+ @click.argument(
648
+ "run_dir",
649
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
650
+ )
651
+ @click.option(
652
+ "--format",
653
+ "fmt",
654
+ type=click.Choice(["html", "web"]),
655
+ default="html",
656
+ show_default=True,
657
+ )
658
+ @click.option(
659
+ "--include-binders/--no-include-binders",
660
+ default=False,
661
+ help=(
662
+ "Embed each ranked binder's designed sequence in the report. The ranked "
663
+ "binder table itself is always included when the design and rank stages "
664
+ "have run."
665
+ ),
666
+ )
667
+ @click.option(
668
+ "--embed-structures/--no-embed-structures",
669
+ default=True,
670
+ show_default=True,
671
+ help=(
672
+ "Draw the predicted complexes in 3-D inside the report. The viewer and "
673
+ "the structures travel in the file, so it still opens with no server "
674
+ "and no network -- at roughly half a megabyte for the viewer and 150 kB "
675
+ "per complex, best-ranked first, up to a budget. Turn it off for a "
676
+ "small file that shows the numbers without the structures behind them."
677
+ ),
678
+ )
679
+ def report(run_dir: Path, fmt: str, include_binders: bool, embed_structures: bool) -> None:
680
+ """Render the run as a self-contained HTML report, or serve the interface.
681
+
682
+ HTML output is one self-contained file (CSS, plot, tables and the predicted
683
+ complexes in 3-D, all embedded) you can email or attach to a paper. `--format web` serves the same run locally
684
+ for interactive browsing; nothing is fetched from a network either way.
685
+ """
686
+ if fmt == "html":
687
+ from bindsight.report import render_run
688
+
689
+ out_path = render_run(
690
+ run_dir, include_binders=include_binders, embed_structures=embed_structures
691
+ )
692
+ provenance.record(
693
+ run_dir,
694
+ name="report",
695
+ tool="bindsight.report",
696
+ outputs={"report_html": out_path},
697
+ params={
698
+ "format": fmt,
699
+ "include_binders": include_binders,
700
+ "embed_structures": embed_structures,
701
+ },
702
+ )
703
+ console.print(
704
+ Panel(
705
+ f"[green]Report rendered.[/green]\n[bold]Open:[/bold] {out_path}",
706
+ title="bindsight report",
707
+ border_style="green",
708
+ )
709
+ )
710
+ return
711
+
712
+ if fmt == "web":
713
+ try:
714
+ from bindsight.report.web.app import serve
715
+ except ImportError as exc: # pragma: no cover - exercised via the extra
716
+ console.print(
717
+ Panel(
718
+ "[yellow]The web interface is not installed.[/yellow]\n"
719
+ f"[dim]{exc}[/dim]\n"
720
+ # Rich reads an unescaped bracket as a style tag, which made
721
+ # this print `pip install -e "."` -- the base package, which
722
+ # is what the reader already had.
723
+ ' [bold]pip install -e ".\\[report]"[/bold]',
724
+ title="Missing dependency",
725
+ border_style="yellow",
726
+ )
727
+ )
728
+ sys.exit(2)
729
+
730
+ console.print(
731
+ Panel(
732
+ f"[bold]http://127.0.0.1:8501[/bold]\n"
733
+ f"[dim]Serving runs from {run_dir.parent}. Ctrl-C to stop.[/dim]",
734
+ title="bindsight",
735
+ border_style="blue",
736
+ )
737
+ )
738
+ try:
739
+ # The root it just announced, not whatever ./runs resolves to from
740
+ # the shell's working directory.
741
+ serve(port=8501, open_browser=True, run_root=run_dir.parent)
742
+ except KeyboardInterrupt: # pragma: no cover - interactive
743
+ console.print("[dim]stopped[/dim]")
744
+ return
745
+
746
+
747
+ # ---------------------------------------------------------------------------
748
+ # run — full pipeline
749
+ # ---------------------------------------------------------------------------
750
+ @main.command()
751
+ @click.argument(
752
+ "config",
753
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
754
+ )
755
+ @click.option(
756
+ "--out",
757
+ "out_dir",
758
+ type=click.Path(file_okay=False, path_type=Path),
759
+ required=True,
760
+ )
761
+ @click.option("--backend", type=_backend_choice())
762
+ @click.option("--designer", type=_designer_choice())
763
+ @click.option("--validator", type=_validator_choice())
764
+ @click.option("--cheap", is_flag=True, help="Use the --cheap profile (T4-friendly defaults).")
765
+ @click.option("--dry-run", is_flag=True, help="Print a cost estimate, don't execute.")
766
+ def run(
767
+ config: Path,
768
+ out_dir: Path,
769
+ backend: str | None,
770
+ designer: str | None,
771
+ validator: str | None,
772
+ cheap: bool,
773
+ dry_run: bool,
774
+ ) -> None:
775
+ """Run the full discover → design → validate → rank → report → export pipeline.
776
+
777
+ CPU stages (discover, rank, report, export) always execute. The GPU stages
778
+ (design, validate) **launch real work** when the configured backend is
779
+ headless (mock, modal, kaggle, local_docker) — this help used to say they
780
+ only reuse outputs from a previous session, which is true for ``colab`` and
781
+ false for every other backend, on a command that spends GPU quota — this command is the
782
+ single-entry-point version of running each stage in turn. To produce those
783
+ outputs, run ``bindsight design`` against a GPU backend first; ``--backend
784
+ kaggle`` is the verified free path.
785
+ """
786
+ _setup_logging(verbose=False)
787
+ from bindsight.config import RunConfig
788
+ from bindsight.pipelines.full_run import run as run_full
789
+
790
+ cfg = RunConfig.from_yaml(config)
791
+ cfg.out_dir = out_dir
792
+ if backend:
793
+ cfg.backend = backend
794
+ if designer:
795
+ cfg.params.design.designer = designer
796
+ if validator:
797
+ cfg.params.validate_.validator = validator
798
+ if cheap:
799
+ _apply_cheap_profile(cfg)
800
+
801
+ # The same refusal `design` performs. This command launches the GPU half on
802
+ # a headless backend, so skipping the check meant `run` could spend quota
803
+ # discovering a designer/backend combination `design` would have refused
804
+ # locally and for free.
805
+ _preflight_backend(
806
+ cfg.backend,
807
+ designer=cfg.params.design.designer,
808
+ validator=cfg.params.validate_.validator,
809
+ )
810
+
811
+ if dry_run:
812
+ from bindsight.cost import estimate_full_run
813
+
814
+ n_targets = cfg.params.target_discovery.top_n
815
+ n_traj = cfg.params.design.n_trajectories
816
+ _d, _v, c = estimate_full_run(
817
+ backend=cfg.backend,
818
+ designer=cfg.params.design.designer,
819
+ validator=cfg.params.validate_.validator,
820
+ n_targets=n_targets,
821
+ n_trajectories=n_traj,
822
+ gpu_type=cfg.params.design.gpu_type,
823
+ )
824
+ _print_cost_panel(c, label="full run (combined design + validate)")
825
+ return
826
+
827
+ result = run_full(cfg, out_dir=out_dir)
828
+ summary_lines = [
829
+ f"discover : {'OK' if result.discover_ok else 'partial / failed'}",
830
+ f"design : {'OK (artifacts present)' if result.design_ok else 'pending — run: bindsight design <run> --backend kaggle'}",
831
+ f"validate : {'OK (artifacts present)' if result.validate_ok else 'pending — needs design output'}",
832
+ f"rank : {'OK' if result.rank_ok else 'skipped (no validate output)'}",
833
+ f"report : {result.report_path}" if result.report_path else "report: not rendered",
834
+ f"crate : {result.crate_path}" if result.crate_path else "crate: not exported",
835
+ ]
836
+ console.print(
837
+ Panel(
838
+ "\n".join(summary_lines),
839
+ title="bindsight run — summary",
840
+ border_style="green" if result.discover_ok else "yellow",
841
+ )
842
+ )
843
+
844
+
845
+ # ---------------------------------------------------------------------------
846
+ # export — RO-Crate
847
+ # ---------------------------------------------------------------------------
848
+ @main.command()
849
+ @click.argument(
850
+ "run_dir",
851
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
852
+ )
853
+ @click.option(
854
+ "--format",
855
+ "fmt",
856
+ type=click.Choice(["ro-crate"]),
857
+ default="ro-crate",
858
+ show_default=True,
859
+ )
860
+ @click.option(
861
+ "--out",
862
+ "out_path",
863
+ type=click.Path(dir_okay=False, path_type=Path),
864
+ required=True,
865
+ )
866
+ def export(run_dir: Path, fmt: str, out_path: Path) -> None:
867
+ """Export a finished run as an RO-Crate zip suitable for deposit."""
868
+ from bindsight.export import export_ro_crate
869
+
870
+ out = export_ro_crate(run_dir, out_path)
871
+ # The crate is NOT recorded as a digested output. A copy of this manifest is
872
+ # sealed inside the crate, so any digest written here describes a file that
873
+ # did not exist when that copy was taken: the crate's own manifest would
874
+ # assert a hash that is not the crate's, and a reviewer checking the deposit
875
+ # against it would find a mismatch and no way to tell which artifact was
876
+ # wrong. The crate's digest belongs outside the crate -- see the SHA256SUMS
877
+ # published beside the deposit.
878
+ provenance.record(
879
+ run_dir,
880
+ name="export",
881
+ tool="bindsight.export",
882
+ outputs={},
883
+ params={"format": fmt, "crate_path": out.name},
884
+ notes=(
885
+ "The crate is not listed as a digested output: it contains a copy of "
886
+ "this manifest, so it cannot also contain its own sha256. Publish the "
887
+ "crate's digest alongside the deposit (SHA256SUMS), not inside it."
888
+ ),
889
+ )
890
+ console.print(
891
+ Panel(
892
+ f"[green]RO-Crate written.[/green]\n[bold]File:[/bold] {out}\n\n"
893
+ "Deposit it in a research data repository to make the run citable.",
894
+ title="bindsight export",
895
+ border_style="green",
896
+ )
897
+ )
898
+
899
+
900
+ # ---------------------------------------------------------------------------
901
+ # benchmark — score rediscovery of held-out known antigens across runs
902
+ # ---------------------------------------------------------------------------
903
+ @main.command()
904
+ @click.argument(
905
+ "run_dirs",
906
+ nargs=-1,
907
+ required=True,
908
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
909
+ )
910
+ @click.option(
911
+ "--known-antigens",
912
+ "known_antigens",
913
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
914
+ default=_default_known_antigens(),
915
+ show_default=True,
916
+ help=(
917
+ "TSV of held-out known antigens (symbol, uniprot, tumor_type, ...). "
918
+ "The default ships with the source tree, not with the wheel: from a pip "
919
+ "install there is no benchmarks/ directory, so pass this explicitly."
920
+ ),
921
+ )
922
+ @click.option(
923
+ "--out",
924
+ "out_path",
925
+ type=click.Path(dir_okay=False, path_type=Path),
926
+ default=Path("benchmark_report.html"),
927
+ show_default=True,
928
+ help="Where to write the HTML benchmark report.",
929
+ )
930
+ @click.option(
931
+ "--k",
932
+ "ks",
933
+ multiple=True,
934
+ type=click.IntRange(min=1),
935
+ help=(
936
+ "Top-k cutoffs for recall@k (repeatable). Default: "
937
+ + ", ".join(str(k) for k in DEFAULT_KS)
938
+ + "."
939
+ ),
940
+ )
941
+ def benchmark(
942
+ run_dirs: tuple[Path, ...],
943
+ known_antigens: Path,
944
+ out_path: Path,
945
+ ks: tuple[int, ...],
946
+ ) -> None:
947
+ """Benchmark how well runs rediscover the held-out known antigens.
948
+
949
+ Scores each RUN_DIR (a finished ``bindsight discover``/``run`` output) by the
950
+ rank of every known antigen in its candidate shortlist, computes recall@k,
951
+ and renders a side-by-side HTML report. The known set ships in
952
+ ``benchmarks/known.tsv`` (see ``benchmarks/PROVENANCE.md``).
953
+ """
954
+ from bindsight.benchmark import run_benchmark
955
+
956
+ # One source for the cutoffs. They were written as a literal here, again in
957
+ # the help text above, and a third time as DEFAULT_KS in the benchmark
958
+ # module -- three places to change, and a help string that could describe a
959
+ # default the command does not use.
960
+ cutoffs = tuple(ks) if ks else DEFAULT_KS
961
+ out, scores = run_benchmark(list(run_dirs), known_antigens, out_html=out_path, ks=cutoffs)
962
+
963
+ table = Table(title="rediscovery benchmark", show_lines=False, title_style="bold")
964
+ table.add_column("run", style="cyan", no_wrap=True)
965
+ table.add_column("found")
966
+ for k in cutoffs:
967
+ table.add_column(f"recall@{k}")
968
+ for s in scores:
969
+ table.add_row(
970
+ s.run_name,
971
+ f"{s.n_found}/{s.n_known}",
972
+ # `n/a`, not a crash. `score_run` leaves `recall_at` empty on
973
+ # purpose when there is nothing to compute a recall over -- an
974
+ # unreadable candidate table, or no on-indication antigen -- and
975
+ # this indexed into it, so the documented command exited 1 with a
976
+ # KeyError on exactly the runs it is most useful for. The HTML
977
+ # report at benchmark/core.py:321 already renders those as n/a.
978
+ *[f"{s.recall_at[k]:.0%}" if k in s.recall_at else "n/a" for k in cutoffs],
979
+ )
980
+ console.print(table)
981
+ console.print(
982
+ Panel(
983
+ f"[green]Benchmark written.[/green]\n[bold]Report:[/bold] {out}\n"
984
+ f"Known set: {known_antigens}",
985
+ title="bindsight benchmark",
986
+ border_style="green",
987
+ )
988
+ )
989
+
990
+
991
+ # ---------------------------------------------------------------------------
992
+ # ui — launch the local web interface
993
+ # ---------------------------------------------------------------------------
994
+ @main.command()
995
+ @click.option(
996
+ "--port",
997
+ type=click.IntRange(min=1, max=65535),
998
+ default=8501,
999
+ show_default=True,
1000
+ help="Port for the local web server.",
1001
+ )
1002
+ @click.option(
1003
+ "--no-browser",
1004
+ is_flag=True,
1005
+ help="Don't auto-open a browser tab; just print the URL.",
1006
+ )
1007
+ def ui(port: int, no_browser: bool) -> None:
1008
+ """Launch the bindsight web interface in your browser.
1009
+
1010
+ Five sections: what the tool is and has shown, the evidence behind that, a
1011
+ demo, your own data, and the runs already on disk. Served locally; nothing
1012
+ leaves the machine and nothing is fetched from a network once installed --
1013
+ the stylesheet, the charts and the structure viewer all travel inside the
1014
+ package.
1015
+ """
1016
+ try:
1017
+ from bindsight.report.web.app import serve
1018
+ except ImportError as exc: # pragma: no cover - exercised via the extra
1019
+ console.print(
1020
+ Panel(
1021
+ "[red]The web interface is not installed.[/red]\n"
1022
+ f"[dim]{exc}[/dim]\n\n"
1023
+ # Escaped: Rich would read `[report]` as a style tag and drop
1024
+ # it, printing a command that installs nothing new.
1025
+ 'Install it with: [bold]pip install -e ".\\[report]"[/bold]',
1026
+ title="ui: missing dependency",
1027
+ border_style="red",
1028
+ )
1029
+ )
1030
+ sys.exit(2)
1031
+
1032
+ url = f"http://127.0.0.1:{port}"
1033
+ console.print(
1034
+ Panel(
1035
+ f"[bold]{url}[/bold]\n[dim]Ctrl-C to stop. Runs are read from ./runs.[/dim]",
1036
+ title="bindsight",
1037
+ border_style="blue",
1038
+ )
1039
+ )
1040
+ # Served in-process. The subprocess this replaced ran with ``check=False``
1041
+ # and its status was discarded, so a server that never started still left
1042
+ # the command printing "Launching bindsight UI" and exiting 0. uvicorn exits
1043
+ # non-zero on a port it cannot bind, and that status is no longer swallowed.
1044
+ try:
1045
+ serve(port=port, open_browser=not no_browser)
1046
+ except KeyboardInterrupt: # pragma: no cover - interactive
1047
+ console.print("[dim]stopped[/dim]")
1048
+
1049
+
1050
+ # ---------------------------------------------------------------------------
1051
+ # verify-licenses — pure-Python, works today
1052
+ # ---------------------------------------------------------------------------
1053
+ @main.command(name="verify-licenses")
1054
+ @click.argument(
1055
+ "config",
1056
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
1057
+ required=False,
1058
+ )
1059
+ def verify_licenses(config: Path | None) -> None:
1060
+ """Print the license summary for components used by bindsight.
1061
+
1062
+ With a config path, audits the components selected by that config and
1063
+ flags any non-commercial choices.
1064
+ """
1065
+ # Single source of truth: (component, license, commercial?, role).
1066
+ # `commercial` is True when the component is usable in commercial work,
1067
+ # False when its terms forbid it, and None when the source states no
1068
+ # terms at all -- which is not a clearance and is not printed as one.
1069
+ components: list[tuple[str, str, bool | None, str]] = [
1070
+ ("bindsight", "AGPL-3.0-or-later", True, "this package"),
1071
+ ("pydeseq2", "MIT", True, "DEG analysis (default)"),
1072
+ ("Open Targets", "CC0", True, "target evidence"),
1073
+ ("GTEx (v8 public)", "Open", True, "tissue baselines"),
1074
+ ("SURFY", "Not stated by source (article CC BY-NC-ND 4.0)", None, "surfaceome filter"),
1075
+ ("SURFACE-Bind", "BSD-3", True, "targetable sites"),
1076
+ ("AlphaFoldDB", "CC BY 4.0", True, "structures"),
1077
+ ("RFdiffusion", "BSD-3", True, "default backbone designer"),
1078
+ ("ProteinMPNN", "MIT", True, "sequence design"),
1079
+ ("Boltz-2", "MIT (code+weights)", True, "default validator"),
1080
+ ("Chai-1r", "Apache-2", True, "alt validator"),
1081
+ ("BoltzGen", "MIT (code+weights)", True, "alt designer"),
1082
+ ("BindCraft", "MIT", True, "premium designer"),
1083
+ ("Snakemake", "MIT", True, "workflow"),
1084
+ (
1085
+ "AF2-IG (opt-in)",
1086
+ "MIT; AF2 params CC BY 4.0; PyRosetta NC",
1087
+ False,
1088
+ "alt validator (banner)",
1089
+ ),
1090
+ ]
1091
+ by_name = {c[0]: c for c in components}
1092
+
1093
+ def _commercial_cell(ok: bool | None) -> str:
1094
+ if ok is None:
1095
+ return "[yellow]verify[/yellow]"
1096
+ return "[green]yes[/green]" if ok else "[red]no[/red]"
1097
+
1098
+ def _render(title: str, rows: list[tuple[str, str, bool | None, str]]) -> None:
1099
+ t = Table(title=title, show_lines=False, title_style="bold")
1100
+ t.add_column("Component", style="cyan", no_wrap=True)
1101
+ t.add_column("License")
1102
+ t.add_column("Commercial?", style="bold")
1103
+ t.add_column("Role")
1104
+ for name, lic, ok, role in rows:
1105
+ t.add_row(name, lic, _commercial_cell(ok), role)
1106
+ console.print(t)
1107
+
1108
+ if config is None:
1109
+ _render("bindsight component licenses (default config)", components)
1110
+ else:
1111
+ from bindsight.config import RunConfig
1112
+
1113
+ cfg = RunConfig.from_yaml(config)
1114
+ designer = cfg.params.design.designer
1115
+ validator = cfg.params.validate_.validator
1116
+ backend = cfg.backend
1117
+
1118
+ # Map the config's plugin choices to the components they pull in.
1119
+ # `.get`, not `[...]`. Now that a third-party plugin can be selected,
1120
+ # a bare lookup would raise a KeyError naming nothing useful. A plugin
1121
+ # this table has never heard of has components, but not ones bindsight
1122
+ # can enumerate -- say that instead of crashing or claiming none.
1123
+ designer_components = {
1124
+ "rfdiff_mpnn": ["RFdiffusion", "ProteinMPNN"],
1125
+ "bindcraft": ["BindCraft"],
1126
+ "boltzgen": ["BoltzGen"],
1127
+ }.get(designer, [])
1128
+ validator_components = {
1129
+ "boltz2": ["Boltz-2"],
1130
+ "chai1r": ["Chai-1r"],
1131
+ "af2_ig": ["AF2-IG (opt-in)"],
1132
+ }.get(validator, [])
1133
+ unresolved = [
1134
+ name
1135
+ for name, found in ((designer, designer_components), (validator, validator_components))
1136
+ if not found
1137
+ ]
1138
+
1139
+ # Core discovery components are always pulled in, regardless of config.
1140
+ core = [
1141
+ "bindsight",
1142
+ "pydeseq2",
1143
+ "Open Targets",
1144
+ "GTEx (v8 public)",
1145
+ "SURFY",
1146
+ "SURFACE-Bind",
1147
+ "AlphaFoldDB",
1148
+ ]
1149
+ selected = [by_name[n] for n in core + designer_components + validator_components]
1150
+
1151
+ _render(
1152
+ f"Components selected by {Path(config).name} "
1153
+ f"(designer={designer}, validator={validator}, backend={backend})",
1154
+ selected,
1155
+ )
1156
+
1157
+ if unresolved:
1158
+ # A green clearance covering a plugin whose licence was never looked
1159
+ # at is worse than no clearance: it is a legal statement about
1160
+ # software this table has never seen. Name it, and withhold the
1161
+ # verdict below.
1162
+ console.print(
1163
+ "\n[yellow bold]⚠ Not assessed:[/yellow bold] "
1164
+ f"{', '.join(unresolved)} — registered through an entry point, so "
1165
+ "bindsight does not know which components or licences it pulls in."
1166
+ )
1167
+
1168
+ nc = [r for r in selected if r[2] is False]
1169
+ unstated = [r for r in selected if r[2] is None]
1170
+ if nc:
1171
+ names = ", ".join(r[0] for r in nc)
1172
+ console.print(
1173
+ f"\n[red bold]⚠ Non-commercial component(s) selected:[/red bold] {names}.\n"
1174
+ "[yellow]This configuration is NOT cleared for commercial use. Switch the "
1175
+ "offending stage (e.g. validator -> boltz2 or chai1r) for a fully "
1176
+ "commercial-friendly run.[/yellow]"
1177
+ )
1178
+ elif unresolved:
1179
+ console.print(
1180
+ "\n[yellow]Every component bindsight could identify is "
1181
+ "commercial-friendly. That is not a clearance for this "
1182
+ "configuration, because the plugin(s) above were not assessed."
1183
+ "[/yellow]"
1184
+ )
1185
+ elif unstated:
1186
+ # A source that states no terms is not a permissive source. The
1187
+ # table says "verify"; the summary must not say "all clear".
1188
+ names = ", ".join(r[0] for r in unstated)
1189
+ console.print(
1190
+ f"\n[yellow bold]⚠ Terms not stated by the source:[/yellow bold] {names}. "
1191
+ "[yellow]Every other component is commercial-friendly; see LICENSING.md "
1192
+ "before commercial use.[/yellow]"
1193
+ )
1194
+ else:
1195
+ console.print(
1196
+ "\n[green bold]✓ All components selected by this config are "
1197
+ "commercial-friendly.[/green bold]"
1198
+ )
1199
+
1200
+ console.print(
1201
+ "\n[dim]See LICENSING.md for the full inventory and commercial-use guidance.[/dim]"
1202
+ )
1203
+
1204
+
1205
+ # ---------------------------------------------------------------------------
1206
+ # CLI helpers used by design + validate
1207
+ # ---------------------------------------------------------------------------
1208
+ def _count_top_targets(epitopes_parquet: Path) -> int | None:
1209
+ """Count top-N targets from a discover-stage epitopes Parquet; None when unknown.
1210
+
1211
+ ``None``, not 5. This returned a hard-coded 5 for a table that was missing or
1212
+ unreadable, and that invented number was printed as ``targets: 5`` and then
1213
+ fed to the cost estimate — so a run with no epitopes table quoted a GPU cost
1214
+ for work it had no targets to do, with nothing on screen to say the figure
1215
+ was made up. Its sibling ``_count_designs`` already says exactly this.
1216
+ """
1217
+ if not epitopes_parquet.exists():
1218
+ LOG_CLI.warning("no epitopes table at %s; target count unknown", epitopes_parquet)
1219
+ return None
1220
+ try:
1221
+ import pandas as pd
1222
+
1223
+ return len(pd.read_parquet(epitopes_parquet))
1224
+ except Exception as exc:
1225
+ LOG_CLI.warning("could not read %s (%s); target count unknown", epitopes_parquet, exc)
1226
+ return None
1227
+
1228
+
1229
+ def _count_designs(design_dir: Path) -> int | None:
1230
+ """Count the designs actually present in ``<run>/design``; None when unknown.
1231
+
1232
+ The executor ships designs inside per-target tarballs, so the loose ``*.pdb``
1233
+ files a plain glob sees are only the ones a user unpacked by hand — both are
1234
+ counted, tarball members by name. ``None`` means nothing countable is there
1235
+ yet (the GPU step hasn't run), which the caller must report as unknown rather
1236
+ than quote a made-up number.
1237
+ """
1238
+ import tarfile
1239
+
1240
+ if not design_dir.exists():
1241
+ return None
1242
+ n = len({p for p in design_dir.rglob("*.pdb") if "validate" not in p.parts})
1243
+ for archive in sorted(design_dir.rglob("*.tar.gz")):
1244
+ try:
1245
+ with tarfile.open(archive, "r:gz", encoding="utf-8") as tf:
1246
+ # Only the archive's own design/ members; the per-target tarballs
1247
+ # nested inside results.tar.gz are counted from their own files.
1248
+ n += sum(
1249
+ 1
1250
+ for name in tf.getnames()
1251
+ if name.lower().endswith(".pdb") and Path(name).parts[:1] == ("design",)
1252
+ )
1253
+ except (tarfile.TarError, OSError) as e:
1254
+ LOG_CLI.warning("could not read %s: %s", archive, e)
1255
+ return n or None
1256
+
1257
+
1258
+ #: The ``--cheap`` profile, as specified in ARCHITECTURE.md §10: "RFdiff+MPNN
1259
+ #: on T4, 10 trajectories, ESM-2 pre-screen". The flag was parsed and then
1260
+ #: silently ignored, so a user asking for the cheap profile got the expensive
1261
+ #: one and a cost estimate quoting A100 prices.
1262
+ CHEAP_DESIGNER = "rfdiff_mpnn"
1263
+ CHEAP_TRAJECTORIES = 10
1264
+ CHEAP_GPU_TYPE = "T4"
1265
+ CHEAP_PRESCREEN_TOP_K = 5
1266
+
1267
+
1268
+ def _apply_cheap_profile(cfg: RunConfig) -> None:
1269
+ """Apply the T4-friendly profile in place.
1270
+
1271
+ Applied before the ``--dry-run`` branch so the printed estimate describes
1272
+ the run that would actually happen.
1273
+
1274
+ Args:
1275
+ cfg: The run configuration to modify.
1276
+ """
1277
+ design = cfg.params.design
1278
+ design.designer = CHEAP_DESIGNER
1279
+ design.n_trajectories = CHEAP_TRAJECTORIES
1280
+ design.gpu_type = CHEAP_GPU_TYPE
1281
+ design.prescreen_top_k = min(CHEAP_PRESCREEN_TOP_K, CHEAP_TRAJECTORIES)
1282
+ console.print(
1283
+ Panel(
1284
+ f"designer={CHEAP_DESIGNER} · trajectories={CHEAP_TRAJECTORIES} · "
1285
+ f"GPU={CHEAP_GPU_TYPE} · ESM-2 pre-screen keeps "
1286
+ f"{design.prescreen_top_k} designs per target",
1287
+ title="--cheap profile",
1288
+ border_style="cyan",
1289
+ )
1290
+ )
1291
+
1292
+
1293
+ def _preflight_backend(backend: str, **plugins: str) -> None:
1294
+ """Refuse a backend/plugin combination the backend cannot run.
1295
+
1296
+ The CLI accepted any designer against any backend and found out remotely.
1297
+ ``--designer bindcraft --backend kaggle`` built the same two-environment
1298
+ kernel every Kaggle job builds, because the kernel takes no designer
1299
+ argument, and failed only after roughly six minutes of environment building
1300
+ had been charged to a weekly GPU quota that does not refund.
1301
+
1302
+ A combination the backend has no environment for is refused here, in about a
1303
+ second, naming what it does provide. A combination the executor would *try*
1304
+ to bootstrap is allowed and announced, because untested is not the same
1305
+ statement as impossible and blocking it would remove a path that may work.
1306
+
1307
+ Args:
1308
+ backend: the runner the job would go to.
1309
+ **plugins: role name -> plugin name, e.g. ``designer="bindcraft"``.
1310
+
1311
+ Raises:
1312
+ SystemExit: with status 2 when any combination is unsupported.
1313
+ """
1314
+ from bindsight.plugins import UNSUPPORTED, UNTESTED, plugin_support
1315
+
1316
+ refusals: list[str] = []
1317
+ for role, name in plugins.items():
1318
+ if not name:
1319
+ continue
1320
+ verdict, why = plugin_support(backend, name)
1321
+ if verdict == UNSUPPORTED:
1322
+ refusals.append(f"[bold]{role} {name}[/bold] — {why}")
1323
+ elif verdict == UNTESTED:
1324
+ console.print(
1325
+ f"[yellow]untested:[/yellow] {why}. Proceeding, but this path has "
1326
+ "not been demonstrated end to end."
1327
+ )
1328
+ if not refusals:
1329
+ return
1330
+ console.print(
1331
+ Panel(
1332
+ "\n\n".join(refusals)
1333
+ + "\n\n[dim]Refused locally so no GPU quota is spent discovering it "
1334
+ "remotely.[/dim]",
1335
+ title=f"{backend}: unsupported combination",
1336
+ border_style="red",
1337
+ )
1338
+ )
1339
+ sys.exit(2)
1340
+
1341
+
1342
+ def _design_defaults_from_run(
1343
+ run_dir: Path, *, designer: str, validator: str, trajectories: int
1344
+ ) -> tuple[str, str, int]:
1345
+ """Fill unset design options from the configuration the run was produced under.
1346
+
1347
+ ``bindsight design <run>`` takes a run directory, not a config, so it had no
1348
+ way to see what the user configured and used its own flag defaults instead.
1349
+ That silently overrode ``params.design.n_trajectories`` — a fivefold change
1350
+ in GPU cost, reported in the cost panel as if it had been requested.
1351
+
1352
+ Click knows whether a value came from the command line or from a default, so
1353
+ an explicit flag still wins and only unset options are filled in.
1354
+
1355
+ Args:
1356
+ run_dir: the run directory, which holds the effective ``config.yaml``.
1357
+ designer: the designer option as Click resolved it.
1358
+ validator: the validator option as Click resolved it.
1359
+ trajectories: the trajectory count as Click resolved it.
1360
+
1361
+ Returns:
1362
+ The three values, with defaults replaced by the run's configuration
1363
+ where one is available.
1364
+ """
1365
+ import logging
1366
+
1367
+ log = logging.getLogger(__name__)
1368
+ cfg_path = run_dir / "config.yaml"
1369
+ if not cfg_path.is_file():
1370
+ return designer, validator, trajectories
1371
+
1372
+ try:
1373
+ import yaml
1374
+
1375
+ params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
1376
+ design_params = params.get("design") or {}
1377
+ validate_params = params.get("validate") or {}
1378
+ except Exception as e: # pragma: no cover - a malformed config is the user's
1379
+ log.warning("could not read %s (%s); using command-line defaults", cfg_path, e)
1380
+ return designer, validator, trajectories
1381
+
1382
+ ctx = click.get_current_context(silent=True)
1383
+
1384
+ def _from_default(name: str) -> bool:
1385
+ if ctx is None:
1386
+ return False
1387
+ return ctx.get_parameter_source(name) is click.core.ParameterSource.DEFAULT
1388
+
1389
+ if _from_default("designer") and design_params.get("designer"):
1390
+ designer = str(design_params["designer"])
1391
+ if _from_default("validator") and validate_params.get("validator"):
1392
+ validator = str(validate_params["validator"])
1393
+ if _from_default("trajectories") and design_params.get("n_trajectories"):
1394
+ trajectories = int(design_params["n_trajectories"])
1395
+ log.info("using n_trajectories=%d from %s", trajectories, cfg_path)
1396
+
1397
+ return designer, validator, trajectories
1398
+
1399
+
1400
+ def _validator_params_from_run(run_dir: Path) -> tuple[int | None, int, int]:
1401
+ """Validator settings this command has no flag for, read from the run's config.
1402
+
1403
+ ``_design_defaults_from_run`` fills in options that *are* flags, deferring to
1404
+ an explicit one. These three are not flags, so the configuration is their
1405
+ only source — and this command was not reading them, which made them inert
1406
+ on the one path that runs design without ``bindsight run``.
1407
+
1408
+ ``prescreen_top_k`` is the expensive one to lose: ``--cheap`` sets it, and
1409
+ dropping it validates every design instead of the configured few, paying the
1410
+ GPU cost the profile exists to avoid. It is the same defect this command
1411
+ already carries a fix for — a configured value honoured by ``bindsight run``
1412
+ and silently ignored here — one field further along.
1413
+
1414
+ Args:
1415
+ run_dir: the run directory holding the effective ``config.yaml``.
1416
+
1417
+ Returns:
1418
+ ``(prescreen_top_k, diffusion_samples, max_parallel_samples)``, using the
1419
+ schema's own defaults when the file is absent or unreadable.
1420
+ """
1421
+ import logging
1422
+
1423
+ log = logging.getLogger(__name__)
1424
+ cfg_path = run_dir / "config.yaml"
1425
+ if not cfg_path.is_file():
1426
+ return None, 1, 1
1427
+ try:
1428
+ import yaml
1429
+
1430
+ params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
1431
+ design_params = params.get("design") or {}
1432
+ validate_params = params.get("validate") or {}
1433
+ except Exception as e: # pragma: no cover - a malformed config is the user's
1434
+ log.warning("could not read %s (%s); using defaults", cfg_path, e)
1435
+ return None, 1, 1
1436
+
1437
+ top_k = design_params.get("prescreen_top_k")
1438
+ samples = int(validate_params.get("diffusion_samples") or 1)
1439
+ parallel = int(validate_params.get("max_parallel_samples") or 1)
1440
+ if top_k:
1441
+ log.info("using prescreen_top_k=%s from %s", top_k, cfg_path)
1442
+ if samples != 1:
1443
+ log.info("using diffusion_samples=%d from %s", samples, cfg_path)
1444
+ return (int(top_k) if top_k else None), samples, parallel
1445
+
1446
+
1447
+ def _gpu_type_from_run(run_dir: Path) -> str | None:
1448
+ """The GPU the run was configured for, or ``None`` when unrecorded.
1449
+
1450
+ ``bindsight run`` already passes ``params.design.gpu_type`` to the cost
1451
+ estimate; ``design --dry-run`` did not, so it quoted A100 prices for a run
1452
+ configured for a T4 -- an estimate for different hardware than the job would
1453
+ ask for, printed with no indication that the two differ.
1454
+ """
1455
+ cfg_path = run_dir / "config.yaml"
1456
+ if not cfg_path.is_file():
1457
+ return None
1458
+ try:
1459
+ import yaml
1460
+
1461
+ params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
1462
+ return (params.get("design") or {}).get("gpu_type")
1463
+ except Exception as e: # pragma: no cover - a malformed config is the user's
1464
+ LOG_CLI.warning("could not read %s (%s); pricing without a gpu_type", cfg_path, e)
1465
+ return None
1466
+
1467
+
1468
+ def _design_spec_params_from_run(run_dir: Path) -> tuple[int, int, int]:
1469
+ """Read the design parameters a run was configured with, for its spec.
1470
+
1471
+ Unlike :func:`_design_defaults_from_run`, none of these are command-line
1472
+ options, so there is no flag to defer to — the configuration is the only
1473
+ source. They were nonetheless not read at all: every job was built with
1474
+ ``make_spec``'s own defaults, so a run whose ``config.yaml`` recorded
1475
+ ``seed: 42`` shipped a spec carrying seed 0 to the GPU. That was verified by
1476
+ decoding the payload of a kernel launched from exactly such a config.
1477
+
1478
+ A silently ignored seed is worse than an inconvenient one. The seed is part
1479
+ of the cache key and part of the manifest, so the artifact claimed to
1480
+ describe a run that had not happened, and the run could never be reproduced
1481
+ from the configuration filed beside it.
1482
+
1483
+ Args:
1484
+ run_dir: the run directory, which holds the effective ``config.yaml``.
1485
+
1486
+ Returns:
1487
+ ``(seed, binder_length_min, binder_length_max)``, falling back to the
1488
+ DesignSpec defaults wherever the configuration is silent or unreadable.
1489
+ """
1490
+ import logging
1491
+
1492
+ from bindsight.design.protocol import DesignSpec
1493
+
1494
+ log = logging.getLogger(__name__)
1495
+ fields = DesignSpec.model_fields
1496
+ seed = int(fields["seed"].default)
1497
+ lo = int(fields["binder_length_min"].default)
1498
+ hi = int(fields["binder_length_max"].default)
1499
+
1500
+ cfg_path = run_dir / "config.yaml"
1501
+ if not cfg_path.is_file():
1502
+ return seed, lo, hi
1503
+ try:
1504
+ import yaml
1505
+
1506
+ params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
1507
+ design_params = params.get("design") or {}
1508
+ except Exception as e: # pragma: no cover - a malformed config is the user's
1509
+ log.warning("could not read %s (%s); using DesignSpec defaults", cfg_path, e)
1510
+ return seed, lo, hi
1511
+
1512
+ if design_params.get("seed") is not None:
1513
+ seed = int(design_params["seed"])
1514
+ if design_params.get("binder_length_min") is not None:
1515
+ lo = int(design_params["binder_length_min"])
1516
+ if design_params.get("binder_length_max") is not None:
1517
+ hi = int(design_params["binder_length_max"])
1518
+ log.info("design spec from %s: seed=%d binder_length=%d-%d", cfg_path, seed, lo, hi)
1519
+ return seed, lo, hi
1520
+
1521
+
1522
+ def _working_tree_wheel(backend: str, run_dir: Path) -> Path | None:
1523
+ """Build a wheel of this checkout for a remote backend to install.
1524
+
1525
+ Remote runners pip-install bindsight before doing anything, and with no
1526
+ wheel that means the repository's default branch — not the code you are
1527
+ running. A designer benchmark launched to validate a fix once spent an hour
1528
+ on a GPU executing the unfixed code for exactly this reason, and reported
1529
+ success.
1530
+
1531
+ Failure is not fatal: the runner falls back to a git install and warns that
1532
+ local changes will not run, so someone who installed bindsight from PyPI can
1533
+ still submit a job.
1534
+
1535
+ Args:
1536
+ backend: the runner name. Local backends install nothing, so they get None.
1537
+ run_dir: the run directory; the wheel is built into ``_wheel`` inside it.
1538
+
1539
+ Returns:
1540
+ Path to the built wheel, or None when it is unnecessary or unavailable.
1541
+ """
1542
+ if backend in {"mock", "local_docker"}:
1543
+ return None
1544
+ from bindsight.runners.source_wheel import build_working_tree_wheel
1545
+
1546
+ return build_working_tree_wheel(run_dir / "_wheel")
1547
+
1548
+
1549
+ def _target_artifact_stem(target: dict[str, Any]) -> str:
1550
+ """Filename stem for one design job's artifacts, unique per epitope site.
1551
+
1552
+ Keyed on the accession alone, a receptor with two qualifying sites had its
1553
+ second job's tarball written over the first's — same name, different
1554
+ binders, no warning — and the reader then found one archive where two jobs
1555
+ had run. Discovery emits one epitopes row per site and each row becomes its
1556
+ own job, so the accession is not the unit of work.
1557
+
1558
+ Mirrors :func:`bindsight.runners.job_exec._binder_id_prefix` deliberately, so
1559
+ an archive's name and the binder ids inside it agree, and a whole-surface
1560
+ job keeps the bare accession it has always had.
1561
+ """
1562
+ from bindsight.runners.job_exec import _binder_id_prefix
1563
+
1564
+ return _binder_id_prefix(
1565
+ {
1566
+ "target_uniprot": target.get("uniprot"),
1567
+ "epitope_residues": target.get("residues") or [],
1568
+ "epitope_chain": target.get("chain") or "A",
1569
+ }
1570
+ )
1571
+
1572
+
1573
+ def _top_targets(run_dir: Path) -> list[dict[str, Any]]:
1574
+ """Return top-N targets (uniprot, structure_path, chain, residues, ranges) to design.
1575
+
1576
+ Structure references are resolved to absolute paths here, which is the one
1577
+ place every design/validate consumer funnels through. New runs store them
1578
+ run-relative so the run stays portable; runs made before that change stored
1579
+ absolute cache paths. ``resolve_run_path`` accepts both.
1580
+
1581
+ ``design_ranges`` (the extracellular ranges discovery annotated from UniProt
1582
+ topology) is carried through to the design job so binders are designed against
1583
+ the reachable part of the receptor. Targets without it are named on the console
1584
+ — designing against the full length, transmembrane helix and cytoplasmic tail
1585
+ included, is a real difference the user has to know about.
1586
+ """
1587
+ import pandas as pd
1588
+
1589
+ from bindsight.io.paths import resolve_run_path
1590
+
1591
+ epitopes_parquet = run_dir / "epitopes" / "epitopes.parquet"
1592
+ if not epitopes_parquet.exists():
1593
+ return []
1594
+ df = pd.read_parquet(epitopes_parquet)
1595
+ targets: list[dict[str, Any]] = []
1596
+ no_ranges: list[str] = []
1597
+ for _, row in df.iterrows():
1598
+ uni = row.get("uniprot_id")
1599
+ resolved = resolve_run_path(run_dir, row.get("structure_path"))
1600
+ if resolved is None or not uni:
1601
+ continue
1602
+ struct = str(resolved)
1603
+ residues = row.get("residues")
1604
+ residues = list(residues) if residues is not None and len(residues) else []
1605
+ ranges = _design_ranges(row.get("design_ranges") if "design_ranges" in df.columns else None)
1606
+ if not ranges:
1607
+ no_ranges.append(str(uni))
1608
+ targets.append(
1609
+ {
1610
+ "uniprot": str(uni),
1611
+ "structure_path": struct,
1612
+ "chain": str(row.get("chain") or "A"),
1613
+ "residues": [int(r) for r in residues],
1614
+ "design_ranges": ranges,
1615
+ }
1616
+ )
1617
+ if no_ranges:
1618
+ console.print(
1619
+ f"[yellow]No extracellular ranges for {', '.join(sorted(set(no_ranges)))}[/yellow] — "
1620
+ "designing against the full-length chain (transmembrane and cytoplasmic "
1621
+ "regions included). Enable params.target_discovery.use_uniprot_topology "
1622
+ "in discover to restrict design to the extracellular domain."
1623
+ )
1624
+ return targets
1625
+
1626
+
1627
+ def _design_ranges(raw: Any) -> list[tuple[int, int]]:
1628
+ """Coerce an epitopes-table ``design_ranges`` cell to ``[(lo, hi), ...]``.
1629
+
1630
+ The column holds a nested array (or NaN/None when topology wasn't annotated),
1631
+ which is exactly the "we never determined the extracellular domain" case.
1632
+ """
1633
+ if raw is None or not hasattr(raw, "__len__") or len(raw) == 0:
1634
+ return []
1635
+ ranges: list[tuple[int, int]] = []
1636
+ for r in raw:
1637
+ if r is None or len(r) != 2:
1638
+ continue
1639
+ ranges.append((int(r[0]), int(r[1])))
1640
+ return ranges
1641
+
1642
+
1643
+ def _write_design_notebooks(run_dir: Path, *, designer: str, trajectories: int) -> int:
1644
+ """Write one Colab design+validate notebook per top target. Returns count."""
1645
+ from bindsight.plugins import get_designer
1646
+ from bindsight.runners.notebook_content import write_design_notebook
1647
+
1648
+ targets = list(_top_targets(run_dir))
1649
+ if not targets:
1650
+ # No directory either. Creating an empty `design/` made the run look
1651
+ # like the stage had started, and the caller has nothing to write into
1652
+ # it.
1653
+ return 0
1654
+
1655
+ design_dir = run_dir / "design"
1656
+ design_dir.mkdir(parents=True, exist_ok=True)
1657
+ plugin = get_designer(designer)
1658
+ # The configured seed and binder-length bounds, read the same way the
1659
+ # launch path reads them. Omitting them here left make_spec's own defaults
1660
+ # embedded in the notebook -- on `colab`, which is the DEFAULT backend --
1661
+ # so a run configured with seed 42 shipped a notebook carrying seed 0.
1662
+ seed, binder_length_min, binder_length_max = _design_spec_params_from_run(run_dir)
1663
+ n = 0
1664
+ for t in targets:
1665
+ spec = plugin.make_spec(
1666
+ target_uniprot=t["uniprot"],
1667
+ target_structure_path=Path(t["structure_path"]),
1668
+ epitope_residues=t["residues"],
1669
+ epitope_chain=t["chain"],
1670
+ design_ranges=t["design_ranges"],
1671
+ n_trajectories=trajectories,
1672
+ seed=seed,
1673
+ binder_length_min=binder_length_min,
1674
+ binder_length_max=binder_length_max,
1675
+ )
1676
+ spec_dict = spec.model_dump()
1677
+ # Embed the target structure (converted to PDB) so the Colab notebook is
1678
+ # self-contained — RFdiffusion needs PDB; AlphaFold ships mmCIF.
1679
+ b64 = _structure_pdb_b64(Path(t["structure_path"]))
1680
+ if b64:
1681
+ spec_dict["target_structure_b64"] = b64
1682
+ spec_dict.setdefault("extra_params", {})["target_structure_name"] = "target.pdb"
1683
+ handle_id = f"{designer}_{t['uniprot']}"
1684
+ write_design_notebook(
1685
+ design_dir / f"{handle_id}.ipynb",
1686
+ handle_id=handle_id,
1687
+ designer=designer,
1688
+ gpu_type="T4",
1689
+ spec=spec_dict,
1690
+ )
1691
+ n += 1
1692
+ return n
1693
+
1694
+
1695
+ def _structure_pdb_b64(structure_path: Path) -> str | None:
1696
+ """Return base64 PDB bytes for a structure (converting mmCIF → PDB)."""
1697
+ import base64
1698
+ import tempfile
1699
+
1700
+ if not structure_path.exists():
1701
+ return None
1702
+ if structure_path.suffix.lower() in {".cif", ".mmcif"}:
1703
+ from bindsight.runners.job_exec import _cif_to_pdb
1704
+
1705
+ tmp = Path(tempfile.mkdtemp()) / "target.pdb"
1706
+ try:
1707
+ _cif_to_pdb(structure_path, tmp)
1708
+ data = tmp.read_bytes()
1709
+ except Exception as e: # pragma: no cover - biopython parse edge cases
1710
+ LOG_CLI.warning("could not convert %s to PDB: %s", structure_path, e)
1711
+ return None
1712
+ return base64.b64encode(data).decode()
1713
+ return base64.b64encode(structure_path.read_bytes()).decode()
1714
+
1715
+
1716
+ def _validators_that_produced(validated: Path) -> list[str]:
1717
+ """Return the validators named in a validated table, as recorded per row.
1718
+
1719
+ ``bindsight validate`` takes a ``--validator`` flag, but by default it does
1720
+ not run one: it materialises metrics the design job already produced, using
1721
+ whichever validator *that* job was given. Recording the flag in the manifest
1722
+ therefore described a validator that may never have run — passing
1723
+ ``--validator chai1r`` to a Boltz-2 run filed Chai-1r's name against
1724
+ Boltz-2's numbers. The rows themselves carry the truth, so ask them.
1725
+
1726
+ Args:
1727
+ validated: path to ``validated.parquet``.
1728
+
1729
+ Returns:
1730
+ Sorted distinct validator names, or an empty list when the table is
1731
+ missing, empty, or carries no ``validator_name``.
1732
+ """
1733
+ if not validated.is_file():
1734
+ return []
1735
+ try:
1736
+ import pandas as pd
1737
+
1738
+ df = pd.read_parquet(validated)
1739
+ except Exception as e: # a corrupt table must not sink the manifest
1740
+ LOG_CLI.warning("could not read %s (%s) to name the validator", validated, e)
1741
+ return []
1742
+ if "validator_name" not in df.columns:
1743
+ return []
1744
+ names = {str(v) for v in df["validator_name"].dropna().tolist() if str(v).strip()}
1745
+ return sorted(names)
1746
+
1747
+
1748
+ def _rank_weights(run_dir: Path) -> Any:
1749
+ """Read the run's rank weights, falling back to the defaults.
1750
+
1751
+ Only the ``params.rank`` section is parsed, not the whole ``RunConfig`` --
1752
+ the same reason :func:`_validate_params` gives just below: validating the
1753
+ entire document would make an unrelated schema change silently revert an
1754
+ existing run to the default weighting, which is the class of quiet fallback
1755
+ these helpers exist to remove.
1756
+
1757
+ Args:
1758
+ run_dir: the run directory, which holds the effective ``config.yaml``.
1759
+
1760
+ Returns:
1761
+ The :class:`~bindsight.config.RankWeights` the run was configured with.
1762
+ A run with no config, or an unreadable one, gets the defaults -- and the
1763
+ unreadable case warns, because silently ranking by a different weighting
1764
+ than the one on disk is exactly the defect this fixes.
1765
+ """
1766
+ from bindsight.config import RankWeights
1767
+
1768
+ cfg_path = run_dir / "config.yaml"
1769
+ if not cfg_path.is_file():
1770
+ return RankWeights()
1771
+ try:
1772
+ import yaml
1773
+
1774
+ params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
1775
+ return RankWeights(**((params.get("rank") or {}).get("weights") or {}))
1776
+ except Exception as e: # a malformed config must not silently reweight the run
1777
+ LOG_CLI.warning("could not read %s (%s); ranking with the default weights", cfg_path, e)
1778
+ return RankWeights()
1779
+
1780
+
1781
+ def _validate_params(run_dir: Path) -> Any:
1782
+ """Read the run's validation parameters, falling back to the defaults.
1783
+
1784
+ Args:
1785
+ run_dir: the run directory, which holds the effective ``config.yaml``.
1786
+
1787
+ Only the ``params.validate`` section is parsed, not the whole ``RunConfig``.
1788
+ Validating the entire document would make an unrelated schema change — a new
1789
+ required field, a renamed input — silently revert the thresholds to their
1790
+ defaults on every existing run, which is the same class of quiet fallback
1791
+ these helpers exist to remove. The values actually used are still validated,
1792
+ because the section is parsed into :class:`ValidateParams`.
1793
+
1794
+ Returns:
1795
+ The :class:`~bindsight.config.ValidateParams` the run was configured
1796
+ with. A malformed config must not cost the caller its metrics, so an
1797
+ unreadable file is a warning and the defaults are used.
1798
+ """
1799
+ from bindsight.config import ValidateParams
1800
+
1801
+ cfg_path = run_dir / "config.yaml"
1802
+ if not cfg_path.is_file():
1803
+ return ValidateParams()
1804
+ try:
1805
+ import yaml
1806
+
1807
+ params = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("params") or {}
1808
+ return ValidateParams(**(params.get("validate") or {}))
1809
+ except Exception as e: # a malformed config must not lose the metrics
1810
+ LOG_CLI.warning("could not read %s (%s); using default thresholds", cfg_path, e)
1811
+ return ValidateParams()
1812
+
1813
+
1814
+ def _split_on_thresholds(df: Any, run_dir: Path, validate_dir: Path) -> Any:
1815
+ """Honour ``apply_thresholds`` by setting failing designs aside, not deleting them.
1816
+
1817
+ The flag was declared in the config, shipped in an example YAML, and read by
1818
+ no code, so a user who asked for the quality bars to be enforced got a table
1819
+ in which every design still appeared. Its own comment claimed it changed what
1820
+ ``bindsight validate`` records, while the annotation ran unconditionally —
1821
+ the flag had no behaviour to describe.
1822
+
1823
+ It is honoured now, and nothing is destroyed to do it. Rows that fail move to
1824
+ ``excluded_by_thresholds.parquet`` beside the table they left, each carrying
1825
+ the reason it failed, and the count is printed. A design that vanishes with
1826
+ no recorded reason is precisely the invisible filter this pipeline exists not
1827
+ to have; a design moved to a named file with its reason attached is not one.
1828
+
1829
+ Designs whose metrics were never measured are kept. A bar a design was not
1830
+ scored against cannot be a bar it failed.
1831
+
1832
+ Args:
1833
+ df: the annotated validation table.
1834
+ run_dir: the run directory, for its configuration.
1835
+ validate_dir: where to write the excluded rows.
1836
+
1837
+ Returns:
1838
+ The table to keep — unchanged unless ``apply_thresholds`` is set.
1839
+ """
1840
+ params = _validate_params(run_dir)
1841
+ if not getattr(params, "apply_thresholds", False) or df.empty:
1842
+ return df
1843
+
1844
+ failing = df["passes_thresholds"] == "fail"
1845
+ if not bool(failing.any()):
1846
+ return df
1847
+
1848
+ excluded = df[failing]
1849
+ excluded.to_parquet(validate_dir / "excluded_by_thresholds.parquet", index=False)
1850
+ console.print(
1851
+ f"[yellow]apply_thresholds:[/yellow] {len(excluded)} of {len(df)} designs "
1852
+ f"failed the configured bars and were written to "
1853
+ f"{validate_dir / 'excluded_by_thresholds.parquet'} with their reasons."
1854
+ )
1855
+ return df[~failing].reset_index(drop=True)
1856
+
1857
+
1858
+ def _mark_thresholds(df: Any, run_dir: Path) -> Any:
1859
+ """Annotate each validated design against the configured quality bars.
1860
+
1861
+ ``iptm_threshold`` and ``pae_interaction_threshold`` were declared in the
1862
+ config, shipped in the example YAMLs, and read by nothing — so a user who
1863
+ tightened either was silently ignored. They are now applied, but only as
1864
+ annotation: every design stays in the table and carries ``passes_thresholds``
1865
+ plus a human-readable ``threshold_reason``. Nothing is dropped, because a
1866
+ design vanishing without a recorded reason is exactly the kind of invisible
1867
+ filter that makes a pipeline untrustworthy. Filtering is the reader's call.
1868
+
1869
+ A metric the validator did not produce cannot fail a bar it was never
1870
+ measured against: such a row is marked ``unassessed`` rather than passed.
1871
+ """
1872
+ import pandas as pd
1873
+
1874
+ params = _validate_params(run_dir)
1875
+
1876
+ if df.empty:
1877
+ df["passes_thresholds"] = pd.Series(dtype="object")
1878
+ df["threshold_reason"] = pd.Series(dtype="object")
1879
+ return df
1880
+
1881
+ iptm = pd.to_numeric(df.get("iptm"), errors="coerce")
1882
+ pae = pd.to_numeric(df.get("pae_interaction"), errors="coerce")
1883
+
1884
+ verdicts: list[str | None] = []
1885
+ reasons: list[str] = []
1886
+ for i, p_ in zip(iptm, pae, strict=False):
1887
+ failed: list[str] = []
1888
+ unmeasured: list[str] = []
1889
+ if pd.isna(i):
1890
+ unmeasured.append("iptm")
1891
+ elif i < params.iptm_threshold:
1892
+ failed.append(f"iptm {i:.3f} < {params.iptm_threshold:.2f}")
1893
+ if pd.isna(p_):
1894
+ unmeasured.append("pae_interaction")
1895
+ elif p_ > params.pae_interaction_threshold:
1896
+ failed.append(f"pae_interaction {p_:.1f} > {params.pae_interaction_threshold:.1f}")
1897
+ if failed:
1898
+ verdicts.append("fail")
1899
+ reasons.append("; ".join(failed))
1900
+ elif unmeasured:
1901
+ verdicts.append("unassessed")
1902
+ reasons.append("not measured: " + ", ".join(unmeasured))
1903
+ else:
1904
+ verdicts.append("pass")
1905
+ reasons.append(
1906
+ f"iptm >= {params.iptm_threshold:.2f} and "
1907
+ f"pae_interaction <= {params.pae_interaction_threshold:.1f}"
1908
+ )
1909
+ df["passes_thresholds"] = verdicts
1910
+ df["threshold_reason"] = reasons
1911
+ return df
1912
+
1913
+
1914
+ def _archive_carries_designs(archive: Path) -> bool:
1915
+ """True when a results tarball still contains the designed sequences.
1916
+
1917
+ ``_launch_revalidate`` replaces a target's tarball with whatever the run
1918
+ returns. That is right when the new archive carries the designs back
1919
+ alongside the fresh validator output, and destructive when it does not: the
1920
+ FASTA is the only record of what was designed, since the staged PDBs are
1921
+ byte-identical per backbone, and a second revalidation would then find
1922
+ nothing left to rescore.
1923
+ """
1924
+ import tarfile
1925
+
1926
+ if not archive.is_file():
1927
+ return False
1928
+ try:
1929
+ with tarfile.open(archive, "r:gz", encoding="utf-8") as tf:
1930
+ return any(
1931
+ m.isfile() and m.name.startswith("design/") and m.name.endswith(".fasta")
1932
+ for m in tf.getmembers()
1933
+ )
1934
+ except (OSError, tarfile.TarError):
1935
+ return False
1936
+
1937
+
1938
+ def _launch_revalidate(run_dir: Path, *, backend: str, validator: str) -> int:
1939
+ """Run ``validator`` against the binders a previous design step produced.
1940
+
1941
+ The designs travel to the GPU alongside the spec and no designer runs, so
1942
+ choosing a different validator costs one validation pass rather than a full
1943
+ redesign. This is what makes cross-validator agreement (Boltz-2 against
1944
+ Chai-1r or AF2 initial-guess on the *same* binders) reachable at all.
1945
+
1946
+ Returns the number of targets revalidated.
1947
+ """
1948
+ import shutil
1949
+ import tarfile
1950
+ import tempfile
1951
+
1952
+ from bindsight.design._common import make_cache_key, submit_via_runner
1953
+ from bindsight.design.protocol import DesignSpec
1954
+ from bindsight.plugins import get_runner
1955
+
1956
+ targets = _top_targets(run_dir)
1957
+ if not targets:
1958
+ return 0
1959
+ design_dir = run_dir / "design"
1960
+ targets_dir = design_dir / "_targets"
1961
+ runner = get_runner(
1962
+ backend,
1963
+ designer="rfdiff_mpnn",
1964
+ n_units_per_target=1,
1965
+ bindsight_wheel=_working_tree_wheel(backend, run_dir),
1966
+ )
1967
+
1968
+ metrics_lines: list[str] = []
1969
+ done = 0
1970
+ for t in targets:
1971
+ # Written under the site-specific stem since the per-site collision was
1972
+ # fixed. Runs made before that carry the bare accession, and they are
1973
+ # still readable: a naming fix must not orphan the archives it renames.
1974
+ tar_path = targets_dir / f"{_target_artifact_stem(t)}.tar.gz"
1975
+ if not tar_path.exists():
1976
+ legacy = targets_dir / f"{t['uniprot']}.tar.gz"
1977
+ if legacy.exists():
1978
+ tar_path = legacy
1979
+ else:
1980
+ LOG_CLI.warning("no design tarball for %s; skipping", t["uniprot"])
1981
+ continue
1982
+ with tempfile.TemporaryDirectory() as tmp:
1983
+ staged = Path(tmp) / "design"
1984
+ staged.mkdir(parents=True, exist_ok=True)
1985
+ with tarfile.open(tar_path, "r:gz", encoding="utf-8") as tf:
1986
+ for m in tf.getmembers():
1987
+ if m.isfile() and m.name.startswith("design/"):
1988
+ src = tf.extractfile(m)
1989
+ if src is not None:
1990
+ (staged / Path(m.name).name).write_bytes(src.read())
1991
+ if not any(staged.glob("*.fasta")):
1992
+ LOG_CLI.warning("no designs inside %s; skipping", tar_path)
1993
+ continue
1994
+ spec = DesignSpec(
1995
+ target_uniprot=t["uniprot"],
1996
+ target_structure_path=str(t["structure_path"]),
1997
+ epitope_chain=t["chain"],
1998
+ epitope_residues=t["residues"],
1999
+ design_ranges=t["design_ranges"],
2000
+ n_trajectories=1,
2001
+ seed=0,
2002
+ extra_params={"mode": "validate_only", "validator": validator},
2003
+ )
2004
+ result = submit_via_runner(
2005
+ spec,
2006
+ runner,
2007
+ designer_name=f"revalidate:{validator}",
2008
+ designer_version="1",
2009
+ designer_commit_sha=None,
2010
+ cache_key=make_cache_key(spec, extra=("validate_only", validator)),
2011
+ payload_dir=staged,
2012
+ )
2013
+ # Never trade the designs for a validator's output. See
2014
+ # :func:`_archive_carries_designs`.
2015
+ if _archive_carries_designs(Path(result.results_archive_path)):
2016
+ shutil.copy2(result.results_archive_path, tar_path)
2017
+ else:
2018
+ LOG_CLI.error(
2019
+ "the revalidated archive for %s carries no designs, so copying it "
2020
+ "over %s would destroy the binders it was meant to rescore. Keeping "
2021
+ "the original; the new metrics are still recorded.",
2022
+ t["uniprot"],
2023
+ tar_path,
2024
+ )
2025
+ mpath = Path(result.metrics_jsonl_path)
2026
+ if mpath.exists():
2027
+ metrics_lines += [
2028
+ ln for ln in mpath.read_text(encoding="utf-8").splitlines() if ln.strip()
2029
+ ]
2030
+ done += 1
2031
+
2032
+ if metrics_lines:
2033
+ (design_dir / "metrics.jsonl").write_text(
2034
+ "\n".join(metrics_lines) + "\n", encoding="utf-8", newline="\n"
2035
+ )
2036
+ return done
2037
+
2038
+
2039
+ def _launch_design(
2040
+ run_dir: Path,
2041
+ *,
2042
+ backend: str,
2043
+ designer: str,
2044
+ validator: str,
2045
+ trajectories: int,
2046
+ prescreen_top_k: int | None = None,
2047
+ diffusion_samples: int = 1,
2048
+ max_parallel_samples: int = 1,
2049
+ ) -> int:
2050
+ """Run design+validation for each top target via a headless runner backend.
2051
+
2052
+ ``prescreen_top_k`` is passed through to the executor, which applies the
2053
+ ESM-2 screen between design and validation — the only point at which
2054
+ dropping a design saves GPU time. ``None`` validates every design.
2055
+ """
2056
+ import shutil
2057
+
2058
+ from bindsight.plugins import get_designer, get_runner
2059
+
2060
+ targets = _top_targets(run_dir)
2061
+ if not targets:
2062
+ return 0
2063
+ seed, binder_length_min, binder_length_max = _design_spec_params_from_run(run_dir)
2064
+ plugin = get_designer(designer)
2065
+ runner = get_runner(
2066
+ backend,
2067
+ designer=designer,
2068
+ n_units_per_target=trajectories,
2069
+ bindsight_wheel=_working_tree_wheel(backend, run_dir),
2070
+ )
2071
+ design_dir = run_dir / "design"
2072
+ targets_dir = design_dir / "_targets"
2073
+ targets_dir.mkdir(parents=True, exist_ok=True)
2074
+
2075
+ metrics_lines: list[str] = []
2076
+ launched = 0
2077
+ for t in targets:
2078
+ spec = plugin.make_spec(
2079
+ target_uniprot=t["uniprot"],
2080
+ target_structure_path=Path(t["structure_path"]),
2081
+ epitope_residues=t["residues"],
2082
+ epitope_chain=t["chain"],
2083
+ design_ranges=t["design_ranges"],
2084
+ n_trajectories=trajectories,
2085
+ seed=seed,
2086
+ binder_length_min=binder_length_min,
2087
+ binder_length_max=binder_length_max,
2088
+ )
2089
+ extra: dict[str, str | int | float | bool] = {
2090
+ **spec.extra_params,
2091
+ "validator": validator,
2092
+ }
2093
+ if prescreen_top_k:
2094
+ extra["prescreen_top_k"] = int(prescreen_top_k)
2095
+ # Only when asked for. Both default to 1, so a run that does not set
2096
+ # them produces the same spec — and the same cache key — as before.
2097
+ if diffusion_samples and int(diffusion_samples) != 1:
2098
+ extra["diffusion_samples"] = int(diffusion_samples)
2099
+ if max_parallel_samples and int(max_parallel_samples) != 1:
2100
+ extra["max_parallel_samples"] = int(max_parallel_samples)
2101
+ spec = spec.model_copy(update={"extra_params": extra})
2102
+ result = plugin.submit(spec, runner)
2103
+ shutil.copy2(
2104
+ result.results_archive_path, targets_dir / f"{_target_artifact_stem(t)}.tar.gz"
2105
+ )
2106
+ mpath = Path(result.metrics_jsonl_path)
2107
+ if mpath.exists():
2108
+ metrics_lines += [
2109
+ ln for ln in mpath.read_text(encoding="utf-8").splitlines() if ln.strip()
2110
+ ]
2111
+ launched += 1
2112
+
2113
+ (design_dir / "metrics.jsonl").write_text(
2114
+ "\n".join(metrics_lines) + ("\n" if metrics_lines else ""), encoding="utf-8", newline="\n"
2115
+ )
2116
+ # A top-level results.tar.gz marks design completion for `bindsight run`.
2117
+ import tarfile
2118
+
2119
+ with tarfile.open(design_dir / "results.tar.gz", "w:gz", encoding="utf-8") as tf:
2120
+ tf.add(design_dir / "metrics.jsonl", arcname="metrics.jsonl")
2121
+ tf.add(targets_dir, arcname="_targets")
2122
+ return launched
2123
+
2124
+
2125
+ def _finalize_validate(run_dir: Path) -> int:
2126
+ """Build validate/validated.parquet from design metrics; unpack per-binder dirs."""
2127
+ import tarfile
2128
+
2129
+ import pandas as pd
2130
+
2131
+ design_dir = run_dir / "design"
2132
+ validate_dir = run_dir / "validate"
2133
+ validate_dir.mkdir(parents=True, exist_ok=True)
2134
+
2135
+ # Unpack each per-target tarball's validate/<binder_id>/ into <run>/validate/.
2136
+ targets_dir = design_dir / "_targets"
2137
+ if targets_dir.exists():
2138
+ for tar_path in targets_dir.glob("*.tar.gz"):
2139
+ try:
2140
+ with tarfile.open(tar_path, "r:gz", encoding="utf-8") as tf:
2141
+ for m in tf.getmembers():
2142
+ if m.name.startswith("validate/"):
2143
+ tf.extract(m, validate_dir.parent, filter="data")
2144
+ except (tarfile.TarError, OSError) as e:
2145
+ LOG_CLI.warning("could not unpack %s: %s", tar_path, e)
2146
+
2147
+ metrics_path = design_dir / "metrics.jsonl"
2148
+ rows = (
2149
+ [
2150
+ json.loads(ln)
2151
+ for ln in metrics_path.read_text(encoding="utf-8").splitlines()
2152
+ if ln.strip()
2153
+ ]
2154
+ if metrics_path.exists()
2155
+ else []
2156
+ )
2157
+ # Always write validated.parquet (with the expected schema even when empty)
2158
+ # so downstream rank / the Snakemake `validate` output always exists.
2159
+ cols = [
2160
+ "binder_id",
2161
+ "target_uniprot",
2162
+ "iptm",
2163
+ "pae_interaction",
2164
+ "affinity_pred_value",
2165
+ "affinity_probability_binary",
2166
+ "validator_name",
2167
+ "validator_version",
2168
+ ]
2169
+ df = pd.DataFrame(rows) if rows else pd.DataFrame(columns=cols)
2170
+ df = _mark_thresholds(df, run_dir)
2171
+ df = _split_on_thresholds(df, run_dir, validate_dir)
2172
+ df.to_parquet(validate_dir / "validated.parquet", index=False)
2173
+ return len(df)
2174
+
2175
+
2176
+ def _print_cost_panel(cost: CostEstimate, label: str) -> None:
2177
+ """Render a CostEstimate as a Rich panel with the user-relevant fields."""
2178
+ usd = cost.usd_estimate or 0.0
2179
+ dollars = "free" if usd == 0 else f"~${usd:.2f}"
2180
+ console.print(
2181
+ Panel(
2182
+ f"[bold]{label}[/bold]\n"
2183
+ f"backend {cost.backend}\n"
2184
+ f"GPU {cost.gpu_type}\n"
2185
+ f"GPU-hours {cost.gpu_hours:.2f}\n"
2186
+ f"USD {dollars}\n"
2187
+ f"queue ~{cost.queue_minutes_estimate or 0:.0f} min · "
2188
+ f"{cost.notes or ''}",
2189
+ title="Cost estimate",
2190
+ border_style="cyan",
2191
+ )
2192
+ )
2193
+
2194
+
2195
+ # ---------------------------------------------------------------------------
2196
+ # demo — one-command end-to-end against the shipped tiny example
2197
+ # ---------------------------------------------------------------------------
2198
+ @main.command()
2199
+ @click.option(
2200
+ "--out",
2201
+ "out_dir",
2202
+ type=click.Path(file_okay=False, path_type=Path),
2203
+ default=Path("runs/demo"),
2204
+ show_default=True,
2205
+ help="Where to write the demo output.",
2206
+ )
2207
+ @click.option(
2208
+ "--no-report",
2209
+ is_flag=True,
2210
+ help="Skip rendering the HTML report at the end.",
2211
+ )
2212
+ def demo(out_dir: Path, no_report: bool) -> None:
2213
+ """Run the full discovery half on a real TCGA breast-cancer cohort.
2214
+
2215
+ Auto-downloads an authentic TCGA-BRCA tumor-vs-adjacent-normal RNA-seq
2216
+ cohort (STAR - Counts, 20 tumour vs 20 normal) from NIH/GDC on first run and
2217
+ runs real DESeq2 over it. Needs network the first time (cohort + SURFY
2218
+ downloaded, then cached); takes a few minutes on real data. Produces an HTML
2219
+ report.
2220
+
2221
+ Which antigens surface is the run's result, not a scripted one: a 20-vs-20
2222
+ subsample can land either side of the significance cutoffs. See
2223
+ benchmarks/study/RESULTS.md for what the full cohorts show.
2224
+ """
2225
+ # This help text once promised the demo rediscovered ERBB2 (HER2) and EGFR
2226
+ # as top antibody-tractable surface antigens. The project's own rediscovery
2227
+ # study measures ERBB2 in an unstratified breast cohort (matched pairs, no
2228
+ # biomarker selection) at log2 fold
2229
+ # change 0.92 -- below the 1.0 floor, so it does not clear the significance
2230
+ # rule -- and the demo config's own comment notes that EGFR is often lower in
2231
+ # bulk tumour than in normal breast epithelium. Naming an expected outcome in
2232
+ # --help turns whatever the run produces into either a confirmation or an
2233
+ # apparent malfunction, and one of those readings would be wrong.
2234
+ from bindsight.config import RunConfig
2235
+ from bindsight.pipelines import discover as discover_pipeline
2236
+
2237
+ _setup_logging(verbose=False)
2238
+
2239
+ # Resolve the bundled config relative to the package install root.
2240
+ repo_root = Path(__file__).parent.parent
2241
+ cfg_path = repo_root / "examples" / "demo" / "config.yaml"
2242
+ if not cfg_path.exists():
2243
+ # Installed wheels put it in shared-data under sys.prefix (pyproject's
2244
+ # [tool.hatch.build.targets.wheel.shared-data]). Without this candidate
2245
+ # `bindsight demo` could only ever run from a source checkout, which is
2246
+ # the one situation the one-button demo is not for.
2247
+ cfg_path = Path(sys.prefix) / "bindsight_demo" / "config.yaml"
2248
+ if not cfg_path.exists():
2249
+ # Editable installs: try CWD as a fallback.
2250
+ cfg_path = Path("examples/demo/config.yaml")
2251
+ if not cfg_path.exists():
2252
+ console.print(
2253
+ Panel(
2254
+ "[red]Could not find examples/demo/config.yaml.[/red] "
2255
+ "Re-install with `pip install -e .` from the repo root.",
2256
+ title="demo failed",
2257
+ border_style="red",
2258
+ )
2259
+ )
2260
+ sys.exit(2)
2261
+
2262
+ console.print(
2263
+ Panel(
2264
+ "[bold]bindsight demo[/bold]\n\n"
2265
+ f"config: {cfg_path}\nout: {out_dir}\n\n"
2266
+ "Real TCGA-BRCA tumor-vs-adjacent-normal cohort (NIH/GDC). The\n"
2267
+ "pipeline discovers antibody-tractable cell-surface antigens that are\n"
2268
+ "over-expressed in tumor (known targets such as ERBB2/HER2 appear when\n"
2269
+ "their signal is present), with full provenance. First run downloads\n"
2270
+ "the cohort + SURFY and enriches via Open Targets (cached afterwards)\n"
2271
+ "and runs real DESeq2 — a few minutes, not seconds.",
2272
+ title="Demo run",
2273
+ border_style="cyan",
2274
+ )
2275
+ )
2276
+
2277
+ cfg = RunConfig.from_yaml(cfg_path)
2278
+ cfg.out_dir = out_dir
2279
+ # Cache the auto-downloaded cohort under the OS user-cache dir (like the
2280
+ # SURFY/AlphaFold/Open Targets caches) so the one-button demo works from any
2281
+ # working directory and is downloaded only once.
2282
+ from bindsight.io.paths import cache_dir
2283
+
2284
+ cohort_dir = cache_dir("gdc") / "tcga_brca"
2285
+ cfg.inputs.counts = cohort_dir / "counts.tsv.gz"
2286
+ cfg.inputs.design = cohort_dir / "design.tsv"
2287
+
2288
+ manifest = discover_pipeline.run(cfg, out_dir=out_dir)
2289
+ failed = [s for s in manifest.stages if s.status == "failed"]
2290
+ if failed:
2291
+ console.print(
2292
+ Panel(
2293
+ "\n".join(f"[red]{s.name}[/red]: {s.error}" for s in failed),
2294
+ title="Demo stage failures",
2295
+ border_style="red",
2296
+ )
2297
+ )
2298
+ sys.exit(1)
2299
+
2300
+ # Render the report unless asked not to.
2301
+ report_path: Path | None = None
2302
+ if not no_report:
2303
+ from bindsight.report import render_run
2304
+
2305
+ report_path = render_run(out_dir)
2306
+
2307
+ console.print(
2308
+ Panel(
2309
+ f"[green]Demo complete![/green]\n\n"
2310
+ f"Manifest: {out_dir / 'run_manifest.jsonld'}\n"
2311
+ f"Targets: {out_dir / 'targets' / 'candidates.parquet'}\n"
2312
+ f"Epitopes: {out_dir / 'epitopes' / 'epitopes.parquet'}"
2313
+ + (f"\nReport HTML: {report_path}" if report_path else "")
2314
+ + "\n\nNext steps:\n"
2315
+ " 1. Open the HTML report in a browser to see the results.\n"
2316
+ " 2. Inspect the Parquet outputs with pandas / DuckDB.\n"
2317
+ " 3. Read docs/how-to-use.md to swap in your own RNA-seq cohort.\n"
2318
+ " 4. See docs/what-is-bindsight.md for the full pitch.",
2319
+ title="bindsight demo",
2320
+ border_style="green",
2321
+ )
2322
+ )
2323
+
2324
+
2325
+ # ---------------------------------------------------------------------------
2326
+ # doctor — diagnose the local install (cache state, vendored data, env vars)
2327
+ # ---------------------------------------------------------------------------
2328
+ @main.command()
2329
+ def doctor() -> None:
2330
+ """Diagnose the local install: Python, deps, cache state, vendored data."""
2331
+ import os
2332
+ import platform
2333
+
2334
+ from bindsight.io.paths import cache_dir
2335
+
2336
+ table = Table(title="bindsight doctor", show_lines=False, title_style="bold")
2337
+ table.add_column("Check", style="cyan", no_wrap=True)
2338
+ table.add_column("Status")
2339
+ table.add_column("Detail", overflow="fold")
2340
+
2341
+ def _row(name: str, ok: bool, detail: str = "") -> None:
2342
+ status = "[green]ok[/green]" if ok else "[yellow]warn[/yellow]"
2343
+ table.add_row(name, status, detail)
2344
+
2345
+ # Runtime
2346
+ # Not a bare `>= 3.11`. `requires-python` has no ceiling, so a newer
2347
+ # interpreter installs cleanly and this row said "ok" about a version CI
2348
+ # has never run -- the first thing `doctor` tells a newcomer about their
2349
+ # setup, and not true of it. Supported means tested.
2350
+ _row(
2351
+ "python",
2352
+ _PYTHON_SUPPORT_STATUS(),
2353
+ platform.python_version() + _python_support_note(),
2354
+ )
2355
+ _row("platform", True, platform.platform())
2356
+ _row("bindsight", True, __version__)
2357
+
2358
+ # Optional deps that the discovery half needs
2359
+ for dep in ("pydeseq2", "pandas", "pyarrow", "requests", "tenacity"):
2360
+ try:
2361
+ v = pkg_version(dep)
2362
+ _row(f"dep: {dep}", True, v)
2363
+ except PackageNotFoundError:
2364
+ _row(
2365
+ f"dep: {dep}",
2366
+ False,
2367
+ 'not installed; run: pip install -e ".\\[discover]"',
2368
+ )
2369
+
2370
+ # Cache state
2371
+ base = cache_dir()
2372
+ _row("cache root", base.exists(), str(base))
2373
+ # Report the surfaceome list discovery will *actually* use, not just whether
2374
+ # a refreshed network cache exists. The full list is vendored with the
2375
+ # package (2,886 accessions) and needs no network, so a missing cache is
2376
+ # normal, not a degradation.
2377
+ from bindsight.surfaceome.surfy import load_vendored_surfy
2378
+
2379
+ surfy_cache = base / "surfy" / "surfy_v1.uniprot.txt"
2380
+ vendored = load_vendored_surfy()
2381
+ if surfy_cache.exists():
2382
+ n_cached = sum(
2383
+ 1
2384
+ for ln in surfy_cache.read_text(encoding="utf-8").splitlines()
2385
+ if ln.strip() and not ln.lstrip().startswith("#")
2386
+ )
2387
+ _row("SURFY surfaceome", n_cached > 0, f"{n_cached} accessions (refreshed cache)")
2388
+ elif vendored is not None:
2389
+ _row(
2390
+ "SURFY surfaceome",
2391
+ True,
2392
+ f"{len(vendored)} accessions (vendored list — no network needed)",
2393
+ )
2394
+ else:
2395
+ _row(
2396
+ "SURFY surfaceome",
2397
+ False,
2398
+ "vendored list missing — reinstall bindsight (only a 10-protein fallback remains)",
2399
+ )
2400
+ afdb_cache = base / "alphafolddb"
2401
+ n_afdb = len(list(afdb_cache.glob("*.cif"))) if afdb_cache.exists() else 0
2402
+ _row("AlphaFoldDB cache", afdb_cache.exists(), f"{n_afdb} mmCIF files cached")
2403
+ ot_cache = base / "opentargets"
2404
+ n_ot = len(list(ot_cache.glob("*.json"))) if ot_cache.exists() else 0
2405
+ _row("Open Targets cache", ot_cache.exists(), f"{n_ot} cached responses")
2406
+
2407
+ # Vendored data (SURFACE-Bind)
2408
+ sb_env = os.environ.get("BINDSIGHT_SURFACE_BIND_DATA")
2409
+ sb_default = Path("data/surface_bind")
2410
+ if sb_env:
2411
+ sb_path = Path(sb_env)
2412
+ _row(
2413
+ "SURFACE-Bind data",
2414
+ sb_path.exists(),
2415
+ f"optional — env BINDSIGHT_SURFACE_BIND_DATA={sb_path}",
2416
+ )
2417
+ else:
2418
+ _row(
2419
+ "SURFACE-Bind data",
2420
+ (sb_default / "sites").exists() if sb_default.exists() else False,
2421
+ "optional — data/surface_bind/sites; without it design targets the "
2422
+ "whole surface. See data/surface_bind/README.md",
2423
+ )
2424
+
2425
+ console.print(table)
2426
+
2427
+
2428
+ if __name__ == "__main__": # pragma: no cover
2429
+ main()