shellde 0.2.0__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 (51) hide show
  1. shellde/__init__.py +36 -0
  2. shellde/acquisition.py +135 -0
  3. shellde/advisor.py +158 -0
  4. shellde/bench/__init__.py +17 -0
  5. shellde/bench/falsification.py +95 -0
  6. shellde/bench/harness.py +134 -0
  7. shellde/bench/stats.py +46 -0
  8. shellde/campaign.py +257 -0
  9. shellde/candidates.py +341 -0
  10. shellde/cli.py +1517 -0
  11. shellde/colab.py +257 -0
  12. shellde/conformal.py +160 -0
  13. shellde/consensus.py +52 -0
  14. shellde/design_space.py +141 -0
  15. shellde/embeddings/__init__.py +6 -0
  16. shellde/embeddings/esm2.py +76 -0
  17. shellde/embeddings/esmc.py +94 -0
  18. shellde/embeddings/provider.py +105 -0
  19. shellde/features/__init__.py +22 -0
  20. shellde/features/base.py +49 -0
  21. shellde/features/defaults.py +11 -0
  22. shellde/features/embedding.py +80 -0
  23. shellde/features/inverse_folding.py +66 -0
  24. shellde/features/matrix.py +50 -0
  25. shellde/features/naturalness.py +65 -0
  26. shellde/features/onehot.py +55 -0
  27. shellde/features/pairwise.py +64 -0
  28. shellde/funclib.py +331 -0
  29. shellde/gating.py +181 -0
  30. shellde/holo.py +175 -0
  31. shellde/hotspots.py +108 -0
  32. shellde/loop.py +161 -0
  33. shellde/msa.py +186 -0
  34. shellde/naturalness.py +142 -0
  35. shellde/oracle.py +94 -0
  36. shellde/plm.py +145 -0
  37. shellde/prereg.py +41 -0
  38. shellde/protocols.py +87 -0
  39. shellde/rank.py +103 -0
  40. shellde/report.py +132 -0
  41. shellde/selector.py +63 -0
  42. shellde/sitefinder.py +465 -0
  43. shellde/structure.py +356 -0
  44. shellde/surrogate.py +323 -0
  45. shellde/types.py +66 -0
  46. shellde/zero_shot.py +160 -0
  47. shellde-0.2.0.dist-info/METADATA +285 -0
  48. shellde-0.2.0.dist-info/RECORD +51 -0
  49. shellde-0.2.0.dist-info/WHEEL +5 -0
  50. shellde-0.2.0.dist-info/entry_points.txt +2 -0
  51. shellde-0.2.0.dist-info/top_level.txt +1 -0
