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/cli.py ADDED
@@ -0,0 +1,1517 @@
1
+ """Command-line entry point: a single ``recommend`` plus ``bench`` and ``report``.
2
+
3
+ recommend MEASURED.csv variant,fitness -> ranked candidates + evidence report
4
+ bench synthetic alpha-spectrum tool-comparative benchmark
5
+ report SPECTRUM.json summarise a benchmark (or recommend) JSON
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from collections import Counter
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ from shellde.acquisition import UCB
18
+ from shellde.bench import run_spectrum
19
+ from shellde.candidates import CandidatePool, generate_candidates, recombine_beneficials, select_positions
20
+ from shellde.conformal import cross_conformal_normalized_q
21
+ from shellde.design_space import AA_ALPHABET, DesignSpace, mutations_of
22
+ from shellde.types import Prediction
23
+ from shellde.features import (
24
+ FeatureBlock,
25
+ FeatureMatrix,
26
+ GatedPairwiseBlock,
27
+ InverseFoldingBlock,
28
+ default_blocks,
29
+ )
30
+ from shellde.gating import should_include_block, should_open_pairwise
31
+ from shellde.structure import (
32
+ contacts_from_pdb,
33
+ load_ddg_table,
34
+ load_inverse_folding_logprobs,
35
+ shells_from_pdb,
36
+ )
37
+ from shellde.advisor import data_readiness
38
+ from shellde.prereg import prereg_hash, stamp
39
+ from shellde.rank import rank_candidates
40
+ from shellde.report import write_report
41
+ from shellde.surrogate import EnsembleSurrogate, GlobalEpistasisSurrogate, RankingSurrogate, RFSurrogate, RidgeSurrogate
42
+ from shellde.embeddings.provider import EmbeddingCache
43
+ from shellde.plm import build_naturalness, build_provider, plm_rerank, read_fasta
44
+ from shellde.funclib import design_library, tolerance_from_logprob_table
45
+ from shellde.msa import build_msa_mmseqs2, load_msa_logprobs
46
+ from shellde.hotspots import rank_hotspots
47
+ from shellde.holo import find_holo_homolog
48
+ from shellde.consensus import build_consensus, consensus_summary
49
+ from shellde.campaign import (
50
+ SurrogateSpec,
51
+ funclib_seed_variants,
52
+ simulate_campaign,
53
+ single_mutant_variants,
54
+ stable_variants,
55
+ )
56
+ from shellde.sitefinder import (
57
+ ResolveResult,
58
+ fetch_alphafold,
59
+ fetch_pdb,
60
+ fetch_uniprot,
61
+ pdb_ids_from_uniprot,
62
+ resolve_from_apo,
63
+ resolve_from_entry,
64
+ resolve_from_pdb,
65
+ uniprot_from_sequence,
66
+ )
67
+
68
+ _SURROGATE = {
69
+ "ridge": RidgeSurrogate, "ensemble": EnsembleSurrogate, "rf": RFSurrogate,
70
+ "ranking": RankingSurrogate, "global_epistasis": GlobalEpistasisSurrogate,
71
+ }
72
+
73
+
74
+ def resolve_plm_pooling(pooling: str, plm_model: str) -> str:
75
+ """Resolve --plm-pooling 'auto' to a concrete mode based on the PLM model.
76
+
77
+ Optimal pooling depends on model size (held-out: esmc_600m favours site-specific,
78
+ esmc_300m and smaller favour whole-sequence mean). 'auto' -> 'site' iff the model is
79
+ esmc_600m, else 'mean' (the safe default that every ESM-C size can run). A non-'auto'
80
+ value is returned unchanged (explicit override).
81
+ """
82
+ if pooling != "auto":
83
+ return pooling
84
+ return "site" if plm_model == "esmc_600m" else "mean"
85
+
86
+
87
+ def _read_design(measured_path: str, design_positions: str | None):
88
+ """Load a variant,fitness CSV and build the DesignSpace (shared by recommend/advise)."""
89
+ import pandas as pd
90
+
91
+ df = pd.read_csv(measured_path)
92
+ for col in ("variant", "fitness"):
93
+ if col not in df.columns:
94
+ raise ValueError(f"{measured_path}: missing required column {col!r} (need variant,fitness)")
95
+ variants = [str(v) for v in df["variant"]]
96
+ fitness = df["fitness"].to_numpy(dtype=float)
97
+ if not variants:
98
+ raise ValueError(f"{measured_path}: no measured variants")
99
+ length = len(variants[0])
100
+ if any(len(v) != length for v in variants):
101
+ raise ValueError("all variants must share one length for a fixed-site design space")
102
+ if design_positions:
103
+ positions = tuple(sorted(int(x) for x in design_positions.split(",")))
104
+ if len(positions) != length:
105
+ raise ValueError(f"--design-positions has {len(positions)} positions but variants have length {length}")
106
+ else:
107
+ positions = tuple(range(1, length + 1))
108
+ ref = {p: Counter(v[j] for v in variants).most_common(1)[0][0] for j, p in enumerate(positions)}
109
+ return variants, fitness, DesignSpace(positions, ref), ref, length
110
+
111
+
112
+ def _print_epistasis_next_step(
113
+ recommendation: str, measured: object = None, positions: object = None
114
+ ) -> None:
115
+ """The ``open_epistasis`` verdict is actionable on ``recommend`` ONLY: neither ``campaign``/
116
+ ``round`` nor ``advise`` exposes the pairwise data-gate flag. ``measured`` is the measured
117
+ path(s) in scope at the call site (a single path on ``advise``, a per-round list on
118
+ ``campaign``/``round``). With exactly one path the printed command names a real file; with
119
+ several (or none) the measured CSV is a ``<placeholder>``, because ``recommend`` takes ONE CSV
120
+ and the caller must concatenate the rounds first. That join is spelled out header-aware on
121
+ purpose: a plain ``cat r0.csv r1.csv`` leaves the SECOND file's header line as a data row, and
122
+ ``_read_design`` then dies inside ``float("fitness")``. The printed command uses ``head``/``tail``
123
+ so exactly one header line survives.
124
+
125
+ The two commands do NOT read the same formats: ``campaign``/``round`` load measured tables via
126
+ ``_read_landscape`` (CSV/TSV *and* Excel, lenient column names), while ``recommend``/``advise``
127
+ load them via ``_read_design``, which is ``pandas.read_csv`` plus exact ``variant``/``fitness``
128
+ columns. Variant NOTATION differs too: ``campaign``/``round`` resolve each variant through
129
+ ``design_space.to_assignment``, which accepts mutation tokens (``V39A``, ``L42M:K88R``) as well as
130
+ fixed-site combo strings, whereas ``_read_design`` requires every variant to be a combo string of
131
+ one shared length and raises otherwise. So a file that ``campaign`` accepted may still be
132
+ rejected by ``recommend``. There is ONE shipped file that always crosses cleanly: the
133
+ ``roundN_measured_template.csv`` that ``campaign``/``round`` writes into ``--outdir`` is already
134
+ ``variant``,``fitness`` with combo-string variants, so it is readable by ``recommend`` as soon as
135
+ its ``fitness`` column is filled. For anything else there is NO shipped converter (the
136
+ subcommands are recommend / advise / funclib / campaign / resolve-site / round / hotspots /
137
+ bench / report; none of them reshapes a measured table), so the user has to reshape it by hand.
138
+ When the caller is that path (``measured`` given as a list, i.e. not a ``str``), the printed
139
+ guidance carries both facts, so the user does not discover them at the shell.
140
+
141
+ ``positions`` is the resolved design region in scope (the campaign/round variant strings are
142
+ the per-position combo encoding over it). It is printed as ``--design-positions`` so the
143
+ suggested command runs over the SAME mutation space: without it ``recommend`` falls back to
144
+ ``1..len(variant)``, which is the wrong space for a campaign round.
145
+ """
146
+ if recommendation != "open_epistasis":
147
+ return
148
+ paths = [str(measured)] if isinstance(measured, str) else [str(m) for m in (measured or [])]
149
+ # advise passes a str already validated by _read_design; campaign/round pass a list read by
150
+ # the lenient _read_landscape, so those files may not be readable by `recommend` as-is.
151
+ fmt = ("" if isinstance(measured, str) else
152
+ "; `recommend` needs a CSV with exact `variant`,`fitness` columns and fixed-site combo "
153
+ "variants (one residue per design position, every row the same length). The "
154
+ "`roundN_measured_template.csv` written into --outdir is already in that format once its "
155
+ "`fitness` column is filled. Any other round file (Excel, other column names, or "
156
+ "mutation-token variants like `V39A`) has to be reshaped BY HAND: ShellDE ships no "
157
+ "converter subcommand")
158
+ if isinstance(positions, str):
159
+ pos = f" --design-positions {positions}"
160
+ elif positions:
161
+ pos = " --design-positions " + ",".join(str(p) for p in positions)
162
+ else:
163
+ pos = ""
164
+ if len(paths) == 1:
165
+ print(f" -> act on it with: shellde recommend {paths[0]}{pos} --auto-gate-pairwise "
166
+ f"(the pairwise block is a data-gated opt-in on `recommend`{fmt})")
167
+ elif len(paths) > 1:
168
+ # A plain `cat` of the rounds leaves the later header lines as data rows and _read_design
169
+ # fails inside float("fitness"), so name a join that keeps exactly ONE header line.
170
+ join = ("head -1 " + paths[0] + " > all_measured.csv; tail -q -n +2 "
171
+ + " ".join(paths) + " >> all_measured.csv")
172
+ print(f" -> act on it with: shellde recommend <all_measured.csv>{pos} --auto-gate-pairwise "
173
+ "(the pairwise block is a data-gated opt-in on `recommend`, which takes ONE measured "
174
+ f"CSV: concatenate {', '.join(paths)} first, keeping exactly ONE header row -- "
175
+ f"`{join}`. A plain `cat` of them leaves the second header as a data row and the read "
176
+ f"then fails with `could not convert string to float: \'fitness\'`{fmt})")
177
+ else:
178
+ print(f" -> act on it with: shellde recommend <measured.csv>{pos} --auto-gate-pairwise "
179
+ f"(the pairwise block is a data-gated opt-in on `recommend`{fmt})")
180
+
181
+
182
+ def cmd_advise(args: argparse.Namespace) -> int:
183
+ variants, fitness, space, _ref, _length = _read_design(args.measured, args.design_positions)
184
+ r = data_readiness(space, variants, fitness)
185
+ spear = "n/a" if r["heldout_spearman"] is None else f"{r['heldout_spearman']:+.3f}"
186
+ print(f"[advise] n={r['n_measured']} sites={space.n_positions} | held-out Spearman={spear} "
187
+ f"| trustworthy={r['model_trustworthy']} | gate_improvement={r['gate_improvement']:+.3f} "
188
+ f"(epistasis_ready={r['epistasis_ready']})")
189
+ print(f"[advise] recommendation: {r['recommendation'].upper()} -- {r['why']}")
190
+ _print_epistasis_next_step(r["recommendation"], args.measured, args.design_positions)
191
+ hw = r["conformal_halfwidth_90"]
192
+ hw_disp = "n/a" if hw is None else f"{hw:+.3f}"
193
+ print(f"[advise] conformal 90% interval half-width: {hw_disp} (distribution-free, valid at low N)")
194
+ return 0
195
+
196
+
197
+ def cmd_recommend(args: argparse.Namespace) -> int:
198
+ variants, fitness, space, ref, length = _read_design(args.measured, getattr(args, "design_positions", None))
199
+ blocks: list[FeatureBlock] = default_blocks(space)
200
+ pairs = contacts_from_pdb(args.contacts_pdb, space.positions, cutoff=args.contact_cutoff) if args.contacts_pdb else None
201
+ use_pairwise = bool(args.contacts_pdb) # explicit contacts -> include the restricted block
202
+ if getattr(args, "auto_gate_pairwise", False):
203
+ use_pairwise = should_open_pairwise(space, variants, fitness, pairs=pairs)
204
+ if use_pairwise:
205
+ blocks.append(GatedPairwiseBlock(space, pairs))
206
+ signal_gate: dict | None = None
207
+ if args.if_logprobs:
208
+ if_block = InverseFoldingBlock(space, load_inverse_folding_logprobs(args.if_logprobs))
209
+ if getattr(args, "auto_signals", True):
210
+ # Tool decides whether the supplied signal helps on THIS protein's held-out data,
211
+ # instead of using it unconditionally (opt-in audit 2026-06-29).
212
+ include, signal_gate = should_include_block(
213
+ space, variants, fitness, blocks, if_block, label="inverse_folding",
214
+ )
215
+ if include:
216
+ blocks.append(if_block)
217
+ else:
218
+ blocks.append(if_block) # --no-auto-signals: expert override, use unconditionally
219
+ matrix = FeatureMatrix(space, blocks)
220
+ x_enc = matrix.encode(variants)
221
+ chosen = args.model_class
222
+ sur = _SURROGATE[chosen](random_state=0).fit(x_enc, fitness)
223
+
224
+ # Normalized cross-conformal quantile on the MEASURED set (heteroscedastic-preserving).
225
+ # Guard: needs enough points + non-degenerate y, else the gate is disabled (None).
226
+ conf_q = (
227
+ cross_conformal_normalized_q(x_enc, fitness, lambda: _SURROGATE[chosen](random_state=0))
228
+ if len(variants) >= 8 and float(np.std(fitness)) > 0.0
229
+ else None
230
+ )
231
+ # Max mutation count among the measured variants (vs WT/consensus reference). Candidates
232
+ # with n_mut above this are out of the calibration distribution (label conformal_oob).
233
+ max_meas_nmut = max(len(mutations_of(v, space)) for v in variants)
234
+
235
+ if args.max_positions is not None and args.max_positions < length:
236
+ pool = select_positions(sur, matrix, m=args.max_positions, interaction_aware=args.interaction_aware)
237
+ else:
238
+ pool = list(space.positions)
239
+ cset = generate_candidates(
240
+ sur, matrix, pinned_positions=pool, max_mut=min(args.max_mut, len(pool)),
241
+ generated_cap=args.generated_cap, scored_cap=args.scored_cap, beta=args.beta,
242
+ interaction_aware=args.interaction_aware, seed=0,
243
+ )
244
+ n_recombined = 0
245
+ if getattr(args, "recombine", False):
246
+ # Evidence-backed strategy: recombine measured beneficials (not just surrogate-guided combos).
247
+ recomb = recombine_beneficials(
248
+ space, variants, fitness, min_gain=args.recombine_min_gain,
249
+ max_mut=min(args.max_mut, len(pool)), cap=args.generated_cap,
250
+ )
251
+ have = set(cset.variants) | set(variants)
252
+ extra = [v for v in recomb if v not in have]
253
+ if extra:
254
+ pr = sur.predict(matrix.encode(extra))
255
+ n_recombined = len(extra)
256
+ cset = CandidatePool(
257
+ variants=list(cset.variants) + extra,
258
+ pred=Prediction(
259
+ np.concatenate([np.asarray(cset.pred.mean, float), np.asarray(pr.mean, float)]),
260
+ np.concatenate([np.asarray(cset.pred.std, float), np.asarray(pr.std, float)]),
261
+ ),
262
+ mut_count=list(cset.mut_count) + [len(mutations_of(v, space)) for v in extra],
263
+ pool_positions=cset.pool_positions,
264
+ stats={**cset.stats, "n_recombined": n_recombined},
265
+ )
266
+ wt_mean = float(sur.predict(matrix.encode([space.wt()])).mean[0])
267
+ if args.plm != "none" or args.naturalness != "none":
268
+ if not args.wt_fasta:
269
+ raise ValueError("--plm/--naturalness requires --wt-fasta (full WT background sequence)")
270
+ emb_cache = EmbeddingCache(args.plm_cache) if args.plm_cache else None
271
+ nat_cache = EmbeddingCache(args.naturalness_cache) if args.naturalness_cache else None
272
+ provider = build_provider(args.plm, model=args.plm_model, cache=emb_cache) if args.plm != "none" else None
273
+ nat = build_naturalness(args.naturalness, model=args.plm_model, cache=nat_cache) if args.naturalness != "none" else None
274
+ resolved_pooling = resolve_plm_pooling(args.plm_pooling, args.plm_model)
275
+ cset = plm_rerank(
276
+ cset, space, variants, fitness, provider=provider, naturalness=nat,
277
+ wt_sequence=read_fasta(args.wt_fasta), top_k=args.plm_rerank,
278
+ plm_pooling=resolved_pooling,
279
+ )
280
+ wt_mean = float(cset.stats.get("wt_mean", wt_mean))
281
+ if emb_cache is not None:
282
+ emb_cache.save()
283
+ if nat_cache is not None:
284
+ nat_cache.save()
285
+ ranking = rank_candidates(
286
+ cset, space, acquisition=UCB(args.beta), per_count_k=args.per_count_k,
287
+ global_k=args.top, wt_mean=wt_mean,
288
+ conformal_q=conf_q, max_measured_nmut=max_meas_nmut,
289
+ )
290
+ paths = write_report(ranking, args.outdir, prereg_hash=prereg_hash())
291
+
292
+ print(f"[recommend] {length} sites, WT={space.wt()}; generated={cset.stats.get('n_generated', 0)} "
293
+ f"scored={cset.stats.get('n_scored', 0)}")
294
+ if n_recombined:
295
+ print(f"[recommend] recombine: added {n_recombined} recombinations of measured beneficials "
296
+ f"(>WT) to the candidate pool")
297
+ if getattr(args, "auto_gate_pairwise", False) or args.contacts_pdb:
298
+ opened = "pairwise" in matrix.active_blocks()
299
+ npairs = "all" if pairs is None else str(len(pairs))
300
+ print(f"[recommend] epistasis block {'OPEN' if opened else 'closed'} "
301
+ f"(pairs={npairs}{', contact-restricted' if args.contacts_pdb else ''}; "
302
+ f"{'data justifies' if opened else 'data does not justify' if args.auto_gate_pairwise else 'structure-provided'})")
303
+ if signal_gate is not None:
304
+ print(f"[recommend] signal gate [{signal_gate['label']}]: "
305
+ f"{'KEPT' if signal_gate['included'] else 'DROPPED'} -- {signal_gate['reason']}")
306
+ if args.plm != "none" or args.naturalness != "none":
307
+ pooling_note = (
308
+ f" pooling={resolve_plm_pooling(args.plm_pooling, args.plm_model)}"
309
+ if args.plm != "none" else ""
310
+ )
311
+ print(f"[recommend] rerank PLM={args.plm} naturalness={args.naturalness}{pooling_note} "
312
+ f"top {cset.stats.get('plm_reranked', 0)} (dim={cset.stats.get('plm_dim', 0)})")
313
+ if args.max_positions is not None and args.max_positions < length:
314
+ print(f"[recommend] auto-selected {len(pool)}/{length} positions: "
315
+ f"{','.join(f'{ref[p]}{p}' for p in pool)}")
316
+ print(f"[recommend] top {min(args.top, len(ranking.global_ranking))} (global):")
317
+ for c in ranking.global_ranking[: args.top]:
318
+ muts = ",".join(c.mutations) or "WT"
319
+ print(f" {c.variant} mu={c.mu:.3f} sigma={c.sigma:.3f} acq={c.acq:.3f} [{muts}] ({c.evidence['risk']})")
320
+ if conf_q is not None:
321
+ top = ranking.global_ranking[: args.top]
322
+ n_abstain = sum(1 for c in top if c.evidence.get("abstain"))
323
+ n_oob = sum(1 for c in top if c.evidence.get("conformal_scope") == "conformal_oob")
324
+ scope_note = (
325
+ "; conformal-derived conservative threshold (calibrated on measured set; "
326
+ "this candidate is a higher-order combination, out of the calibration distribution)"
327
+ if n_oob else ""
328
+ )
329
+ print(f"[recommend] conformal abstain gate (q_norm={conf_q:.3f}, max measured n_mut="
330
+ f"{max_meas_nmut}): {n_abstain}/{len(top)} top candidates flagged abstain "
331
+ f"(cannot conclude they beat WT){f', {n_oob} out-of-distribution' if n_oob else ''}"
332
+ f"{scope_note}")
333
+ for kind, p in paths.items():
334
+ print(f"[recommend] wrote {kind}: {p}")
335
+ return 0
336
+
337
+
338
+ def _funclib_signals(args: argparse.Namespace, space: DesignSpace):
339
+ """Resolve (ddG table, per-substitution tolerance) from --ddg/--tolerance/--if-logprobs/--msa.
340
+
341
+ Shared by `funclib` and `campaign` so both gate the seed library with identical signal
342
+ precedence: explicit --tolerance > --if-logprobs > --msa (first present wins).
343
+ """
344
+ ddg = load_ddg_table(args.ddg) if args.ddg else None
345
+ tolerance: dict[str, float] | None = None
346
+ if getattr(args, "tolerance", None):
347
+ raw = json.loads(Path(args.tolerance).read_text())
348
+ if raw and all(isinstance(v, dict) for v in raw.values()):
349
+ tolerance = tolerance_from_logprob_table({int(k): v for k, v in raw.items()}, space)
350
+ else:
351
+ tolerance = {str(k): float(v) for k, v in raw.items()}
352
+ elif args.if_logprobs:
353
+ tolerance = tolerance_from_logprob_table(load_inverse_folding_logprobs(args.if_logprobs), space)
354
+ elif args.msa:
355
+ tolerance = tolerance_from_logprob_table(load_msa_logprobs(args.msa), space)
356
+ return ddg, tolerance
357
+
358
+
359
+ def _funclib_positions(args: argparse.Namespace, seq_len: int) -> list[int]:
360
+ """Resolve the design region: explicit --positions, else an active-site shell from --holo."""
361
+ if args.positions:
362
+ positions = sorted({int(x) for x in args.positions.split(",")})
363
+ elif args.holo:
364
+ resn = args.ligand_resnames.split(",") if args.ligand_resnames else None
365
+ shells = shells_from_pdb(args.holo, ligand_resnames=resn, contact_cutoff=args.contact_cutoff)
366
+ if args.shell == "both":
367
+ positions = sorted(set(shells[1]) | set(shells[2]))
368
+ else:
369
+ positions = sorted(shells[int(args.shell)])
370
+ else:
371
+ raise ValueError("funclib needs --positions or --holo (a ligand pose) to define the design region")
372
+ bad = [p for p in positions if p < 1 or p > seq_len]
373
+ if bad:
374
+ raise ValueError(f"positions {bad} fall outside the WT sequence (length {seq_len})")
375
+ return positions
376
+
377
+
378
+ def cmd_funclib(args: argparse.Namespace) -> int:
379
+ seq = read_fasta(args.wt_fasta)
380
+ outdir = Path(args.outdir)
381
+ outdir.mkdir(parents=True, exist_ok=True)
382
+ positions = _funclib_positions(args, len(seq))
383
+ if not positions:
384
+ (outdir / "funclib.json").write_text(json.dumps(
385
+ {"abstained": True, "reason": "no positions (apo structure or empty shell)", "library": []}, indent=2))
386
+ print("[funclib] ABSTAIN: no positions (apo structure or empty shell)")
387
+ return 0
388
+
389
+ ref = {p: seq[p - 1] for p in positions}
390
+ space = DesignSpace(tuple(positions), ref)
391
+
392
+ ddg, tolerance = _funclib_signals(args, space)
393
+
394
+ lib = design_library(
395
+ space, ddg=ddg, tolerance=tolerance,
396
+ ddg_cutoff=args.ddg_cutoff, tolerance_cutoff=args.tolerance_cutoff,
397
+ per_position_cap=args.per_position_cap, min_mut=args.min_mut, max_mut=args.max_mut,
398
+ additive_ddg_budget=args.additive_ddg_budget, max_library=args.max_library,
399
+ allow_unscored=args.allow_unscored,
400
+ )
401
+
402
+ proposal = {
403
+ "positions": list(lib.positions),
404
+ "abstained": lib.abstained,
405
+ "reason": lib.reason,
406
+ "stats": lib.stats,
407
+ "tolerated": {str(p): [[aa, s] for aa, s in lib.tolerated.get(p, [])] for p in lib.positions},
408
+ "library": [
409
+ {"variant": c.variant, "mutations": list(c.mutations), "n_mut": c.n_mut, **c.evidence}
410
+ for c in lib.candidates
411
+ ],
412
+ }
413
+ (outdir / "funclib.json").write_text(json.dumps(proposal, indent=2))
414
+ import csv as _csv
415
+ with (outdir / "funclib_library.csv").open("w", newline="") as fh:
416
+ w = _csv.writer(fh)
417
+ w.writerow(["mutations", "n_mut", "combo_score", "additive_ddg", "variant"])
418
+ for c in lib.candidates:
419
+ w.writerow([":".join(c.mutations), c.n_mut, f"{c.acq:.4f}",
420
+ f"{c.evidence['additive_ddg']:.4f}" if "additive_ddg" in c.evidence else "", c.variant])
421
+
422
+ if lib.abstained:
423
+ print(f"[funclib] ABSTAIN: {lib.reason}")
424
+ else:
425
+ st = lib.stats
426
+ gates = "+".join(g for g in ("ddG", "tolerance") if st["gates"][g.lower() if g == "tolerance" else "ddg"])
427
+ print(f"[funclib] region={st['n_positions']} positions, gates={gates or 'none'}; "
428
+ f"{st['n_kept_positions']} mutable after filters")
429
+ print(f"[funclib] naive saturation {st['naive_saturation_space']:.3g} -> library {st['library_size']} "
430
+ f"(reduction {st['reduction_factor']:.3g}x){' [truncated]' if st['truncated'] else ''}")
431
+ for p in lib.positions:
432
+ opts = lib.tolerated.get(p, [])
433
+ tag = ",".join(aa for aa, _ in opts) if opts else "(excluded: conserved/destabilizing core)"
434
+ print(f" {ref[p]}{p}: {tag}")
435
+ print(f"[funclib] top {min(10, len(lib.candidates))} combinable multipoint targets (to MEASURE, not predictions):")
436
+ for c in lib.candidates[:10]:
437
+ extra = f" ddG={c.evidence['additive_ddg']:+.2f}" if "additive_ddg" in c.evidence else ""
438
+ print(f" {':'.join(c.mutations)} score={c.acq:.3f} n_mut={c.n_mut}{extra}")
439
+ print(f"[funclib] wrote json: {outdir / 'funclib.json'}")
440
+ print(f"[funclib] wrote csv: {outdir / 'funclib_library.csv'}")
441
+ return 0
442
+
443
+
444
+ def _read_table(path: str) -> tuple[list[dict], list[str]]:
445
+ """Read a tabular file (CSV/TSV or Excel .xlsx/.xls/.xlsm) -> (rows as dicts, column names).
446
+
447
+ Excel needs pandas (clear error otherwise). CSV/TSV is stdlib with BOM stripping and a simple
448
+ tab-vs-comma delimiter sniff, so files exported straight from a spreadsheet load without a crash.
449
+ """
450
+ import csv as _csv
451
+
452
+ suffix = Path(path).suffix.lower()
453
+ if suffix in (".xlsx", ".xls", ".xlsm"):
454
+ try:
455
+ import pandas as pd
456
+ except Exception as exc: # noqa: BLE001 (no pandas -> tell the user to export CSV)
457
+ raise ValueError(
458
+ f"{path} is an Excel file but pandas is unavailable to read it; export it to CSV "
459
+ "and pass the CSV instead"
460
+ ) from exc
461
+ frame = pd.read_excel(path) # first sheet
462
+ return (
463
+ [{str(k): v for k, v in rec.items()} for rec in frame.to_dict("records")],
464
+ [str(c) for c in frame.columns],
465
+ )
466
+ with open(path, newline="", encoding="utf-8-sig") as fh: # utf-8-sig strips Excel's BOM
467
+ sample = fh.read(8192)
468
+ fh.seek(0)
469
+ delim = "\t" if ("\t" in sample and sample.count("\t") >= sample.count(",")) else ","
470
+ rdr = _csv.DictReader(fh, delimiter=delim)
471
+ return list(rdr), list(rdr.fieldnames or [])
472
+
473
+
474
+ def _read_landscape(path: str) -> dict[str, float]:
475
+ """Read a variant,fitness table into {variant: fitness}. Last value wins on dup.
476
+
477
+ Accepts CSV/TSV *and* Excel (.xlsx/.xls) -- users routinely upload spreadsheets. Column
478
+ detection is lenient: a variant column (variant/mutant/mutation/sequence) and a fitness column
479
+ (fitness/activity/score/value/y), else the first two columns. Rows with a blank or non-numeric
480
+ fitness are skipped (an unfilled template column, or a predictions-only export).
481
+ """
482
+ rows, fieldnames = _read_table(path)
483
+ if not fieldnames:
484
+ raise ValueError(f"{path}: no columns found (need a variant column and a fitness column)")
485
+ cols = {str(c).strip().lower(): c for c in fieldnames}
486
+ vcol = next((cols[k] for k in ("variant", "variants", "mutant", "mutation", "mutations",
487
+ "sequence", "seq") if k in cols), fieldnames[0])
488
+ fcol = next((cols[k] for k in ("fitness", "activity", "score", "value", "y", "measured",
489
+ "y_actual", "fit") if k in cols),
490
+ fieldnames[1] if len(fieldnames) > 1 else None)
491
+ if fcol is None:
492
+ raise ValueError(f"{path}: need a fitness column (e.g. 'fitness'/'activity'); found {fieldnames}")
493
+ table: dict[str, float] = {}
494
+ for row in rows:
495
+ v = str(row.get(vcol, "")).strip()
496
+ raw = row.get(fcol, None)
497
+ s = "" if raw is None else str(raw).strip()
498
+ if not v or s == "":
499
+ continue
500
+ try:
501
+ val = float(s)
502
+ except ValueError:
503
+ continue
504
+ if val != val: # NaN (e.g. a blank Excel cell)
505
+ continue
506
+ table[v] = val
507
+ if not table:
508
+ raise ValueError(
509
+ f"{path}: found columns {fieldnames} but no rows with a numeric '{fcol}' value "
510
+ "(predictions-only or unfilled template? need measured variant,fitness)"
511
+ )
512
+ return table
513
+
514
+
515
+ def _funclib_space(args: argparse.Namespace, seq: str) -> DesignSpace:
516
+ positions = _funclib_positions(args, len(seq))
517
+ ref = {p: seq[p - 1] for p in positions}
518
+ return DesignSpace(tuple(positions), ref)
519
+
520
+
521
+ def cmd_campaign(args: argparse.Namespace) -> int:
522
+ """funclib -> active-learning handoff: R0 seed (funclib) then measurement-driven AL rounds.
523
+
524
+ --simulate LANDSCAPE.csv reproduces/validates the handoff against a measured landscape
525
+ (D19/D25). Without --simulate it is the live loop: no --measured -> emit the funclib R0 seed
526
+ plate; --measured given -> fit on accumulated data and propose the next plate over the FULL
527
+ saturation universe of the design positions (never hard-restricted to the funclib library).
528
+ """
529
+ seq = read_fasta(args.wt_fasta)
530
+ space = _funclib_space(args, seq)
531
+ outdir = Path(args.outdir)
532
+ outdir.mkdir(parents=True, exist_ok=True)
533
+ # auto-MSA seeds the funclib R0 library only; Rk (measured given) is activity-driven and never
534
+ # uses the tolerance signal, so skip the network build there.
535
+ if getattr(args, "auto_msa", False) and not args.msa and not args.measured:
536
+ print("[campaign] no --msa given; building the seed MSA from the WT sequence via ColabFold MMseqs2 ...")
537
+ a3m = build_msa_mmseqs2(seq)
538
+ if a3m:
539
+ msa_path = outdir / "auto_msa.a3m"
540
+ msa_path.write_text(a3m)
541
+ args.msa = str(msa_path)
542
+ print(f"[campaign] auto-MSA: {a3m.count('>')} sequences -> {msa_path}")
543
+ else:
544
+ print("[campaign] auto-MSA failed (MMseqs2 API unreachable / no hits); "
545
+ "R0 will abstain unless you supply --msa / --ddg / --if-logprobs")
546
+ ddg, tolerance = _funclib_signals(args, space)
547
+ make_surrogate = SurrogateSpec(args.model_class) # picklable factory (enables --jobs)
548
+
549
+ if args.simulate:
550
+ table = _read_landscape(args.simulate)
551
+ # design positions must match the landscape's combo encoding (one residue per position)
552
+ wt = space.wt()
553
+ if wt not in table:
554
+ print(f"[campaign] note: WT combo {wt!r} not in landscape (ok if landscape lacks WT row)")
555
+ constraint_info = None
556
+ if args.stability_constraint:
557
+ if ddg is None:
558
+ raise ValueError("--stability-constraint requires --ddg (predicted folding ddG table)")
559
+ global_win = max(table, key=table.get)
560
+ stable = set(stable_variants(
561
+ list(table), space, ddg, ddg_cutoff=args.ddg_cutoff,
562
+ additive_budget=args.additive_ddg_budget,
563
+ ))
564
+ stable.add(wt) # always allow WT (the campaign baseline)
565
+ kept = {v: f for v, f in table.items() if v in stable}
566
+ constr_win = max(kept, key=kept.get) if kept else None
567
+ constraint_info = {
568
+ "global_activity_winner": global_win,
569
+ "global_winner_fitness": table[global_win],
570
+ "global_winner_passes_stability": global_win in stable,
571
+ "n_stable": len(kept), "n_total": len(table),
572
+ "constrained_optimum": constr_win,
573
+ "constrained_optimum_fitness": (kept[constr_win] if constr_win else None),
574
+ }
575
+ table = kept
576
+ print(f"[campaign] stability constraint (ddG<= {args.ddg_cutoff}"
577
+ f"{f', budget {args.additive_ddg_budget}' if args.additive_ddg_budget is not None else ''}): "
578
+ f"{len(kept)}/{constraint_info['n_total']} variants stable; "
579
+ f"global activity winner {global_win} {'PASSES' if constraint_info['global_winner_passes_stability'] else 'FAILS'} the gate")
580
+ seed_lib = None
581
+ results = {}
582
+ for strat in args.strategies:
583
+ if strat == "funclib":
584
+ sv, seed_lib = funclib_seed_variants(
585
+ space, plate=args.plate, ddg=ddg, tolerance=tolerance,
586
+ ddg_cutoff=args.ddg_cutoff, tolerance_cutoff=args.tolerance_cutoff,
587
+ per_position_cap=args.per_position_cap, min_mut=args.min_mut,
588
+ max_mut=args.max_mut, additive_ddg_budget=args.additive_ddg_budget,
589
+ allow_unscored=args.allow_unscored,
590
+ )
591
+ if not sv:
592
+ print(f"[campaign] funclib ABSTAINED ({seed_lib.reason}); skipping funclib arm")
593
+ continue
594
+ elif strat == "singles":
595
+ sv = single_mutant_variants(space)
596
+ elif strat == "random":
597
+ sv = None
598
+ else:
599
+ raise ValueError(f"unknown strategy {strat!r}")
600
+ res = simulate_campaign(
601
+ table, space, seed_variants=sv, strategy=strat, plate=args.plate,
602
+ rounds=args.rounds, n_seeds=args.seeds, beta=args.beta,
603
+ make_surrogate=make_surrogate, n_jobs=args.jobs,
604
+ )
605
+ results[strat] = res
606
+
607
+ payload = {
608
+ "mode": "simulate", "landscape": args.simulate,
609
+ "positions": list(space.positions), "wt": space.wt(),
610
+ "plate": args.plate, "rounds": args.rounds, "n_seeds": args.seeds,
611
+ "acquisition": "greedy" if args.beta <= 0 else f"ucb(beta={args.beta})",
612
+ "surrogate": args.model_class,
613
+ "stability_constraint": constraint_info,
614
+ "funclib_seed_size": (len(seed_lib.candidates) if seed_lib else None),
615
+ "strategies": {
616
+ s: {
617
+ "winner_reach_rate": r.winner_reach_rate,
618
+ "rounds_to_winner_median": r.rounds_to_winner_median,
619
+ "best_found_mean": r.best_found_mean,
620
+ "best_found_percentile_mean": r.best_found_percentile_mean,
621
+ "per_seed": r.per_seed,
622
+ } for s, r in results.items()
623
+ },
624
+ }
625
+ (outdir / "campaign_simulate.json").write_text(json.dumps(payload, indent=2))
626
+ print(f"[campaign] simulate over {len(table)} measured variants; positions={list(space.positions)} "
627
+ f"plate={args.plate} rounds={args.rounds} seeds={args.seeds} "
628
+ f"acq={'greedy' if args.beta<=0 else f'ucb{args.beta}'}")
629
+ for s, r in results.items():
630
+ rtw = f"{r.rounds_to_winner_median:.1f}" if r.rounds_to_winner_median is not None else "n/a"
631
+ print(f" {s:8s}: winner-reach {r.winner_reach_rate*100:5.1f}% median rounds-to-winner {rtw} "
632
+ f"best-found percentile {r.best_found_percentile_mean:.3f}")
633
+ print(f"[campaign] wrote {outdir / 'campaign_simulate.json'}")
634
+ return 0
635
+
636
+ # ---- live mode ----
637
+ measured_paths = args.measured or []
638
+ if not measured_paths:
639
+ # R0: cold start -> funclib seed plate to MEASURE (AL is blind with zero data).
640
+ sv, lib = funclib_seed_variants(
641
+ space, plate=args.plate, ddg=ddg, tolerance=tolerance,
642
+ ddg_cutoff=args.ddg_cutoff, tolerance_cutoff=args.tolerance_cutoff,
643
+ per_position_cap=args.per_position_cap, min_mut=args.min_mut,
644
+ max_mut=args.max_mut, additive_ddg_budget=args.additive_ddg_budget,
645
+ allow_unscored=args.allow_unscored,
646
+ )
647
+ if lib.abstained or not sv:
648
+ print(f"[campaign] R0 ABSTAIN: {lib.reason or 'no seed variants'} (supply --msa/--ddg/--if-logprobs or relax cutoffs)")
649
+ return 0
650
+ import csv as _csv
651
+
652
+ def _r0_rows(cands):
653
+ for c in cands:
654
+ yield [":".join(c.mutations) or "WT", c.n_mut, f"{c.acq:.4f}", c.variant]
655
+
656
+ header = ["mutations", "n_mut", "seed_score", "variant"]
657
+ top = lib.candidates[: args.plate]
658
+ for name, rows in (("round0_plate.csv", top), ("round0_all.csv", lib.candidates)):
659
+ with (outdir / name).open("w", newline="") as fh:
660
+ w = _csv.writer(fh)
661
+ w.writerow(header)
662
+ w.writerows(_r0_rows(rows))
663
+ with (outdir / "round0_measured_template.csv").open("w", newline="") as fh:
664
+ w = _csv.writer(fh)
665
+ w.writerow(["variant", "fitness"])
666
+ for c in top:
667
+ w.writerow([c.variant, ""])
668
+ print(f"[campaign] R0 funclib seed over positions {list(space.positions)}: "
669
+ f"{len(top)} to MEASURE (round0_plate.csv), {len(lib.candidates)} total candidates "
670
+ f"(round0_all.csv)")
671
+ print("[campaign] fill the 'fitness' column of round0_measured_template.csv, then re-run with "
672
+ "--measured round0_measured_template.csv for R1 (AL over the full saturation space).")
673
+ return 0
674
+
675
+ # Rk: fit on accumulated measurements, propose the next plate over the FULL saturation universe.
676
+ table: dict[str, float] = {}
677
+ for p in measured_paths:
678
+ table.update(_read_landscape(p))
679
+ variants = list(table)
680
+ if getattr(args, "wt_fitness", None) is not None:
681
+ # WT is not on the plate, but under WT-normalisation its (relative) activity is known
682
+ # (e.g. 1.0). Anchor it so "beats WT" and the surrogate baseline are exact, not predicted.
683
+ table.setdefault(space.wt(), float(args.wt_fitness))
684
+ variants = list(table)
685
+ # Guard: measured variants must fit the resolved design region. A whole-protein single-mutant
686
+ # scan (e.g. "29I", or positions outside the active-site shell) is a different regime -- abstain
687
+ # with guidance instead of crashing deep in the variant parser / encoder.
688
+ _measured_only = [v for v in variants if v != space.wt()]
689
+ _unfit: dict[str, str] = {}
690
+ for _v in _measured_only:
691
+ try:
692
+ mutations_of(_v, space)
693
+ except ValueError as _exc:
694
+ _unfit[_v] = str(_exc)
695
+ if _measured_only and len(_unfit) == len(_measured_only):
696
+ _lo, _hi = space.positions[0], space.positions[-1]
697
+ print(f"[campaign] ABSTAIN: none of the {len(_measured_only)} measured variants fit the design "
698
+ f"region (positions {_lo}-{_hi}, {space.n_positions} sites).")
699
+ for _v, _why in list(_unfit.items())[:4]:
700
+ print(f" {_v}: {_why}")
701
+ print("[campaign] this looks like a whole-protein single-mutant scan, not active-site "
702
+ "combinatorial data for these positions.")
703
+ print("[campaign] -> rank positions from a scan with `shellde hotspots <scan.csv>`, then "
704
+ "`round --positions <top>`; or set --positions to the measured positions.")
705
+ print("[campaign] -> variant notation must be [WT][pos][MT] (e.g. V29I), not 29I.")
706
+ return 1
707
+ if _unfit:
708
+ print(f"[campaign] WARNING: dropping {len(_unfit)}/{len(_measured_only)} measured variants "
709
+ f"outside the design region (e.g. {next(iter(_unfit))}); fitting on the rest.")
710
+ variants = [v for v in variants if v == space.wt() or v not in _unfit]
711
+ fitness = np.asarray([table[v] for v in variants], dtype=float)
712
+ matrix = FeatureMatrix(space, default_blocks(space))
713
+ if args.if_logprobs:
714
+ matrix = FeatureMatrix(space, [*default_blocks(space), InverseFoldingBlock(space, load_inverse_folding_logprobs(args.if_logprobs))])
715
+ x_enc = matrix.encode(variants)
716
+ sur = make_surrogate().fit(x_enc, fitness)
717
+ conf_q = (
718
+ cross_conformal_normalized_q(x_enc, fitness, make_surrogate)
719
+ if len(variants) >= 8 and float(np.std(fitness)) > 0.0 else None
720
+ )
721
+ max_meas_nmut = max(len(mutations_of(v, space)) for v in variants)
722
+ cset = generate_candidates(
723
+ sur, matrix, pinned_positions=list(space.positions), max_mut=min(args.max_mut, space.n_positions),
724
+ generated_cap=args.generated_cap, scored_cap=args.scored_cap, beta=args.beta, seed=0,
725
+ )
726
+ wt_mean = float(sur.predict(matrix.encode([space.wt()])).mean[0])
727
+ if getattr(args, "plm", "none") != "none" or getattr(args, "naturalness", "none") != "none":
728
+ # EVOLVEpro-style Rk: refit a surrogate on the measured data over ESM embeddings and rerank the
729
+ # top-K one-hot candidates. Embeds only measured + shortlist (tractable). GPU-recommended.
730
+ emb_cache = EmbeddingCache(args.plm_cache) if getattr(args, "plm_cache", None) else None
731
+ nat_cache = EmbeddingCache(args.naturalness_cache) if getattr(args, "naturalness_cache", None) else None
732
+ provider = build_provider(args.plm, model=args.plm_model, cache=emb_cache) if args.plm != "none" else None
733
+ nat = (build_naturalness(args.naturalness, model=args.plm_model, cache=nat_cache)
734
+ if args.naturalness != "none" else None)
735
+ resolved_pooling = resolve_plm_pooling(args.plm_pooling, args.plm_model)
736
+ print(f"[campaign] Rk rerank: refit PLM/naturalness surrogate on {len(variants)} measured -> "
737
+ f"rerank top {args.plm_rerank} candidates ({args.plm}/{args.naturalness})")
738
+ cset = plm_rerank(cset, space, variants, fitness, provider=provider, naturalness=nat,
739
+ wt_sequence=read_fasta(args.wt_fasta), top_k=args.plm_rerank,
740
+ plm_pooling=resolved_pooling)
741
+ wt_mean = float(cset.stats.get("wt_mean", wt_mean))
742
+ if emb_cache is not None:
743
+ emb_cache.save()
744
+ if nat_cache is not None:
745
+ nat_cache.save()
746
+ ranking = rank_candidates(
747
+ cset, space, acquisition=UCB(max(args.beta, 0.0)), per_count_k=args.plate,
748
+ global_k=args.plate, wt_mean=wt_mean, conformal_q=conf_q, max_measured_nmut=max_meas_nmut,
749
+ )
750
+ rk = len(measured_paths)
751
+ import csv as _csv
752
+
753
+ def _rk_rows(cands):
754
+ for c in cands:
755
+ yield [":".join(c.mutations) or "WT", len(c.mutations), f"{c.mu:.4f}", f"{c.sigma:.4f}",
756
+ f"{c.acq:.4f}", c.evidence.get("risk", ""), c.variant]
757
+
758
+ header = ["mutations", "n_mut", "mu", "sigma", "acq", "risk", "variant"]
759
+ top = ranking.global_ranking[: args.plate]
760
+ for name, rows in ((f"round{rk}_plate.csv", top), (f"round{rk}_all.csv", ranking.global_ranking)):
761
+ with (outdir / name).open("w", newline="") as fh:
762
+ w = _csv.writer(fh)
763
+ w.writerow(header)
764
+ w.writerows(_rk_rows(rows))
765
+ with (outdir / f"round{rk}_measured_template.csv").open("w", newline="") as fh:
766
+ w = _csv.writer(fh)
767
+ w.writerow(["variant", "fitness"])
768
+ for c in top:
769
+ w.writerow([c.variant, ""])
770
+ print(f"[campaign] R{rk}: fit on {len(variants)} measured; {len(top)} to MEASURE "
771
+ f"(round{rk}_plate.csv), {len(ranking.global_ranking)} scored candidates (round{rk}_all.csv) "
772
+ f"over full saturation of {space.n_positions} positions")
773
+ for c in top[: min(10, len(top))]:
774
+ print(f" [{','.join(c.mutations) or 'WT'}] mu={c.mu:.3f} sigma={c.sigma:.3f} "
775
+ f"acq={c.acq:.3f} ({c.evidence.get('risk', '')})")
776
+ print(f"[campaign] fill the 'fitness' column of round{rk}_measured_template.csv for the next round")
777
+ try: # auto-advise: is the surrogate trustworthy yet, or keep measuring? (advisory, never fatal)
778
+ adv = data_readiness(space, variants, fitness)
779
+ sp = "n/a" if adv["heldout_spearman"] is None else f"{adv['heldout_spearman']:+.3f}"
780
+ print(f"[campaign] advise: held-out Spearman={sp}, trustworthy={adv['model_trustworthy']} "
781
+ f"-> {adv['recommendation'].upper()} ({adv['why']})")
782
+ _print_epistasis_next_step(
783
+ adv["recommendation"], getattr(args, "measured", None), space.positions,
784
+ )
785
+ except Exception: # noqa: BLE001 (advisory only)
786
+ pass
787
+ return 0
788
+
789
+
790
+ def _resolve_site_ladder(
791
+ args: argparse.Namespace, seq: str
792
+ ) -> tuple[ResolveResult | None, Path | None]:
793
+ """Evidence ladder (UniProt features -> UniProt-linked PDB cocrystal -> AlphaFold fetch),
794
+ every position sequence-aligned onto the WT numbering. Returns (result, alphafold_model_path).
795
+ Returns (None, None) when no lookup source (--uniprot/--query/--pdb) was given. Shared by
796
+ ``resolve-site`` and the ``round`` front door so both use one ladder with one behaviour.
797
+ """
798
+ if getattr(args, "uniprot", None) == "auto":
799
+ accs = uniprot_from_sequence(seq, timeout=args.timeout)
800
+ if accs:
801
+ print(f"[resolve-site] exact UniProt match for this sequence: {accs[0]}"
802
+ f"{f' (+{len(accs) - 1} identical entrie(s))' if len(accs) > 1 else ''}")
803
+ args.uniprot = accs[0]
804
+ else:
805
+ print("[resolve-site] no EXACT UniProt match for this sequence (engineered construct?) "
806
+ "-> pass --uniprot ACC / --query / --pdb / --positions")
807
+ args.uniprot = None
808
+ lig = args.ligand_resnames.split(",") if args.ligand_resnames else None
809
+
810
+ def _try_pdb(pdb_ref: str) -> ResolveResult:
811
+ # accept a LOCAL structure file (your own AlphaFold model or cocrystal) OR an RCSB ID
812
+ if Path(pdb_ref).is_file():
813
+ text = Path(pdb_ref).read_text()
814
+ pid = Path(pdb_ref).stem
815
+ else:
816
+ try:
817
+ text = fetch_pdb(pdb_ref, timeout=args.timeout)
818
+ except Exception as exc: # noqa: BLE001 (network/HTTP failures -> skip this PDB)
819
+ return ResolveResult(source=f"PDB:{pdb_ref}", abstained=True,
820
+ reason=f"download failed ({exc})")
821
+ pid = pdb_ref
822
+ return resolve_from_pdb(seq, text, pdb_id=pid, ligand_resnames=lig,
823
+ contact_cutoff=args.contact_cutoff, min_coverage=args.min_coverage)
824
+
825
+ acc = args.uniprot
826
+ if args.pdb and (args.uniprot or args.query):
827
+ # Both a structure AND a UniProt source: the PDB cocrystal shell is the (broad) design set,
828
+ # and UniProt curated sites annotate its catalytic core (+ QC that they fall in the shell).
829
+ # --exclude-catalytic then designs only the tunable 2nd-shell ring (avoids saturating the
830
+ # usually-lethal catalytic core).
831
+ res = _try_pdb(args.pdb)
832
+ if res is not None and not res.abstained:
833
+ catalytic: set[int] = set()
834
+ try:
835
+ udata = fetch_uniprot(accession=args.uniprot, query=args.query, timeout=args.timeout)
836
+ except Exception: # noqa: BLE001 (UniProt lookup failure -> just skip annotation)
837
+ udata = None
838
+ if udata:
839
+ ures = resolve_from_entry(seq, udata, min_coverage=args.min_coverage)
840
+ if not ures.abstained:
841
+ catalytic = set(ures.positions)
842
+ in_shell = sorted(catalytic & set(res.positions))
843
+ print(f"[resolve-site] combine: PDB shell {len(res.positions)} positions + UniProt catalytic "
844
+ f"{len(catalytic)}; {len(in_shell)}/{len(catalytic)} catalytic residues fall in the shell (QC)")
845
+ res.consensus = build_consensus({
846
+ "pdb_cocrystal_shell": list(res.positions),
847
+ "uniprot_catalytic": sorted(catalytic),
848
+ })
849
+ print(f"[resolve-site] evidence consensus: {consensus_summary(res.consensus)} "
850
+ "(high = >=2 evidence types agree; measure high-confidence positions first)")
851
+ if getattr(args, "exclude_catalytic", False):
852
+ keep = [p for p in res.positions if p not in catalytic]
853
+ res.positions = keep
854
+ res.sites = [s for s in res.sites if s.wt_position in keep]
855
+ res.source = f"{res.source}+UniProt(-catalytic)"
856
+ print(f"[resolve-site] --exclude-catalytic: designing over {len(keep)} tunable-ring "
857
+ f"positions (dropped {len(in_shell)} catalytic-core positions {in_shell})")
858
+ else:
859
+ res.source = f"{res.source}+UniProt(catalytic-flagged)"
860
+ if in_shell:
861
+ print(f"[resolve-site] catalytic core flagged: {in_shell} "
862
+ "(pass --exclude-catalytic to design only the tunable ring)")
863
+ elif args.pdb:
864
+ res = _try_pdb(args.pdb)
865
+ elif not (args.uniprot or args.query):
866
+ return None, None
867
+ else:
868
+ try:
869
+ data = fetch_uniprot(accession=args.uniprot, query=args.query, timeout=args.timeout)
870
+ except Exception as exc: # noqa: BLE001 (network/HTTP/timeout -> abstain, never crash)
871
+ return ResolveResult(source="UniProt", abstained=True,
872
+ reason=f"UniProt lookup failed ({exc})"), None
873
+ if data is None:
874
+ return ResolveResult(source="UniProt", abstained=True,
875
+ reason="no UniProt entry matched"), None
876
+ acc = acc or data.get("primaryAccession")
877
+ res = resolve_from_entry(seq, data, min_coverage=args.min_coverage)
878
+ if res.abstained and not args.no_pdb_fallback:
879
+ pdb_ids = pdb_ids_from_uniprot(data)
880
+ if pdb_ids:
881
+ print(f"[resolve-site] UniProt features did not map ({res.reason}); "
882
+ f"trying {min(len(pdb_ids), args.max_pdb)} linked PDB cocrystal(s): {pdb_ids[:args.max_pdb]}")
883
+ for pid in pdb_ids[: args.max_pdb]:
884
+ cand = _try_pdb(pid)
885
+ print(f"[resolve-site] {pid}: {'ok' if not cand.abstained else 'skip -- ' + cand.reason}")
886
+ if not cand.abstained:
887
+ res = cand
888
+ break
889
+
890
+ outdir = Path(args.outdir)
891
+ outdir.mkdir(parents=True, exist_ok=True)
892
+ af_saved = None
893
+ if res.abstained and acc and not args.no_alphafold:
894
+ af = fetch_alphafold(acc, timeout=args.timeout)
895
+ if af:
896
+ af_saved = outdir / f"AF-{acc}.pdb"
897
+ af_saved.write_text(af)
898
+ if args.apo_pocket:
899
+ apo = resolve_from_apo(seq, af, fpocket_bin=args.fpocket)
900
+ if not apo.abstained:
901
+ res = apo # predicted-pocket -- LOW confidence, must verify
902
+ return res, af_saved
903
+
904
+
905
+ def _find_holo_pdb(args: argparse.Namespace, seq: str) -> str | None:
906
+ """--find-holo: get a WT query structure (--pdb, or the AlphaFold model via accession) -> Foldseek
907
+ -> top ligand-bound (holo) PDB id, to use as the design region. None if nothing usable."""
908
+ query = None
909
+ if args.pdb:
910
+ query = Path(args.pdb).read_text() if Path(args.pdb).exists() else fetch_pdb(args.pdb, timeout=args.timeout)
911
+ else:
912
+ acc = args.uniprot if args.uniprot and args.uniprot != "auto" else None
913
+ if acc is None and args.uniprot == "auto":
914
+ hits = uniprot_from_sequence(seq, timeout=args.timeout)
915
+ acc = hits[0] if hits else None
916
+ if acc:
917
+ try:
918
+ query = fetch_alphafold(acc, timeout=args.timeout)
919
+ except Exception: # noqa: BLE001
920
+ query = None
921
+ if not query:
922
+ print("[round] --find-holo: no query structure (need --pdb, or --uniprot ACC/auto for the "
923
+ "AlphaFold model); skipping")
924
+ return None
925
+ print("[round] --find-holo: Foldseek-searching for ligand-bound homologs (network; ~1 min)...")
926
+ cands = find_holo_homolog(query, timeout=args.timeout)
927
+ if not cands:
928
+ print("[round] --find-holo: no ligand-bound homolog found -> continuing with the normal ladder")
929
+ return None
930
+ print("[round] holo homolog candidates: " + ", ".join(
931
+ f"{c['pdb_id']}(lig {';'.join(c['ligands'])}; E={c['evalue']:.0e}, cov {c['coverage']:.0%})"
932
+ for c in cands))
933
+ top = cands[0]["pdb_id"]
934
+ print(f"[round] --find-holo: using {top} as --pdb (override by passing a different pdb=)")
935
+ return top
936
+
937
+
938
+ def cmd_round(args: argparse.Namespace) -> int:
939
+ """One command per wet-lab round: auto-resolve the active-site design region (evidence ladder),
940
+ then run the campaign round. No --measured -> emit the R0 funclib seed plate to MEASURE; add each
941
+ round's results via --measured to get the next AL plate over the full saturation universe. The
942
+ front door: WT FASTA + an accession/PDB in, a plate CSV out, no position bookkeeping by hand.
943
+ """
944
+ seq = read_fasta(args.wt_fasta)
945
+ if getattr(args, "find_holo", False) and not args.pdb and not args.positions and not args.holo:
946
+ holo_id = _find_holo_pdb(args, seq)
947
+ if holo_id:
948
+ args.pdb = holo_id
949
+ if not args.positions and not args.holo:
950
+ if not (args.uniprot or args.query or args.pdb):
951
+ print("[round] need a design region: --positions, --holo <PDB>, or a lookup source "
952
+ "(--uniprot ACC / --query / --pdb ID) for the auto evidence ladder")
953
+ return 1
954
+ res, af_saved = _resolve_site_ladder(args, seq)
955
+ if res is None or res.abstained or not res.positions:
956
+ reason = "no lookup source" if res is None else (res.reason or "no positions mapped")
957
+ print(f"[round] active-site auto-resolve ABSTAINED ({reason}); supply --positions or --holo <PDB>")
958
+ if af_saved:
959
+ print(f"[round] fetched AlphaFold model -> {af_saved} (apo; add a ligand pose and pass via --pdb)")
960
+ return 1
961
+ args.positions = ",".join(map(str, res.positions))
962
+ print(f"[round] resolved {len(res.positions)} active-site positions from {res.source} "
963
+ f"(WT-coverage {res.coverage:.0%}): {args.positions}")
964
+ return cmd_campaign(args)
965
+
966
+ def cmd_resolve_site(args: argparse.Namespace) -> int:
967
+ """Resolve active-site/binding positions via an evidence ladder, mapped to the WT numbering.
968
+
969
+ Ladder: (1) UniProt curated site features; (2) if those do not map and fallback is allowed, a
970
+ UniProt-linked PDB cocrystal -> active-site shells. ``--pdb ID`` forces the structure path.
971
+ Abstains (defer to --holo / --positions) when no evidence maps. Every position is sequence-aligned
972
+ onto WT so numbering offsets (transit peptide / species / PDB gaps) are handled, never guessed.
973
+ """
974
+ seq = read_fasta(args.wt_fasta)
975
+ res, af_saved = _resolve_site_ladder(args, seq)
976
+ if res is None:
977
+ print("[resolve-site] no --uniprot/--query/--pdb given; cannot look up evidence")
978
+ print("[resolve-site] -> provide --uniprot ACC, --query, or --pdb ID (or use --holo/--positions)")
979
+ return 0
980
+ if res.source == "UniProt" and res.reason == "no UniProt entry matched" and not res.sites:
981
+ print("[resolve-site] no UniProt entry matched; abstaining -> use --holo/--positions")
982
+ return 0
983
+ outdir = Path(args.outdir)
984
+ outdir.mkdir(parents=True, exist_ok=True)
985
+
986
+ payload = {
987
+ "source": res.source, "wt_coverage": res.coverage, "abstained": res.abstained,
988
+ "reason": res.reason, "positions": res.positions, "consensus": res.consensus,
989
+ "alphafold_model": (str(af_saved) if af_saved else None),
990
+ "sites": [
991
+ {"wt_position": s.wt_position, "residue": s.residue, "source_position": s.source_position,
992
+ "types": list(s.types), "ligands": list(s.ligands), "confidence": s.confidence}
993
+ for s in res.sites
994
+ ],
995
+ "unmapped_features": [
996
+ {"type": f.type, "uniprot_position": f.start, "ligand": f.ligand, "evidence": f.evidence}
997
+ for f in res.unmapped
998
+ ],
999
+ }
1000
+ (outdir / "resolve_site.json").write_text(json.dumps(payload, indent=2))
1001
+ if getattr(args, "find_holo", False):
1002
+ qtext = None
1003
+ if af_saved:
1004
+ qtext = Path(af_saved).read_text()
1005
+ elif getattr(args, "holo", None) and Path(args.holo).exists():
1006
+ qtext = Path(args.holo).read_text()
1007
+ elif args.pdb:
1008
+ qtext = Path(args.pdb).read_text() if Path(args.pdb).exists() else fetch_pdb(args.pdb, timeout=args.timeout)
1009
+ if not qtext:
1010
+ print("[resolve-site] --find-holo needs a query structure (AlphaFold model / --holo / --pdb); none available")
1011
+ else:
1012
+ print("[resolve-site] --find-holo: Foldseek-searching for ligand-bound homologs (network; ~1 min)...")
1013
+ cands = find_holo_homolog(qtext, timeout=args.timeout)
1014
+ if cands:
1015
+ print(f"[resolve-site] holo homolog candidates ({len(cands)}):")
1016
+ for c in cands:
1017
+ print(f" {c['pdb_id']} ligands={';'.join(c['ligands'])} "
1018
+ f"(E={c['evalue']:.0e}, coverage {c['coverage']:.0%}, Foldseek rank {c['rank']})")
1019
+ print(f"[resolve-site] -> re-run with --pdb {cands[0]['pdb_id']} to use its ligand pose (aligned onto WT)")
1020
+ else:
1021
+ print("[resolve-site] --find-holo: no ligand-bound homolog found (abstained)")
1022
+ if res.abstained:
1023
+ print(f"[resolve-site] ABSTAIN ({res.source}, WT-coverage {res.coverage:.0%}): {res.reason}")
1024
+ if af_saved:
1025
+ print(f"[resolve-site] fetched AlphaFold model -> {af_saved} (predicted, APO / no ligand)")
1026
+ if args.apo_pocket:
1027
+ print("[resolve-site] apo-pocket (fpocket) found no pocket that maps to WT")
1028
+ else:
1029
+ print("[resolve-site] -> add a ligand pose and pass via --pdb, or try --apo-pocket "
1030
+ "(fpocket; LOW confidence -- can miss the catalytic site), or --positions.")
1031
+ print("[resolve-site] -> options: --positions (manual); --pdb <RCSB id or local holo PDB>; --holo in campaign.")
1032
+ print(f"[resolve-site] wrote {outdir / 'resolve_site.json'}")
1033
+ return 0
1034
+ if any(s.confidence == "predicted-pocket" for s in res.sites):
1035
+ print("[resolve-site] WARNING: predicted-pocket (apo fpocket) is LOW-confidence and can MISS the "
1036
+ "catalytic site (it did on ispS in our test). Verify before committing a campaign.")
1037
+ print(f"[resolve-site] {res.source} WT-coverage {res.coverage:.0%} {len(res.sites)} mapped site positions")
1038
+ for s in res.sites:
1039
+ ligs = f" lig={';'.join(s.ligands)}" if s.ligands else ""
1040
+ print(f" {s.residue}{s.wt_position} [{','.join(s.types)}]{ligs} ({s.confidence}; src {s.source_position})")
1041
+ if res.unmapped:
1042
+ print(f"[resolve-site] {len(res.unmapped)} UniProt features did NOT align onto WT "
1043
+ f"(numbering/region mismatch) -- not used (see json)")
1044
+ pstr = ",".join(map(str, res.positions))
1045
+ print(f"[resolve-site] positions: {pstr}")
1046
+ print(f"[resolve-site] -> next: shellde campaign {args.wt_fasta} --positions {pstr} --msa ... --plate 95")
1047
+ print(f"[resolve-site] wrote {outdir / 'resolve_site.json'}")
1048
+ return 0
1049
+
1050
+
1051
+ def cmd_bench(args: argparse.Namespace) -> int:
1052
+ alphabet = AA_ALPHABET[: args.q]
1053
+ positions = tuple(range(1, args.n_sites + 1))
1054
+ space = DesignSpace(positions, {p: alphabet[0] for p in positions}, alphabet=alphabet)
1055
+ out = run_spectrum(
1056
+ space, alphas=args.alphas, seeds=list(range(args.seeds)),
1057
+ n_init=args.n_init, batch_size=args.batch_size, n_rounds=args.n_rounds,
1058
+ )
1059
+ payload = stamp(out)
1060
+ outdir = Path(args.outdir)
1061
+ outdir.mkdir(parents=True, exist_ok=True)
1062
+ (outdir / "spectrum.json").write_text(json.dumps(payload, indent=2))
1063
+ print(f"[bench] prereg {payload['prereg_hash'][:12]} | sites={args.n_sites} q={args.q} seeds={args.seeds}")
1064
+ _print_spectrum(out)
1065
+ print(f"[bench] wrote {outdir / 'spectrum.json'}")
1066
+ return 0
1067
+
1068
+
1069
+ def cmd_report(args: argparse.Namespace) -> int:
1070
+ path = Path(args.path)
1071
+ if not path.exists():
1072
+ print(f"[report] not found: {path}", file=sys.stderr)
1073
+ return 1
1074
+ data = json.loads(path.read_text())
1075
+ if "per_alpha" in data:
1076
+ _print_spectrum(data)
1077
+ elif "global_ranking" in data:
1078
+ print(f"[report] {len(data['global_ranking'])} global candidates (prereg {str(data.get('prereg_hash'))[:12]})")
1079
+ for i, c in enumerate(data["global_ranking"][:20], start=1):
1080
+ print(f" {i}. {c['variant']} acq={c['acq']:.3f} risk={c['risk']}")
1081
+ else:
1082
+ print("[report] unrecognised JSON (expected a bench spectrum or a recommend report)")
1083
+ return 1
1084
+ return 0
1085
+
1086
+
1087
+ def _print_spectrum(out: dict) -> None:
1088
+ for alpha, row in out["per_alpha"].items():
1089
+ mr = row["mean_regret"]
1090
+ print(f" alpha={alpha}: " + " ".join(f"{k}={v:.4f}" for k, v in mr.items()))
1091
+ for name, comp in row["ours_vs"].items():
1092
+ sig = "sig" if comp.get("fdr_significant") else "-"
1093
+ print(f" ours vs {name}: dReg={comp['regret_reduction_mean']:+.4f} "
1094
+ f"p={comp['p']:.3f} [{sig}]")
1095
+
1096
+
1097
+ def _read_singles(path: str, mutation_col: str | None, score_col: str | None):
1098
+ """Read a single-mutant scan CSV -> [(mutation_token, score)], auto-detecting the columns.
1099
+
1100
+ Mutation column: named variant/mutation/mutant, else the first column whose values look like
1101
+ ``V100F``. Score column: prefer a measured column (y_actual/fitness/activity/measured) with
1102
+ numeric values, else a predicted one (score/y_pred/prediction/pred). Returns (rows, mut_col, score_col).
1103
+ """
1104
+ from shellde.hotspots import parse_mutation
1105
+
1106
+ rows, fields = _read_table(path) # CSV/TSV or Excel
1107
+ low = {f.lower(): f for f in fields}
1108
+
1109
+ if mutation_col is None:
1110
+ for cand in ("variant", "mutation", "mutant", "mut"):
1111
+ if cand in low:
1112
+ mutation_col = low[cand]
1113
+ break
1114
+ else:
1115
+ for f in fields:
1116
+ v = next((r[f] for r in rows if r.get(f)), "")
1117
+ if parse_mutation(str(v)):
1118
+ mutation_col = f
1119
+ break
1120
+ if mutation_col is None:
1121
+ raise ValueError("could not find a mutation column (e.g. 'variant' with tokens like V100F)")
1122
+
1123
+ def _numeric_count(f: str) -> int:
1124
+ c = 0
1125
+ for r in rows:
1126
+ v = r.get(f, "")
1127
+ if v in ("", None):
1128
+ continue
1129
+ try:
1130
+ float(v)
1131
+ c += 1
1132
+ except (TypeError, ValueError):
1133
+ return -1
1134
+ return c
1135
+
1136
+ if score_col is None:
1137
+ for cand in ("y_actual", "fitness", "activity", "measured", "score", "y_pred", "prediction", "pred"):
1138
+ if cand in low and _numeric_count(low[cand]) > 0:
1139
+ score_col = low[cand]
1140
+ break
1141
+ if score_col is None:
1142
+ raise ValueError("could not find a numeric score column (e.g. fitness/activity/y_pred)")
1143
+
1144
+ out = [(r[mutation_col], r[score_col]) for r in rows
1145
+ if r.get(mutation_col) and r.get(score_col) not in ("", None)]
1146
+ return out, mutation_col, score_col
1147
+
1148
+
1149
+ def cmd_hotspots(args: argparse.Namespace) -> int:
1150
+ """Scan->focus bridge: rank hotspot positions from a whole-protein single-mutant scan."""
1151
+ singles, mcol, scol = _read_singles(args.scan, args.mutation_col, args.score_col)
1152
+ hs = rank_hotspots(singles, top_k=args.top_k, metric=args.metric,
1153
+ baseline=args.baseline, min_effect=args.min_effect)
1154
+ if not hs:
1155
+ print("[hotspots] no positions ranked -- check the CSV (a mutation column like V100F "
1156
+ "+ a numeric score column)")
1157
+ return 1
1158
+ print(f"[hotspots] scan {args.scan}: mutation='{mcol}', score='{scol}', metric={args.metric}, "
1159
+ f"top {len(hs)} of the scanned positions")
1160
+ for h in hs:
1161
+ print(f" {h.wt}{h.position} best {h.best_sub} ({h.best_effect:.4g}) n_beneficial={h.n_beneficial}")
1162
+ positions = ",".join(str(h.position) for h in sorted(hs, key=lambda x: x.position))
1163
+ print(f"[hotspots] positions: {positions}")
1164
+ print(f"[hotspots] -> next: shellde round WT.fasta --positions {positions} --auto-msa "
1165
+ "(combinatorial AL over the hotspots: the candidate pool is multi-mutant, "
1166
+ "the fitted model is additive by default)")
1167
+ outdir = Path(args.outdir)
1168
+ outdir.mkdir(parents=True, exist_ok=True)
1169
+ (outdir / "hotspots.json").write_text(json.dumps({
1170
+ "scan": args.scan, "metric": args.metric, "positions": [h.position for h in hs],
1171
+ "hotspots": [{"position": h.position, "wt": h.wt, "best_sub": h.best_sub,
1172
+ "best_effect": h.best_effect, "n_beneficial": h.n_beneficial} for h in hs],
1173
+ }, indent=2))
1174
+ print(f"[hotspots] wrote {outdir / 'hotspots.json'}")
1175
+ return 0
1176
+
1177
+
1178
+ def _add_plm_args(p: argparse.ArgumentParser) -> None:
1179
+ """PLM/naturalness options for the Rk surrogate rerank (EVOLVEpro-style). Shared by campaign/round."""
1180
+ p.add_argument("--plm", default="none", choices=["none", "esmc", "esm2"],
1181
+ help="PLM embedding surrogate for the Rk plate (EVOLVEpro-style: refit on YOUR measured "
1182
+ "data over ESM embeddings, rerank the plate). GPU-recommended; opt-in")
1183
+ p.add_argument("--plm-model", dest="plm_model", default="", help="specific PLM model, e.g. esmc_600m")
1184
+ p.add_argument("--plm-pooling", dest="plm_pooling", default="auto", choices=["auto", "mean", "site"],
1185
+ help="embedding pooling; auto -> site for esmc_600m, else mean")
1186
+ p.add_argument("--plm-cache", dest="plm_cache", default=None,
1187
+ help="embedding cache path (reuse across rounds so ESM runs once per sequence)")
1188
+ p.add_argument("--plm-rerank", dest="plm_rerank", type=int, default=256,
1189
+ help="rerank the top-K one-hot candidates with the PLM surrogate (default 256)")
1190
+ p.add_argument("--naturalness", default="none", choices=["none", "esmc", "esm2"],
1191
+ help="zero-shot naturalness block for the Rk rerank")
1192
+ p.add_argument("--naturalness-cache", dest="naturalness_cache", default=None, help="naturalness cache path")
1193
+
1194
+ def build_parser() -> argparse.ArgumentParser:
1195
+ parser = argparse.ArgumentParser(
1196
+ prog="shellde",
1197
+ description="Low-N, acquisition-driven mutation recommendation (calibrated-additive default; "
1198
+ "pairwise epistasis is an opt-in on `recommend` only, CV-gated under "
1199
+ "--auto-gate-pairwise, while --contacts-pdb opens it with no gate)",
1200
+ )
1201
+ sub = parser.add_subparsers(dest="command", required=True)
1202
+
1203
+ rc = sub.add_parser("recommend", help="rank mutation candidates from a measured CSV")
1204
+ rc.add_argument("measured", help="CSV with variant,fitness columns")
1205
+ rc.add_argument("--outdir", default="outputs/recommend")
1206
+ rc.add_argument("--max-mut", dest="max_mut", type=int, default=3)
1207
+ rc.add_argument("--max-positions", dest="max_positions", type=int, default=None)
1208
+ rc.add_argument("--interaction-aware-positions", dest="interaction_aware", action="store_true",
1209
+ help="rank positions by interaction-aware importance (for epistatic targets)")
1210
+ rc.add_argument("--top", type=int, default=20)
1211
+ rc.add_argument("--per-count-k", dest="per_count_k", type=int, default=10)
1212
+ rc.add_argument("--model-class", dest="model_class", default="ensemble",
1213
+ choices=[*_SURROGATE],
1214
+ help="surrogate class (default ensemble = nested-CV best at low N); ridge/rf/ranking/"
1215
+ "global_epistasis are expert overrides for known regimes (the default needs no "
1216
+ "manual choice). CV auto-selection was pruned (not shown to beat the fixed default).")
1217
+ rc.add_argument("--beta", type=float, default=1.0, help="UCB exploration weight")
1218
+ rc.add_argument("--generated-cap", dest="generated_cap", type=int, default=200000)
1219
+ rc.add_argument("--scored-cap", dest="scored_cap", type=int, default=20000)
1220
+ rc.add_argument("--plm", default="none", choices=["none", "esmc", "esm2"],
1221
+ help="PLM front end for stage-2 re-rank (ESM-C is the N=95 lever; needs the SDK)")
1222
+ rc.add_argument("--plm-model", dest="plm_model", default="",
1223
+ help="PLM model id (e.g. esmc_300m, facebook/esm2_t30_150M_UR50D)")
1224
+ rc.add_argument("--plm-pooling", dest="plm_pooling", default="auto",
1225
+ choices=["auto", "mean", "site"],
1226
+ help="PLM embedding pooling: 'mean' (whole-seq), 'site' (design-position "
1227
+ "residues), or 'auto' (site for esmc_600m, else mean)")
1228
+ rc.add_argument("--wt-fasta", dest="wt_fasta", default=None,
1229
+ help="full WT background FASTA (required with --plm; design positions splice into it)")
1230
+ rc.add_argument("--plm-cache", dest="plm_cache", default=None,
1231
+ help="path to a .npz embedding cache (persisted across runs/rounds)")
1232
+ rc.add_argument("--plm-rerank", dest="plm_rerank", type=int, default=256,
1233
+ help="shortlist size the PLM surrogate re-scores (keep small: CPU PLM is slow)")
1234
+ rc.add_argument("--design-positions", dest="design_positions", default=None,
1235
+ help="comma-separated absolute 1-based residue positions (default 1..len); needed when variants are a non-contiguous subset")
1236
+ rc.add_argument("--naturalness", default="none", choices=["none", "esmc", "esm2"],
1237
+ help="zero-shot naturalness warm-start feature in stage-2 rerank (needs --wt-fasta + SDK)")
1238
+ rc.add_argument("--naturalness-cache", dest="naturalness_cache", default=None,
1239
+ help="path to a .npz naturalness-score cache")
1240
+ rc.add_argument("--auto-gate-pairwise", dest="auto_gate_pairwise", action="store_true",
1241
+ help="open the explicit-pairwise epistasis block only if k-fold CV shows it helps (data-gate)")
1242
+ rc.add_argument("--contacts-pdb", dest="contacts_pdb", default=None,
1243
+ help="PDB structure; restrict the pairwise epistasis block to Cbeta contacts "
1244
+ "among design positions (absolute residue numbers). NOTE: this OPENS the "
1245
+ "pairwise block unconditionally (no CV gate) unless --auto-gate-pairwise "
1246
+ "is also given")
1247
+ rc.add_argument("--contact-cutoff", dest="contact_cutoff", type=float, default=8.0,
1248
+ help="Cbeta-Cbeta contact distance cutoff in Angstrom (default 8.0)")
1249
+ rc.add_argument("--if-logprobs", dest="if_logprobs", default=None,
1250
+ help="JSON {position: {aa: logprob}} inverse-folding table (e.g. from ProteinMPNN); adds an IF score feature")
1251
+ rc.add_argument("--no-auto-signals", dest="auto_signals", action="store_false",
1252
+ help="expert override: include provided optional signals (--if-logprobs) UNCONDITIONALLY "
1253
+ "instead of CV-gating them. Default: the tool held-out-CV-tests each supplied signal "
1254
+ "and opens it only if it beats the additive baseline on this protein's data (abstains at low N)")
1255
+ rc.set_defaults(auto_signals=True)
1256
+ rc.add_argument("--recombine", action="store_true",
1257
+ help="add recombinations of MEASURED beneficials (variants that beat WT) to the "
1258
+ "candidate pool (Arnold-style; the strategy the real-data evidence supports)")
1259
+ rc.add_argument("--recombine-min-gain", dest="recombine_min_gain", type=float, default=0.0,
1260
+ help="only recombine mutations from variants whose fitness exceeds WT by at least this")
1261
+ rc.set_defaults(func=cmd_recommend)
1262
+
1263
+ ad = sub.add_parser("advise", help="readiness check on measured data: learned enough? open epistasis? proceed?")
1264
+ ad.add_argument("measured", help="CSV with variant,fitness columns (current accumulated data)")
1265
+ ad.add_argument("--design-positions", dest="design_positions", default=None,
1266
+ help="comma-separated absolute 1-based residue positions (default 1..len)")
1267
+ ad.set_defaults(func=cmd_advise)
1268
+
1269
+ fl = sub.add_parser(
1270
+ "funclib",
1271
+ help="FuncLib-style active-site library: tolerated+stable combinable multipoint mutants "
1272
+ "to MEASURE directly (design-space construction, NOT activity predictions)",
1273
+ )
1274
+ fl.add_argument("wt_fasta", help="full WT background FASTA (defines sequence + reference residues)")
1275
+ fl.add_argument("--outdir", default="outputs/funclib")
1276
+ fl.add_argument("--holo", default=None,
1277
+ help="HOLO PDB with a ligand pose; design region = active-site shell(s) by contact topology")
1278
+ fl.add_argument("--ligand-resnames", dest="ligand_resnames", default=None,
1279
+ help="comma-separated HETATM resnames to treat as the ligand (else auto; additives blocklisted)")
1280
+ fl.add_argument("--shell", default="2", choices=["1", "2", "both"],
1281
+ help="active-site shell to design over (default 2 = tunable ring; 1 = catalytic core, usually lethal to saturate)")
1282
+ fl.add_argument("--contact-cutoff", dest="contact_cutoff", type=float, default=4.5,
1283
+ help="ligand/residue heavy-atom contact cutoff in Angstrom for shells (default 4.5)")
1284
+ fl.add_argument("--positions", default=None,
1285
+ help="explicit comma-separated absolute 1-based positions (overrides --holo)")
1286
+ fl.add_argument("--ddg", default=None,
1287
+ help="ddG table (JSON {position:{aa:ddG}} or CSV/TSV with position+aa+ddG or mutation token); stability gate")
1288
+ fl.add_argument("--tolerance", default=None,
1289
+ help="JSON tolerance: {token: score} (MSA PSSM / PLM zero-shot) or {position: {aa: logp}} (auto-detected)")
1290
+ fl.add_argument("--if-logprobs", dest="if_logprobs", default=None,
1291
+ help="JSON inverse-folding {position: {aa: logp}} used as the tolerance signal (log-odds vs WT)")
1292
+ fl.add_argument("--msa", default=None,
1293
+ help="a3m MSA file; Henikoff-weighted PSSM log-odds as the tolerance signal "
1294
+ "(evolutionary conservation; GPU-free alternative to PLM, comparable to "
1295
+ "ESM across 217 ProteinGym DMS, not a proven improvement)")
1296
+ fl.add_argument("--ddg-cutoff", dest="ddg_cutoff", type=float, default=2.5,
1297
+ help="keep mutations with folding ddG <= this (kcal/mol; default 2.5)")
1298
+ fl.add_argument("--tolerance-cutoff", dest="tolerance_cutoff", type=float, default=0.0,
1299
+ help="keep mutations with tolerance score >= this (default 0.0)")
1300
+ fl.add_argument("--per-position-cap", dest="per_position_cap", type=int, default=4,
1301
+ help="keep at most this many tolerated+stable AAs per position (default 4)")
1302
+ fl.add_argument("--min-mut", dest="min_mut", type=int, default=1)
1303
+ fl.add_argument("--max-mut", dest="max_mut", type=int, default=4,
1304
+ help="max mutated positions per combo (FuncLib designs carry ~3-6; default 4)")
1305
+ fl.add_argument("--additive-ddg-budget", dest="additive_ddg_budget", type=float, default=None,
1306
+ help="drop combos whose summed single-mutation ddG exceeds this (additive destabilization proxy)")
1307
+ fl.add_argument("--max-library", dest="max_library", type=int, default=2000,
1308
+ help="keep at most this many combos in the final library (default 2000)")
1309
+ fl.add_argument("--allow-unscored", dest="allow_unscored", action="store_true",
1310
+ help="treat mutations missing from the ddG table as passing the stability gate (ddG=0)")
1311
+ fl.set_defaults(func=cmd_funclib)
1312
+
1313
+ cp = sub.add_parser(
1314
+ "campaign",
1315
+ help="funclib R0 seed -> active-learning rounds (the validated handoff); "
1316
+ "--simulate to reproduce/validate against a measured landscape",
1317
+ )
1318
+ cp.add_argument("wt_fasta", help="full WT background FASTA (defines sequence + reference residues)")
1319
+ cp.add_argument("--outdir", default="outputs/campaign")
1320
+ cp.add_argument("--simulate", default=None,
1321
+ help="variant,fitness landscape CSV: reproduce the funclib->AL handoff against it "
1322
+ "(design --positions must match the landscape's per-position combo encoding)")
1323
+ cp.add_argument("--measured", nargs="*", default=None,
1324
+ help="live mode: accumulated variant,fitness CSV(s). None -> emit the funclib R0 seed plate")
1325
+ cp.add_argument("--strategies", nargs="+", default=["funclib", "random"],
1326
+ choices=["funclib", "singles", "random"],
1327
+ help="simulate arms to compare (default funclib vs random)")
1328
+ cp.add_argument("--plate", type=int, default=95, help="variants measured per round (default 95)")
1329
+ cp.add_argument("--rounds", type=int, default=3, help="active-learning rounds after the seed (default 3)")
1330
+ cp.add_argument("--seeds", type=int, default=20, help="RNG seeds per strategy in --simulate (default 20)")
1331
+ cp.add_argument("--jobs", type=int, default=1,
1332
+ help="parallel processes for --simulate seeds (default 1 = serial; >1 = byte-identical, "
1333
+ "for batch evidence/bench runs). Threads do not help (GIL-bound loop body)")
1334
+ cp.add_argument("--beta", type=float, default=0.0,
1335
+ help="acquisition: 0 = greedy exploitation (D25 default), >0 = UCB exploration weight")
1336
+ cp.add_argument("--model-class", dest="model_class", default="ensemble", choices=[*_SURROGATE],
1337
+ help="surrogate class for the AL rounds (default ensemble)")
1338
+ # design region + seed signals (shared with funclib)
1339
+ cp.add_argument("--positions", default=None,
1340
+ help="explicit comma-separated 1-based positions (required for --simulate to match the landscape)")
1341
+ cp.add_argument("--holo", default=None, help="HOLO PDB with a ligand pose; design region = active-site shell(s)")
1342
+ cp.add_argument("--ligand-resnames", dest="ligand_resnames", default=None,
1343
+ help="comma-separated HETATM resnames for the ligand (else auto)")
1344
+ cp.add_argument("--shell", default="2", choices=["1", "2", "both"], help="active-site shell to design over")
1345
+ cp.add_argument("--contact-cutoff", dest="contact_cutoff", type=float, default=4.5,
1346
+ help="ligand/residue heavy-atom contact cutoff for shells (Angstrom)")
1347
+ cp.add_argument("--ddg", default=None, help="ddG table (stability gate for the funclib seed)")
1348
+ cp.add_argument("--tolerance", default=None, help="JSON tolerance ({token:score} or {pos:{aa:logp}})")
1349
+ cp.add_argument("--if-logprobs", dest="if_logprobs", default=None,
1350
+ help="JSON inverse-folding {position:{aa:logp}} (tolerance signal + AL IF feature). "
1351
+ "NOTE: on `campaign` the AL IF feature is added UNCONDITIONALLY, with no CV "
1352
+ "gate -- unlike `recommend`, which held-out-CV-tests it first")
1353
+ cp.add_argument("--msa", default=None, help="a3m MSA file; PSSM log-odds tolerance signal for the seed")
1354
+ cp.add_argument("--auto-msa", dest="auto_msa", action="store_true",
1355
+ help="if --msa is not given, build the seed MSA from the WT sequence via the "
1356
+ "ColabFold MMseqs2 API (GPU-free; network). Abstains gracefully if unreachable")
1357
+ cp.add_argument("--ddg-cutoff", dest="ddg_cutoff", type=float, default=2.5)
1358
+ cp.add_argument("--tolerance-cutoff", dest="tolerance_cutoff", type=float, default=0.0)
1359
+ cp.add_argument("--per-position-cap", dest="per_position_cap", type=int, default=4)
1360
+ cp.add_argument("--min-mut", dest="min_mut", type=int, default=1)
1361
+ cp.add_argument("--max-mut", dest="max_mut", type=int, default=4)
1362
+ cp.add_argument("--additive-ddg-budget", dest="additive_ddg_budget", type=float, default=None)
1363
+ cp.add_argument("--allow-unscored", dest="allow_unscored", action="store_true")
1364
+ cp.add_argument("--stability-constraint", dest="stability_constraint", action="store_true",
1365
+ help="simulate: restrict the AL universe to variants passing the --ddg stability gate "
1366
+ "throughout (predicted-stability constraint + measured-activity objective = the "
1367
+ "honest multi-objective decomposition); reports if the activity winner survives")
1368
+ cp.add_argument("--generated-cap", dest="generated_cap", type=int, default=200000)
1369
+ cp.add_argument("--scored-cap", dest="scored_cap", type=int, default=20000)
1370
+ cp.add_argument("--wt-fitness", dest="wt_fitness", type=float, default=None,
1371
+ help="known (normalised) WT activity, e.g. 1.0 under WT-normalisation. WT is not "
1372
+ "on the plate; this anchors the 'beats WT' baseline exactly instead of "
1373
+ "predicting it. Injected as the WT combo in the measured set")
1374
+ _add_plm_args(cp)
1375
+ cp.set_defaults(func=cmd_campaign)
1376
+
1377
+ rs = sub.add_parser(
1378
+ "resolve-site",
1379
+ help="resolve active-site/binding positions from UniProt experimental annotation "
1380
+ "(alignment-mapped to your WT numbering); abstains when no curated evidence maps",
1381
+ )
1382
+ rs.add_argument("wt_fasta", help="WT FASTA whose numbering the resolved positions are mapped to")
1383
+ rs.add_argument("--uniprot", default=None,
1384
+ help="UniProt accession, e.g. Q50L36 (most reliable); or 'auto' to find the "
1385
+ "accession by EXACT sequence match against UniProt (abstains if no exact match)")
1386
+ rs.add_argument("--query", default=None,
1387
+ help="UniProt text search, e.g. 'isoprene synthase Populus' (top hit used)")
1388
+ rs.add_argument("--min-coverage", dest="min_coverage", type=float, default=0.6,
1389
+ help="abstain if WT aligns to less than this fraction of the UniProt sequence (wrong entry guard)")
1390
+ rs.add_argument("--timeout", type=float, default=20.0, help="network timeout seconds")
1391
+ rs.add_argument("--pdb", default=None,
1392
+ help="force the structure path: an RCSB PDB ID or a LOCAL structure file "
1393
+ "(cocrystal, or an AlphaFold model with a docked ligand)")
1394
+ rs.add_argument("--find-holo", dest="find_holo", action="store_true",
1395
+ help="Foldseek-search for a ligand-bound (holo) structural homolog and list candidate "
1396
+ "PDB ids (with their ligands) to feed back via --pdb; fills the apo/no-ligand gap")
1397
+ rs.add_argument("--exclude-catalytic", dest="exclude_catalytic", action="store_true",
1398
+ help="when BOTH --pdb and --uniprot are given: design over the PDB shell MINUS the "
1399
+ "UniProt catalytic core (the tunable 2nd-shell ring), instead of flagging it")
1400
+ rs.add_argument("--no-pdb-fallback", dest="no_pdb_fallback", action="store_true",
1401
+ help="do not fall back to UniProt-linked PDB cocrystals when features do not map")
1402
+ rs.add_argument("--max-pdb", dest="max_pdb", type=int, default=5,
1403
+ help="max UniProt-linked PDB cocrystals to try in the fallback")
1404
+ rs.add_argument("--no-alphafold", dest="no_alphafold", action="store_true",
1405
+ help="do not fetch an AlphaFold model when no experimental evidence is available")
1406
+ rs.add_argument("--apo-pocket", dest="apo_pocket", action="store_true",
1407
+ help="LOW-confidence: run fpocket on the fetched AlphaFold (apo) model to guess pocket "
1408
+ "positions. Can miss the catalytic site (it did on ispS); verify. Needs fpocket.")
1409
+ rs.add_argument("--fpocket", default="fpocket", help="fpocket binary path (for --apo-pocket)")
1410
+ rs.add_argument("--ligand-resnames", dest="ligand_resnames", default=None,
1411
+ help="comma-separated HETATM ligand resnames for shell detection (else auto)")
1412
+ rs.add_argument("--contact-cutoff", dest="contact_cutoff", type=float, default=4.5,
1413
+ help="ligand/residue heavy-atom contact cutoff (Angstrom) for PDB shells")
1414
+ rs.add_argument("--outdir", default="outputs/resolve_site")
1415
+ rs.set_defaults(func=cmd_resolve_site)
1416
+
1417
+ rd = sub.add_parser(
1418
+ "round",
1419
+ help="one command per round: auto-resolve active-site positions then emit the next plate "
1420
+ "(no --measured -> R0 funclib seed; --measured -> Rk AL plate). The wet-lab front door.",
1421
+ )
1422
+ rd.add_argument("wt_fasta", help="full WT background FASTA")
1423
+ rd.add_argument("--measured", nargs="*", default=None,
1424
+ help="accumulated variant,fitness CSV(s); omit for the R0 seed plate, add each "
1425
+ "round's measured results to advance (R1, R2, ...)")
1426
+ rd.add_argument("--outdir", default="outputs/round")
1427
+ # design region: pick one (the auto ladder is the easy path)
1428
+ rd.add_argument("--uniprot", default=None,
1429
+ help="UniProt accession (e.g. Q50L36) -> auto active-site positions; or 'auto' "
1430
+ "to match the accession from the WT sequence exactly (abstains if none)")
1431
+ rd.add_argument("--query", default=None, help="UniProt text search (top hit); auto active-site positions")
1432
+ rd.add_argument("--pdb", default=None, help="RCSB PDB ID or local holo structure; active-site shell (aligned to WT)")
1433
+ rd.add_argument("--find-holo", dest="find_holo", action="store_true",
1434
+ help="no cocrystal? Foldseek-find a ligand-bound (holo) homolog of the WT (via its "
1435
+ "AlphaFold model or --pdb) and use it as the design region automatically")
1436
+ rd.add_argument("--exclude-catalytic", dest="exclude_catalytic", action="store_true",
1437
+ help="with both --pdb and --uniprot: design the tunable ring (PDB shell minus the "
1438
+ "UniProt catalytic core) instead of the full shell")
1439
+ rd.add_argument("--holo", default=None, help="local HOLO PDB with a ligand pose; active-site shell by contact")
1440
+ rd.add_argument("--positions", default=None, help="explicit comma-separated 1-based positions (skips auto-resolve)")
1441
+ # seed signals (optional but recommended for a strong R0)
1442
+ rd.add_argument("--msa", default=None, help="a3m MSA; PSSM tolerance signal for the funclib seed (GPU-free)")
1443
+ rd.add_argument("--auto-msa", dest="auto_msa", action="store_true",
1444
+ help="build the R0 seed MSA from the WT sequence via ColabFold MMseqs2 when no "
1445
+ "--msa is given (GPU-free; network) -- lets R0 run from just WT + region")
1446
+ rd.add_argument("--ddg", default=None, help="folding ddG table; stability gate for the seed")
1447
+ rd.add_argument("--if-logprobs", dest="if_logprobs", default=None,
1448
+ help="inverse-folding {pos:{aa:logp}}; tolerance signal for the seed + AL IF feature. "
1449
+ "NOTE: `round` runs the campaign round, which adds the AL IF feature "
1450
+ "UNCONDITIONALLY, with no CV gate -- unlike `recommend`")
1451
+ rd.add_argument("--tolerance", default=None, help="explicit tolerance JSON (alternative to --msa/--if-logprobs)")
1452
+ rd.add_argument("--ligand-resnames", dest="ligand_resnames", default=None,
1453
+ help="HETATM ligand resnames for shell detection (else auto)")
1454
+ # plate + model knobs (validated defaults; usually leave alone)
1455
+ rd.add_argument("--plate", type=int, default=95, help="variants per plate (default 95)")
1456
+ rd.add_argument("--max-mut", dest="max_mut", type=int, default=4, help="max mutated positions per combo")
1457
+ rd.add_argument("--beta", type=float, default=0.0, help="acquisition: 0 greedy (default), >0 UCB weight")
1458
+ rd.add_argument("--model-class", dest="model_class", default="ensemble", choices=[*_SURROGATE],
1459
+ help="AL surrogate class (default ensemble)")
1460
+ rd.add_argument("--min-coverage", dest="min_coverage", type=float, default=0.6,
1461
+ help="abstain if WT aligns to less than this fraction of the reference (wrong-entry guard)")
1462
+ rd.add_argument("--timeout", type=float, default=20.0, help="network timeout seconds (auto-resolve)")
1463
+ rd.add_argument("--wt-fitness", dest="wt_fitness", type=float, default=None,
1464
+ help="known (normalised) WT activity (e.g. 1.0) to anchor the 'beats WT' baseline "
1465
+ "exactly; WT stays off the plate")
1466
+ _add_plm_args(rd)
1467
+ rd.set_defaults(
1468
+ func=cmd_round,
1469
+ simulate=None, strategies=["funclib", "random"], stability_constraint=False,
1470
+ seeds=20, jobs=1, rounds=3, generated_cap=200000, scored_cap=20000,
1471
+ ddg_cutoff=2.5, tolerance_cutoff=0.0, per_position_cap=4, min_mut=1,
1472
+ additive_ddg_budget=None, allow_unscored=False, shell="2", contact_cutoff=4.5,
1473
+ max_pdb=5, no_pdb_fallback=False, no_alphafold=False, apo_pocket=False, fpocket="fpocket",
1474
+ exclude_catalytic=False,
1475
+ )
1476
+ b = sub.add_parser("bench", help="synthetic alpha-spectrum tool-comparative benchmark")
1477
+ b.add_argument("--outdir", default="outputs/bench")
1478
+ b.add_argument("--n-sites", dest="n_sites", type=int, default=5)
1479
+ b.add_argument("--q", type=int, default=4, help="alphabet size (first q amino acids)")
1480
+ b.add_argument("--alphas", type=float, nargs="+", default=[0.0, 0.5, 1.0, 2.0])
1481
+ b.add_argument("--seeds", type=int, default=20)
1482
+ b.add_argument("--n-init", dest="n_init", type=int, default=95)
1483
+ b.add_argument("--batch-size", dest="batch_size", type=int, default=95)
1484
+ b.add_argument("--n-rounds", dest="n_rounds", type=int, default=3)
1485
+ b.set_defaults(func=cmd_bench)
1486
+
1487
+ r = sub.add_parser("report", help="summarise a bench spectrum or recommend report JSON")
1488
+ r.add_argument("path", help="path to spectrum.json or report.json")
1489
+ r.set_defaults(func=cmd_report)
1490
+ hp = sub.add_parser(
1491
+ "hotspots",
1492
+ help="whole-protein single-mutant SCAN -> top hotspot positions for combinatorial AL "
1493
+ "(the scan->focus bridge; feed the positions to `round`/`campaign`)",
1494
+ )
1495
+ hp.add_argument("scan", help="single-mutant CSV: a mutation column (e.g. V100F) + a score column "
1496
+ "(measured fitness/activity, or a predicted y_pred/score)")
1497
+ hp.add_argument("--top-k", dest="top_k", type=int, default=6, help="number of hotspot positions to return")
1498
+ hp.add_argument("--metric", default="max", choices=["max", "mean", "count"],
1499
+ help="rank positions by best single ('max', default), average ('mean'), or count of "
1500
+ "beneficials above --baseline ('count')")
1501
+ hp.add_argument("--baseline", type=float, default=0.0, help="score above which a single counts as beneficial")
1502
+ hp.add_argument("--min-effect", dest="min_effect", type=float, default=None,
1503
+ help="drop positions whose best single scores below this")
1504
+ hp.add_argument("--mutation-col", dest="mutation_col", default=None, help="mutation column name (else auto)")
1505
+ hp.add_argument("--score-col", dest="score_col", default=None, help="score column name (else auto)")
1506
+ hp.add_argument("--outdir", default="outputs/hotspots")
1507
+ hp.set_defaults(func=cmd_hotspots)
1508
+ return parser
1509
+
1510
+
1511
+ def main(argv: list[str] | None = None) -> int:
1512
+ args = build_parser().parse_args(argv)
1513
+ return int(args.func(args))
1514
+
1515
+
1516
+ if __name__ == "__main__":
1517
+ sys.exit(main())