bindsight 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
@@ -0,0 +1,140 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Binder developability — sequence-level manufacturability / liability flags.
4
+
5
+ A high-ipTM design is useless if it won't express, aggregates, or is hard to
6
+ manufacture. This module computes sequence-level biophysical descriptors (no
7
+ network, no GPU) so poor-developability designs can be down-ranked before any
8
+ wet-lab or GPU spend.
9
+
10
+ **Three of them are combined into ``developability_score``**, and only these
11
+ three affect any ranking:
12
+
13
+ - **Instability index** (Guruprasad 1990) — > 40 predicts an unstable protein.
14
+ - **GRAVY** (Kyte-Doolittle grand average hydropathy) — higher ⇒ more hydrophobic
15
+ ⇒ harder to express / more aggregation-prone.
16
+ - **Aggregation-prone fraction** — fraction of the chain inside a hydrophobic
17
+ (Kyte-Doolittle window) aggregation-prone region (APR), a standard first-order
18
+ aggregation signal.
19
+
20
+ **The rest are reported and not scored.** They are real liabilities and a reader
21
+ should see them, but nothing down-ranks a design for them, and saying otherwise
22
+ would claim a filter that does not exist:
23
+
24
+ - **Isoelectric point** and **aromaticity** — formulation / handling liabilities.
25
+ - **Cysteine count** — ``n_cys`` is the *total* number of cysteines in the
26
+ sequence, not the free ones. An odd count flags a probable unpaired cysteine
27
+ and so a disulfide / oxidation liability; pairing cannot be determined from
28
+ sequence alone, and this module does not read a structure.
29
+ - **Length** and **molecular weight** — context for everything above.
30
+
31
+ Five descriptors come from Biopython's ``ProtParam`` (the reference
32
+ implementation): molecular weight, GRAVY, instability index, isoelectric point
33
+ and aromaticity. The other three — length, cysteine count and aggregation-prone
34
+ fraction — are computed here; the APR window is this module's own
35
+ implementation, not ProtParam's. All are deterministic, offline and exact.
36
+
37
+ (T-cell–epitope / immunogenicity scoring, which needs a licensed or heavy MHC
38
+ predictor, is intentionally left for a follow-up so this module ships only
39
+ signals it can compute exactly here.)
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import math
46
+ from dataclasses import asdict, dataclass
47
+ from typing import Any
48
+
49
+ LOG = logging.getLogger(__name__)
50
+
51
+ _STANDARD_AA = set("ACDEFGHIKLMNPQRSTVWY")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Developability:
56
+ """Deterministic sequence-level developability descriptors for one binder."""
57
+
58
+ length: int
59
+ molecular_weight: float
60
+ gravy: float
61
+ instability_index: float
62
+ isoelectric_point: float
63
+ aromaticity: float
64
+ n_cys: int
65
+ aggregation_prone_fraction: float
66
+ developability_score: float # composite in [0, 1], higher = more developable
67
+
68
+ @property
69
+ def is_stable(self) -> bool:
70
+ """ProtParam instability index < 40 ⇒ predicted stable."""
71
+ return self.instability_index < 40.0
72
+
73
+ def as_dict(self) -> dict[str, Any]:
74
+ """Return the descriptors as a plain dict (for TSV/JSON export)."""
75
+ return asdict(self)
76
+
77
+
78
+ def _aggregation_prone_fraction(seq: str, window: int = 7, threshold: float = 1.0) -> float:
79
+ """Fraction of residues inside a hydrophobic (Kyte-Doolittle) APR window.
80
+
81
+ Slides a ``window``-residue window; a window whose mean Kyte-Doolittle
82
+ hydropathy exceeds ``threshold`` is aggregation-prone, and all its residues
83
+ are marked. Returns marked / length (0.0 for sequences shorter than a window).
84
+ """
85
+ from Bio.SeqUtils.ProtParamData import kd # Kyte-Doolittle scale
86
+
87
+ n = len(seq)
88
+ if n < window:
89
+ return 0.0
90
+ prone = [False] * n
91
+ for i in range(n - window + 1):
92
+ w = seq[i : i + window]
93
+ if sum(kd.get(a, 0.0) for a in w) / window > threshold:
94
+ for j in range(i, i + window):
95
+ prone[j] = True
96
+ return sum(prone) / n
97
+
98
+
99
+ def _composite(instability: float, gravy: float, aggregation_prone_fraction: float) -> float:
100
+ """Combine stability, solubility and aggregation into a [0, 1] developability score."""
101
+ # Stable below the 40 instability threshold, decaying above it.
102
+ stability = 1.0 if instability < 40.0 else max(0.0, 1.0 - (instability - 40.0) / 40.0)
103
+ # Solubility: sigmoid of -GRAVY (GRAVY 0 → 0.5, negative/hydrophilic → higher).
104
+ solubility = 1.0 / (1.0 + math.exp(gravy))
105
+ non_aggregating = 1.0 - aggregation_prone_fraction
106
+ return (stability + solubility + non_aggregating) / 3.0
107
+
108
+
109
+ def developability(sequence: str) -> Developability | None:
110
+ """Compute developability descriptors for a binder amino-acid sequence.
111
+
112
+ Returns ``None`` for an empty sequence or one containing non-standard
113
+ residues (ProtParam is only defined on the 20 standard amino acids).
114
+ """
115
+ if not sequence:
116
+ return None
117
+ seq = sequence.strip().upper()
118
+ if not seq or any(a not in _STANDARD_AA for a in seq):
119
+ return None
120
+ try:
121
+ from Bio.SeqUtils.ProtParam import ProteinAnalysis
122
+
123
+ pa = ProteinAnalysis(seq)
124
+ instability = float(pa.instability_index())
125
+ gravy = float(pa.gravy())
126
+ apr = _aggregation_prone_fraction(seq)
127
+ return Developability(
128
+ length=len(seq),
129
+ molecular_weight=round(float(pa.molecular_weight()), 2),
130
+ gravy=round(gravy, 4),
131
+ instability_index=round(instability, 2),
132
+ isoelectric_point=round(float(pa.isoelectric_point()), 2),
133
+ aromaticity=round(float(pa.aromaticity()), 4),
134
+ n_cys=seq.count("C"),
135
+ aggregation_prone_fraction=round(apr, 4),
136
+ developability_score=round(_composite(instability, gravy, apr), 4),
137
+ )
138
+ except Exception as e: # malformed sequence ProtParam can't handle
139
+ LOG.warning("developability failed for a %d-aa sequence: %s", len(seq), e)
140
+ return None
@@ -0,0 +1,165 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Protein-language-model embeddings + a pre-GPU sequence-space visualizer.
4
+
5
+ Before spending GPU on structure prediction / validation, it's useful to *see*
6
+ the sequence space of your designed binders (or candidate targets): which designs
7
+ cluster, which are outliers, how diverse the set is. This is the ProtSpace idea —
8
+ project protein-language-model (pLM) embeddings to 2-D.
9
+
10
+ **pLM choice — ESM-2 (`facebook/esm2_t6_8M_UR50D`, 8 M params).** ESM-2 is the
11
+ de-facto open protein LM; the 8 M (t6) checkpoint runs on a CPU in seconds, needs
12
+ no GPU, and its mean-pooled residue embedding is a strong, standard per-protein
13
+ representation. Larger ESM-2 checkpoints (35 M…3 B) or ProtT5 can be dropped in via
14
+ ``model_name`` when accuracy matters more than speed.
15
+
16
+ Embedding (ESM-2) needs the optional ``embed`` extra (torch + transformers):
17
+ ``pip install 'bindsight[embed]'``. The 2-D projection (PCA) is pure NumPy and
18
+ always available, so the visualizer works on any precomputed embedding matrix.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+
28
+ LOG = logging.getLogger(__name__)
29
+
30
+ DEFAULT_PLM = "facebook/esm2_t6_8M_UR50D"
31
+
32
+ _INSTALL_HINT = (
33
+ "ESM-2 embeddings need the optional 'embed' extra: pip install 'bindsight[embed]' "
34
+ "(installs torch + transformers)."
35
+ )
36
+
37
+
38
+ def esm2_embed(
39
+ sequences: list[str],
40
+ *,
41
+ model_name: str = DEFAULT_PLM,
42
+ batch_size: int = 8,
43
+ ) -> np.ndarray:
44
+ """Mean-pooled ESM-2 embeddings for ``sequences`` → array of shape (N, D).
45
+
46
+ Real protein-LM inference (no placeholder). Pools over residue token states,
47
+ excluding the special BOS/EOS and padding tokens. Raises ``ImportError`` (with
48
+ an install hint) if the ``embed`` extra isn't installed.
49
+ """
50
+ if not sequences:
51
+ return np.zeros((0, 0), dtype=np.float32)
52
+ try:
53
+ import torch
54
+ from transformers import AutoModel, AutoTokenizer
55
+ except ImportError as e: # optional extra not installed
56
+ raise ImportError(_INSTALL_HINT) from e
57
+
58
+ tok = AutoTokenizer.from_pretrained(model_name)
59
+ model = AutoModel.from_pretrained(model_name)
60
+ model.eval()
61
+
62
+ vecs: list[np.ndarray] = []
63
+ with torch.no_grad():
64
+ for i in range(0, len(sequences), batch_size):
65
+ batch = sequences[i : i + batch_size]
66
+ enc = tok(
67
+ batch,
68
+ return_tensors="pt",
69
+ padding=True,
70
+ return_special_tokens_mask=True,
71
+ )
72
+ special = enc.pop("special_tokens_mask")
73
+ hidden = model(**enc).last_hidden_state # (B, L, D)
74
+ # Real residues only: attended to, and not a special token.
75
+ keep = (enc["attention_mask"] * (1 - special)).cpu().numpy()
76
+ vecs.append(mean_pool_residues(hidden.cpu().numpy(), keep))
77
+ return np.vstack(vecs)
78
+
79
+
80
+ def mean_pool_residues(hidden: np.ndarray, keep: np.ndarray) -> np.ndarray:
81
+ """Mean of ``hidden`` over the positions ``keep`` marks, per sequence.
82
+
83
+ Split out of :func:`esm2_embed` so it can be tested. It is arithmetic --
84
+ no model, no tokenizer, no ``torch`` -- and it is the step that decides the
85
+ numbers: a padding token or a BOS/EOS marker left in the average shifts
86
+ every embedding, and the prescreen built on those embeddings decides which
87
+ designs reach validation. Inside ``esm2_embed`` it was reachable only with
88
+ the ``embed`` extra installed, which no CI job does, so the one test that
89
+ exercised it skipped on every platform.
90
+
91
+ Args:
92
+ hidden: ``(B, L, D)`` final hidden states.
93
+ keep: ``(B, L)`` of 1 for a real residue and 0 for padding or a special
94
+ token.
95
+
96
+ Returns:
97
+ ``(B, D)`` float32. A sequence with no kept position averages over a
98
+ count of 1 rather than dividing by zero, giving a zero vector.
99
+ """
100
+ weights = keep[..., None].astype(hidden.dtype)
101
+ summed = (hidden * weights).sum(axis=1)
102
+ counts = weights.sum(axis=1).clip(min=1.0)
103
+ pooled: np.ndarray = (summed / counts).astype(np.float32)
104
+ return pooled
105
+
106
+
107
+ def pca_2d(embeddings: np.ndarray) -> np.ndarray:
108
+ """Project an (N, D) embedding matrix to (N, 2) principal coordinates.
109
+
110
+ Pure NumPy (centred SVD) so it needs no scikit-learn/UMAP and runs anywhere.
111
+ Returns zeros for fewer than 2 rows. An embedding with a single feature has
112
+ only one principal axis; the second coordinate is then zero, and the shape
113
+ is still (N, 2) because every caller relies on it.
114
+ """
115
+ x = np.asarray(embeddings, dtype=float)
116
+ if x.ndim != 2 or x.shape[0] < 2:
117
+ return np.zeros((x.shape[0] if x.ndim == 2 else 0, 2), dtype=float)
118
+ xc = x - x.mean(axis=0, keepdims=True)
119
+ _u, _s, vt = np.linalg.svd(xc, full_matrices=False)
120
+ coords = np.asarray(xc @ vt[:2].T, dtype=float) # principal coordinates
121
+ if coords.shape[1] < 2:
122
+ # A one-dimensional embedding has one principal axis, so `vt[:2]` is a
123
+ # single row and this returned (N, 1) while promising (N, 2) -- which
124
+ # `render_embedding_png` then unpacks as x and y. The second axis is not
125
+ # missing, it is zero: there is no variance left to put on it. Say that
126
+ # rather than hand back a shape the signature rules out.
127
+ coords = np.concatenate(
128
+ [coords, np.zeros((coords.shape[0], 2 - coords.shape[1]), dtype=float)], axis=1
129
+ )
130
+ return coords
131
+
132
+
133
+ def render_embedding_png(
134
+ coords: np.ndarray,
135
+ labels: list[str],
136
+ out_path: str | Path,
137
+ *,
138
+ title: str = "Binder sequence space (ESM-2 → PCA)",
139
+ ) -> bool:
140
+ """Render a 2-D scatter of ``coords`` labelled by ``labels`` to a PNG.
141
+
142
+ Returns False (no-op) if matplotlib isn't installed. Used to produce the
143
+ committed ProtSpace-style visualization of the designed binders.
144
+ """
145
+ try:
146
+ import matplotlib
147
+
148
+ matplotlib.use("Agg")
149
+ import matplotlib.pyplot as plt
150
+ except ImportError:
151
+ return False
152
+
153
+ fig, ax = plt.subplots(figsize=(7, 6), dpi=120)
154
+ ax.scatter(coords[:, 0], coords[:, 1], s=40, alpha=0.8, c="#1f77b4")
155
+ for (x, y), lab in zip(coords, labels, strict=False):
156
+ ax.annotate(
157
+ str(lab), (x, y), fontsize=7, alpha=0.75, xytext=(3, 3), textcoords="offset points"
158
+ )
159
+ ax.set_xlabel("PC1")
160
+ ax.set_ylabel("PC2")
161
+ ax.set_title(title)
162
+ fig.tight_layout()
163
+ fig.savefig(out_path)
164
+ plt.close(fig)
165
+ return True
@@ -0,0 +1,169 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """ESM-2 pre-screen: cut the validation set before it reaches the GPU.
4
+
5
+ Structure-and-affinity validation is the expensive half of a design run —
6
+ Boltz-2 costs roughly 20 GPU-seconds per design against RFdiffusion+ProteinMPNN's
7
+ ~45 for producing one. Validating fifty near-identical backbones is mostly
8
+ wasted compute.
9
+
10
+ This screen embeds each designed sequence with ESM-2 and keeps a
11
+ *representative* subset: designs closest to the embedding centroid, which are
12
+ the ones the model considers most typical of what the designer produced.
13
+ Outliers are dropped first, on the reasoning that a design far from the rest of
14
+ the batch in sequence space is more often a degenerate backbone than a
15
+ breakthrough.
16
+
17
+ Deliberate properties:
18
+
19
+ - **Opt-in.** ``top_k=None`` returns everything untouched, so default runs are
20
+ bit-identical to before this module existed.
21
+ - **Degrading, never fatal.** ``torch``/``transformers`` live in the optional
22
+ ``embed`` extra. Without them the screen logs and returns everything: a
23
+ missing optional dependency must not lose a GPU run that already succeeded.
24
+ - **Deterministic.** Ties break on the input order, so the same batch always
25
+ yields the same selection.
26
+ - **Auditable.** :func:`prescreen_report` describes exactly what was dropped,
27
+ and that goes into provenance — silently discarding candidates would violate
28
+ the project's own contract that failures are recorded, not swallowed.
29
+
30
+ Reuses :func:`bindsight.design.embeddings.esm2_embed`; this module adds only
31
+ the selection, not a second embedding implementation.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ from dataclasses import dataclass
38
+
39
+ LOG = logging.getLogger(__name__)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class PrescreenResult:
44
+ """Outcome of a pre-screen pass."""
45
+
46
+ kept: list[int]
47
+ dropped: list[int]
48
+ applied: bool
49
+ reason: str
50
+
51
+ @property
52
+ def n_kept(self) -> int:
53
+ """Number of designs carried forward to validation."""
54
+ return len(self.kept)
55
+
56
+ @property
57
+ def n_dropped(self) -> int:
58
+ """Number of designs screened out before validation."""
59
+ return len(self.dropped)
60
+
61
+
62
+ def _fallback(n: int, reason: str, *, benign: bool = False) -> PrescreenResult:
63
+ """Keep everything, recording why the screen did not run.
64
+
65
+ ``benign`` separates "nothing needed screening" from "the screen could not
66
+ run". Both keep every design and both must be recorded in provenance, but
67
+ only the second is worth a warning: a run with fewer designs than its cap is
68
+ behaving exactly as configured, and logging that as a problem trains readers
69
+ to ignore the line that does signal one.
70
+ """
71
+ if reason:
72
+ LOG.log(
73
+ logging.INFO if benign else logging.WARNING,
74
+ "ESM-2 pre-screen not applied: %s; validating all %d designs",
75
+ reason,
76
+ n,
77
+ )
78
+ return PrescreenResult(kept=list(range(n)), dropped=[], applied=False, reason=reason)
79
+
80
+
81
+ def select_representative(sequences: list[str], top_k: int | None) -> PrescreenResult:
82
+ """Choose which designs to carry into validation.
83
+
84
+ Args:
85
+ sequences: Designed binder sequences, in design order.
86
+ top_k: How many to keep. ``None`` or a value at least as large as the
87
+ input keeps everything and skips the embedding entirely.
88
+
89
+ Returns:
90
+ A :class:`PrescreenResult` whose ``kept`` indices are in ascending
91
+ design order, so downstream output ordering is unchanged.
92
+ """
93
+ n = len(sequences)
94
+ if top_k is None:
95
+ return _fallback(n, "")
96
+ if top_k <= 0:
97
+ return _fallback(n, f"top_k={top_k} is not positive")
98
+ if n <= top_k:
99
+ # Distinct from `top_k is None`. The screen *was* configured; there were
100
+ # simply not enough designs to cut. Reporting both as an empty reason
101
+ # made the provenance line say "no top_k set" about a run that set one,
102
+ # so an auditor reading it would conclude the screen had never been
103
+ # asked for.
104
+ return _fallback(
105
+ n,
106
+ f"only {n} design{'s' if n != 1 else ''} to screen, top_k={top_k}",
107
+ benign=True,
108
+ )
109
+ if any(not s for s in sequences):
110
+ return _fallback(n, "one or more designs have an empty sequence")
111
+
112
+ try:
113
+ import numpy as np
114
+
115
+ from bindsight.design.embeddings import esm2_embed
116
+ except ImportError as e: # pragma: no cover - exercised via the extra
117
+ return _fallback(n, f"the 'embed' extra is not installed ({e})")
118
+
119
+ try:
120
+ emb = esm2_embed(sequences)
121
+ except Exception as e: # a screen must never lose an already-successful GPU run
122
+ return _fallback(n, f"embedding failed ({e})")
123
+
124
+ matrix = np.asarray(emb, dtype=float)
125
+ if matrix.ndim != 2 or matrix.shape[0] != n:
126
+ return _fallback(n, f"unexpected embedding shape {matrix.shape} for {n} designs")
127
+
128
+ centroid = matrix.mean(axis=0)
129
+ distances = np.linalg.norm(matrix - centroid, axis=1)
130
+ # argsort is stable, so equal distances keep their original design order.
131
+ order = np.argsort(distances, kind="stable")
132
+ kept = sorted(int(i) for i in order[:top_k])
133
+ dropped = sorted(int(i) for i in order[top_k:])
134
+
135
+ LOG.info(
136
+ "ESM-2 pre-screen: keeping %d of %d designs (dropped %d least-representative)",
137
+ len(kept),
138
+ n,
139
+ len(dropped),
140
+ )
141
+ return PrescreenResult(
142
+ kept=kept,
143
+ dropped=dropped,
144
+ applied=True,
145
+ reason=f"kept the {top_k} designs closest to the ESM-2 embedding centroid",
146
+ )
147
+
148
+
149
+ def prescreen_report(result: PrescreenResult, binder_ids: list[str]) -> str:
150
+ """Render a one-line, provenance-ready summary of the screen.
151
+
152
+ Args:
153
+ result: The screen outcome.
154
+ binder_ids: Binder identifiers in design order.
155
+
156
+ Returns:
157
+ A human-readable summary naming the dropped designs.
158
+ """
159
+ if not result.applied:
160
+ return (
161
+ f"ESM-2 pre-screen not applied ({result.reason or 'no top_k set'}); "
162
+ f"all {len(binder_ids)} designs validated"
163
+ )
164
+ dropped = [binder_ids[i] for i in result.dropped if i < len(binder_ids)]
165
+ shown = ", ".join(dropped[:10]) + (" …" if len(dropped) > 10 else "")
166
+ return (
167
+ f"ESM-2 pre-screen: validated {result.n_kept} of {len(binder_ids)} designs; "
168
+ f"{result.reason}. Screened out: {shown or 'none'}"
169
+ )
@@ -0,0 +1,111 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Designer plugin protocol.
4
+
5
+ A Designer turns a ``DesignSpec`` (target structure + epitope + params) into
6
+ ``DesignResult`` (a tarball of binder PDBs + per-design metrics). All real
7
+ work is offloaded to a :class:`bindsight.runners.GPURunner`; the Designer
8
+ itself only owns the spec shape and result schema.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Literal, Protocol, runtime_checkable
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ # A real import, not a TYPE_CHECKING one. ``submit`` annotates this type, and
19
+ # the annotation was previously silenced with ``# type: ignore[name-defined]``
20
+ # plus a ``noqa: F821`` directive -- which quieted two checkers rather than
21
+ # telling either
22
+ # what the name is, so ``typing.get_type_hints`` on this Protocol raised and
23
+ # nothing could introspect the contract. ``bindsight.runners.protocol`` imports
24
+ # nothing from this package, so there is no cycle to avoid.
25
+ from bindsight.runners.protocol import GPURunner
26
+
27
+
28
+ class DesignSpec(BaseModel):
29
+ """Inputs to a single design job (one target × N trajectories)."""
30
+
31
+ model_config = ConfigDict(extra="forbid", frozen=True)
32
+
33
+ target_uniprot: str
34
+ target_structure_path: str = Field(..., description="Local path to mmCIF/PDB.")
35
+ epitope_chain: str = "A"
36
+ # Hotspot residues to focus the binder on. Empty = whole-target design
37
+ # (valid before SURFACE-Bind epitope prediction is wired); RFdiffusion then
38
+ # runs without ``ppi.hotspot_res``.
39
+ epitope_residues: list[int] = Field(default_factory=list)
40
+ # Inclusive residue ranges of the target the binder may be designed against —
41
+ # the extracellular domain(s) annotated by discovery. Empty means no topology
42
+ # was available, so the whole chain (transmembrane helix and cytoplasmic tail
43
+ # included) is the design surface; the executor says so rather than implying
44
+ # the full-length receptor was intended.
45
+ design_ranges: list[tuple[int, int]] = Field(default_factory=list)
46
+ binder_length_min: int = 50
47
+ binder_length_max: int = 100
48
+ n_trajectories: int = 50
49
+ seed: int = 0
50
+ extra_params: dict[str, str | int | float | bool] = Field(default_factory=dict)
51
+
52
+
53
+ class DesignResult(BaseModel):
54
+ """Result of a design job."""
55
+
56
+ model_config = ConfigDict(extra="forbid", frozen=True)
57
+
58
+ spec: DesignSpec
59
+ results_archive_path: str = Field(..., description="Local path to the .tar.gz of PDBs.")
60
+ metrics_jsonl_path: str = Field(
61
+ ..., description="Local path to per-design metrics JSONL (one row per design)."
62
+ )
63
+ designer_name: str
64
+ designer_version: str = Field(
65
+ ...,
66
+ description=(
67
+ "Version of the bindsight adapter, NOT of the upstream tool it drives. "
68
+ "The upstream revision is pinned in designer_commit_sha and in the "
69
+ "spec's extra_params (e.g. rfdiff_commit, proteinmpnn_commit), which "
70
+ "are what the cache key and reproducibility depend on."
71
+ ),
72
+ )
73
+ designer_commit_sha: str | None = None
74
+ weights_sha256: str | None = None
75
+ cache_key: str = Field(..., description="Used to deduplicate identical jobs across runs.")
76
+ cache_status: Literal["hit", "miss"] = Field(
77
+ "miss",
78
+ description=(
79
+ "Whether this result was reused from a previous run with the same cache_key "
80
+ "('hit') or newly computed ('miss'). Recorded so a manifest shows which units "
81
+ "actually consumed GPU time."
82
+ ),
83
+ )
84
+
85
+
86
+ @runtime_checkable
87
+ class Designer(Protocol):
88
+ """Protocol every designer plugin must implement."""
89
+
90
+ name: str
91
+ version: str
92
+
93
+ def make_spec(
94
+ self,
95
+ *,
96
+ target_uniprot: str,
97
+ target_structure_path: Path,
98
+ epitope_residues: list[int],
99
+ epitope_chain: str = "A",
100
+ design_ranges: list[tuple[int, int]] | None = None,
101
+ n_trajectories: int = 50,
102
+ seed: int = 0,
103
+ binder_length_min: int = 50,
104
+ binder_length_max: int = 100,
105
+ ) -> DesignSpec:
106
+ """Build a designer-specific DesignSpec from generic inputs."""
107
+ ...
108
+
109
+ def submit(self, spec: DesignSpec, runner: GPURunner) -> DesignResult:
110
+ """Execute the design job (synchronous from the caller's POV)."""
111
+ ...
@@ -0,0 +1,93 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """RFdiffusion + ProteinMPNN designer plugin.
4
+
5
+ The default designer. Generates backbones with `RFdiffusion
6
+ <https://github.com/RosettaCommons/RFdiffusion>`_ (BSD-3, weights open) and
7
+ fills in sequences with `ProteinMPNN <https://github.com/dauparas/ProteinMPNN>`_
8
+ (MIT). Both fit in 16 GB VRAM, so this designer works on free Colab T4 — that
9
+ is why it's the default over BindCraft (which needs ≥32 GB).
10
+
11
+ The actual GPU work runs in :mod:`bindsight.runners.job_exec` (via whichever
12
+ runner backend the user picks). This module owns the plugin entry point and the
13
+ spec it builds; the submit body is shared via :mod:`bindsight.design._common`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from pathlib import Path
20
+
21
+ from bindsight.design._common import make_cache_key, submit_via_runner
22
+ from bindsight.design.protocol import DesignResult, DesignSpec
23
+ from bindsight.runners.protocol import GPURunner
24
+ from bindsight.runners.tools import PROTEINMPNN_COMMIT, RFDIFF_COMMIT
25
+
26
+ LOG = logging.getLogger(__name__)
27
+
28
+ # Pinned real upstream revisions (resolved via git ls-remote; see runners.tools).
29
+ DEFAULT_RFDIFF_COMMIT = RFDIFF_COMMIT
30
+ DEFAULT_PROTEINMPNN_COMMIT = PROTEINMPNN_COMMIT
31
+
32
+
33
+ class RFdiffMPNNDesigner:
34
+ """Plugin: RFdiffusion backbone + ProteinMPNN sequence."""
35
+
36
+ name = "rfdiff_mpnn"
37
+ # The adapter's own version, not the upstream tool's. Pinned upstream
38
+ # revisions live in the spec's extra_params and in the cache key; this
39
+ # number moves only when this adapter's behaviour does.
40
+ version = "0.1.0"
41
+ license_notice = "RFdiffusion: BSD-3. ProteinMPNN: MIT. Both commercial-OK."
42
+
43
+ def make_spec(
44
+ self,
45
+ *,
46
+ target_uniprot: str,
47
+ target_structure_path: Path,
48
+ epitope_residues: list[int],
49
+ epitope_chain: str = "A",
50
+ design_ranges: list[tuple[int, int]] | None = None,
51
+ n_trajectories: int = 50,
52
+ seed: int = 0,
53
+ binder_length_min: int = 50,
54
+ binder_length_max: int = 100,
55
+ ) -> DesignSpec:
56
+ """Build a DesignSpec carrying the RFdiff+MPNN parameters."""
57
+ return DesignSpec(
58
+ target_uniprot=target_uniprot,
59
+ target_structure_path=str(target_structure_path),
60
+ epitope_chain=epitope_chain,
61
+ epitope_residues=epitope_residues,
62
+ design_ranges=list(design_ranges or []),
63
+ n_trajectories=n_trajectories,
64
+ seed=seed,
65
+ binder_length_min=binder_length_min,
66
+ binder_length_max=binder_length_max,
67
+ extra_params={
68
+ "designer": self.name,
69
+ "designer_version": self.version,
70
+ "rfdiff_commit": DEFAULT_RFDIFF_COMMIT,
71
+ "proteinmpnn_commit": DEFAULT_PROTEINMPNN_COMMIT,
72
+ "binder_length_min": binder_length_min,
73
+ "binder_length_max": binder_length_max,
74
+ },
75
+ )
76
+
77
+ def submit(self, spec: DesignSpec, runner: GPURunner) -> DesignResult:
78
+ """Ship the spec+structure to the runner and stage the results."""
79
+ cache_key = make_cache_key(
80
+ spec,
81
+ extra=(
82
+ str(spec.extra_params.get("rfdiff_commit", "")),
83
+ str(spec.extra_params.get("proteinmpnn_commit", "")),
84
+ ),
85
+ )
86
+ return submit_via_runner(
87
+ spec,
88
+ runner,
89
+ designer_name=self.name,
90
+ designer_version=self.version,
91
+ designer_commit_sha=DEFAULT_RFDIFF_COMMIT,
92
+ cache_key=cache_key,
93
+ )
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Epitope identification — SURFACE-Bind targetable-site lookup.
4
+
5
+ An fpocket fallback for proteins SURFACE-Bind does not cover is planned and
6
+ not implemented; this docstring previously named it as though it shipped.
7
+ """