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/naturalness.py ADDED
@@ -0,0 +1,142 @@
1
+ """Zero-shot naturalness providers (PLM likelihood) + a test/compose stub.
2
+
3
+ Naturalness is FolDE's actual lever (per the 6-paper tool-development synthesis): a
4
+ PLM zero-shot score, distilled into the supervised readout as a WARM START, is what
5
+ prevents the round-2 collapse of pure activity-only few-shot AL. Here a provider maps
6
+ full-length sequences to a per-sequence scalar; it is injected as a feature
7
+ (``features.NaturalnessBlock``) and/or used as a discovery scorer (``zero_shot``).
8
+
9
+ ``FunctionNaturalness`` wraps any ``str -> float`` callable so the plumbing is
10
+ testable without a heavy PLM SDK. The ESM providers are import-guarded.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable, Sequence
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+ from shellde.embeddings.provider import EmbeddingCache, _key
20
+
21
+
22
+ class FunctionNaturalness:
23
+ """A NaturalnessProvider backed by a plain ``str -> float`` function (cached)."""
24
+
25
+ def __init__(
26
+ self, fn: Callable[[str], float], *, model_id: str = "fn", cache: EmbeddingCache | None = None
27
+ ) -> None:
28
+ self._fn = fn
29
+ self.model_id = model_id
30
+ self._cache = cache if cache is not None else EmbeddingCache()
31
+ self.calls = 0
32
+
33
+ def score(self, sequences: Sequence[str]) -> np.ndarray:
34
+ out: list[float] = []
35
+ for s in sequences:
36
+ k = _key(self.model_id, s)
37
+ v = self._cache.get(k)
38
+ if v is None:
39
+ self.calls += 1
40
+ v = np.asarray([float(self._fn(s))], dtype=float)
41
+ self._cache.put(k, v)
42
+ out.append(float(v[0]))
43
+ return np.asarray(out, dtype=float)
44
+
45
+
46
+ class ESM2Naturalness:
47
+ """ESM-2 pseudo-log-likelihood naturalness (mean per-residue log-prob), import-guarded."""
48
+
49
+ def __init__(
50
+ self, model: str = "facebook/esm2_t30_150M_UR50D", *, device: str | None = None,
51
+ cache: EmbeddingCache | None = None,
52
+ ) -> None:
53
+ self.model = model
54
+ self.device = device
55
+ self._cache = cache if cache is not None else EmbeddingCache()
56
+ self._tok: Any = None
57
+ self._mdl: Any = None
58
+ self._torch: Any = None
59
+
60
+ def _load(self) -> None: # pragma: no cover - exercised only with the SDK
61
+ try:
62
+ import torch
63
+ from transformers import AutoModelForMaskedLM, AutoTokenizer # type: ignore[import-not-found, import-untyped]
64
+ except ImportError as exc:
65
+ raise ImportError("ESM2Naturalness requires 'transformers' and 'torch'.") from exc
66
+ self._torch = torch
67
+ self._tok = AutoTokenizer.from_pretrained(self.model)
68
+ self._mdl = AutoModelForMaskedLM.from_pretrained(self.model).eval()
69
+
70
+ def score(self, sequences: Sequence[str]) -> np.ndarray:
71
+ keys = [_key(self.model, s) for s in sequences]
72
+ misses = [(i, s) for i, s in enumerate(sequences) if self._cache.get(keys[i]) is None]
73
+ if misses: # pragma: no cover - exercised only with the SDK
74
+ if self._mdl is None:
75
+ self._load()
76
+ assert self._torch is not None and self._tok is not None and self._mdl is not None
77
+ with self._torch.no_grad():
78
+ for i, s in misses:
79
+ enc = self._tok(s, return_tensors="pt")
80
+ logits = self._mdl(**enc).logits[0]
81
+ logp = self._torch.log_softmax(logits, dim=-1)
82
+ ids = enc["input_ids"][0]
83
+ tok_lp = logp[range(len(ids)), ids][1:-1] # drop BOS/EOS
84
+ self._cache.put(keys[i], np.asarray([float(tok_lp.mean())], dtype=float))
85
+ out = []
86
+ for k in keys:
87
+ v = self._cache.get(k)
88
+ assert v is not None
89
+ out.append(float(v[0]))
90
+ return np.asarray(out, dtype=float)
91
+
92
+
93
+ class ESMCNaturalness:
94
+ """ESM-C naturalness via the EvolutionaryScale SDK (mean per-residue log-prob), import-guarded."""
95
+
96
+ def __init__(
97
+ self, model_name: str = "esmc_300m", *, device: str | None = None,
98
+ cache: EmbeddingCache | None = None,
99
+ ) -> None:
100
+ self.model_name = model_name
101
+ self.device = device
102
+ self._cache = cache if cache is not None else EmbeddingCache()
103
+ self._model: Any = None
104
+ self._torch: Any = None
105
+
106
+ def _load(self) -> None: # pragma: no cover - exercised only with the SDK
107
+ try:
108
+ import torch
109
+ from esm.models.esmc import ESMC # type: ignore[import-not-found, import-untyped]
110
+ except ImportError as exc:
111
+ raise ImportError("ESMCNaturalness requires the EvolutionaryScale 'esm' SDK.") from exc
112
+ self._torch = torch
113
+ dev = self.device or ("cuda" if torch.cuda.is_available() else "cpu")
114
+ self._model = ESMC.from_pretrained(self.model_name).to(dev).eval()
115
+
116
+ def score(self, sequences: Sequence[str]) -> np.ndarray:
117
+ keys = [_key(self.model_name, s) for s in sequences]
118
+ misses = [(i, s) for i, s in enumerate(sequences) if self._cache.get(keys[i]) is None]
119
+ if misses: # pragma: no cover - exercised only with the SDK
120
+ if self._model is None:
121
+ self._load()
122
+ from esm.sdk.api import ESMProtein, LogitsConfig # type: ignore[import-not-found, import-untyped]
123
+
124
+ assert self._torch is not None and self._model is not None
125
+ with self._torch.no_grad():
126
+ for i, s in misses:
127
+ tensor = self._model.encode(ESMProtein(sequence=s))
128
+ res = self._model.logits(tensor, LogitsConfig(sequence=True))
129
+ seq_logits = res.logits.sequence
130
+ if seq_logits.dim() == 3:
131
+ seq_logits = seq_logits[0]
132
+ logp = self._torch.log_softmax(seq_logits.float(), dim=-1) # (L+2, V)
133
+ ids = tensor.sequence.long() # token ids incl. BOS/EOS
134
+ pos = self._torch.arange(1, ids.shape[0] - 1) # residues only
135
+ tok_lp = logp[pos, ids[pos]]
136
+ self._cache.put(keys[i], np.asarray([float(tok_lp.mean())], dtype=float))
137
+ out = []
138
+ for k in keys:
139
+ v = self._cache.get(k)
140
+ assert v is not None
141
+ out.append(float(v[0]))
142
+ return np.asarray(out, dtype=float)
shellde/oracle.py ADDED
@@ -0,0 +1,94 @@
1
+ """Oracles: the function-aligned information source (one Protocol, two impls).
2
+
3
+ - ``DMSLookupOracle``: ground-truth lookup over a measured (variant -> fitness)
4
+ table; ``universe`` is the measurable pool (real-DMS benchmarks).
5
+ - ``SyntheticOracle``: additive backbone + ``alpha`` * pairwise epistasis over a
6
+ design space, with the global optimum knowable from the enumerated ``universe``
7
+ (tunable-epistasis benchmarks). alpha=0 is purely additive.
8
+
9
+ Both satisfy ``protocols.Oracle`` and expose an enumerable ``universe`` so the
10
+ active-learning loop is uniform across them.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import itertools
15
+ from collections.abc import Mapping, Sequence
16
+
17
+ import numpy as np
18
+
19
+ from shellde.design_space import DesignSpace, to_assignment
20
+
21
+ _UNIVERSE_CAP = 200_000
22
+
23
+
24
+ class DMSLookupOracle:
25
+ """Ground-truth fitness by lookup over a measured table."""
26
+
27
+ def __init__(self, table: Mapping[str, float]) -> None:
28
+ self._table = {str(k): float(v) for k, v in table.items()}
29
+ self._universe = list(self._table)
30
+
31
+ def evaluate(self, variants: Sequence[str]) -> np.ndarray:
32
+ try:
33
+ return np.array([self._table[str(v)] for v in variants], dtype=float)
34
+ except KeyError as exc:
35
+ raise KeyError(f"variant {exc} not in the DMS lookup table") from exc
36
+
37
+ @property
38
+ def universe(self) -> list[str] | None:
39
+ return list(self._universe)
40
+
41
+
42
+ class SyntheticOracle:
43
+ """Additive + alpha*pairwise-epistatic landscape over a design space.
44
+
45
+ Fields h and couplings J are drawn once from ``seed``; ``alpha`` then scales the
46
+ pairwise term, so sweeping alpha walks from the additive null into progressively
47
+ stronger epistasis with the same underlying landscape.
48
+ """
49
+
50
+ def __init__(self, space: DesignSpace, *, alpha: float = 0.0, seed: int = 0) -> None:
51
+ self.space = space
52
+ self.alpha = alpha
53
+ n, q = space.n_positions, space.q
54
+ rng = np.random.default_rng(seed)
55
+ self._h = rng.standard_normal((n, q))
56
+ self._j = rng.standard_normal((n, n, q, q))
57
+ self._aa = {a: i for i, a in enumerate(space.alphabet)}
58
+ self._universe: list[str] | None = None
59
+
60
+ def evaluate(self, variants: Sequence[str]) -> np.ndarray:
61
+ space = self.space
62
+ n = space.n_positions
63
+ out = np.zeros(len(variants), dtype=float)
64
+ for k, v in enumerate(variants):
65
+ a = to_assignment(v, space)
66
+ idx = [self._aa[a[p]] for p in space.positions]
67
+ f = sum(self._h[i, idx[i]] for i in range(n))
68
+ if self.alpha != 0.0:
69
+ for i in range(n):
70
+ for j in range(i + 1, n):
71
+ f += self.alpha * self._j[i, j, idx[i], idx[j]]
72
+ out[k] = f
73
+ return out
74
+
75
+ @property
76
+ def universe(self) -> list[str] | None:
77
+ if self._universe is None:
78
+ space = self.space
79
+ total = space.q**space.n_positions
80
+ if total > _UNIVERSE_CAP:
81
+ raise ValueError(
82
+ f"SyntheticOracle universe {total} exceeds cap {_UNIVERSE_CAP}; "
83
+ "use a smaller design space for enumerated benchmarks"
84
+ )
85
+ self._universe = ["".join(c) for c in itertools.product(space.alphabet, repeat=space.n_positions)]
86
+ return list(self._universe)
87
+
88
+ def optimum(self) -> tuple[str, float]:
89
+ """The true global optimum over the enumerated universe (for regret)."""
90
+ uni = self.universe
91
+ assert uni is not None
92
+ f = self.evaluate(uni)
93
+ i = int(np.argmax(f))
94
+ return uni[i], float(f[i])
shellde/plm.py ADDED
@@ -0,0 +1,145 @@
1
+ """First-class PLM (ESM-C) front end for the real-data recommend path.
2
+
3
+ At the N=95 constraint, explicit pairwise epistasis is unestimable (one pairwise
4
+ term over q=20 is 19^2=361 params > 95 samples), so the lever that actually wins is
5
+ a protein language model: ESM-C embeddings carry IMPLICIT epistasis from pretraining
6
+ (attention makes them non-additive) and N=95 only fits a cheap readout on top.
7
+
8
+ ESM-C CPU inference is slow and unbatched, so scoring a 200k-candidate pool with it
9
+ is intractable AND wasteful (embeddings dilute on dense combinatorial libraries).
10
+ The honest, correct integration is a TWO-STAGE re-rank: a cheap one-hot surrogate
11
+ shortlists high-value candidates, then a PLM surrogate fit on the measured
12
+ embeddings re-scores ONLY the shortlist. The PLM block earns its place on the front
13
+ end, not on the bulk scoring. Embeds exactly ``len(measured) + top_k`` sequences.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable, Sequence
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ from shellde.candidates import CandidatePool
23
+ from shellde.design_space import DesignSpace
24
+ from shellde.embeddings.provider import EmbeddingCache
25
+ from shellde.features import (
26
+ FeatureBlock,
27
+ FeatureMatrix,
28
+ NaturalnessBlock,
29
+ OneHotBlock,
30
+ PLMEmbeddingBlock,
31
+ )
32
+ from shellde.protocols import EmbeddingProvider, NaturalnessProvider, Surrogate
33
+ from shellde.surrogate import RidgeSurrogate
34
+
35
+
36
+ def read_fasta(path: str | Path) -> str:
37
+ """Return the first record's sequence from a FASTA file (whitespace stripped)."""
38
+ seq: list[str] = []
39
+ started = False
40
+ for line in Path(path).read_text().splitlines():
41
+ if line.startswith(">"):
42
+ if started:
43
+ break
44
+ started = True
45
+ continue
46
+ seq.append(line.strip())
47
+ out = "".join(seq).replace(" ", "")
48
+ if not out:
49
+ raise ValueError(f"{path}: no sequence found in FASTA")
50
+ return out
51
+
52
+
53
+ def build_provider(
54
+ kind: str, *, model: str = "", cache: EmbeddingCache | None = None
55
+ ) -> EmbeddingProvider:
56
+ """Construct an import-guarded PLM provider ('esmc' | 'esm2').
57
+
58
+ The heavy SDK import is lazy (inside the provider), so an unavailable SDK only
59
+ raises when ``embed`` is first called, with a clear install message.
60
+ """
61
+ if kind == "esmc":
62
+ from shellde.embeddings.esmc import ESMCProvider
63
+
64
+ return ESMCProvider(model or "esmc_300m", cache=cache)
65
+ if kind == "esm2":
66
+ from shellde.embeddings.esm2 import ESM2Provider
67
+
68
+ return ESM2Provider(model or "facebook/esm2_t30_150M_UR50D", cache=cache)
69
+ raise ValueError(f"unknown PLM kind {kind!r} (expected 'esmc' or 'esm2')")
70
+
71
+
72
+ def build_naturalness(
73
+ kind: str, *, model: str = "", cache: EmbeddingCache | None = None
74
+ ) -> NaturalnessProvider:
75
+ """Construct an import-guarded zero-shot naturalness provider ('esmc' | 'esm2')."""
76
+ if kind == "esmc":
77
+ from shellde.naturalness import ESMCNaturalness
78
+
79
+ return ESMCNaturalness(model or "esmc_300m", cache=cache)
80
+ if kind == "esm2":
81
+ from shellde.naturalness import ESM2Naturalness
82
+
83
+ return ESM2Naturalness(model or "facebook/esm2_t30_150M_UR50D", cache=cache)
84
+ raise ValueError(f"unknown naturalness kind {kind!r} (expected 'esmc' or 'esm2')")
85
+
86
+
87
+ def plm_rerank(
88
+ pool: CandidatePool,
89
+ space: DesignSpace,
90
+ measured_variants: Sequence[str],
91
+ fitness: np.ndarray,
92
+ *,
93
+ provider: EmbeddingProvider | None = None,
94
+ naturalness: "NaturalnessProvider | None" = None,
95
+ wt_sequence: str,
96
+ top_k: int,
97
+ surrogate_factory: Callable[[], Surrogate] = lambda: RidgeSurrogate(random_state=0),
98
+ use_onehot: bool = True,
99
+ plm_pooling: str = "mean",
100
+ ) -> CandidatePool:
101
+ """Stage-2: refit a PLM/naturalness surrogate on measured data, re-score the shortlist.
102
+
103
+ Adds the (cost-bearing) PLM embedding and/or naturalness blocks only on the bounded
104
+ top-``top_k`` shortlist, not the 200k-candidate base scoring. At least one of
105
+ ``provider`` (embedding) or ``naturalness`` must be given. ``stats['wt_mean']`` is
106
+ the refit surrogate's WT prediction (for risk flagging). Embeds/scores only
107
+ ``len(measured) + top_k`` sequences, so CPU PLM inference stays tractable.
108
+ """
109
+ if provider is None and naturalness is None:
110
+ raise ValueError("plm_rerank needs at least one of provider (embedding) or naturalness")
111
+ if not pool.variants:
112
+ return pool
113
+ k = max(1, min(top_k, len(pool.variants)))
114
+ shortlist = list(pool.variants[:k])
115
+ mut_count = list(pool.mut_count[:k])
116
+
117
+ blocks: list[FeatureBlock] = []
118
+ if use_onehot:
119
+ blocks.append(OneHotBlock(space))
120
+ dim = 0
121
+ if provider is not None:
122
+ blocks.append(PLMEmbeddingBlock(space, provider, wt_sequence, pooling=plm_pooling))
123
+ dim += int(provider.dim)
124
+ if naturalness is not None:
125
+ blocks.append(NaturalnessBlock(space, naturalness, wt_sequence))
126
+ dim += 1
127
+ matrix = FeatureMatrix(space, blocks)
128
+
129
+ measured = [str(v) for v in measured_variants]
130
+ sur = surrogate_factory().fit(matrix.encode(measured), np.asarray(fitness, dtype=float))
131
+ pred = sur.predict(matrix.encode(shortlist))
132
+ wt_mean = float(sur.predict(matrix.encode([space.wt()])).mean[0])
133
+
134
+ stats = dict(pool.stats)
135
+ stats.update({
136
+ "plm_reranked": k, "plm_dim": dim, "wt_mean": wt_mean,
137
+ "plm_embedding": provider is not None, "plm_naturalness": naturalness is not None,
138
+ })
139
+ return CandidatePool(
140
+ variants=shortlist,
141
+ pred=pred,
142
+ mut_count=mut_count,
143
+ pool_positions=pool.pool_positions,
144
+ stats=stats,
145
+ )
shellde/prereg.py ADDED
@@ -0,0 +1,41 @@
1
+ """Pre-registration: a locked protocol + deterministic hash stamped into outputs.
2
+
3
+ Locking the protocol once (metric, budget, seeds, statistics, decision rule) before
4
+ running is the falsifiability guard: results are interpreted against a fixed bar, not
5
+ a metric/seed/universe chosen after seeing the numbers.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from typing import Any
12
+
13
+ PROTOCOL: dict[str, Any] = {
14
+ "version": 1,
15
+ "primary_metric": "normalised_simple_regret", # (true_max - best_found)/(true_max - true_min)
16
+ "secondary_metrics": ["best_found", "heldout_spearman"],
17
+ "n_seeds": 20,
18
+ "budget": {"n_init": 95, "batch_size": 95, "n_rounds": 3},
19
+ "alpha_spectrum": [0.0, 0.5, 1.0, 1.5, 2.0],
20
+ "statistics": {"alpha": 0.05, "correction": "benjamini_hochberg", "paired": True},
21
+ "methods": ["ours", "additive_greedy", "rf_greedy", "ucb_bo"],
22
+ "decision_rule": (
23
+ "ours must never be FDR-significantly worse than a baseline on any landscape; "
24
+ "superiority is claimed only on an FDR-significant paired regret reduction with "
25
+ "CI lower bound > 0."
26
+ ),
27
+ }
28
+
29
+
30
+ def prereg_hash(protocol: dict[str, Any] | None = None) -> str:
31
+ """Deterministic SHA-256 over the canonical protocol JSON (sorted keys)."""
32
+ blob = json.dumps(protocol if protocol is not None else PROTOCOL, sort_keys=True, separators=(",", ":"))
33
+ return hashlib.sha256(blob.encode()).hexdigest()
34
+
35
+
36
+ def stamp(payload: dict[str, Any]) -> dict[str, Any]:
37
+ """Return a copy of ``payload`` stamped with the protocol + its hash."""
38
+ out = dict(payload)
39
+ out["protocol"] = PROTOCOL
40
+ out["prereg_hash"] = prereg_hash()
41
+ return out
shellde/protocols.py ADDED
@@ -0,0 +1,87 @@
1
+ """Structural typing contracts (typing.Protocol) for the pluggable pieces.
2
+
3
+ These pin the seams that make ShellDE one coherent pipeline instead of two
4
+ lineages: a single Surrogate contract (calibrated std), a single Acquisition
5
+ contract, a single Oracle contract, and an EmbeddingProvider for PLM features.
6
+ Concrete implementations live in their own modules and need no registration.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ import numpy as np
14
+
15
+ from shellde.design_space import DesignSpace
16
+ from shellde.types import Prediction
17
+
18
+
19
+ @runtime_checkable
20
+ class Surrogate(Protocol):
21
+ """A fitted fitness model over a feature matrix; returns calibrated uncertainty."""
22
+
23
+ def fit(
24
+ self, x: np.ndarray, y: np.ndarray, sample_weight: np.ndarray | None = None
25
+ ) -> "Surrogate":
26
+ ...
27
+
28
+ def predict(self, x: np.ndarray) -> Prediction:
29
+ """Return mean + CALIBRATED predictive std (1 sigma)."""
30
+ ...
31
+
32
+ def main_effects(self) -> np.ndarray | None:
33
+ """Additive per-column coefficients for the position-selection prior (or None)."""
34
+ ...
35
+
36
+
37
+ @runtime_checkable
38
+ class Acquisition(Protocol):
39
+ """Selects which candidates to measure next, given surrogate predictions."""
40
+
41
+ def select(
42
+ self,
43
+ pred: Prediction,
44
+ k: int,
45
+ *,
46
+ candidates: Sequence[str],
47
+ space: DesignSpace,
48
+ ) -> list[int]:
49
+ """Return indices into ``candidates`` for the chosen batch of size <= k."""
50
+ ...
51
+
52
+
53
+ @runtime_checkable
54
+ class Oracle(Protocol):
55
+ """Maps variants to (measured) fitness; the only function-aligned information source."""
56
+
57
+ def evaluate(self, variants: Sequence[str]) -> np.ndarray:
58
+ ...
59
+
60
+ @property
61
+ def universe(self) -> list[str] | None:
62
+ """The measurable variant pool for lookup oracles, else None (enumerable space)."""
63
+ ...
64
+
65
+
66
+ @runtime_checkable
67
+ class EmbeddingProvider(Protocol):
68
+ """Produces per-sequence embeddings (PLM); implementations cache on disk."""
69
+
70
+ dim: int
71
+
72
+ def embed(self, sequences: Sequence[str]) -> np.ndarray:
73
+ """Return an (len(sequences), dim) float array of mean-pooled embeddings."""
74
+ ...
75
+
76
+
77
+ @runtime_checkable
78
+ class NaturalnessProvider(Protocol):
79
+ """Produces a per-sequence zero-shot naturalness score (PLM likelihood-style).
80
+
81
+ Higher means more 'natural' under the model's pretraining prior. Used as a
82
+ warm-start signal (a feature and/or discovery scorer); implementations cache.
83
+ """
84
+
85
+ def score(self, sequences: Sequence[str]) -> np.ndarray:
86
+ """Return a (len(sequences),) float array of per-sequence naturalness."""
87
+ ...
shellde/rank.py ADDED
@@ -0,0 +1,103 @@
1
+ """Rank a candidate pool into per-mutation-count tiers + a global ranking.
2
+
3
+ Each ranked entry carries evidence: predicted mean/std, acquisition value, the
4
+ mutation tokens, the baseline gain over WT, and a CALIBRATED risk flag. The risk
5
+ flag is the opportunity-cost framing made concrete: a recommendation is "confident"
6
+ when its predicted gain over WT exceeds one calibrated standard deviation, and
7
+ "speculative" (the predicted gain is within the calibrated sigma, so the ranking of
8
+ that candidate is not resolved by the current data) otherwise. It is a gain-vs-sigma
9
+ comparison only; it does not detect or classify epistasis.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ import numpy as np
16
+
17
+ from shellde.acquisition import _ScoreAcquisition
18
+ from shellde.candidates import CandidatePool
19
+ from shellde.design_space import DesignSpace, mutations_of
20
+ from shellde.types import Candidate
21
+
22
+
23
+ @dataclass
24
+ class RankingResult:
25
+ """Per-mutation-count tiers + a global (count-mixing) exploratory ranking."""
26
+
27
+ per_count: dict[int, list[Candidate]] = field(default_factory=dict)
28
+ global_ranking: list[Candidate] = field(default_factory=list)
29
+ global_is_exploratory: bool = True
30
+
31
+
32
+ def _risk_flag(gain: float, sigma: float) -> str:
33
+ return "confident" if gain >= max(sigma, 0.0) else "speculative"
34
+
35
+
36
+ def rank_candidates(
37
+ pool: CandidatePool,
38
+ space: DesignSpace,
39
+ *,
40
+ acquisition: _ScoreAcquisition,
41
+ per_count_k: int = 10,
42
+ global_k: int = 20,
43
+ wt_mean: float = 0.0,
44
+ conformal_q: float | None = None,
45
+ max_measured_nmut: int | None = None,
46
+ ) -> RankingResult:
47
+ """Build per-count tiers + a global ranking from a scored candidate pool.
48
+
49
+ When ``conformal_q`` is given (the normalized cross-conformal quantile from the
50
+ MEASURED set), each candidate gets an ADDITIVE conformal abstain gate on its
51
+ WT-relative gain: ``conformal_lo = gain - conformal_q * sigma`` and ``abstain`` is
52
+ True when that lower end is <= 0 (cannot conclude the candidate beats WT at this
53
+ conformal level). ``conformal_scope`` is ``"conformal"`` when the candidate is
54
+ in-distribution (``n_mut <= max_measured_nmut``) and ``"conformal_oob"`` when it is a
55
+ higher-order combination out of the calibration distribution. The sigma-based
56
+ ``risk`` flag is kept exactly as-is. When ``conformal_q`` is None the evidence dict is
57
+ byte-identical to the no-conformal behaviour (no new keys).
58
+ """
59
+ n = len(pool.variants)
60
+ if n == 0:
61
+ return RankingResult({}, [], True)
62
+ scores = np.asarray(acquisition.score(pool.pred), dtype=float)
63
+ mean = np.asarray(pool.pred.mean, dtype=float)
64
+ std = np.asarray(pool.pred.std, dtype=float)
65
+
66
+ entries: list[Candidate] = []
67
+ for i, v in enumerate(pool.variants):
68
+ gain = float(mean[i] - wt_mean)
69
+ sigma = float(std[i])
70
+ n_mut = int(pool.mut_count[i])
71
+ evidence: dict = {"baseline_gain": gain, "risk": _risk_flag(gain, sigma)}
72
+ if conformal_q is not None:
73
+ conformal_lo = gain - conformal_q * sigma
74
+ evidence["conformal_lo"] = conformal_lo
75
+ evidence["abstain"] = bool(conformal_lo <= 0.0)
76
+ evidence["conformal_scope"] = (
77
+ "conformal_oob"
78
+ if (max_measured_nmut is not None and n_mut > max_measured_nmut)
79
+ else "conformal"
80
+ )
81
+ entries.append(
82
+ Candidate(
83
+ variant=v,
84
+ n_mut=n_mut,
85
+ mu=float(mean[i]),
86
+ sigma=sigma,
87
+ acq=float(scores[i]),
88
+ mutations=mutations_of(v, space),
89
+ evidence=evidence,
90
+ )
91
+ )
92
+
93
+ by_acq = sorted(entries, key=lambda c: c.acq, reverse=True)
94
+ per_count: dict[int, list[Candidate]] = {}
95
+ for c in by_acq:
96
+ per_count.setdefault(c.n_mut, [])
97
+ if len(per_count[c.n_mut]) < per_count_k:
98
+ per_count[c.n_mut].append(c)
99
+ return RankingResult(
100
+ per_count=dict(sorted(per_count.items())),
101
+ global_ranking=by_acq[:global_k],
102
+ global_is_exploratory=True,
103
+ )