shellde/colab.py ADDED
@@ -0,0 +1,257 @@
1
+ """Notebook / Colab front end: one function call == one directed-evolution round.
2
+
3
+ Root design: the notebook stays a tiny, stable bootstrap ::
4
+
5
+ %pip install -q -U shellde
6
+ from shellde.colab import plate
7
+ df = plate(wt=\"\"\"...WT sequence...\"\"\", uniprot=\"Q50L36\", auto_msa=True)
8
+
9
+ All behaviour lives HERE, versioned with the package, so improvements arrive via ``pip install -U``
10
+ and the notebook file itself never needs re-uploading. This is a thin wrapper over ``shellde round``
11
+ (``cli.main``) -- the science (funclib seed, active learning, calibrated abstention) is unchanged.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import glob
16
+ import os
17
+ from pathlib import Path
18
+
19
+ from shellde.cli import main as _main
20
+ from shellde.hotspots import rank_hotspots
21
+
22
+
23
+ _AA_LETTERS = set("ACDEFGHIKLMNPQRSTVWYXBZUO")
24
+
25
+
26
+ def _clean_sequence(wt: str) -> str:
27
+ """Raw sequence or FASTA text -> bare uppercase amino acids only.
28
+
29
+ Drops '>' headers and keeps ONLY amino-acid letters, so stray spaces/newlines/digits/punctuation
30
+ (and non-latin placeholder text) are removed -- a too-short result then trips plate()'s guard.
31
+ """
32
+ body = "".join(line for line in wt.splitlines() if not line.strip().startswith(">"))
33
+ return "".join(c for c in body.upper() if c in _AA_LETTERS)
34
+
35
+
36
+ def _download_links(paths: list[str]) -> None:
37
+ """Show CLICKABLE download links for the result files (a zip-all + one per file).
38
+
39
+ No forced download: the files are embedded as base64 data URIs, so the user clicks what they want.
40
+ Works in Colab and Jupyter (no server round-trip). No-op outside a notebook.
41
+ """
42
+ import base64
43
+ import html
44
+ import io
45
+ import zipfile
46
+
47
+ try:
48
+ from IPython.display import HTML, display
49
+ except Exception: # noqa: BLE001 (not in a notebook -> nothing to render)
50
+ return
51
+ existing = [p for p in paths if os.path.exists(p)]
52
+ if not existing:
53
+ return
54
+ items = []
55
+ for p in existing:
56
+ data = Path(p).read_bytes()
57
+ b64 = base64.b64encode(data).decode()
58
+ name = html.escape(os.path.basename(p))
59
+ items.append(
60
+ f'<li><a download="{name}" href="data:text/csv;base64,{b64}">{name}</a>'
61
+ f' <span style="color:#888">({len(data) / 1024:.1f} KB)</span></li>'
62
+ )
63
+ buf = io.BytesIO()
64
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
65
+ for p in existing:
66
+ z.write(p, os.path.basename(p))
67
+ zb64 = base64.b64encode(buf.getvalue()).decode()
68
+ display(HTML(
69
+ '<div style="font-family:sans-serif;line-height:1.6">'
70
+ '<b>Download results</b> (click):<br>'
71
+ f'<a download="shellde_round_results.zip" href="data:application/zip;base64,{zb64}">'
72
+ '&#128230; all files (.zip)</a>'
73
+ f'<ul style="margin:4px 0">{"".join(items)}</ul>'
74
+ '</div>'
75
+ ))
76
+
77
+
78
+ def plate(
79
+ wt: str,
80
+ *,
81
+ uniprot: str | None = None,
82
+ positions: str | None = None,
83
+ pdb: str | None = None,
84
+ query: str | None = None,
85
+ holo: str | None = None,
86
+ scan: str | None = None,
87
+ top_k: int = 6,
88
+ measured: list[str] | str | None = None,
89
+ auto_msa: bool = True,
90
+ msa: str | None = None,
91
+ ddg: str | None = None,
92
+ if_logprobs: str | None = None,
93
+ tolerance: str | None = None,
94
+ wt_fitness: float | None = None,
95
+ plm: str = "none",
96
+ plm_rerank: int = 256,
97
+ exclude_catalytic: bool = False,
98
+ find_holo: bool = False,
99
+ model_class: str | None = None,
100
+ beta: float | None = None,
101
+ ligand_resnames: str | None = None,
102
+ min_coverage: float | None = None,
103
+ timeout: float | None = None,
104
+ plate_size: int = 95,
105
+ max_mut: int = 4,
106
+ outdir: str = "outputs/round",
107
+ download: bool = True,
108
+ wt_path: str = "WT.fasta",
109
+ ):
110
+ """Run one round and return the proposed plate as a DataFrame (or None if it abstained).
111
+
112
+ ``wt`` is the wild-type sequence (raw or FASTA text). Pass whatever design-region inputs you have
113
+ -- ``positions=`` (e.g. ``\"183,184,227,228\"``), ``holo=`` (local ligand-bound PDB), ``pdb=`` (RCSB
114
+ id / local cocrystal), ``uniprot=`` (accession or ``\"auto\"`` = exact sequence match), ``query=``
115
+ (UniProt text search) -- and the region is picked automatically, most-specific first (positions >
116
+ holo > pdb+uniprot combine > pdb > uniprot/query > sequence match). Omit ``measured`` for the R0 seed plate; pass measured
117
+ ``variant,fitness`` CSV path(s) to advance a round. ``auto_msa=True`` builds the R0 seed MSA from
118
+ the WT via ColabFold MMseqs2 so R0 runs with no uploaded file (natural proteins; GPU-free).
119
+ Prints the running version + command and shows clickable download links for the result files
120
+ (a zip of all, plus one per file); nothing is force-downloaded.
121
+ """
122
+ try:
123
+ import importlib.metadata as _md
124
+ print("shellde", _md.version("shellde"))
125
+ except Exception: # noqa: BLE001
126
+ pass
127
+
128
+ seq = _clean_sequence(wt)
129
+ if len(seq) < 20:
130
+ raise ValueError(
131
+ f"wt cleaned to only {len(seq)} amino acids ({seq!r}) -- paste the FULL wild-type protein "
132
+ "sequence (you likely left the placeholder text, or pasted a fragment). Raw sequence or "
133
+ "FASTA both work."
134
+ )
135
+ Path(wt_path).write_text(">WT\n" + seq + "\n")
136
+ print(f"WT: {len(seq)} residues -> {wt_path}")
137
+
138
+ argv = ["round", wt_path, "--plate", str(plate_size), "--max-mut", str(max_mut), "--outdir", outdir]
139
+
140
+ if scan and not (uniprot or positions or pdb or query or holo):
141
+ positions = hotspots(scan, top_k=top_k) # niche C: single-mutant scan -> hotspot positions
142
+ # Auto region dispatch: use whatever inputs are given, most-specific first (positions > holo >
143
+ # pdb+uniprot combine > pdb > uniprot/query > sequence match). No forced single choice; the chosen
144
+ # region and any also-provided-but-unused inputs are printed so nothing is silent.
145
+ given = {k: v for k, v in
146
+ {"positions": positions, "holo": holo, "pdb": pdb, "uniprot": uniprot, "query": query}.items() if v}
147
+ if positions:
148
+ use = {"positions": positions}
149
+ elif holo:
150
+ use = {"holo": holo}
151
+ elif pdb and (uniprot or query):
152
+ use = {"pdb": pdb, "uniprot": uniprot} if uniprot else {"pdb": pdb, "query": query}
153
+ elif pdb:
154
+ use = {"pdb": pdb}
155
+ elif uniprot or query:
156
+ use = {"uniprot": uniprot} if uniprot else {"query": query}
157
+ else:
158
+ use = {"uniprot": "auto"} # match the accession from the sequence
159
+ if not given:
160
+ print("auto region: no inputs given -> --uniprot auto (exact sequence match)")
161
+ else:
162
+ ignored = sorted(set(given) - set(use))
163
+ how = "combining" if len(use) > 1 else "using"
164
+ msg = f"auto region: {how} {sorted(use)}"
165
+ if ignored:
166
+ msg += f" (also provided, not needed here: {ignored})"
167
+ print(msg)
168
+ for key, val in use.items():
169
+ argv += [f"--{key}", val]
170
+ if exclude_catalytic:
171
+ argv += ["--exclude-catalytic"]
172
+ if find_holo:
173
+ argv += ["--find-holo"]
174
+
175
+ if msa:
176
+ argv += ["--msa", msa]
177
+ elif auto_msa:
178
+ argv += ["--auto-msa"]
179
+ if ddg:
180
+ argv += ["--ddg", ddg]
181
+ if if_logprobs:
182
+ argv += ["--if-logprobs", if_logprobs]
183
+ if tolerance:
184
+ argv += ["--tolerance", tolerance]
185
+ if measured:
186
+ meas = [measured] if isinstance(measured, str) else list(measured)
187
+ argv += ["--measured", *meas]
188
+ if wt_fitness is not None:
189
+ argv += ["--wt-fitness", str(wt_fitness)]
190
+ if plm != "none":
191
+ argv += ["--plm", plm, "--plm-rerank", str(plm_rerank)]
192
+ if model_class:
193
+ argv += ["--model-class", model_class]
194
+ if beta is not None:
195
+ argv += ["--beta", str(beta)]
196
+ if ligand_resnames:
197
+ argv += ["--ligand-resnames", ligand_resnames]
198
+ if min_coverage is not None:
199
+ argv += ["--min-coverage", str(min_coverage)]
200
+ if timeout is not None:
201
+ argv += ["--timeout", str(timeout)]
202
+
203
+ print("running: shellde " + " ".join(argv))
204
+ print("-" * 70)
205
+ _main(argv)
206
+ print("-" * 70)
207
+
208
+ found = sorted(glob.glob(os.path.join(outdir, "round*_plate.csv")), key=os.path.getmtime)
209
+ if not found:
210
+ print("no plate written (abstained -- read the message above). Common fixes: give a design "
211
+ "region (uniprot=/positions=/pdb=); for the first plate keep auto_msa=True or pass "
212
+ "msa=/ddg=.")
213
+ return None
214
+ latest = found[-1]
215
+ prefix = os.path.basename(latest)[: -len("_plate.csv")] # e.g. "round0"
216
+ saved = [os.path.join(outdir, prefix + s) for s in ("_plate.csv", "_all.csv", "_measured_template.csv")]
217
+ saved = [p for p in saved if os.path.exists(p)]
218
+ print(f"saved to {outdir}/: " + ", ".join(os.path.basename(p) for p in saved))
219
+ print(" _plate = variants to MAKE/measure | _all = full scored set | _measured_template = fill "
220
+ "fitness -> next round")
221
+ try:
222
+ import pandas as pd
223
+ df = pd.read_csv(latest)
224
+ except Exception: # noqa: BLE001 (pandas missing / unreadable -> still report the path)
225
+ print("plate written to", latest)
226
+ return None
227
+ print(f"plate: {len(df)} variants (top of {prefix})")
228
+ if download:
229
+ _download_links(saved)
230
+ return df
231
+
232
+
233
+ def hotspots(
234
+ scan: str,
235
+ *,
236
+ top_k: int = 6,
237
+ metric: str = "max",
238
+ baseline: float = 0.0,
239
+ min_effect: float | None = None,
240
+ mutation_col: str | None = None,
241
+ score_col: str | None = None,
242
+ ) -> str:
243
+ """Rank hotspot positions from a whole-protein single-mutant scan CSV (the niche-C bridge).
244
+
245
+ ``scan`` is a CSV path with a mutation column (e.g. ``V100F``) and a score column (measured
246
+ fitness/activity, or a predicted ``y_pred``/``score``). Returns a comma-separated positions
247
+ string ready for ``plate(positions=...)`` / ``round --positions``. Prints the ranked positions.
248
+ """
249
+ from shellde.cli import _read_singles
250
+
251
+ singles, mcol, scol = _read_singles(scan, mutation_col, score_col)
252
+ hs = rank_hotspots(singles, top_k=top_k, metric=metric, baseline=baseline, min_effect=min_effect)
253
+ positions = ",".join(str(h.position) for h in sorted(hs, key=lambda x: x.position))
254
+ print(f"hotspots (mutation='{mcol}', score='{scol}', metric={metric}) -> {positions}")
255
+ for h in hs:
256
+ print(f" {h.wt}{h.position} best {h.best_sub} ({h.best_effect:.4g}) n_beneficial={h.n_beneficial}")
257
+ return positions
shellde/conformal.py ADDED
@@ -0,0 +1,160 @@
1
+ """Distribution-free conformal prediction intervals for the surrogate.
2
+
3
+ The calibrated ``Prediction.std`` is a *parametric* uncertainty: variance scaling
4
+ fits one scalar by k-fold, so it is Gaussian-flavoured and, by construction, OFF at
5
+ very low N (``surrogate._calibrate`` returns 1.0 when n < 4). Split conformal sidesteps
6
+ both problems: it gives finite-sample *marginal* coverage with no Gaussian assumption
7
+ and is valid at small N (the interval just gets wider). It needs only the surrogate's
8
+ point prediction (``.mean``), never its ``.std``, so it is robust precisely where the
9
+ variance-scaling path is weakest.
10
+
11
+ This is split (a.k.a. inductive) conformal: the surrogate is fitted on a TRAIN split,
12
+ the nonconformity scores ``|y - mean|`` are collected on a disjoint CALIBRATION split,
13
+ and coverage holds on any further exchangeable point. The wrapper never fits the
14
+ surrogate; it consumes an already-fitted one, keeping the Prediction(mean, std) contract
15
+ and the surrogate machinery untouched.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from collections.abc import Callable
21
+
22
+ import numpy as np
23
+ from sklearn.model_selection import KFold
24
+
25
+ from shellde.protocols import Surrogate
26
+
27
+ ALPHA_90 = 0.1 # default miscoverage level: a 90% prediction interval
28
+ _FLOOR = 1e-9 # sigma floor for the normalized score (matches surrogate._FLOOR)
29
+
30
+
31
+ def conformal_quantile(scores: np.ndarray, alpha: float = ALPHA_90) -> float:
32
+ """Distribution-free conformal quantile: the ceil((n+1)(1-alpha))-th smallest |score|.
33
+
34
+ The index is clipped to n, so at low N (when (n+1)(1-alpha) > n) it saturates at the
35
+ largest score (the conformal "infinite quantile"), keeping the half-width finite and
36
+ marginally valid. Shared by ``SplitConformal._quantile`` and the cross-conformal
37
+ half-width surfaced in ``advisor.data_readiness``.
38
+ """
39
+ if not 0.0 < alpha < 1.0:
40
+ raise ValueError(f"alpha must be in (0, 1), got {alpha}")
41
+ s = np.sort(np.abs(np.asarray(scores, dtype=float).ravel()))
42
+ n = s.shape[0]
43
+ if n < 1:
44
+ raise ValueError("need at least 1 nonconformity score")
45
+ k = math.ceil((n + 1) * (1.0 - alpha))
46
+ return float(s[min(k, n) - 1])
47
+
48
+
49
+ class SplitConformal:
50
+ """Split-conformal interval wrapper around an already-fitted surrogate.
51
+
52
+ Usage (disjoint splits): fit ``surrogate`` on a TRAIN split, then ``fit`` this on a
53
+ disjoint CALIBRATION split, then ``interval(x)`` on new points. Coverage at level
54
+ ``1 - alpha`` is finite-sample valid (marginal) for exchangeable data.
55
+ """
56
+
57
+ def __init__(self, surrogate: Surrogate) -> None:
58
+ self.surrogate = surrogate
59
+ self._scores: np.ndarray | None = None
60
+
61
+ def fit(self, x_cal: np.ndarray, y_cal: np.ndarray) -> "SplitConformal":
62
+ """Store sorted absolute nonconformity scores ``|y - surrogate.mean|`` on the cal split."""
63
+ x_cal = np.asarray(x_cal, dtype=float)
64
+ y_cal = np.asarray(y_cal, dtype=float).ravel()
65
+ if x_cal.ndim != 2 or x_cal.shape[0] != y_cal.shape[0]:
66
+ raise ValueError(f"x_cal {x_cal.shape} incompatible with y_cal {y_cal.shape}")
67
+ if x_cal.shape[0] < 1:
68
+ raise ValueError("need at least 1 calibration sample")
69
+ mean = np.asarray(self.surrogate.predict(x_cal).mean, dtype=float).ravel()
70
+ self._scores = np.sort(np.abs(y_cal - mean))
71
+ return self
72
+
73
+ def _quantile(self, alpha: float) -> float:
74
+ if self._scores is None:
75
+ raise RuntimeError("SplitConformal not fitted; call fit() first")
76
+ return conformal_quantile(self._scores, alpha)
77
+
78
+ def interval(self, x: np.ndarray, alpha: float = ALPHA_90) -> tuple[np.ndarray, np.ndarray]:
79
+ """Return ``(lo, hi)`` = mean +/- conformal quantile, vectorized over ``x``."""
80
+ x = np.asarray(x, dtype=float)
81
+ q = self._quantile(alpha)
82
+ mean = np.asarray(self.surrogate.predict(x).mean, dtype=float).ravel()
83
+ return mean - q, mean + q
84
+
85
+
86
+ def empirical_coverage(
87
+ lo: np.ndarray, hi: np.ndarray, y: np.ndarray
88
+ ) -> float:
89
+ """Fraction of held-out targets that fall inside ``[lo, hi]`` (the realised coverage)."""
90
+ lo = np.asarray(lo, dtype=float).ravel()
91
+ hi = np.asarray(hi, dtype=float).ravel()
92
+ y = np.asarray(y, dtype=float).ravel()
93
+ if not (lo.shape == hi.shape == y.shape):
94
+ raise ValueError(f"shape mismatch: lo {lo.shape}, hi {hi.shape}, y {y.shape}")
95
+ return float(np.mean((y >= lo) & (y <= hi)))
96
+
97
+
98
+ def conformal_interval(
99
+ surrogate: Surrogate,
100
+ x_cal: np.ndarray,
101
+ y_cal: np.ndarray,
102
+ x_query: np.ndarray,
103
+ *,
104
+ alpha: float = ALPHA_90,
105
+ ) -> tuple[np.ndarray, np.ndarray]:
106
+ """Standalone helper: calibrate on (x_cal, y_cal) and return the interval at x_query.
107
+
108
+ Additive convenience over an already-fitted ``surrogate``; changes no existing
109
+ signature. The surrogate must have been fitted on data DISJOINT from ``x_cal``.
110
+ """
111
+ return SplitConformal(surrogate).fit(x_cal, y_cal).interval(x_query, alpha=alpha)
112
+
113
+
114
+ def cross_conformal_normalized_q(
115
+ x: np.ndarray,
116
+ y: np.ndarray,
117
+ make_surrogate: Callable[[], Surrogate],
118
+ *,
119
+ k: int = 5,
120
+ alpha: float = ALPHA_90,
121
+ seed: int = 0,
122
+ ) -> float:
123
+ """Normalized cross-conformal quantile (heteroscedastic-preserving).
124
+
125
+ Runs seeded k-fold; on each out-of-fold point collects the NORMALIZED nonconformity
126
+ score ``|y - mean_oof| / max(sigma_oof, floor)`` using a surrogate refit on the fold's
127
+ train split (its ``Prediction.mean`` and ``Prediction.std``). The pooled scores'
128
+ ``conformal_quantile`` is returned as ``q_norm``: a per-candidate interval half-width
129
+ is then ``q_norm * sigma(x)``, which preserves the surrogate's heteroscedastic sigma
130
+ instead of collapsing it to a constant.
131
+
132
+ Coverage is MARGINAL and distribution-free only for points EXCHANGEABLE with the
133
+ measured set. It is NOT valid for out-of-distribution candidates (e.g. higher-order
134
+ mutation combinations when the measured set is single mutants); the caller must label
135
+ the scope accordingly. This is not redundant with ``advisor.data_readiness`` (that one
136
+ is a mean-only additive one-hot Ridge half-width; this one needs the chosen surrogate's
137
+ calibrated sigma).
138
+ """
139
+ x = np.asarray(x, dtype=float)
140
+ y = np.asarray(y, dtype=float).ravel()
141
+ n = y.shape[0]
142
+ if x.ndim != 2 or x.shape[0] != n:
143
+ raise ValueError(f"x {x.shape} incompatible with y {y.shape}")
144
+ n_splits = min(k, n)
145
+ if n_splits < 2:
146
+ raise ValueError("need at least 2 samples for cross-conformal")
147
+ kf = KFold(n_splits=n_splits, shuffle=True, random_state=seed)
148
+ scores: list[float] = []
149
+ for tr, te in kf.split(x):
150
+ if len(tr) < 2 or float(np.std(y[tr])) < _FLOOR:
151
+ # Degenerate fold: predict the train mean, sigma = residual floor.
152
+ mean_oof = np.full(len(te), float(np.mean(y[tr])) if len(tr) else 0.0)
153
+ sigma_oof = np.full(len(te), _FLOOR)
154
+ else:
155
+ pred = make_surrogate().fit(x[tr], y[tr]).predict(x[te])
156
+ mean_oof = np.asarray(pred.mean, dtype=float).ravel()
157
+ sigma_oof = np.asarray(pred.std, dtype=float).ravel()
158
+ denom = np.maximum(sigma_oof, _FLOOR)
159
+ scores.extend((np.abs(y[te] - mean_oof) / denom).tolist())
160
+ return conformal_quantile(np.asarray(scores, dtype=float), alpha)
shellde/consensus.py ADDED
@@ -0,0 +1,52 @@
1
+ """Merge multiple active-site evidence sources into one per-position confidence map.
2
+
3
+ The evidence ladder picks ONE source. When several independent lines are available (UniProt curated
4
+ catalytic sites, a PDB cocrystal contact shell, a Foldseek holo-homolog shell, a whole-protein scan),
5
+ overlaying them says WHERE they AGREE - and agreement across independent evidence types is the honest
6
+ confidence signal (it does NOT manufacture activity prediction; it says which positions to trust/measure
7
+ first). Pure, no I/O.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+
13
+ # Evidence types that are experimental/curated (a single one already justifies designing there);
14
+ # everything else is a weaker/heuristic prior that wants corroboration.
15
+ STRONG_EVIDENCE = frozenset({"uniprot_catalytic", "pdb_cocrystal_shell", "holo_homolog_shell"})
16
+
17
+
18
+ def build_consensus(sources: dict[str, Iterable[int]], *, strong: frozenset[str] = STRONG_EVIDENCE) -> list[dict]:
19
+ """Overlay named position sources -> sorted per-position ``{position, evidence, tier}``.
20
+
21
+ ``sources`` maps an evidence-type name (e.g. ``"pdb_cocrystal_shell"``) to its positions. Tier:
22
+ ``high`` = supported by >=2 independent evidence types; ``medium`` = exactly one STRONG source;
23
+ ``low`` = exactly one weak/heuristic source. Deterministic (evidence names sorted).
24
+ """
25
+ by_pos: dict[int, set[str]] = {}
26
+ for name, positions in sources.items():
27
+ for p in positions:
28
+ by_pos.setdefault(int(p), set()).add(name)
29
+ out: list[dict] = []
30
+ for pos in sorted(by_pos):
31
+ ev = sorted(by_pos[pos])
32
+ if len(ev) >= 2:
33
+ tier = "high"
34
+ elif any(e in strong for e in ev):
35
+ tier = "medium"
36
+ else:
37
+ tier = "low"
38
+ out.append({"position": pos, "evidence": ev, "tier": tier})
39
+ return out
40
+
41
+
42
+ def tier_positions(consensus: list[dict], tier: str) -> list[int]:
43
+ """Positions at a given confidence tier (``high``/``medium``/``low``)."""
44
+ return [c["position"] for c in consensus if c["tier"] == tier]
45
+
46
+
47
+ def consensus_summary(consensus: list[dict]) -> str:
48
+ """One-line count summary, e.g. ``high=7 medium=78 low=0``."""
49
+ counts = {"high": 0, "medium": 0, "low": 0}
50
+ for c in consensus:
51
+ counts[c["tier"]] = counts.get(c["tier"], 0) + 1
52
+ return " ".join(f"{t}={counts[t]}" for t in ("high", "medium", "low"))
@@ -0,0 +1,141 @@
1
+ """Frozen design space + variant<->assignment normalisation.
2
+
3
+ A ``DesignSpace`` is a sorted tuple of ABSOLUTE 1-based residue positions plus the
4
+ reference (WT) residue at each. Freezing it is what guarantees a feature column
5
+ layout that is stable across active-learning rounds selecting DIFFERENT candidate
6
+ pools. Variants are accepted as fixed-site combo strings ("FRMNY") or absolute
7
+ mutation tokens ("A42V:L88M") and normalised to a full per-position assignment.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping, Sequence
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+
16
+ AA_ALPHABET = "ACDEFGHIKLMNPQRSTVWY"
17
+
18
+ Mutation = tuple[int, str] # (1-based absolute position, target AA)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DesignSpace:
23
+ """Frozen set of designed positions + reference residues + alphabet."""
24
+
25
+ positions: tuple[int, ...]
26
+ reference: Mapping[int, str]
27
+ alphabet: str = AA_ALPHABET
28
+
29
+ def __post_init__(self) -> None:
30
+ if list(self.positions) != sorted(set(self.positions)):
31
+ raise ValueError("DesignSpace.positions must be sorted and unique")
32
+ missing = [p for p in self.positions if p not in self.reference]
33
+ if missing:
34
+ raise ValueError(f"reference missing residues for positions {missing}")
35
+ bad = [p for p in self.positions if self.reference[p] not in self.alphabet]
36
+ if bad:
37
+ raise ValueError(f"reference residue outside alphabet at positions {bad}")
38
+
39
+ @property
40
+ def n_positions(self) -> int:
41
+ return len(self.positions)
42
+
43
+ @property
44
+ def q(self) -> int:
45
+ return len(self.alphabet)
46
+
47
+ def position_index(self, pos: int) -> int:
48
+ try:
49
+ return self.positions.index(pos)
50
+ except ValueError as exc:
51
+ raise ValueError(f"position {pos} not in design space") from exc
52
+
53
+ def wt(self) -> str:
54
+ """The reference combo string over the designed positions."""
55
+ return "".join(self.reference[p] for p in self.positions)
56
+
57
+
58
+ def combo_to_assignment(combo: str, space: DesignSpace) -> dict[int, str]:
59
+ """Map a fixed-site combo string (one AA per designed position) to an assignment."""
60
+ if len(combo) != space.n_positions:
61
+ raise ValueError(f"combo {combo!r} length {len(combo)} != n_positions {space.n_positions}")
62
+ invalid = sorted({c for c in combo if c not in space.alphabet})
63
+ if invalid:
64
+ raise ValueError(f"combo {combo!r} has residues outside alphabet: {invalid}")
65
+ return {pos: combo[i] for i, pos in enumerate(space.positions)}
66
+
67
+
68
+ def tokens_to_assignment(tokens: str, space: DesignSpace) -> dict[int, str]:
69
+ """Map absolute mutation tokens ('A42V:L88M' / 'A42V,L88M') to a full assignment.
70
+
71
+ Unspecified designed positions are filled from the reference (WT) residue. The
72
+ cited wild-type residue is validated against the reference when given.
73
+ """
74
+ assignment = dict(space.reference)
75
+ raw = tokens.replace(",", ":").replace(" ", ":")
76
+ for tok in (t for t in raw.split(":") if t):
77
+ wt, pos, mt = tok[0], tok[1:-1], tok[-1]
78
+ if not wt.isalpha():
79
+ raise ValueError(f"mutation token {tok!r} must be [WT][pos][MT], e.g. A42V")
80
+ try:
81
+ ipos = int(pos)
82
+ except ValueError as exc:
83
+ raise ValueError(f"bad mutation token {tok!r}") from exc
84
+ if ipos not in space.reference:
85
+ raise ValueError(f"token {tok!r} position {ipos} not in design space")
86
+ if space.reference[ipos] != wt:
87
+ raise ValueError(f"token {tok!r} WT {wt} != reference {space.reference[ipos]} at {ipos}")
88
+ if mt not in space.alphabet:
89
+ raise ValueError(f"token {tok!r} target residue {mt!r} outside alphabet")
90
+ assignment[ipos] = mt
91
+ return assignment
92
+
93
+
94
+ def to_assignment(variant: str, space: DesignSpace) -> dict[int, str]:
95
+ """Normalise a variant string to a full per-position assignment over the space.
96
+
97
+ A pure-alphabetic string of length n_positions is a fixed-site combo; anything
98
+ containing a digit is treated as mutation tokens.
99
+ """
100
+ if not variant:
101
+ raise ValueError("empty variant string")
102
+ if variant.isalpha() and len(variant) == space.n_positions:
103
+ return combo_to_assignment(variant, space)
104
+ return tokens_to_assignment(variant, space)
105
+
106
+
107
+ def mutations_of(variant: str, space: DesignSpace) -> tuple[str, ...]:
108
+ """Mutation tokens (e.g. 'V39A') where the variant differs from the reference."""
109
+ a = to_assignment(variant, space)
110
+ return tuple(
111
+ f"{space.reference[p]}{p}{a[p]}" for p in space.positions if a[p] != space.reference[p]
112
+ )
113
+
114
+
115
+ def combo_indices(variants: Sequence[str], space: DesignSpace) -> np.ndarray | None:
116
+ """Vectorized combo-string -> (N, n_positions) alphabet-index array, or None to fall back.
117
+
118
+ Returns the per-position alphabet indices for ``variants`` IFF every variant is a valid
119
+ fixed-site combo over this space (``len == n_positions`` and every residue in ``alphabet``)
120
+ -- exactly the case where :func:`to_assignment` routes to :func:`combo_to_assignment`. Returns
121
+ ``None`` (so the caller uses the per-variant slow path) for any token string, wrong length, or
122
+ out-of-alphabet residue, preserving identical behaviour AND error semantics. Pure speed path.
123
+ """
124
+ n, length = len(variants), space.n_positions
125
+ if n == 0:
126
+ return np.zeros((0, length), dtype=np.intp)
127
+ if any(len(v) != length for v in variants):
128
+ return None
129
+ try:
130
+ buf = np.frombuffer("".join(variants).encode("latin-1"), dtype=np.uint8)
131
+ except (UnicodeEncodeError, ValueError):
132
+ return None
133
+ if buf.size != n * length:
134
+ return None
135
+ lut = np.full(256, -1, dtype=np.intp)
136
+ for i, aa in enumerate(space.alphabet):
137
+ lut[ord(aa)] = i
138
+ idx = lut[buf].reshape(n, length)
139
+ if (idx < 0).any(): # a digit (token) or out-of-alphabet residue -> slow path handles/raises
140
+ return None
141
+ return idx
@@ -0,0 +1,6 @@
1
+ """Embedding providers (PLM) + on-disk cache."""
2
+ from __future__ import annotations
3
+
4
+ from shellde.embeddings.provider import EmbeddingCache, FunctionProvider
5
+
6
+ __all__ = ["EmbeddingCache", "FunctionProvider"]