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/campaign.py ADDED
@@ -0,0 +1,257 @@
1
+ """funclib -> active-learning campaign orchestrator (the validated D18->D19 handoff).
2
+
3
+ This wires the two halves the tool already ships into one flow:
4
+
5
+ R0 (cold start): ``funclib.design_library`` proposes the active-site measurement library
6
+ -- the seed plate -- using only zero-shot / structure signals (no fitness yet). This is
7
+ where prediction is irreplaceable: with zero measurements the AL surrogate is blind.
8
+
9
+ R1.. (engine): measurement-driven active learning takes over. Each round re-fits a fresh
10
+ surrogate on ALL accumulated measurements and proposes the next plate over the FULL
11
+ saturation universe of the design positions -- NOT hard-restricted to the funclib
12
+ library (D19: the non-natural winner is frequently outside the seed; restricting the
13
+ candidate space caps you below it). funclib only SEEDS; AL explores past it.
14
+
15
+ ``simulate_campaign`` runs this against a lookup oracle (a measured landscape CSV) so the
16
+ handoff can be validated/reproduced (D19/D25). The live R0 plate generation lives in the CLI
17
+ (``shellde campaign`` without ``--simulate``).
18
+
19
+ Honest scope: this does not predict winners. It increases the probability/efficiency of
20
+ *finding* a better variant per experimental round, conditional on the winner staying in the
21
+ saturation universe and the active-site framing being right (see D19/D25/D27).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import statistics
26
+ from collections.abc import Callable, Mapping, Sequence
27
+ from dataclasses import dataclass
28
+
29
+ import numpy as np
30
+
31
+ from shellde.acquisition import Greedy, UCB
32
+ from shellde.design_space import DesignSpace
33
+ from shellde.features.defaults import default_blocks
34
+ from shellde.features.matrix import FeatureMatrix
35
+ from shellde.funclib import FuncLibLibrary, design_library
36
+ from shellde.loop import run_campaign
37
+ from shellde.oracle import DMSLookupOracle
38
+ from shellde.protocols import Surrogate
39
+ from shellde.surrogate import (
40
+ EnsembleSurrogate,
41
+ GlobalEpistasisSurrogate,
42
+ RankingSurrogate,
43
+ RFSurrogate,
44
+ RidgeSurrogate,
45
+ )
46
+
47
+ _SURROGATE_CLASSES = {
48
+ "ridge": RidgeSurrogate, "ensemble": EnsembleSurrogate, "rf": RFSurrogate,
49
+ "ranking": RankingSurrogate, "global_epistasis": GlobalEpistasisSurrogate,
50
+ }
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class SurrogateSpec:
55
+ """Picklable surrogate factory (a class+kwargs, unlike a lambda) so simulate_campaign can run
56
+ across processes. Callable like the lambdas the serial path also accepts."""
57
+
58
+ name: str
59
+ random_state: int = 0
60
+
61
+ def __call__(self) -> Surrogate:
62
+ return _SURROGATE_CLASSES[self.name](random_state=self.random_state)
63
+
64
+
65
+ def funclib_seed_variants(
66
+ space: DesignSpace,
67
+ *,
68
+ plate: int,
69
+ ddg: Mapping[int, Mapping[str, float]] | None = None,
70
+ tolerance: Mapping[str, float] | None = None,
71
+ **design_kwargs: object,
72
+ ) -> tuple[list[str], FuncLibLibrary]:
73
+ """Build the funclib R0 seed plate: top-``plate`` combinable library variants.
74
+
75
+ Returns (combo strings sorted by funclib combo score, the FuncLibLibrary for provenance).
76
+ On abstain (no signal / everything filtered) the variant list is empty.
77
+ """
78
+ lib = design_library(space, ddg=ddg, tolerance=tolerance, **design_kwargs) # type: ignore[arg-type]
79
+ variants = [c.variant for c in lib.candidates[:plate]]
80
+ return variants, lib
81
+
82
+
83
+ def single_mutant_variants(space: DesignSpace) -> list[str]:
84
+ """All single-substitution combo strings over the design positions (EVOLVEpro-style seed)."""
85
+ wt = space.wt()
86
+ out: list[str] = []
87
+ for i in range(space.n_positions):
88
+ for aa in space.alphabet:
89
+ if aa == wt[i]:
90
+ continue
91
+ out.append(wt[:i] + aa + wt[i + 1 :])
92
+ return out
93
+
94
+
95
+ def stable_variants(
96
+ variants: Sequence[str],
97
+ space: DesignSpace,
98
+ ddg: Mapping[int, Mapping[str, float]],
99
+ *,
100
+ ddg_cutoff: float = 2.5,
101
+ additive_budget: float | None = None,
102
+ allow_unscored: bool = False,
103
+ ) -> list[str]:
104
+ """Keep combo variants whose every mutation passes the folding-ddG stability gate.
105
+
106
+ A mutation at position p->aa passes iff ``ddg[p][aa] <= ddg_cutoff``; WT residues contribute 0.
107
+ If ``additive_budget`` is set, the summed single-mutation ddG must also be <= the budget
108
+ (the validated additive-destabilization proxy, D16). This makes predicted stability a PERSISTENT
109
+ constraint across the whole campaign -- the honest multi-objective decomposition for the
110
+ asymmetric-data regime (D14/D15: stability is predictable -> constraint; activity needs
111
+ measurement -> AL objective), not a two-measured-objective Pareto.
112
+ """
113
+ wt = space.wt()
114
+ out: list[str] = []
115
+ for v in variants:
116
+ total = 0.0
117
+ ok = True
118
+ for i, pos in enumerate(space.positions):
119
+ aa = v[i]
120
+ if aa == wt[i]:
121
+ continue
122
+ d = ddg.get(pos, {}).get(aa)
123
+ if d is None:
124
+ if allow_unscored:
125
+ continue
126
+ ok = False
127
+ break
128
+ if d > ddg_cutoff:
129
+ ok = False
130
+ break
131
+ total += d
132
+ if ok and (additive_budget is None or total <= additive_budget):
133
+ out.append(v)
134
+ return out
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class SimulationResult:
139
+ """Aggregate of a multi-seed simulated campaign against a lookup oracle."""
140
+
141
+ strategy: str
142
+ n_seeds: int
143
+ winner_reach_rate: float
144
+ rounds_to_winner_median: float | None
145
+ best_found_mean: float
146
+ best_found_percentile_mean: float
147
+ per_seed: list[dict]
148
+
149
+
150
+ def _percentile_of(value: float, sorted_fitness: np.ndarray) -> float:
151
+ """Fraction of landscape variants with fitness <= ``value`` (empirical percentile)."""
152
+ return float(np.searchsorted(sorted_fitness, value, side="right")) / float(len(sorted_fitness))
153
+
154
+
155
+ _WORKER_STATE: dict = {}
156
+
157
+
158
+ def _seed_result(
159
+ s: int, oracle: DMSLookupOracle, space: DesignSpace, seed_variants, make_surrogate,
160
+ blocks, beta: float, plate: int, rounds: int,
161
+ ) -> tuple[list[float], str, float]:
162
+ """One independent seeded campaign -> (trajectory, best_variant, best_fitness). Deterministic in s."""
163
+ acquisition = Greedy() if beta <= 0.0 else UCB(beta)
164
+ res = run_campaign(
165
+ oracle, space,
166
+ make_matrix=lambda: FeatureMatrix(space, blocks(space)),
167
+ make_surrogate=make_surrogate, acquisition=acquisition,
168
+ n_init=plate, batch_size=plate, n_rounds=rounds, seed=s,
169
+ init_variants=list(seed_variants) if seed_variants is not None else None,
170
+ )
171
+ return [float(x) for x in res.trajectory], res.best_variant, float(res.best_fitness)
172
+
173
+
174
+ def _init_worker(table, space, seed_variants, make_surrogate, blocks, beta, plate, rounds) -> None:
175
+ _WORKER_STATE.clear()
176
+ _WORKER_STATE.update(
177
+ oracle=DMSLookupOracle(table), space=space, seed_variants=seed_variants,
178
+ make_surrogate=make_surrogate, blocks=blocks, beta=beta, plate=plate, rounds=rounds,
179
+ )
180
+
181
+
182
+ def _seed_worker(s: int) -> tuple[list[float], str, float]:
183
+ w = _WORKER_STATE
184
+ return _seed_result(s, w["oracle"], w["space"], w["seed_variants"], w["make_surrogate"],
185
+ w["blocks"], w["beta"], w["plate"], w["rounds"])
186
+
187
+
188
+ def simulate_campaign(
189
+ table: Mapping[str, float],
190
+ space: DesignSpace,
191
+ *,
192
+ seed_variants: Sequence[str] | None,
193
+ strategy: str,
194
+ plate: int = 95,
195
+ rounds: int = 3,
196
+ n_seeds: int = 20,
197
+ beta: float = 0.0,
198
+ make_surrogate: Callable[[], Surrogate],
199
+ make_blocks: Callable[[DesignSpace], list] | None = None,
200
+ winner_tol: float = 1e-9,
201
+ n_jobs: int = 1,
202
+ ) -> SimulationResult:
203
+ """Run the funclib->AL handoff against a measured landscape (oracle = lookup table).
204
+
205
+ ``seed_variants`` is the R0 seed (funclib library or singles); ``None`` = random R0.
206
+ The AL pool is the full landscape universe each round, so the loop explores past the seed.
207
+ ``beta``=0 is greedy exploitation (D25: greedy was not beaten by UCB on a good seed); >0 is UCB.
208
+ """
209
+ blocks = make_blocks or default_blocks
210
+ # Each seed is an independent, deterministic campaign (run_campaign(seed=s)). Serial by default;
211
+ # n_jobs>1 runs them across processes (NOT threads: the loop body is Python/GIL-bound). The
212
+ # aggregate is collected in seed order and percentiles/winner are computed centrally, so a
213
+ # parallel run is byte-identical to serial. Parallel needs picklable make_surrogate/make_blocks
214
+ # (use SurrogateSpec / the default blocks), not lambdas.
215
+ if n_jobs and n_jobs > 1 and n_seeds > 1:
216
+ import multiprocessing as _mp
217
+ from concurrent.futures import ProcessPoolExecutor
218
+ # spawn (not fork): the parent may hold BLAS threads, and fork()-with-threads can deadlock.
219
+ with ProcessPoolExecutor(
220
+ max_workers=min(n_jobs, n_seeds),
221
+ mp_context=_mp.get_context("spawn"),
222
+ initializer=_init_worker,
223
+ initargs=(dict(table), space, seed_variants, make_surrogate, blocks, beta, plate, rounds),
224
+ ) as ex:
225
+ raw = list(ex.map(_seed_worker, range(n_seeds)))
226
+ else:
227
+ oracle = DMSLookupOracle(table)
228
+ raw = [_seed_result(s, oracle, space, seed_variants, make_surrogate, blocks, beta, plate, rounds)
229
+ for s in range(n_seeds)]
230
+
231
+ fitness = np.asarray(list(table.values()), dtype=float)
232
+ sorted_fitness = np.sort(fitness)
233
+ global_max = float(fitness.max())
234
+ per_seed: list[dict] = []
235
+ for s, (traj, best_variant, best_fitness) in enumerate(raw):
236
+ # trajectory[0] is best-after-seed (R0); trajectory[k] is best after AL round k.
237
+ reached = next((i for i, b in enumerate(traj) if b >= global_max - winner_tol), None)
238
+ per_seed.append({
239
+ "rng_seed": s,
240
+ "best_found": best_fitness,
241
+ "best_variant": best_variant,
242
+ "best_percentile": _percentile_of(best_fitness, sorted_fitness),
243
+ "winner_reached": reached is not None,
244
+ "rounds_to_winner": reached,
245
+ "trajectory": traj,
246
+ })
247
+
248
+ reaches = [p["rounds_to_winner"] for p in per_seed if p["winner_reached"]]
249
+ return SimulationResult(
250
+ strategy=strategy,
251
+ n_seeds=n_seeds,
252
+ winner_reach_rate=sum(1 for p in per_seed if p["winner_reached"]) / n_seeds,
253
+ rounds_to_winner_median=(statistics.median(reaches) if reaches else None),
254
+ best_found_mean=float(np.mean([p["best_found"] for p in per_seed])),
255
+ best_found_percentile_mean=float(np.mean([p["best_percentile"] for p in per_seed])),
256
+ per_seed=per_seed,
257
+ )
shellde/candidates.py ADDED
@@ -0,0 +1,341 @@
1
+ """Candidate generation: pick positions, then lazily enumerate up-to-N combos.
2
+
3
+ Two public entry points:
4
+ - ``select_positions``: narrow a large design space to the top-m positions, by
5
+ additive main-effect spread (cheap) or an interaction-aware Sobol total-effect
6
+ estimate (for epistatic targets; additive importance misses interaction-only
7
+ positions).
8
+ - ``generate_candidates``: lazily enumerate high-value up-to-``max_mut`` mutation
9
+ combinations under hard caps (never iterates the Cartesian product), add a random
10
+ exploration quota so non-additive optima are reachable, then prefilter to a
11
+ scorable pool with the calibrated surrogate. Returns a ``CandidatePool`` carrying
12
+ the surrogate Prediction so downstream ranking does not re-predict.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import heapq
17
+ import itertools
18
+ import time
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass, field
21
+
22
+ import numpy as np
23
+
24
+ from shellde.design_space import DesignSpace
25
+ from shellde.features.matrix import FeatureMatrix
26
+ from shellde.protocols import Surrogate
27
+ from shellde.types import Prediction
28
+
29
+ GENERATED_CAP = 200_000
30
+ SCORED_CAP = 20_000
31
+ DEFAULT_M = 15
32
+ DEFAULT_MAX_MUT = 5
33
+ DEFAULT_EXPLORE_FRAC = 0.2
34
+ DEFAULT_BETA = 1.0
35
+ DEFAULT_TIME_BUDGET_S = 60.0
36
+
37
+
38
+ @dataclass
39
+ class CandidatePool:
40
+ """Prefiltered candidate variants + the surrogate prediction on them."""
41
+
42
+ variants: list[str]
43
+ pred: Prediction
44
+ mut_count: list[int]
45
+ pool_positions: list[int]
46
+ stats: dict = field(default_factory=dict)
47
+
48
+
49
+ # --------------------------------------------------------------------------- #
50
+ # Position selection
51
+ # --------------------------------------------------------------------------- #
52
+ def _additive_scores(surrogate: Surrogate, matrix: FeatureMatrix) -> np.ndarray:
53
+ """Per-(position, AA) additive main-effect, shape (n_positions, q); zeros if no onehot."""
54
+ space = matrix.space
55
+ npos, q = space.n_positions, space.q
56
+ coef = surrogate.main_effects()
57
+ oh = next((se for name, *se in matrix.column_layout() if name == "onehot"), None)
58
+ if coef is None or oh is None:
59
+ return np.zeros((npos, q), dtype=float)
60
+ start, end = oh
61
+ block = np.asarray(coef, dtype=float)[start:end]
62
+ if block.size != npos * q:
63
+ return np.zeros((npos, q), dtype=float)
64
+ return block.reshape(npos, q)
65
+
66
+
67
+ def _interaction_importance(
68
+ surrogate: Surrogate, matrix: FeatureMatrix, *, n_backgrounds: int = 64, seed: int = 0
69
+ ) -> np.ndarray:
70
+ """Sobol-style total-effect importance per position via the full surrogate.
71
+
72
+ Vary each position over the alphabet on RANDOM backgrounds and average the
73
+ prediction variance across residues; random backgrounds make the estimate
74
+ include interaction effects (a position important only through epistasis still
75
+ scores high), unlike the additive main-effect spread.
76
+ """
77
+ space = matrix.space
78
+ npos, q = space.n_positions, space.q
79
+ alphabet = space.alphabet
80
+ rng = np.random.default_rng(seed)
81
+ nb = min(n_backgrounds, max(1, q**npos))
82
+ bg = rng.integers(0, q, size=(nb, npos))
83
+ imp = np.zeros(npos, dtype=float)
84
+ for j in range(npos):
85
+ preds = np.zeros((nb, q), dtype=float)
86
+ for ai in range(q):
87
+ variants = []
88
+ for b in range(nb):
89
+ chars = [alphabet[int(bg[b, k])] for k in range(npos)]
90
+ chars[j] = alphabet[ai]
91
+ variants.append("".join(chars))
92
+ preds[:, ai] = np.asarray(surrogate.predict(matrix.encode(variants)).mean, dtype=float)
93
+ imp[j] = float(np.mean(preds.var(axis=1)))
94
+ return imp
95
+
96
+
97
+ def select_positions(
98
+ surrogate: Surrogate,
99
+ matrix: FeatureMatrix,
100
+ *,
101
+ m: int,
102
+ interaction_aware: bool = False,
103
+ seed: int = 0,
104
+ ) -> list[int]:
105
+ """Top-m designed positions by importance.
106
+
107
+ Default importance is the additive main-effect spread (cheap, but MISSES a
108
+ position that matters only through epistasis). ``interaction_aware=True`` ranks by
109
+ a Sobol total-effect estimate over the full surrogate. ``m >= n_positions`` keeps
110
+ all positions (the safe choice for small combinatorial design spaces).
111
+ """
112
+ space = matrix.space
113
+ if interaction_aware:
114
+ importance = _interaction_importance(surrogate, matrix, seed=seed)
115
+ else:
116
+ importance = _additive_scores(surrogate, matrix).var(axis=1)
117
+ order = np.argsort(importance)[::-1][: max(1, min(m, space.n_positions))]
118
+ return sorted(space.positions[int(j)] for j in order)
119
+
120
+
121
+ # --------------------------------------------------------------------------- #
122
+ # Combinatorial generation
123
+ # --------------------------------------------------------------------------- #
124
+ def _assignment_to_combo(mut: tuple[tuple[int, str], ...], space: DesignSpace) -> str:
125
+ chosen = dict(space.reference)
126
+ for pos, aa in mut:
127
+ chosen[pos] = aa
128
+ return "".join(chosen[p] for p in space.positions)
129
+
130
+
131
+ def _mut_count(variant: str, space: DesignSpace) -> int:
132
+ ref = space.wt()
133
+ return sum(1 for a, b in zip(variant, ref, strict=True) if a != b)
134
+
135
+
136
+ def _generate_additive(
137
+ add: np.ndarray, space: DesignSpace, pool: list[int], *, max_mut: int, gen_cap: int, deadline: float
138
+ ) -> set[str]:
139
+ """Lazy best-first enumeration of high-gain <= max_mut combos (no Cartesian sweep)."""
140
+ aa_to_int = {a: i for i, a in enumerate(space.alphabet)}
141
+ subs: dict[int, list[tuple[str, float]]] = {}
142
+ for p in pool:
143
+ j = space.position_index(p)
144
+ wt_score = add[j, aa_to_int[space.reference[p]]]
145
+ subs[p] = sorted(
146
+ ((a, float(add[j, aa_to_int[a]] - wt_score)) for a in space.alphabet if a != space.reference[p]),
147
+ key=lambda kv: kv[1],
148
+ reverse=True,
149
+ )
150
+ gain_of = {p: dict(subs[p]) for p in pool}
151
+ out: set[str] = set()
152
+ visited: set[tuple[tuple[int, str], ...]] = {()}
153
+ visit_cap = max(4 * gen_cap, 1000)
154
+ heap: list[tuple[float, tuple[tuple[int, str], ...]]] = [(0.0, ())]
155
+ while heap and len(out) < gen_cap:
156
+ if time.perf_counter() > deadline:
157
+ break
158
+ neg, mut = heapq.heappop(heap)
159
+ out.add(_assignment_to_combo(mut, space))
160
+ used = {p for p, _ in mut}
161
+ if len(mut) < max_mut and len(visited) < visit_cap:
162
+ for p in pool:
163
+ if p in used or not subs[p]:
164
+ continue
165
+ a, g = subs[p][0]
166
+ nm = tuple(sorted((*mut, (p, a))))
167
+ if nm not in visited:
168
+ visited.add(nm)
169
+ heapq.heappush(heap, (neg - g, nm))
170
+ if len(visited) < visit_cap:
171
+ for i, (p, a) in enumerate(mut):
172
+ ranked = subs[p]
173
+ k = next((idx for idx, (aa, _) in enumerate(ranked) if aa == a), -1)
174
+ if 0 <= k and k + 1 < len(ranked):
175
+ na, gna = ranked[k + 1]
176
+ nm = tuple(sorted((*mut[:i], (p, na), *mut[i + 1 :])))
177
+ if nm not in visited:
178
+ visited.add(nm)
179
+ heapq.heappush(heap, (neg + gain_of[p][a] - gna, nm))
180
+ return out
181
+
182
+
183
+ def _explore_sample(
184
+ space: DesignSpace, pool: list[int], *, max_mut: int, n: int, rng: np.random.Generator, deadline: float
185
+ ) -> set[str]:
186
+ """Random non-additive <= max_mut combos over the pool (exploration quota)."""
187
+ out: set[str] = set()
188
+ subs = {p: [a for a in space.alphabet if a != space.reference[p]] for p in pool}
189
+ attempts = 0
190
+ while len(out) < n and attempts < 20 * n + 50:
191
+ if time.perf_counter() > deadline:
192
+ break
193
+ attempts += 1
194
+ k = min(int(rng.integers(1, max_mut + 1)), len(pool))
195
+ chosen_pos = rng.choice(len(pool), size=k, replace=False)
196
+ mut = tuple(
197
+ sorted((pool[int(i)], subs[pool[int(i)]][int(rng.integers(0, len(subs[pool[int(i)]])))]) for i in chosen_pos)
198
+ )
199
+ out.add(_assignment_to_combo(mut, space))
200
+ return out
201
+
202
+
203
+ def generate_candidates(
204
+ surrogate: Surrogate,
205
+ matrix: FeatureMatrix,
206
+ *,
207
+ m: int = DEFAULT_M,
208
+ max_mut: int = DEFAULT_MAX_MUT,
209
+ pinned_positions: Sequence[int] | None = None,
210
+ interaction_aware: bool = False,
211
+ generated_cap: int = GENERATED_CAP,
212
+ scored_cap: int = SCORED_CAP,
213
+ explore_frac: float = DEFAULT_EXPLORE_FRAC,
214
+ beta: float = DEFAULT_BETA,
215
+ time_budget_s: float = DEFAULT_TIME_BUDGET_S,
216
+ seed: int = 0,
217
+ ) -> CandidatePool:
218
+ """Generate + prefilter up-to-``max_mut`` mutation candidates under hard caps."""
219
+ if max_mut < 1:
220
+ raise ValueError("max_mut must be >= 1")
221
+ if not 0.0 <= explore_frac < 1.0:
222
+ raise ValueError("explore_frac must be in [0, 1)")
223
+ space = matrix.space
224
+ rng = np.random.default_rng(seed)
225
+ t0 = time.perf_counter()
226
+ deadline = t0 + time_budget_s
227
+
228
+ if pinned_positions is not None:
229
+ pool = sorted(int(p) for p in pinned_positions)
230
+ bad = [p for p in pool if p not in space.positions]
231
+ if bad:
232
+ raise ValueError(f"pinned positions {bad} not in design space")
233
+ else:
234
+ pool = select_positions(surrogate, matrix, m=m, interaction_aware=interaction_aware, seed=seed)
235
+
236
+ add = _additive_scores(surrogate, matrix)
237
+ n_explore = int(round(generated_cap * explore_frac))
238
+ generated = _generate_additive(
239
+ add, space, pool, max_mut=max_mut, gen_cap=max(0, generated_cap - n_explore), deadline=deadline
240
+ )
241
+ generated |= _explore_sample(space, pool, max_mut=max_mut, n=n_explore, rng=rng, deadline=deadline)
242
+ generated.discard(space.wt()) # never let WT dominate the tier ranking
243
+ if len(generated) > generated_cap:
244
+ generated = set(sorted(generated)[:generated_cap])
245
+
246
+ variants = sorted(generated)
247
+ if not variants:
248
+ empty = Prediction(np.zeros(0), np.zeros(0))
249
+ return CandidatePool([], empty, [], pool, {"n_generated": 0, "n_scored": 0, "n_pool_positions": len(pool)})
250
+
251
+ # full-surrogate prefilter (mean + beta*calibrated std), batched, deadline-bounded
252
+ n_total = len(variants)
253
+ mu_parts: list[np.ndarray] = []
254
+ sd_parts: list[np.ndarray] = []
255
+ for i in range(0, n_total, 4096):
256
+ if time.perf_counter() > deadline:
257
+ break
258
+ chunk = variants[i : i + 4096]
259
+ pr = surrogate.predict(matrix.encode(chunk))
260
+ mu_parts.append(np.asarray(pr.mean, dtype=float))
261
+ sd_parts.append(np.asarray(pr.std, dtype=float))
262
+ mu = np.concatenate(mu_parts) if mu_parts else np.zeros(0)
263
+ sigma = np.concatenate(sd_parts) if sd_parts else np.zeros(0)
264
+ variants = variants[: mu.shape[0]]
265
+ acq = mu + beta * sigma
266
+ keep = np.argsort(acq)[::-1][:scored_cap]
267
+ order = keep[np.argsort(acq[keep])[::-1]]
268
+ sel = [variants[int(i)] for i in order]
269
+ return CandidatePool(
270
+ variants=sel,
271
+ pred=Prediction(mu[order], sigma[order]),
272
+ mut_count=[_mut_count(v, space) for v in sel],
273
+ pool_positions=pool,
274
+ stats={
275
+ "n_generated": n_total,
276
+ "n_scored": len(sel),
277
+ "n_pool_positions": len(pool),
278
+ "wall_clock_s": time.perf_counter() - t0,
279
+ },
280
+ )
281
+
282
+
283
+ def recombine_beneficials(
284
+ space: DesignSpace,
285
+ variants: Sequence[str],
286
+ fitness: Sequence[float],
287
+ *,
288
+ wt_fitness: float | None = None,
289
+ min_gain: float = 0.0,
290
+ max_mut: int = 4,
291
+ cap: int = GENERATED_CAP,
292
+ ) -> list[str]:
293
+ """Recombine the mutations of MEASURED variants that beat WT (Arnold-style recombination).
294
+
295
+ The evidence-backed strategy (broad measurement then recombine confirmed beneficials, not
296
+ predictor ranking): pool every substitution that appears in a measured variant with
297
+ fitness > ``wt_fitness`` (+ ``min_gain``), then enumerate combinations of those beneficial
298
+ substitutions (one AA per position) up to ``max_mut``, ordered by observed gain, capped.
299
+
300
+ ``wt_fitness`` defaults to the measured WT combo's fitness if present, else the 75th percentile
301
+ of the measured fitness (a "clearly-above-typical" proxy, since true WT is unmeasured). Returns
302
+ combo strings over ``space.positions`` (WT elsewhere), excluding WT itself.
303
+ """
304
+ wt = space.wt()
305
+ fit = {str(v): float(f) for v, f in zip(variants, fitness, strict=True)}
306
+ if wt_fitness is None:
307
+ wt_fitness = fit.get(wt, float(np.quantile(np.asarray(list(fit.values()), dtype=float), 0.75)))
308
+ thr = wt_fitness + min_gain
309
+ # best observed gain per (position-index, aa) among beneficial variants
310
+ mut_gain: dict[tuple[int, str], float] = {}
311
+ for v, f in fit.items():
312
+ if f <= thr or len(v) != len(wt):
313
+ continue
314
+ gain = f - wt_fitness
315
+ for i, (a, w) in enumerate(zip(v, wt, strict=True)):
316
+ if a != w:
317
+ key = (i, a)
318
+ if gain > mut_gain.get(key, float("-inf")):
319
+ mut_gain[key] = gain
320
+ if not mut_gain:
321
+ return []
322
+ per_pos: dict[int, list[str]] = {}
323
+ for (i, a), _g in sorted(mut_gain.items(), key=lambda kv: -kv[1]):
324
+ per_pos.setdefault(i, []).append(a)
325
+ pos_order = sorted(per_pos, key=lambda i: -max(mut_gain[(i, a)] for a in per_pos[i]))
326
+ out: list[str] = []
327
+ seen: set[str] = set()
328
+ for k in range(1, min(max_mut, len(pos_order)) + 1):
329
+ for pos_combo in itertools.combinations(pos_order, k):
330
+ for aa_combo in itertools.product(*(per_pos[i] for i in pos_combo)):
331
+ chosen = list(wt)
332
+ for i, a in zip(pos_combo, aa_combo, strict=True):
333
+ chosen[i] = a
334
+ cs = "".join(chosen)
335
+ if cs == wt or cs in seen:
336
+ continue
337
+ seen.add(cs)
338
+ out.append(cs)
339
+ if len(out) >= cap:
340
+ return out
341
+ return out