Upload Staplebridge files

#1
by Jingjie00 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. README.md +334 -2
  3. THIRD_PARTY_NOTICES.md +70 -0
  4. checkpoints/staplebridge_seed42_best.pt +3 -0
  5. configs/staplebridge_main.yaml +244 -0
  6. data/README.md +53 -0
  7. figure/framework.pdf +3 -0
  8. figure/framework.png +3 -0
  9. requirements.txt +21 -0
  10. scripts/train.py +413 -0
  11. staplebridge/.DS_Store +0 -0
  12. staplebridge/__init__.py +4 -0
  13. staplebridge/chemistry/__init__.py +1 -0
  14. staplebridge/chemistry/actions.py +80 -0
  15. staplebridge/chemistry/edit_distance.py +18 -0
  16. staplebridge/chemistry/edit_metrics.py +97 -0
  17. staplebridge/chemistry/protected.py +5 -0
  18. staplebridge/chemistry/state.py +45 -0
  19. staplebridge/data/__init__.py +1 -0
  20. staplebridge/data/catalog.py +46 -0
  21. staplebridge/data/dataset.py +23 -0
  22. staplebridge/data/schemas.py +58 -0
  23. staplebridge/data/vocab.py +11 -0
  24. staplebridge/hydrocarbon/__init__.py +39 -0
  25. staplebridge/hydrocarbon/actions.py +384 -0
  26. staplebridge/hydrocarbon/catalog.py +198 -0
  27. staplebridge/hydrocarbon/curriculum.py +249 -0
  28. staplebridge/hydrocarbon/data/pair_spacing_probabilities.json +28 -0
  29. staplebridge/hydrocarbon/endpoint_prior.py +442 -0
  30. staplebridge/hydrocarbon/exact_sb_cache.py +692 -0
  31. staplebridge/hydrocarbon/factorized_plan_reference.py +220 -0
  32. staplebridge/hydrocarbon/geometry.py +102 -0
  33. staplebridge/hydrocarbon/monomers.py +419 -0
  34. staplebridge/hydrocarbon/plan_control.py +962 -0
  35. staplebridge/hydrocarbon/plan_reference.py +932 -0
  36. staplebridge/hydrocarbon/plan_validation.py +214 -0
  37. staplebridge/hydrocarbon/property_energy.py +703 -0
  38. staplebridge/hydrocarbon/smiles_builder.py +1071 -0
  39. staplebridge/hydrocarbon/terminal_energy.py +227 -0
  40. staplebridge/hydrocarbon/tokenizer.py +210 -0
  41. staplebridge/integrations/__init__.py +1 -0
  42. staplebridge/integrations/peptiverse.py +537 -0
  43. staplebridge/oracles/__init__.py +1 -0
  44. staplebridge/oracles/anchor_prior.py +27 -0
  45. staplebridge/oracles/base.py +84 -0
  46. staplebridge/oracles/block_prior.py +43 -0
  47. staplebridge/oracles/catalog_block_prior.py +144 -0
  48. staplebridge/oracles/esm2_peptide_prior.py +565 -0
  49. staplebridge/oracles/heuristic_peptide_prior.py +56 -0
  50. staplebridge/oracles/motif_anchor_prior.py +190 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ figure/framework.pdf filter=lfs diff=lfs merge=lfs -text
37
+ figure/framework.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,3 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- license: apache-2.0
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # StapleBridge
2
+
3
+ Official training code for **StapleBridge**, a chemistry-aware framework for optimizing existing peptide leads through hydrocarbon stapling.
4
+
5
+ StapleBridge constructs a finite set of chemically and geometrically feasible stapling interventions for each peptide, learns to rank these interventions, and executes the selected plan with minimal sequence edits.
6
+
7
+ This repository contains the **main StapleBridge training pipeline** and **one representative pretrained checkpoint**.
8
+
9
+ ## Framework
10
+
11
+ [![StapleBridge framework: feasible stapling plans, exact finite-support control target, and plan execution](figure/framework.png)](figure/framework.pdf)
12
+
13
  ---
14
+
15
+ ## 1. What is included
16
+
17
+ The code required to **train the main StapleBridge model**, and one
18
+ representative pretrained checkpoint.
19
+
20
+ ```
21
+ release/staplebridge_training/
22
+ ├── README.md
23
+ ├── THIRD_PARTY_NOTICES.md
24
+ ├── requirements.txt
25
+ ├── .gitignore
26
+ ├── configs/
27
+ │ └── staplebridge_main.yaml # configuration used for the checkpoint
28
+ ├── scripts/
29
+ │ └──train.py # canonical training entry point
30
+ ├── staplebridge/
31
+ │ ├── chemistry/ # design state, actions, edit distance
32
+ │ ├── data/ # split loading, schemas, catalog, vocab
33
+ │ ├── hydrocarbon/ # plans, catalog, q_ref, q*, q_theta, geometry
34
+ │ ├── models/ # policy / value nets, controlled kernel
35
+ │ ├── oracles/ # ESM2 + anchor/block reference priors
36
+ │ ├── reference/ # reference energy and kernel
37
+ │ ├── training/ # stack construction, main loop, losses
38
+ │ ├── integrations/ # PeptiVerse wrapper
39
+ │ └── utils/ # paths, profiling
40
+ ├── data/
41
+ │ └── README.md # expected input schema and file layout
42
+ └── checkpoints/
43
+ └── staplebridge_seed42_best.pt # representative seed=42 model
44
+ ```
45
+
46
+ The training code in `staplebridge/training/` is numerically identical to the
47
+ run that produced the shipped checkpoint.
48
+
49
+ <!-- ## 2. What is intentionally not included
50
+
51
+ This release covers **training only**. Not included:
52
+
53
+ - Baseline and ablation implementations, and their checkpoints.
54
+ - Alternative stapling chemistries.
55
+ - Downstream and post-hoc evaluation pipelines, including the test-set
56
+ evaluator, structural and docking analyses, and permeability benchmarking.
57
+ - Analysis, figure-generation and manuscript material.
58
+ - Training outputs: logs, cached predictions, intermediate checkpoints.
59
+ - **Processed training and validation data** — see §5; these will be handled
60
+ separately.
61
+
62
+ One checkpoint is shipped: the representative seed=42 model.
63
+
64
+ Reproducing the paper's *evaluation* numbers requires the evaluation pipelines,
65
+ which are outside this release. What is here reproduces *training*. -->
66
+
67
+ ## 2. Environment setup
68
+
69
+ The released checkpoint was trained under:
70
+
71
+ | | Version |
72
+ | --- | --- |
73
+ | Python | 3.10.20 |
74
+ | PyTorch | 2.12.1+cu130 (CUDA 13.0) |
75
+ | NumPy | 2.0.2 |
76
+ | PyYAML | 6.0.3 |
77
+ | RDKit | 2026.03.5 |
78
+ | transformers | 4.46.0 |
79
+
80
+ ```bash
81
+ python -m venv .venv && source .venv/bin/activate
82
+ # Install torch first, matched to your CUDA build: https://pytorch.org
83
+ pip install -r requirements.txt
84
+ ```
85
+
86
+ <!-- StapleBridge's own model is small (~142k parameters total) and runs on CPU. A
87
+ GPU is needed in practice because the frozen ESM2-650M prior and the PeptiVerse
88
+ oracles are invoked per candidate. Set the device in one place — the config
89
+ requires `training.device`, `property_predictor.device` and
90
+ `reference_priors.peptide.device` to agree, and `check_config.py` enforces it. -->
91
+
92
+ ## 3. External model dependencies
93
+
94
+ **No third-party model weights are bundled.** Four external resources must be
95
+ provided and referenced from the config: the ESM2-650M snapshot, the PeptiVerse
96
+ distribution, and the two SMILES encoders PeptiVerse depends on. Paths may be
97
+ absolute, or relative to the package root.
98
+
99
+ ### ESM2-650M (frozen sequence context)
100
+
101
+ `facebook/esm2_t33_650M_UR50D`, used frozen — never fine-tuned. It supplies the
102
+ reference-process peptide prior and the V2 plan head's anchor/local-context
103
+ features. It is also the feature source for the plan head, which refuses to
104
+ build without it.
105
+
106
+ ```bash
107
+ huggingface-cli download facebook/esm2_t33_650M_UR50D \
108
+ --local-dir models/esm2_t33_650M_UR50D
109
+ ```
110
+
111
+ Then set, in `configs/staplebridge_main.yaml`:
112
+
113
+ ```yaml
114
+ reference_priors:
115
+ peptide:
116
+ model_name_or_path: models/esm2_t33_650M_UR50D
117
+ property_predictor:
118
+ esm_model_name_or_path: models/esm2_t33_650M_UR50D
119
+ ```
120
+
121
+ The config runs the prior with `offline: true` and `strict_runtime: true`, so the
122
+ snapshot must already be on disk; training fails fast rather than downloading or
123
+ silently substituting a fallback. Licensed by Meta under the ESM2 terms.
124
+
125
+ ### PeptiVerse (property oracles)
126
+
127
+ The main training objective optimises the PeptiVerse
128
+ permeability-penetrance E/Z product mean. Obtain the PeptiVerse checkout and its
129
+ classifier weights separately, then set:
130
+
131
+ ```yaml
132
+ property_predictor:
133
+ peptiverse_root: external/PeptiVerse
134
+ classifier_weight_root: external/PeptiVerse
135
+ manifest_path: external/PeptiVerse/basic_models.txt
136
+ ```
137
+
138
+ Scoring is **strict**: `strict: true`, `enable_fallback: false`,
139
+ `allow_wt_token_fallback: false`. If the oracle stack cannot load, training
140
+ aborts — it never degrades to a heuristic.
141
+
142
+ Toxicity, hemolysis and half-life are monitored only. Solubility and binding
143
+ affinity are excluded from the objective.
144
+
145
+ ### PeptideCLM-23M and ChemBERTa-77M (required)
146
+
147
+ The `basic_models.txt` manifest selects predictors embedded with PeptideCLM and
148
+ ChemBERTa, so **both are required** — not optional. The permeability-penetrance
149
+ predictor that defines the objective is itself ChemBERTa-embedded. Loading fails
150
+ fast without them.
151
+
152
+ ```bash
153
+ huggingface-cli download aaronfeller/PeptideCLM-23M-all \
154
+ --local-dir models/PeptideCLM-23M-all
155
+ huggingface-cli download DeepChem/ChemBERTa-77M-MLM \
156
+ --local-dir models/ChemBERTa-77M-MLM
157
+ ```
158
+
159
+ ```yaml
160
+ property_predictor:
161
+ peptideclm_model_name_or_path: models/PeptideCLM-23M-all
162
+ chemberta_model_name_or_path: models/ChemBERTa-77M-MLM
163
+ ```
164
+
165
+ `scripts/check_config.py` verifies all four before training starts.
166
+
167
+ ## 4. Data
168
+
169
+ **The processed training and validation data are not included in this release**
170
+ and will be handled separately. No preprocessing, download or reconstruction
171
+ utilities are provided.
172
+
173
+ Training reads two JSON Lines files, resolved from the config:
174
+
175
+ ```
176
+ data/real/ # data.root
177
+ ├── train.jsonl # data.train_file
178
+ └── valid.jsonl # data.valid_file
179
+ ```
180
+
181
+ See [`data/README.md`](data/README.md) for the expected input schema — in
182
+ particular the required per-residue Cα coordinates, which staple-geometry
183
+ feasibility depends on.
184
+
185
+ The reference protocol uses 4020 training and 111 validation leads;
186
+ `scripts/check_config.py` asserts those counts, so substituting a differently
187
+ sized dataset requires relaxing the check.
188
+
189
+ ## 5. Training command
190
+
191
+ ```bash
192
+
193
+ python scripts/train.py \
194
+ --config configs/staplebridge_main.yaml \
195
+ --out-dir outputs/main_seed42
196
+ ```
197
+
198
+ <!-- `check_config.py` is read-only: it verifies the configuration and the presence of
199
+ the external assets without loading a model or training. Run it first — it turns
200
+ a misconfiguration into an immediate error rather than a failure hours in.
201
+
202
+ The CLI is intentionally small — **seed (42) and device come from the config**,
203
+ not from flags, so a run cannot silently diverge from the recorded protocol.
204
+ `--resume <checkpoint>` restores model, optimizer and RNG state so the remaining
205
+ epochs match an uninterrupted run. `train.py` refuses to start in a non-empty
206
+ `--out-dir`.
207
+
208
+ Written to `--out-dir`: `resolved_config.yaml`, `metrics.jsonl` (per-epoch),
209
+ `run_summary.json`, per-epoch validation summaries, and
210
+ `checkpoints/{best_kl,best_pv,latest,epoch_NNN}.pt`. **`best_kl.pt` is the
211
+ selected model** — see §7. -->
212
+
213
+ <!-- ## 7. Main reproducibility settings
214
+
215
+ Read from `configs/staplebridge_main.yaml` and asserted by
216
+ `scripts/check_config.py`:
217
+
218
+ | Setting | Value |
219
+ | --- | --- |
220
+ | Splits | `train.jsonl` (4020) / `valid.jsonl` (111), full validation every epoch |
221
+ | Seed | 42 |
222
+ | Epochs | 10, no early stopping (all 10 always run) |
223
+ | Horizon | 8 (training and validation must match) |
224
+ | Max neighboring actions | 128 |
225
+ | Chunk size | 32 |
226
+ | Committed plans per lead | 4 |
227
+ | `beta` (Exact-SB) | 1.0 |
228
+ | Plan-loss weight | 1.0 |
229
+ | Plan head | hidden dim 128, frozen ESM2-650M features |
230
+ | Objective | `permeability_penetrance`, E/Z product mean, neutral-canonical SMILES |
231
+ | Monitored only | toxicity, hemolysis, half-life |
232
+ | Excluded | solubility, binding affinity |
233
+ | PeptiVerse | strict SMILES mode, all fallbacks disabled |
234
+ | Catalog | hydrocarbon only (`include_optional: false`): S5-S5/i,i+4 and R8-S5/i,i+7 |
235
+ | Hard constraints | chemistry + geometry + edit budget 6.0, min sequence identity 0.6, protected edits forbidden, exact committed-plan completion |
236
+ | Decoding | strict hierarchical plan-first (`hierarchical_plan_ranking: true`) |
237
+ | **Checkpoint selection** | **minimum validation `q_star_vs_q_theta_kl`**, guarded by `require_both_topologies` |
238
+
239
+ Optimizer: Adam, lr 1e-3, grad-norm clip 1.0.
240
+
241
+ ### Checkpoint selection
242
+
243
+ The rule is: the epoch minimising `KL(q* ‖ q_theta)` on the full 111-lead
244
+ validation split, among epochs where both staple topologies appear in the
245
+ selected candidates. Validation permeability and the test split play no part in
246
+ selecting it.
247
+
248
+ For the shipped checkpoint that rule chose **epoch 9**, validation
249
+ `q_star_vs_q_theta_kl = 0.081568` — the minimum over all 10 epochs (epoch 10 rose
250
+ to 0.082585). `train.py` also maintains `best_pv.pt` by validation
251
+ delta-penetrance for monitoring; it is **not** the selected model and is not
252
+ shipped.
253
+
254
+ ## 8. Checkpoint loading
255
+
256
+ `checkpoints/staplebridge_seed42_best.pt` is the `best_kl.pt` of the seed=42
257
+ training run, copied byte-for-byte with weights unmodified
258
+ (sha256 `22d273164a3132d43617c51649947028f11859f90ca0bee28540edc9ad62a298`).
259
+
260
+ Verify it loads against this package's model definitions: -->
261
+
262
+ <!-- ```bash
263
+ python scripts/smoke_test.py
264
+ ```
265
+
266
+ This checks imports, config, model construction and a strict checkpoint load
267
+ (zero missing / unexpected keys). It is not an evaluation suite.
268
+
269
+ Minimal manual load:
270
+
271
+ ```python
272
+ import torch, yaml
273
+ from staplebridge.hydrocarbon.plan_control import build_hydrocarbon_plan_head
274
+ from staplebridge.models.policy_net import PolicyNet
275
+ from staplebridge.models.value_net import ValueNet
276
+
277
+ config = yaml.safe_load(open("configs/staplebridge_main.yaml"))
278
+ emb_dim = int(config["model"]["emb_dim"])
279
+
280
+ ckpt = torch.load("checkpoints/staplebridge_seed42_best.pt",
281
+ map_location="cpu", weights_only=False)
282
+
283
+ policy = PolicyNet(emb_dim=emb_dim)
284
+ value = ValueNet(emb_dim=emb_dim)
285
+ policy.load_state_dict(ckpt["policy_state_dict"]) # strict, exact match
286
+ value.load_state_dict(ckpt["value_state_dict"])
287
+ print("epoch:", ckpt["epoch"]) # -> 9
288
+ ```
289
+
290
+ The plan head is the one component that cannot be built without the frozen
291
+ ESM2-650M prior — it refuses a stand-in by design, so training can never
292
+ silently substitute a different context model:
293
+
294
+ ```python
295
+ from staplebridge.training.stack import build_stack
296
+ stack = build_stack(config, seed=42) # requires ESM2 on disk
297
+ head = build_hydrocarbon_plan_head(config, emb_dim, "cpu",
298
+ esm2_prior=stack["reference_priors"].peptide)
299
+ head.load_state_dict(ckpt["plan_head_state_dict"])
300
+ ```
301
+
302
+ Checkpoint payload: `policy_state_dict` (20 tensors), `value_state_dict` (20),
303
+ `plan_head_state_dict` (6), plus non-model metadata — `epoch`, the full resolved
304
+ `config`, `plan_control_enabled`, `optimizer_state_dict`, and RNG state
305
+ (`plan_rng_state`, `python_random_state`, `numpy_random_state`,
306
+ `torch_rng_state`, `cuda_rng_state_all`). The optimizer and RNG entries exist so
307
+ `--resume` can continue a run bit-identically; they are not model weights.
308
+
309
+ ## 9. Reproducibility note
310
+
311
+ - **Seed.** Fixed at 42 in the config and applied to Python, NumPy and Torch
312
+ (including all CUDA devices). Plan sampling uses its own seeded
313
+ `random.Random(42)`, checkpointed so `--resume` continues the same stream.
314
+ - **Determinism.** `torch.use_deterministic_algorithms(False)`, matching the
315
+ reference run. Results are therefore **device- and version-dependent**: a
316
+ different GPU model, CUDA version, or PyTorch build can shift metrics
317
+ slightly. Exact bit-level reproduction requires the environment in §3.
318
+ - **External models.** The frozen ESM2-650M snapshot and the PeptiVerse
319
+ classifier stack are part of the training objective. Different versions of
320
+ either change the optimisation target and will not reproduce these numbers.
321
+ - **Caches.** Training maintains a persistent ESM2 prior cache and an exact-SB
322
+ target cache (SQLite, under `outputs/cache/`). These are pure speedups —
323
+ deterministic values keyed by content — and do not change results.
324
+ - **Cost.** 10 epochs over 4020 leads with full 111-lead validation each epoch.
325
+ The reference run totalled about 3.9 h of epoch time on one GPU (~16 GiB peak).
326
+ Cache warming dominates the first epoch — 4964 s, against 760-890 s for later
327
+ epochs once the ESM2 and exact-SB caches are populated.
328
+ - **Scale guard.** `train.py` hard-asserts `train_n == 4020` and `epochs == 10`,
329
+ so the protocol cannot be shrunk by editing the config alone.
330
+
331
+ ## License
332
+
333
+ See [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) for third-party
334
+ components. A license for the StapleBridge code itself has not yet been
335
+ selected; add a `LICENSE` file before publishing. -->
THIRD_PARTY_NOTICES.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-party notices
2
+
3
+ This release bundles no third-party model weights and no third-party source
4
+ code. The components below are **required at runtime** and must be obtained by
5
+ the user under their own licenses. See README.md §4.
6
+
7
+ ## ESM2-650M — `facebook/esm2_t33_650M_UR50D`
8
+
9
+ Used frozen (never fine-tuned) as the sequence-context model: it provides the
10
+ reference-process peptide prior and the plan head's anchor/local-context
11
+ features.
12
+
13
+ - Publisher: Meta AI (Fundamental AI Research Protein Team)
14
+ - Weights: not redistributed here; download from Hugging Face.
15
+ - License: the ESM2 model license from Meta. Review it before redistributing
16
+ weights or derivatives.
17
+ - Reference: Lin et al., "Evolutionary-scale prediction of atomic-level protein
18
+ structure with a language model", *Science* 379 (2023).
19
+
20
+ ## PeptiVerse — peptide property oracles
21
+
22
+ Supplies the permeability-penetrance predictor that defines the main training
23
+ objective, plus the monitored toxicity / hemolysis / half-life predictors.
24
+
25
+ - Weights and source: not redistributed here; obtain the PeptiVerse
26
+ distribution separately.
27
+ - License: as specified by the PeptiVerse authors.
28
+
29
+ ## PeptideCLM-23M — `aaronfeller/PeptideCLM-23M-all`
30
+
31
+ Required. Supplies SMILES embeddings for several PeptiVerse predictors selected
32
+ by the official `basic_models.txt` manifest (including the half-life and
33
+ nonfouling models).
34
+
35
+ - Weights: not redistributed here; download from Hugging Face.
36
+ - License: as published with the model.
37
+
38
+ ## ChemBERTa-77M — `DeepChem/ChemBERTa-77M-MLM`
39
+
40
+ Required. Supplies SMILES embeddings for the permeability-penetrance predictor
41
+ that defines the main training objective, plus the toxicity, PAMPA and Caco-2
42
+ models.
43
+
44
+ - Weights: not redistributed here; download from Hugging Face.
45
+ - License: as published by DeepChem.
46
+ - Reference: Chithrananda et al., "ChemBERTa: Large-Scale Self-Supervised
47
+ Pretraining for Molecular Property Prediction" (2020).
48
+
49
+ ## Python dependencies
50
+
51
+ Declared in `requirements.txt` and installed from PyPI, each under its own
52
+ license:
53
+
54
+ | Package | License |
55
+ | --- | --- |
56
+ | PyTorch | BSD-3-Clause |
57
+ | NumPy | BSD-3-Clause |
58
+ | PyYAML | MIT |
59
+ | RDKit | BSD-3-Clause |
60
+ | transformers (Hugging Face) | Apache-2.0 |
61
+
62
+ The PeptiVerse distribution brings its own further dependencies (scikit-learn,
63
+ XGBoost, MAPIE, pandas, joblib and others); those are governed by their
64
+ respective licenses and are not declared by this package.
65
+
66
+ ## Data
67
+
68
+ No dataset is included in this release. The processed training and validation
69
+ splits are handled separately; nothing here downloads, reconstructs or
70
+ redistributes data. See `data/README.md`.
checkpoints/staplebridge_seed42_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:22d273164a3132d43617c51649947028f11859f90ca0bee28540edc9ad62a298
3
+ size 1394383
configs/staplebridge_main.yaml ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Main StapleBridge training configuration (Full Exact-SB).
2
+ #
3
+ # This is the configuration that produced checkpoints/staplebridge_seed42_best.pt.
4
+ # Scale, objective, chemistry and checkpoint-selection settings are those of the
5
+ # reported run and should not be changed to reproduce it.
6
+ #
7
+ # Paths marked EXTERNAL ASSET must point at resources you provide (see README.md
8
+ # section 4 and data/README.md). Relative paths resolve against the package root,
9
+ # absolute paths are used as given.
10
+ chemistry: hydrocarbon
11
+ seed: 42
12
+ exp_name: staplebridge_main_seed42
13
+ train_n: 4020
14
+ max_neighbors: 128
15
+ data:
16
+ # EXTERNAL ASSET: official train/valid splits. See data/README.md.
17
+ root: data/real
18
+ train_file: train.jsonl
19
+ valid_file: valid.jsonl
20
+ max_lead_length: 25
21
+ model:
22
+ mode: policy_tilt
23
+ emb_dim: 32
24
+ hydrocarbon:
25
+ reference:
26
+ factorized_plan_reference: false
27
+ decode:
28
+ hierarchical_plan_ranking: true
29
+ catalog:
30
+ include_optional: false
31
+ actions:
32
+ max_neighbors: 128
33
+ allow_noop: true
34
+ plan_control:
35
+ enabled: true
36
+ plans_per_lead: 4
37
+ hidden_dim: 128
38
+ loss_weight: 1.0
39
+ target_temperature: 0.1
40
+ exact_sb_objective: true
41
+ exact_sb_beta: 1.0
42
+ plan_encoder_v2: true
43
+ validation_all_plans: true
44
+ exact_sb_cache:
45
+ enabled: true
46
+ path: outputs/cache/exact_sb_targets_full.sqlite
47
+ curriculum:
48
+ enabled: false
49
+ max_anchor_edits: 2
50
+ num_trajectories_per_lead: 4
51
+ prefer_existing_anchors: true
52
+ protect_positions: true
53
+ require_valid_terminal: true
54
+ plan_reference:
55
+ enabled: true
56
+ mode_prior:
57
+ prior_dir: staplebridge/hydrocarbon/data
58
+ dedup_version: sequence_deduplicated
59
+ use_smoothed: true
60
+ beta: 0.75
61
+ unobserved_probability: 0.001
62
+ bias:
63
+ first_anchor: 3.0
64
+ second_anchor: 3.5
65
+ anchor_assign: 4.0
66
+ block_assign: 4.0
67
+ topology_activation: 4.5
68
+ off_plan_topology_activation: -4.5
69
+ off_plan_substitution: -3.0
70
+ off_plan_anchor_selection: -3.0
71
+ off_plan_block_assign: -1.0
72
+ noop: -1.0
73
+ endpoint_prior:
74
+ enabled: false
75
+ # Unused: this prior is disabled (and force-disabled in build_stack); the
76
+ # empirical endpoint evidence enters only via plan_reference.mode_prior above.
77
+ prior_dir: null
78
+ dedup_version: sequence_deduplicated
79
+ use_smoothed: true
80
+ weight_pair: 0.0
81
+ use_length: false
82
+ weight_length: 0.0
83
+ use_relative_position: false
84
+ weight_relative_position: 0.0
85
+ use_local_context: false
86
+ weight_local_context: 0.0
87
+ local_context_window: 2
88
+ max_component_energy: 25.0
89
+ terminal_energy:
90
+ invalid_topology_penalty: 10.0
91
+ penalize_unstapled: true
92
+ property:
93
+ enabled: true
94
+ enable_developability_constraints: false
95
+ enable_halflife_preservation: false
96
+ penetrance_weight: 5.0
97
+ toxicity_guard_enabled: false
98
+ toxicity_threshold: 0.49
99
+ toxicity_guard_weight: 0.0
100
+ input_convention: neutral_canonical
101
+ product_geometries:
102
+ - E
103
+ - Z
104
+ product_aggregation: mean
105
+ ez_uncertainty: absolute_difference
106
+ objective: permeability_penetrance
107
+ log_only:
108
+ - toxicity
109
+ - hemolysis
110
+ - halflife
111
+ excluded:
112
+ - solubility
113
+ - binding_affinity
114
+ geometry:
115
+ sentinel_cgeom: 10.0
116
+ reference_priors:
117
+ strict_no_mock: true
118
+ peptide:
119
+ backend: esm2_delta
120
+ # EXTERNAL ASSET: local frozen ESM2-650M snapshot directory (not redistributed).
121
+ model_name_or_path: models/esm2_t33_650M_UR50D
122
+ # Persistent ESM2 prior cache (SQLite, WAL). Created on first use; reusing a
123
+ # warm cache across runs is a pure speedup and does not change any value.
124
+ cache_path: outputs/cache/esm2_peptide_prior.sqlite
125
+ device: cuda:0
126
+ offline: true
127
+ strict_runtime: true
128
+ temperature: 1.0
129
+ max_batch_size: 128
130
+ ncaa_policy:
131
+ mode: canonical_surrogate
132
+ surrogates:
133
+ S5: A
134
+ R8: A
135
+ R5: A
136
+ S8: A
137
+ unknown_penalty: 0.2
138
+ anchor:
139
+ backend: motif_support_geometry
140
+ allowed_spacings:
141
+ - 4
142
+ - 7
143
+ max_motif_edits: 2
144
+ avoid_protected: true
145
+ project_motif_for_geometry: true
146
+ weights:
147
+ valid_spacing: 1.0
148
+ existing_motif: 2.0
149
+ one_edit_motif: 1.2
150
+ two_edit_motif: 0.4
151
+ protected_penalty: 4.0
152
+ geometry_surrogate: 1.0
153
+ block:
154
+ backend: catalog_scored
155
+ max_motif_edits: 2
156
+ weights:
157
+ spacing: 2.0
158
+ residue_compatibility: 2.0
159
+ motif_edit_distance: 1.0
160
+ synthetic_accessibility: 0.5
161
+ spps: 0.5
162
+ cost: 0.2
163
+ reference:
164
+ eta_cost: 0.5
165
+ eta_spps: 0.3
166
+ eta_type: 5.0
167
+ eta_geom: 1.0
168
+ training:
169
+ epochs: 10
170
+ lr: 0.001
171
+ device: cuda:0
172
+ horizon: 8
173
+ trajectories_per_lead: 4
174
+ chunk_size: 32
175
+ eps_geom: 2.5
176
+ lambda_close: 1.0
177
+ lambda_edit: 0.2
178
+ lambda_cost: 0.2
179
+ infeasible_penalty: 20.0
180
+ grad_clip_norm: 1.0
181
+ property_predictor:
182
+ backend: peptiverse
183
+ # EXTERNAL ASSET: PeptiVerse checkout + its classifier weights (not redistributed).
184
+ peptiverse_root: external/PeptiVerse
185
+ classifier_weight_root: external/PeptiVerse
186
+ manifest_path: external/PeptiVerse/basic_models.txt
187
+ hf_cache_dir: models/hf
188
+ esm_model_name_or_path: models/esm2_t33_650M_UR50D
189
+ peptideclm_model_name_or_path: models/PeptideCLM-23M-all
190
+ chemberta_model_name_or_path: models/ChemBERTa-77M-MLM
191
+ device: cuda:0
192
+ strict: true
193
+ enable_fallback: false
194
+ allow_wt_token_fallback: false
195
+ cache_enabled: true
196
+ offline: true
197
+ uncertainty: false
198
+ mode: smiles
199
+ validation:
200
+ enabled: true
201
+ every_epoch: true
202
+ n_leads: 111
203
+ max_lead_length: 25
204
+ beam_size: 8
205
+ horizon: 8
206
+ seed: 42
207
+ checkpointing:
208
+ save_last: true
209
+ save_best: true
210
+ save_every_epoch: true
211
+ save_optimizer: true
212
+ save_rng_state: true
213
+ best_metric: q_star_vs_q_theta_kl
214
+ mode: min
215
+ early_stopping:
216
+ # All 10 epochs must complete. scripts/train.py ignores this block entirely
217
+ # and never early-stops.
218
+ enabled: false
219
+ metric: q_star_vs_q_theta_kl
220
+ mode: min
221
+ patience: 3
222
+ min_delta: 0.001
223
+ restore_best: false
224
+ guardrails:
225
+ chemistry_valid_min: 0.9
226
+ stapled_min: 0.9
227
+ edit_distance_target: 4.0
228
+ edit_distance_tolerance: 0.5
229
+ require_both_topologies: true
230
+ no_block_set_worsening_tolerance: 0.01
231
+ edit_constraints:
232
+ max_edit_budget: 6.0
233
+ min_sequence_identity: 0.6
234
+ allow_protected_edits: false
235
+ wandb:
236
+ # Disabled in the official run, and no released code reads this block.
237
+ # Retained only so the config matches the one that produced the checkpoint.
238
+ enabled: false
239
+ mode: disabled
240
+ profiling:
241
+ # Per-32-lead chunk timing breakdown printed as `[chunk timing] {...}`.
242
+ # Wall-clock instrumentation only; does not affect loss, RNG, sampling,
243
+ # batch/chunk size, or model. See train_enabled_epoch for the CUDA-sync note.
244
+ chunk_timing: true
data/README.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data
2
+
3
+ **The processed training and validation data are not included in this release.**
4
+ They will be handled separately. This directory is a placeholder; nothing here
5
+ downloads, reconstructs, or redistributes a dataset.
6
+
7
+ ## Where to place files
8
+
9
+ Training reads exactly two files, resolved from the config
10
+ (`configs/staplebridge_main.yaml`):
11
+
12
+ ```yaml
13
+ data:
14
+ root: data/real # relative paths resolve against the release root
15
+ train_file: train.jsonl
16
+ valid_file: valid.jsonl
17
+ max_lead_length: 25
18
+ ```
19
+
20
+ giving the default layout:
21
+
22
+ ```
23
+ data/real/
24
+ ├── train.jsonl
25
+ └── valid.jsonl
26
+ ```
27
+
28
+ Any location works — set `data.root` to an absolute path if you prefer.
29
+
30
+ ## Expected input schema
31
+
32
+ JSON Lines: one object per line, loaded into
33
+ `staplebridge.data.schemas.LeadExample` by
34
+ `staplebridge.data.dataset.load_leads`.
35
+
36
+ | Field | Type | Required | Meaning |
37
+ | --- | --- | --- | --- |
38
+ | `example_id` | str | yes | Unique lead identifier; also the property-cache key. |
39
+ | `linear_sequence` | str | yes | Linear lead peptide, one letter per residue. |
40
+ | `protected_positions` | list[int] | yes (may be `[]`) | 0-based positions edits must not touch; enforced as a hard constraint. |
41
+ | `target_context` | object | yes in practice | Must contain `peptide_ca` (below). |
42
+ | `target_id` | str \| null | no | Binding-partner identifier. |
43
+ | `preferred_property_direction` | object | no | Not used by the main objective. |
44
+ | `thresholds` | object | no | Not used by the main objective. |
45
+ | `known_active_motif_positions` | list[int] \| null | no | Optional motif annotation. |
46
+
47
+ `target_context.peptide_ca` must be a list of `[x, y, z]` Cα coordinates, one
48
+ per residue of `linear_sequence`, in order. Staple-geometry feasibility (the
49
+ `ca_window` span check gating every candidate plan) is computed from these; a
50
+ lead without them cannot yield a feasible plan support. Other keys in
51
+ `target_context` are ignored.
52
+
53
+ Leads longer than `max_lead_length` are filtered out before use.
figure/framework.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d7fabc5efb91b9cef419d11bb646493004ef796d043c4863c19b678dcce470a2
3
+ size 884444
figure/framework.png ADDED

Git LFS Details

  • SHA256: 6d6032d2c83de1513c5aa4f29dfdf5f106fe1e0df019319e65e69a7c79ce8eb7
  • Pointer size: 131 Bytes
  • Size of remote file: 444 kB
requirements.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Direct dependencies of this package, pinned to the versions the shipped
2
+ # checkpoint was trained under. Install torch first, matched to your CUDA build:
3
+ # https://pytorch.org
4
+ #
5
+ # Reference environment: Python 3.10.20, torch 2.12.1+cu130 (CUDA 13.0).
6
+
7
+ torch==2.12.1
8
+ numpy==2.0.2
9
+ PyYAML==6.0.3
10
+
11
+ # Strict SMILES construction and validation of stapled products.
12
+ rdkit==2026.3.5
13
+
14
+ # Loads the frozen ESM2-650M sequence-context model. Weights are not bundled;
15
+ # see README.md section 4.
16
+ transformers==4.46.0
17
+
18
+ # Not listed here on purpose: the PeptiVerse property oracles are an external
19
+ # dependency that brings its own requirements (scikit-learn, XGBoost, MAPIE,
20
+ # pandas, joblib). Nothing in this package imports them directly, so install
21
+ # them from the PeptiVerse distribution instead. See README.md section 4.
scripts/train.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Training entry point for the main StapleBridge model (Full Exact-SB).
3
+
4
+ This is the orchestrator that produced the released checkpoint:
5
+
6
+ * full 4020-lead training each epoch;
7
+ * full validation on every validation lead (111), every epoch;
8
+ * a fixed 10 epochs, no early stopping (all 10 always run);
9
+ * two checkpoints maintained independently, each updated after that epoch's
10
+ validation pass:
11
+ - ``best_kl.pt``: minimum ``q_star_vs_q_theta_kl`` -- the selection rule;
12
+ - ``best_pv.pt``: maximum ``mean_delta_penetrance_vs_original_lead``,
13
+ recorded for monitoring only and not used to select the released model;
14
+ * per-epoch logging of both metrics with their running bests.
15
+
16
+ ``checkpoints/staplebridge_seed42_best.pt`` is the ``best_kl.pt`` of this run:
17
+ the epoch minimising ``q_star_vs_q_theta_kl`` on the validation split. Model,
18
+ loss, Exact-SB, property scoring, decoding and every other training setting are
19
+ read from the config.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import gc
25
+ import json
26
+ import random
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import numpy as np
33
+ import torch
34
+ import yaml
35
+
36
+ PACKAGE_ROOT = Path(__file__).resolve().parents[1]
37
+ if str(PACKAGE_ROOT) not in sys.path:
38
+ sys.path.insert(0, str(PACKAGE_ROOT))
39
+
40
+ from staplebridge.data.dataset import load_leads # noqa: E402
41
+ from staplebridge.hydrocarbon.exact_sb_cache import build_cache_from_config # noqa: E402
42
+ from staplebridge.hydrocarbon.plan_control import ( # noqa: E402
43
+ HydrocarbonPlanControlConfig, build_hydrocarbon_plan_head,
44
+ )
45
+ from staplebridge.hydrocarbon.property_energy import ( # noqa: E402
46
+ HydrocarbonPropertyEnergyConfig,
47
+ HydrocarbonPropertyScorer,
48
+ required_original_lead_properties,
49
+ )
50
+ from staplebridge.hydrocarbon.tokenizer import tokenize_sequence # noqa: E402
51
+ from staplebridge.training.main_loop import ( # noqa: E402
52
+ train_enabled_epoch, validate_enabled,
53
+ )
54
+ from staplebridge.training.records import write_json, write_jsonl # noqa: E402
55
+ from staplebridge.training.stack import ( # noqa: E402
56
+ build_energy, build_models, build_predictor, build_stack, load_config,
57
+ seed_everything, select_leads,
58
+ )
59
+
60
+ FULL_TRAIN_N = 4020
61
+ SEED = 42
62
+ EPOCHS = 10
63
+ KL_KEY = "q_star_vs_q_theta_kl"
64
+ PV_KEY = "mean_delta_penetrance_vs_original_lead"
65
+
66
+
67
+ def parse_args() -> argparse.Namespace:
68
+ parser = argparse.ArgumentParser(description=__doc__)
69
+ parser.add_argument("--config", type=Path,
70
+ default=PACKAGE_ROOT / "configs/staplebridge_main.yaml")
71
+ parser.add_argument("--out-dir", type=Path,
72
+ default=PACKAGE_ROOT / "outputs/main_seed42")
73
+ parser.add_argument("--resume", type=Path, default=None,
74
+ help="Checkpoint to resume from (e.g. checkpoints/epoch_008.pt). "
75
+ "Restores model/optimizer/RNG so remaining epochs are identical "
76
+ "to an uninterrupted run; appends to the existing metrics/log.")
77
+ return parser.parse_args()
78
+
79
+
80
+ def require(condition: bool, message: str) -> None:
81
+ if not condition:
82
+ raise SystemExit(message)
83
+
84
+
85
+ def rng_payload() -> dict[str, Any]:
86
+ return {
87
+ "python_random_state": random.getstate(),
88
+ "numpy_random_state": np.random.get_state(),
89
+ "torch_rng_state": torch.get_rng_state(),
90
+ "cuda_rng_state_all": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
91
+ }
92
+
93
+
94
+ def build_original_cache(config: dict[str, Any], leads: list[Any], out_dir: Path) -> dict:
95
+ """Compute the configured unedited-lead property cache once."""
96
+ path = out_dir / "original_linear_cache.jsonl"
97
+ wrapper, _ = build_predictor(config)
98
+ cache: dict[tuple[Any, ...], dict[str, Any]] = {}
99
+ scorer = HydrocarbonPropertyScorer(wrapper, original_linear_cache=cache)
100
+ property_cfg = HydrocarbonPropertyEnergyConfig.from_dict(
101
+ (((config.get("hydrocarbon") or {}).get("terminal_energy") or {}).get("property"))
102
+ )
103
+ properties = required_original_lead_properties(property_cfg)
104
+ started = time.perf_counter()
105
+ if (
106
+ property_cfg.enable_developability_constraints
107
+ or property_cfg.enable_halflife_preservation
108
+ or property_cfg.enable_joint_perm_halflife_support
109
+ ):
110
+ scorer.prefetch(
111
+ properties,
112
+ [
113
+ scorer.original_linear_smiles(tokenize_sequence(lead.linear_sequence))
114
+ for lead in leads
115
+ ],
116
+ )
117
+ for index, lead in enumerate(leads):
118
+ scorer.score_original_linear(
119
+ tokenize_sequence(lead.linear_sequence),
120
+ lead_key=str(lead.example_id),
121
+ properties=properties,
122
+ )
123
+ if (index + 1) % 512 == 0 or index + 1 == len(leads):
124
+ print(f"[original baseline] {index + 1}/{len(leads)}", flush=True)
125
+ rows = [{"lead_key": k[0], "tokens": list(k[1]), "scores": v} for k, v in cache.items()]
126
+ write_jsonl(path, rows)
127
+ del scorer, wrapper
128
+ import gc
129
+ gc.collect()
130
+ if torch.cuda.is_available():
131
+ torch.cuda.empty_cache()
132
+ print(f"[original baseline] built {len(cache)} entries in {time.perf_counter() - started:.1f}s", flush=True)
133
+ return cache
134
+
135
+
136
+ def load_original_cache(path: Path) -> dict[tuple[Any, ...], dict[str, Any]]:
137
+ cache: dict[tuple[Any, ...], dict[str, Any]] = {}
138
+ with path.open(encoding="utf-8") as handle:
139
+ for line in handle:
140
+ row = json.loads(line)
141
+ cache[(row["lead_key"], tuple(row["tokens"]))] = dict(row["scores"])
142
+ return cache
143
+
144
+
145
+ def save_checkpoint(path: Path, payload: dict[str, Any]) -> None:
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ tmp = path.with_suffix(path.suffix + ".tmp")
148
+ torch.save(payload, tmp)
149
+ tmp.replace(path)
150
+
151
+
152
+ def main() -> None:
153
+ args = parse_args()
154
+ config = load_config(args.config.resolve())
155
+ out_dir = args.out_dir.resolve()
156
+ resuming = args.resume is not None
157
+
158
+ if resuming:
159
+ require(out_dir.is_dir(), f"resume requires existing output dir: {out_dir}")
160
+ require(args.resume.is_file(), f"resume checkpoint not found: {args.resume}")
161
+ else:
162
+ # Never overwrite prior outputs: require a fresh/empty directory.
163
+ if out_dir.exists():
164
+ leftovers = [p for p in out_dir.iterdir()]
165
+ require(not leftovers, f"output directory not empty (refusing to overwrite): {out_dir}")
166
+ out_dir.mkdir(parents=True, exist_ok=True)
167
+
168
+ require(int(config.get("train_n", -1)) == FULL_TRAIN_N, "train_n must be 4020")
169
+ require(int(config["training"]["epochs"]) == EPOCHS, f"epochs must be {EPOCHS}")
170
+ device = torch.device(str(config["training"]["device"]))
171
+ if device.type == "cuda":
172
+ require(torch.cuda.is_available(), f"CUDA unavailable for {device}")
173
+ torch.cuda.set_device(device)
174
+
175
+ (out_dir / "resolved_config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
176
+ seed_everything(SEED)
177
+
178
+ # ---- data: full train (4020) + FULL valid (all 111, not the 32 subset) --
179
+ train_leads = select_leads(
180
+ Path(config["data"]["root"]) / config["data"]["train_file"],
181
+ FULL_TRAIN_N, int(config["data"]["max_lead_length"]),
182
+ )
183
+ valid_cap = int(config["validation"]["max_lead_length"])
184
+ valid_leads = [
185
+ lead for lead in load_leads(Path(config["data"]["root"]) / config["data"]["valid_file"])
186
+ if len(lead.linear_sequence) <= valid_cap
187
+ ]
188
+ print(f"[data] train={len(train_leads)} full_valid={len(valid_leads)} (cap len<={valid_cap})", flush=True)
189
+
190
+ # ---- original-lead penetrance cache (train + valid) for delta-PV --------
191
+ original_path = out_dir / "original_linear_cache.jsonl"
192
+ if resuming and original_path.is_file():
193
+ original_cache = load_original_cache(original_path)
194
+ print(f"[original baseline] reused {len(original_cache)} entries from {original_path}", flush=True)
195
+ else:
196
+ original_cache = build_original_cache(config, train_leads + valid_leads, out_dir)
197
+
198
+ # ---- model + energy stack (identical construction to the standard run) --
199
+ stack = build_stack(config, SEED)
200
+ wrapper, _ = build_predictor(config)
201
+ scorer = HydrocarbonPropertyScorer(wrapper, original_linear_cache=original_cache)
202
+ energy_fn = build_energy(config, stack, scorer)
203
+ policy, value, kernel, optimizer = build_models(config, stack, device)
204
+ head = build_hydrocarbon_plan_head(
205
+ config, int(config["model"]["emb_dim"]), device,
206
+ esm2_prior=stack["reference_priors"].peptide,
207
+ )
208
+ optimizer.add_param_group({"params": list(head.parameters())})
209
+ parameters = list(policy.parameters()) + list(value.parameters()) + list(head.parameters())
210
+ plan_cfg = HydrocarbonPlanControlConfig.from_config(config)
211
+ plan_rng = random.Random(SEED)
212
+
213
+ # ---- reuse the existing persistent q* cache (fingerprint unchanged) -----
214
+ exact_sb_cache = build_cache_from_config(config, catalog=stack["catalog"], repo_root=PACKAGE_ROOT)
215
+ if exact_sb_cache.enabled:
216
+ print(f"[exact-sb cache] {json.dumps(exact_sb_cache.describe(), ensure_ascii=False)}", flush=True)
217
+
218
+ def make_payload(epoch: int) -> dict[str, Any]:
219
+ return {
220
+ "epoch": epoch, "config": config, "plan_control_enabled": True,
221
+ "policy_state_dict": policy.state_dict(), "value_state_dict": value.state_dict(),
222
+ "plan_head_state_dict": head.state_dict(), "optimizer_state_dict": optimizer.state_dict(),
223
+ "plan_rng_state": plan_rng.getstate(), **rng_payload(),
224
+ }
225
+
226
+ metrics_path = out_dir / "metrics.jsonl"
227
+ history: list[dict[str, Any]] = []
228
+ best_kl = {"value": float("inf"), "epoch": None}
229
+ best_pv = {"value": float("-inf"), "epoch": None}
230
+ start_epoch = 0
231
+
232
+ if resuming:
233
+ ckpt = torch.load(args.resume, map_location=device, weights_only=False)
234
+ policy.load_state_dict(ckpt["policy_state_dict"])
235
+ value.load_state_dict(ckpt["value_state_dict"])
236
+ head.load_state_dict(ckpt["plan_head_state_dict"])
237
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
238
+ plan_rng.setstate(ckpt["plan_rng_state"])
239
+ random.setstate(ckpt["python_random_state"])
240
+ np.random.set_state(ckpt["numpy_random_state"])
241
+ torch.set_rng_state(ckpt["torch_rng_state"].cpu())
242
+ if torch.cuda.is_available() and ckpt.get("cuda_rng_state_all") is not None:
243
+ torch.cuda.set_rng_state_all([s.cpu() for s in ckpt["cuda_rng_state_all"]])
244
+ start_epoch = int(ckpt["epoch"])
245
+ # Rebuild running bests + history from the persisted per-epoch metrics so
246
+ # best_kl/best_pv provenance carries across the resume boundary.
247
+ for line in metrics_path.read_text().splitlines():
248
+ rec = json.loads(line)
249
+ if int(rec["epoch"]) <= start_epoch:
250
+ history.append(rec)
251
+ if history:
252
+ last = history[-1]
253
+ best_kl = {"value": float(last["best_kl_value"]), "epoch": int(last["best_kl_epoch"])}
254
+ best_pv = {"value": float(last["best_pv_value"]), "epoch": int(last["best_pv_epoch"])}
255
+ print(
256
+ f"[resume] from {args.resume} completed_epoch={start_epoch}; "
257
+ f"best_kl={best_kl['value']:.6f}@ep{best_kl['epoch']} "
258
+ f"best_pv={best_pv['value']:.6f}@ep{best_pv['epoch']}",
259
+ flush=True,
260
+ )
261
+ run_started = time.perf_counter()
262
+
263
+ for epoch in range(start_epoch, EPOCHS):
264
+ policy.train(); value.train(); head.train()
265
+ train_started = time.perf_counter()
266
+ rows, train_metrics = train_enabled_epoch(
267
+ train_leads, config, stack, energy_fn, policy, kernel, optimizer,
268
+ parameters, head, plan_cfg, plan_rng, epoch, exact_sb_cache=exact_sb_cache,
269
+ )
270
+ train_seconds = time.perf_counter() - train_started
271
+ write_jsonl(out_dir / "training" / f"epoch_{epoch + 1:03d}_candidates.jsonl", rows)
272
+ joint_train_audit = train_metrics.get("joint_perm_halflife_support")
273
+ if joint_train_audit and epoch == 0:
274
+ write_jsonl(
275
+ out_dir / "joint_support_audit" / "train4020_per_lead.jsonl",
276
+ joint_train_audit["per_lead"],
277
+ )
278
+ write_json(
279
+ out_dir / "joint_support_audit" / "train4020_summary.json",
280
+ {k: v for k, v in joint_train_audit.items() if k != "per_lead"},
281
+ )
282
+
283
+ policy.eval(); value.eval(); head.eval()
284
+ valid_started = time.perf_counter()
285
+ selected, validation = validate_enabled(
286
+ valid_leads, config, stack, energy_fn, policy, kernel, head,
287
+ exact_sb_cache=exact_sb_cache,
288
+ )
289
+ valid_seconds = time.perf_counter() - valid_started
290
+ valid_dir = out_dir / "validation" / f"epoch_{epoch + 1:03d}"
291
+ write_jsonl(valid_dir / "selected.jsonl", selected)
292
+ write_json(valid_dir / "summary.json", validation)
293
+ joint_valid_audit = validation.get("joint_perm_halflife_support")
294
+ if joint_valid_audit:
295
+ write_jsonl(
296
+ valid_dir / "joint_support_per_lead.jsonl",
297
+ joint_valid_audit["per_lead"],
298
+ )
299
+ write_json(
300
+ valid_dir / "joint_support_summary.json",
301
+ {k: v for k, v in joint_valid_audit.items() if k != "per_lead"},
302
+ )
303
+
304
+ kl = float(validation[KL_KEY])
305
+ pv = float(validation[PV_KEY])
306
+ guard_cfg = dict(config.get("guardrails") or {})
307
+ both_topologies_present = bool(
308
+ validation.get("s5_s5_i4_count", 0) > 0
309
+ and validation.get("r8_s5_i7_count", 0) > 0
310
+ )
311
+ checkpoint_eligible = bool(
312
+ not guard_cfg.get("require_both_topologies", False)
313
+ or both_topologies_present
314
+ )
315
+ improved_kl = checkpoint_eligible and kl < best_kl["value"]
316
+ improved_pv = checkpoint_eligible and pv > best_pv["value"]
317
+ if improved_kl:
318
+ best_kl = {"value": kl, "epoch": epoch + 1}
319
+ save_checkpoint(out_dir / "checkpoints" / "best_kl.pt", make_payload(epoch + 1))
320
+ if improved_pv:
321
+ best_pv = {"value": pv, "epoch": epoch + 1}
322
+ save_checkpoint(out_dir / "checkpoints" / "best_pv.pt", make_payload(epoch + 1))
323
+ # Per-epoch + latest snapshots so no epoch is lost (fresh dir; nothing
324
+ # is overwritten across runs).
325
+ payload = make_payload(epoch + 1)
326
+ save_checkpoint(out_dir / "checkpoints" / f"epoch_{epoch + 1:03d}.pt", payload)
327
+ save_checkpoint(out_dir / "checkpoints" / "latest.pt", payload)
328
+
329
+ record = {
330
+ "epoch": epoch + 1,
331
+ "valid_kl": kl,
332
+ "valid_delta_penetrance": pv,
333
+ "best_kl_epoch": best_kl["epoch"], "best_kl_value": best_kl["value"], "kl_improved": improved_kl,
334
+ "best_pv_epoch": best_pv["epoch"], "best_pv_value": best_pv["value"], "pv_improved": improved_pv,
335
+ "valid_product_penetrance": validation.get("mean_product_penetrance"),
336
+ "valid_top1_chemistry_valid_rate": validation.get("top1_chemistry_valid_rate"),
337
+ "valid_top1_stapled_rate": validation.get("top1_stapled_rate"),
338
+ "valid_q_star_top1_agreement": validation.get("q_star_top1_agreement"),
339
+ "valid_q_star_spearman": validation.get("q_star_spearman"),
340
+ "valid_edit_distance": validation.get("mean_weighted_edit_distance"),
341
+ "both_topologies_present": both_topologies_present,
342
+ "checkpoint_eligible": checkpoint_eligible,
343
+ "train_loss": train_metrics.get("loss"),
344
+ "train_plan_loss": train_metrics.get("plan_loss"),
345
+ "train_q_star_vs_q_theta_kl": train_metrics.get("q_star_vs_q_theta_kl"),
346
+ "train_seconds": train_seconds,
347
+ "valid_seconds": valid_seconds,
348
+ "epoch_seconds": train_seconds + valid_seconds,
349
+ "train_stage_seconds": train_metrics.get("stage_seconds"),
350
+ "exact_sb_cache": exact_sb_cache.describe() if exact_sb_cache.enabled else {"enabled": False},
351
+ "train_joint_perm_halflife_support": (
352
+ {k: v for k, v in (joint_train_audit or {}).items() if k != "per_lead"}
353
+ if joint_train_audit
354
+ else None
355
+ ),
356
+ "valid_joint_perm_halflife_support": (
357
+ {
358
+ k: v
359
+ for k, v in (joint_valid_audit or {}).items()
360
+ if k != "per_lead"
361
+ }
362
+ if joint_valid_audit
363
+ else None
364
+ ),
365
+ }
366
+ history.append(record)
367
+ write_jsonl(metrics_path, [record], mode="a")
368
+ print(
369
+ f"[epoch {epoch + 1}/{EPOCHS}] "
370
+ f"valid_KL={kl:.6f} (best {best_kl['value']:.6f} @ep{best_kl['epoch']}"
371
+ f"{' NEW' if improved_kl else ''}) "
372
+ f"valid_deltaPV={pv:.6f} (best {best_pv['value']:.6f} @ep{best_pv['epoch']}"
373
+ f"{' NEW' if improved_pv else ''}) "
374
+ f"epoch_seconds={record['epoch_seconds']:.1f}",
375
+ flush=True,
376
+ )
377
+ # Semantics-neutral memory hygiene: reclaim inter-epoch CUDA cache /
378
+ # Python garbage so allocator fragmentation does not accumulate across
379
+ # the 10 epochs. Does not touch weights, RNG, or any cached value.
380
+ gc.collect()
381
+ if device.type == "cuda":
382
+ torch.cuda.empty_cache()
383
+ print(
384
+ f"[gpu] epoch {epoch + 1} allocated={torch.cuda.memory_allocated(device) / 2**20:.0f}MiB "
385
+ f"reserved={torch.cuda.memory_reserved(device) / 2**20:.0f}MiB",
386
+ flush=True,
387
+ )
388
+
389
+ summary = {
390
+ "exp_name": config.get("exp_name"),
391
+ "epochs": EPOCHS,
392
+ "early_stopping": False,
393
+ "train_n": FULL_TRAIN_N,
394
+ "n_valid_leads": len(valid_leads),
395
+ "seed": SEED,
396
+ "best_kl": best_kl,
397
+ "best_pv": best_pv,
398
+ "kl_curve": [(r["epoch"], r["valid_kl"]) for r in history],
399
+ "pv_curve": [(r["epoch"], r["valid_delta_penetrance"]) for r in history],
400
+ "runtime_seconds": time.perf_counter() - run_started,
401
+ "gpu_peak_mib": (torch.cuda.max_memory_allocated(device) / 2 ** 20) if device.type == "cuda" else 0,
402
+ "checkpoints": {
403
+ "best_kl": str(out_dir / "checkpoints" / "best_kl.pt"),
404
+ "best_pv": str(out_dir / "checkpoints" / "best_pv.pt"),
405
+ },
406
+ "history": history,
407
+ }
408
+ write_json(out_dir / "run_summary.json", summary)
409
+ print("\n" + json.dumps({k: v for k, v in summary.items() if k != "history"}, indent=2, default=str), flush=True)
410
+
411
+
412
+ if __name__ == "__main__":
413
+ main()
staplebridge/.DS_Store ADDED
Binary file (8.2 kB). View file
 
staplebridge/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """StapleBridge: reference-guided discrete Schrodinger bridge for stapled peptides."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
staplebridge/chemistry/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """"""
staplebridge/chemistry/actions.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from staplebridge.chemistry.state import StapleState
6
+
7
+
8
+ class Action:
9
+ """Base action for editing StapleState."""
10
+
11
+ def apply(self, state: StapleState) -> StapleState:
12
+ raise NotImplementedError
13
+
14
+
15
+ @dataclass
16
+ class ResidueSubstitutionAction(Action):
17
+ position: int
18
+ new_token: str
19
+
20
+ def apply(self, state: StapleState) -> StapleState:
21
+ ns = state.copy()
22
+ ns.sequence_tokens[self.position] = self.new_token
23
+ return ns
24
+
25
+
26
+ @dataclass
27
+ class NcAASubstitutionAction(Action):
28
+ position: int
29
+ block_or_token: str
30
+
31
+ def apply(self, state: StapleState) -> StapleState:
32
+ ns = state.copy()
33
+ ns.sequence_tokens[self.position] = self.block_or_token
34
+ return ns
35
+
36
+
37
+ @dataclass
38
+ class AnchorAssignAction(Action):
39
+ i: int
40
+ j: int
41
+
42
+ def apply(self, state: StapleState) -> StapleState:
43
+ ns = state.copy()
44
+ ns.anchor_pair = (self.i, self.j)
45
+ return ns
46
+
47
+
48
+ @dataclass
49
+ class AnchorReassignAction(Action):
50
+ i: int
51
+ j: int
52
+
53
+ def apply(self, state: StapleState) -> StapleState:
54
+ ns = state.copy()
55
+ ns.anchor_pair = (self.i, self.j)
56
+ return ns
57
+
58
+
59
+ @dataclass
60
+ class BlockAssignAction(Action):
61
+ block_id: str
62
+
63
+ def apply(self, state: StapleState) -> StapleState:
64
+ ns = state.copy()
65
+ ns.block_id = self.block_id
66
+ return ns
67
+
68
+
69
+ @dataclass
70
+ class TopologyActivationAction(Action):
71
+ def apply(self, state: StapleState) -> StapleState:
72
+ ns = state.copy()
73
+ ns.topology = "stapled"
74
+ return ns
75
+
76
+
77
+ @dataclass
78
+ class NoOpAction(Action):
79
+ def apply(self, state: StapleState) -> StapleState:
80
+ return state.copy()
staplebridge/chemistry/edit_distance.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from staplebridge.chemistry.state import StapleState
4
+
5
+
6
+ def weighted_edit_distance(
7
+ state: StapleState,
8
+ lead: StapleState,
9
+ token_weight: float = 1.0,
10
+ anchor_weight: float = 1.0,
11
+ topology_weight: float = 0.5,
12
+ block_weight: float = 0.5,
13
+ ) -> float:
14
+ token_diff = sum(a != b for a, b in zip(state.sequence_tokens, lead.sequence_tokens))
15
+ anchor_pen = 0.0 if state.anchor_pair == lead.anchor_pair else 1.0
16
+ top_pen = 0.0 if state.topology == lead.topology else 1.0
17
+ block_pen = 0.0 if state.block_id == lead.block_id else 1.0
18
+ return token_weight * token_diff + anchor_weight * anchor_pen + topology_weight * top_pen + block_weight * block_pen
staplebridge/chemistry/edit_metrics.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Edit-distance + minimal-action diagnostics.
2
+
3
+ These helpers extend the simple ``weighted_edit_distance`` with structured
4
+ information needed by the diagnostics protocol:
5
+
6
+ edit_distance : raw token-level edits (positions where seq differs from lead)
7
+ edited_positions : list[int]
8
+ sequence_identity : 1 - (edit_distance / max(len(lead), len(seq)))
9
+ protected_edit_violation: any edited position falls in the protected set
10
+ anchor_created_by_edit : the anchor's i/j residues were edited to create the motif
11
+ anchor_used_existing_motif: the anchor's i/j residues were already correct in the lead
12
+ edit_script : list of {pos, lead_aa, designed_aa}
13
+
14
+ This module is read-only with respect to StapleState; it does not mutate
15
+ anything.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from staplebridge.chemistry.state import StapleState
23
+ from staplebridge.data.catalog import default_catalog
24
+ from staplebridge.data.schemas import BuildingBlock
25
+
26
+
27
+ def _motif_for_block(block_id: str | None, catalog: dict[str, BuildingBlock] | None) -> dict[str, Any] | None:
28
+ if block_id is None:
29
+ return None
30
+ if catalog is None:
31
+ for b in default_catalog():
32
+ if b.block_id == block_id:
33
+ return b.motif
34
+ return None
35
+ block = catalog.get(block_id)
36
+ return block.motif if block else None
37
+
38
+
39
+ def edit_diagnostics(
40
+ state: StapleState,
41
+ lead: StapleState,
42
+ *,
43
+ protected_positions: list[int] | None = None,
44
+ catalog: dict[str, BuildingBlock] | None = None,
45
+ ) -> dict[str, Any]:
46
+ """Compute structured edit metrics for ``state`` relative to ``lead``."""
47
+ seq = state.sequence_tokens
48
+ lead_seq = lead.sequence_tokens
49
+ n = max(len(seq), len(lead_seq))
50
+ edited: list[int] = []
51
+ edit_script: list[dict[str, Any]] = []
52
+ for i in range(n):
53
+ a = seq[i] if i < len(seq) else None
54
+ b = lead_seq[i] if i < len(lead_seq) else None
55
+ if a != b:
56
+ edited.append(i)
57
+ edit_script.append({"pos": i, "lead_aa": b, "designed_aa": a})
58
+ edit_distance = float(len(edited))
59
+ seq_identity = 1.0 - (edit_distance / max(n, 1))
60
+
61
+ protected = set(protected_positions or [])
62
+ protected_edits = [p for p in edited if p in protected]
63
+ protected_violation = len(protected_edits) > 0
64
+
65
+ anchor_created = False
66
+ anchor_existing = False
67
+ if state.anchor_pair is not None:
68
+ i, j = state.anchor_pair
69
+ i_edited = i in edited
70
+ j_edited = j in edited
71
+ motif = _motif_for_block(state.block_id, catalog)
72
+ i_aa_ok = True
73
+ j_aa_ok = True
74
+ if motif:
75
+ if motif.get("i_aa") and 0 <= i < len(seq):
76
+ i_aa_ok = seq[i] in motif["i_aa"]
77
+ if motif.get("j_aa") and 0 <= j < len(seq):
78
+ j_aa_ok = seq[j] in motif["j_aa"]
79
+ # "Created by edit": at least one of the anchor sites was changed AND
80
+ # the resulting site satisfies the motif.
81
+ if (i_edited or j_edited) and i_aa_ok and j_aa_ok:
82
+ anchor_created = True
83
+ # "Used existing motif": both anchor sites were already correct in
84
+ # the lead (no edits at i/j) and the motif matches.
85
+ if (not i_edited) and (not j_edited) and i_aa_ok and j_aa_ok:
86
+ anchor_existing = True
87
+
88
+ return {
89
+ "edit_distance": edit_distance,
90
+ "edited_positions": edited,
91
+ "sequence_identity": float(seq_identity),
92
+ "protected_edit_violation": bool(protected_violation),
93
+ "protected_edited_positions": protected_edits,
94
+ "anchor_created_by_edit": bool(anchor_created),
95
+ "anchor_used_existing_motif": bool(anchor_existing),
96
+ "edit_script": edit_script,
97
+ }
staplebridge/chemistry/protected.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ def can_edit_position(position: int, protected_positions: list[int]) -> bool:
5
+ return position not in set(protected_positions)
staplebridge/chemistry/state.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(eq=False)
8
+ class StapleState:
9
+ sequence_tokens: list[str]
10
+ anchor_pair: tuple[int, int] | None = None
11
+ block_id: str | None = None
12
+ topology: str = "linear"
13
+ metadata: dict[str, Any] = field(default_factory=dict)
14
+
15
+ @classmethod
16
+ def from_sequence(cls, seq: str, **kwargs: Any) -> "StapleState":
17
+ return cls(sequence_tokens=list(seq), **kwargs)
18
+
19
+ def to_sequence(self) -> str:
20
+ return "".join(self.sequence_tokens)
21
+
22
+ def copy(self) -> "StapleState":
23
+ return StapleState(
24
+ sequence_tokens=self.sequence_tokens.copy(),
25
+ anchor_pair=None if self.anchor_pair is None else tuple(self.anchor_pair),
26
+ block_id=self.block_id,
27
+ topology=self.topology,
28
+ metadata=dict(self.metadata),
29
+ )
30
+
31
+ def __hash__(self) -> int:
32
+ return hash((tuple(self.sequence_tokens), self.anchor_pair, self.block_id, self.topology))
33
+
34
+ def __eq__(self, other: object) -> bool:
35
+ if not isinstance(other, StapleState):
36
+ return False
37
+ return (
38
+ self.sequence_tokens == other.sequence_tokens
39
+ and self.anchor_pair == other.anchor_pair
40
+ and self.block_id == other.block_id
41
+ and self.topology == other.topology
42
+ )
43
+
44
+ def __str__(self) -> str:
45
+ return f"StapleState(seq={self.to_sequence()}, anchor={self.anchor_pair}, block={self.block_id}, topo={self.topology})"
staplebridge/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """"""
staplebridge/data/catalog.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ from staplebridge.data.schemas import BuildingBlock
8
+
9
+
10
+ def default_catalog() -> list[BuildingBlock]:
11
+ """Single CP-Composer-style stapled block: K ↔ D/E lactam at i,i+3 or i,i+4.
12
+
13
+ Motif and Cα window come straight from CP-Composer's success criterion
14
+ (`evaluate_utils/success_utils.ipynb`):
15
+ - peptide[i] == 'K' and peptide[i+3] in {'D','E'} or peptide[i+4] in {'D','E'}
16
+ - 4.0 ≤ Cα(i)-Cα(j) ≤ 6.5 Å
17
+ """
18
+ return [
19
+ BuildingBlock(
20
+ block_id="STAPLE_LACTAM",
21
+ name="K-(D/E) lactam staple",
22
+ chemistry_class="stapled",
23
+ synthetic_accessibility_score=0.85,
24
+ cost_score=0.5,
25
+ spps_score=0.7,
26
+ motif={"i_aa": ["K"], "j_aa": ["D", "E"], "spacings": [3, 4]},
27
+ ca_window=(4.0, 6.5),
28
+ ),
29
+ ]
30
+
31
+
32
+ def save_catalog(blocks: Iterable[BuildingBlock], out_path: str | Path) -> None:
33
+ path = Path(out_path)
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ with path.open("w", encoding="utf-8") as f:
36
+ json.dump([b.to_dict() for b in blocks], f, indent=2)
37
+
38
+
39
+ def load_catalog(path: str | Path) -> list[BuildingBlock]:
40
+ with Path(path).open("r", encoding="utf-8") as f:
41
+ data = json.load(f)
42
+ return [BuildingBlock.from_dict(x) for x in data]
43
+
44
+
45
+ def catalog_index(blocks: Iterable[BuildingBlock]) -> dict[str, BuildingBlock]:
46
+ return {b.block_id: b for b in blocks}
staplebridge/data/dataset.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from staplebridge.data.schemas import LeadExample
7
+
8
+
9
+ def save_leads(leads: list[LeadExample], out_path: str | Path) -> None:
10
+ path = Path(out_path)
11
+ path.parent.mkdir(parents=True, exist_ok=True)
12
+ with path.open("w", encoding="utf-8") as f:
13
+ for lead in leads:
14
+ f.write(json.dumps(lead.to_dict()) + "\n")
15
+
16
+
17
+ def load_leads(path: str | Path) -> list[LeadExample]:
18
+ leads: list[LeadExample] = []
19
+ with Path(path).open("r", encoding="utf-8") as f:
20
+ for line in f:
21
+ if line.strip():
22
+ leads.append(LeadExample(**json.loads(line)))
23
+ return leads
staplebridge/data/schemas.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, asdict
4
+ from typing import Any, Optional
5
+
6
+
7
+ @dataclass
8
+ class LeadExample:
9
+ example_id: str
10
+ linear_sequence: str
11
+ target_id: Optional[str] = None
12
+ target_context: Optional[dict[str, Any]] = None
13
+ protected_positions: list[int] = field(default_factory=list)
14
+ preferred_property_direction: dict[str, str] = field(default_factory=dict)
15
+ thresholds: dict[str, float] = field(default_factory=dict)
16
+ known_active_motif_positions: Optional[list[int]] = None
17
+
18
+ def to_dict(self) -> dict[str, Any]:
19
+ return asdict(self)
20
+
21
+
22
+ @dataclass
23
+ class BuildingBlock:
24
+ """Stapling building block.
25
+
26
+ The geometry / motif fields drive CP-Composer-style feasibility:
27
+ - `motif`: sequence-level pattern; for K↔D/E lactam stapling this is
28
+ {"i_aa": ["K"], "j_aa": ["D","E"], "spacings": [3, 4]}.
29
+ - `ca_window`: allowed Cα(i)-Cα(j) distance window in Å.
30
+
31
+ `chemistry_class` is now restricted to {"stapled"} (head_to_tail /
32
+ disulfide / bicycle were removed; the previous hydrocarbon i,i+4 / i,i+7
33
+ blocks were also removed because they fall outside CP-Composer's scope).
34
+ """
35
+
36
+ block_id: str
37
+ name: str
38
+ chemistry_class: str
39
+ synthetic_accessibility_score: float
40
+ cost_score: float
41
+ spps_score: float
42
+ motif: Optional[dict[str, Any]] = None
43
+ ca_window: tuple[float, float] = (4.0, 6.5)
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ d = asdict(self)
47
+ d["ca_window"] = list(self.ca_window)
48
+ return d
49
+
50
+ @classmethod
51
+ def from_dict(cls, d: dict[str, Any]) -> "BuildingBlock":
52
+ d = dict(d)
53
+ if "ca_window" in d and isinstance(d["ca_window"], list):
54
+ d["ca_window"] = tuple(d["ca_window"])
55
+ # tolerate legacy catalogs by dropping retired fields
56
+ for legacy in ("allowed_anchor_spacings", "compatible_residue_types", "token_substitution"):
57
+ d.pop(legacy, None)
58
+ return cls(**d)
staplebridge/data/vocab.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ AMINO_ACIDS = list("ACDEFGHIKLMNPQRSTVWY")
2
+ NCAA_TOKENS = ["X", "B"]
3
+ PAD_TOKEN = "<pad>"
4
+ UNK_TOKEN = "<unk>"
5
+
6
+ ALL_TOKENS = [PAD_TOKEN, UNK_TOKEN] + AMINO_ACIDS + NCAA_TOKENS
7
+ TOKEN_TO_ID = {t: i for i, t in enumerate(ALL_TOKENS)}
8
+ ID_TO_TOKEN = {i: t for t, i in TOKEN_TO_ID.items()}
9
+
10
+ HYDROPHOBIC = set("AILMFWVY")
11
+ RISKY_TOKENS = {"W", "F", "B"}
staplebridge/hydrocarbon/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon (RCM) stapling branch.
2
+
3
+ Entry points used by the main StapleBridge training pipeline:
4
+ :mod:`staplebridge.hydrocarbon.catalog` - hydrocarbon building blocks.
5
+ :mod:`staplebridge.hydrocarbon.tokenizer` - multi-character monomer
6
+ tokenizer (S5, R8, ...) plus the model-vocab projection.
7
+ :mod:`staplebridge.hydrocarbon.actions` - hydrocarbon action generator.
8
+ :mod:`staplebridge.hydrocarbon.curriculum` - hydrocarbon demonstration paths.
9
+ :mod:`staplebridge.hydrocarbon.endpoint_prior` - empirical endpoint prior.
10
+ :mod:`staplebridge.hydrocarbon.monomers` - monomer SMILES fragment library.
11
+ :mod:`staplebridge.hydrocarbon.smiles_builder` - linear precursor and
12
+ RCM-stapled product SMILES construction with strict RDKit validation.
13
+ :mod:`staplebridge.hydrocarbon.plan_reference` - plan-aware empirical
14
+ reference process (select a whole staple plan, then complete it).
15
+ :mod:`staplebridge.hydrocarbon.plan_control` - finite feasible plan support,
16
+ q_ref, plan head q_theta and committed-plan trajectory sampling.
17
+ :mod:`staplebridge.hydrocarbon.exact_sb_cache` - exact finite-support
18
+ teacher q*(p|x) construction and its persistent cache.
19
+ :mod:`staplebridge.hydrocarbon.terminal_energy` - hydrocarbon terminal
20
+ energy augmentation.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ __all__ = [
26
+ "actions",
27
+ "catalog",
28
+ "curriculum",
29
+ "endpoint_prior",
30
+ "exact_sb_cache",
31
+ "monomers",
32
+ "plan_control",
33
+ "plan_reference",
34
+ "plan_validation",
35
+ "property_energy",
36
+ "smiles_builder",
37
+ "terminal_energy",
38
+ "tokenizer",
39
+ ]
staplebridge/hydrocarbon/actions.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon action generator.
2
+
3
+ A separate generator from :func:`staplebridge.graph.neighbors.enumerate_neighbors`,
4
+ which keeps its exact lactam behaviour (K/D/E residue substitution, lactam motif
5
+ anchors, ``STAPLE_LACTAM``). Neither generator can produce the other's actions:
6
+
7
+ * this generator substitutes ncAA anchor monomers (``S5``, ``R8``, ...) and
8
+ never proposes a K-D/E lactam block;
9
+ * the lactam generator substitutes only ``K``/``D``/``E``/``A`` and never emits
10
+ a multi-character monomer.
11
+
12
+ Action families (mirroring the lactam generator's shape so the trainer, graph
13
+ and beam search need no special-casing):
14
+
15
+ 1. ncAA substitution - install an anchor monomer at an editable position
16
+ 2. anchor assignment - select the (i, j) pair for a catalog topology
17
+ 3. block assignment - attach the hydrocarbon block
18
+ 4. topology activation - close the staple
19
+
20
+ Every rejection carries an explicit reason (:class:`FailureReason`) rather than
21
+ being silently dropped, so an empty neighbour set can always be explained.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections import Counter
27
+ from dataclasses import dataclass, field
28
+ from enum import Enum
29
+ from typing import Any, Final
30
+
31
+ from staplebridge.chemistry.actions import (
32
+ AnchorAssignAction,
33
+ AnchorReassignAction,
34
+ BlockAssignAction,
35
+ NcAASubstitutionAction,
36
+ NoOpAction,
37
+ TopologyActivationAction,
38
+ )
39
+ from staplebridge.chemistry.protected import can_edit_position
40
+ from staplebridge.chemistry.state import StapleState
41
+ from staplebridge.data.schemas import BuildingBlock
42
+ from staplebridge.hydrocarbon.catalog import (
43
+ UNSUPPORTED_ANCHOR_TOKENS,
44
+ block_topology,
45
+ is_hydrocarbon_block,
46
+ )
47
+ from staplebridge.hydrocarbon.tokenizer import is_anchor_token
48
+
49
+
50
+ class FailureReason(str, Enum):
51
+ """Explicit reasons a hydrocarbon action or state was rejected."""
52
+
53
+ OK = "ok"
54
+ WRONG_CHEMISTRY = "wrong_chemistry"
55
+ NON_HYDROCARBON_BLOCK = "non_hydrocarbon_block"
56
+ UNSUPPORTED_ANCHOR_TOKEN = "unsupported_anchor_token"
57
+ UNSUPPORTED_PAIR = "unsupported_anchor_pair"
58
+ UNSUPPORTED_SPACING = "unsupported_spacing"
59
+ UNSUPPORTED_PAIR_SPACING = "unsupported_pair_spacing_combination"
60
+ WRONG_ANCHOR_COUNT = "wrong_anchor_count"
61
+ ANCHOR_OUT_OF_RANGE = "anchor_out_of_range"
62
+ ANCHOR_POSITION_PROTECTED = "anchor_position_protected"
63
+ ANCHOR_TOKEN_MISMATCH = "anchor_token_mismatch"
64
+ NO_ANCHOR_PAIR = "no_anchor_pair_set"
65
+ NO_BLOCK = "no_block_set"
66
+ ALREADY_STAPLED = "already_stapled"
67
+ DOUBLE_STAPLE_UNSUPPORTED = "double_staple_unsupported"
68
+ SEQUENCE_TOO_SHORT = "sequence_too_short"
69
+
70
+
71
+ #: Exactly two anchors define a single hydrocarbon staple. Double staples are
72
+ #: out of scope, so any other count is a failure rather than a truncation.
73
+ REQUIRED_ANCHOR_COUNT: Final[int] = 2
74
+
75
+
76
+ @dataclass
77
+ class ActionGenerationReport:
78
+ """Diagnostics for one call to :func:`enumerate_hydrocarbon_neighbors`."""
79
+
80
+ n_candidates: int = 0
81
+ by_family: Counter = field(default_factory=Counter)
82
+ failures: Counter = field(default_factory=Counter)
83
+
84
+ def record_failure(self, reason: FailureReason, count: int = 1) -> None:
85
+ """Tally one rejection."""
86
+ self.failures[reason.value] += count
87
+
88
+ def as_dict(self) -> dict[str, Any]:
89
+ """JSON-serialisable view."""
90
+ return {
91
+ "n_candidates": int(self.n_candidates),
92
+ "by_family": {k: int(v) for k, v in sorted(self.by_family.items())},
93
+ "failures": {k: int(v) for k, v in sorted(self.failures.items())},
94
+ }
95
+
96
+
97
+ def validate_hydrocarbon_staple(
98
+ tokens: list[str],
99
+ anchor_pair: tuple[int, int] | None,
100
+ block: BuildingBlock | None,
101
+ catalog: list[BuildingBlock],
102
+ ) -> FailureReason:
103
+ """Validate a candidate hydrocarbon staple.
104
+
105
+ Checks, in order: block provenance, anchor presence, index range, anchor
106
+ monomer identity, and whether the (pair, spacing) combination is one the
107
+ catalog actually describes.
108
+
109
+ Returns:
110
+ :attr:`FailureReason.OK` when the staple is legal, else the specific
111
+ reason it is not.
112
+ """
113
+ if block is None:
114
+ return FailureReason.NO_BLOCK
115
+ if not is_hydrocarbon_block(block):
116
+ return FailureReason.NON_HYDROCARBON_BLOCK
117
+ if anchor_pair is None:
118
+ return FailureReason.NO_ANCHOR_PAIR
119
+
120
+ i, j = anchor_pair
121
+ if not (0 <= i < len(tokens)) or not (0 <= j < len(tokens)):
122
+ return FailureReason.ANCHOR_OUT_OF_RANGE
123
+ if i == j:
124
+ return FailureReason.WRONG_ANCHOR_COUNT
125
+
126
+ token_i = tokens[i].upper()
127
+ token_j = tokens[j].upper()
128
+ for token in (token_i, token_j):
129
+ if token in {t.upper() for t in UNSUPPORTED_ANCHOR_TOKENS}:
130
+ return FailureReason.UNSUPPORTED_ANCHOR_TOKEN
131
+
132
+ # More than two anchor monomers in the chain would be a double staple.
133
+ if sum(1 for t in tokens if is_anchor_token(t)) != REQUIRED_ANCHOR_COUNT:
134
+ return FailureReason.DOUBLE_STAPLE_UNSUPPORTED
135
+
136
+ if not is_anchor_token(token_i) or not is_anchor_token(token_j):
137
+ return FailureReason.ANCHOR_TOKEN_MISMATCH
138
+
139
+ pair = f"{token_i}-{token_j}"
140
+ spacing = j - i
141
+ catalog_topologies = {block_topology(b) for b in catalog if is_hydrocarbon_block(b)}
142
+ block_pair, block_spacing = block_topology(block)
143
+
144
+ if pair != block_pair:
145
+ # Distinguish "this pair exists elsewhere in the catalog" from "this pair
146
+ # is unknown", which are different problems for the caller.
147
+ if any(p == pair for p, _ in catalog_topologies):
148
+ return FailureReason.UNSUPPORTED_PAIR_SPACING
149
+ return FailureReason.UNSUPPORTED_PAIR
150
+ if spacing != block_spacing:
151
+ if (pair, spacing) in catalog_topologies:
152
+ return FailureReason.UNSUPPORTED_PAIR_SPACING
153
+ return FailureReason.UNSUPPORTED_SPACING
154
+ if (pair, spacing) not in catalog_topologies:
155
+ return FailureReason.UNSUPPORTED_PAIR_SPACING
156
+
157
+ return FailureReason.OK
158
+
159
+
160
+ def _existing_anchor_pairs(
161
+ tokens: list[str], block: BuildingBlock
162
+ ) -> list[tuple[int, int]]:
163
+ """(i, j) pairs already realised in ``tokens`` for this block's topology."""
164
+ pair, spacing = block_topology(block)
165
+ i_token, j_token = pair.split("-")
166
+ pairs: list[tuple[int, int]] = []
167
+ for i in range(len(tokens)):
168
+ j = i + spacing
169
+ if j >= len(tokens):
170
+ continue
171
+ if tokens[i].upper() == i_token and tokens[j].upper() == j_token:
172
+ pairs.append((i, j))
173
+ return pairs
174
+
175
+
176
+ def _substitution_sites(
177
+ tokens: list[str],
178
+ block: BuildingBlock,
179
+ protected_positions: list[int],
180
+ report: ActionGenerationReport,
181
+ ) -> list[tuple[int, str]]:
182
+ """Positions where installing an anchor monomer opens a legal topology.
183
+
184
+ A substitution is only proposed when it could actually contribute to a
185
+ staple this block supports: either it completes a pair against an existing
186
+ partner anchor, or it starts one at a position whose partner slot is
187
+ editable.
188
+ """
189
+ pair, spacing = block_topology(block)
190
+ i_token, j_token = pair.split("-")
191
+ sites: list[tuple[int, str]] = []
192
+
193
+ for position in range(len(tokens)):
194
+ if not can_edit_position(position, protected_positions):
195
+ report.record_failure(FailureReason.ANCHOR_POSITION_PROTECTED)
196
+ continue
197
+
198
+ # Install the i-side monomer when the j-side partner is reachable.
199
+ partner_j = position + spacing
200
+ if partner_j < len(tokens) and tokens[position].upper() != i_token:
201
+ partner_ok = tokens[partner_j].upper() == j_token or can_edit_position(
202
+ partner_j, protected_positions
203
+ )
204
+ if partner_ok:
205
+ sites.append((position, i_token))
206
+
207
+ # Install the j-side monomer when the i-side partner is reachable.
208
+ partner_i = position - spacing
209
+ if partner_i >= 0 and tokens[position].upper() != j_token:
210
+ partner_ok = tokens[partner_i].upper() == i_token or can_edit_position(
211
+ partner_i, protected_positions
212
+ )
213
+ if partner_ok:
214
+ sites.append((position, j_token))
215
+
216
+ return sites
217
+
218
+
219
+ def enumerate_hydrocarbon_neighbors(
220
+ state: StapleState,
221
+ catalog: list[BuildingBlock],
222
+ protected_positions: list[int],
223
+ allow_noop: bool = True,
224
+ max_neighbors: int = 128,
225
+ report: ActionGenerationReport | None = None,
226
+ ) -> list[StapleState]:
227
+ """Enumerate hydrocarbon edit neighbours of ``state``.
228
+
229
+ Only topologies present in ``catalog`` are generated: no unsupported pair,
230
+ no unsupported spacing, and never a lactam block.
231
+
232
+ Args:
233
+ state: current state, whose tokens are hydrocarbon monomers.
234
+ catalog: hydrocarbon blocks (non-hydrocarbon blocks are rejected).
235
+ protected_positions: positions that must not be edited.
236
+ allow_noop: include the no-op self transition.
237
+ max_neighbors: cap on returned candidates.
238
+ report: optional diagnostics sink recording failure reasons.
239
+
240
+ Returns:
241
+ Candidate states, ordered so that structurally meaningful actions are
242
+ never crowded out by substitutions (same priority discipline as the
243
+ lactam generator).
244
+ """
245
+ report = report if report is not None else ActionGenerationReport()
246
+
247
+ hydrocarbon_blocks: list[BuildingBlock] = []
248
+ for block in catalog:
249
+ if is_hydrocarbon_block(block):
250
+ hydrocarbon_blocks.append(block)
251
+ else:
252
+ report.record_failure(FailureReason.NON_HYDROCARBON_BLOCK)
253
+
254
+ # Absorbing terminal, matching the lactam generator's contract: an empty
255
+ # neighbour set is how every consumer recognises a terminal state.
256
+ if state.topology == "stapled":
257
+ report.record_failure(FailureReason.ALREADY_STAPLED)
258
+ return []
259
+
260
+ tokens = state.sequence_tokens
261
+ if len(tokens) < 2:
262
+ report.record_failure(FailureReason.SEQUENCE_TOO_SHORT)
263
+ return []
264
+
265
+ noop_candidates: list[StapleState] = []
266
+ if allow_noop:
267
+ noop_candidates.append(NoOpAction().apply(state))
268
+
269
+ anchor_candidates: list[StapleState] = []
270
+ topology_candidates: list[StapleState] = []
271
+ block_candidates: list[StapleState] = []
272
+ substitution_candidates: list[StapleState] = []
273
+
274
+ for block in hydrocarbon_blocks:
275
+ # -- anchor assignment over already-installed monomer pairs ---------
276
+ for (i, j) in _existing_anchor_pairs(tokens, block):
277
+ verdict = validate_hydrocarbon_staple(tokens, (i, j), block, hydrocarbon_blocks)
278
+ if verdict is not FailureReason.OK:
279
+ report.record_failure(verdict)
280
+ continue
281
+ if state.anchor_pair is None:
282
+ candidate = AnchorAssignAction(i, j).apply(state)
283
+ elif (i, j) != tuple(state.anchor_pair):
284
+ candidate = AnchorReassignAction(i, j).apply(state)
285
+ else:
286
+ candidate = None
287
+ if candidate is not None:
288
+ candidate.block_id = block.block_id
289
+ anchor_candidates.append(candidate)
290
+
291
+ # -- block assignment: anchors already right, block not yet set --
292
+ if (
293
+ state.anchor_pair is not None
294
+ and tuple(state.anchor_pair) == (i, j)
295
+ and state.block_id != block.block_id
296
+ ):
297
+ block_candidates.append(BlockAssignAction(block.block_id).apply(state))
298
+
299
+ # -- ncAA substitution ---------------------------------------------
300
+ for position, monomer in _substitution_sites(
301
+ tokens, block, protected_positions, report
302
+ ):
303
+ substitution_candidates.append(
304
+ NcAASubstitutionAction(position, monomer).apply(state)
305
+ )
306
+
307
+ # -- topology activation: needs a valid anchor pair and a valid block ----
308
+ if state.topology == "linear" and state.anchor_pair is not None and state.block_id:
309
+ current_block = next(
310
+ (b for b in hydrocarbon_blocks if b.block_id == state.block_id), None
311
+ )
312
+ verdict = validate_hydrocarbon_staple(
313
+ tokens, tuple(state.anchor_pair), current_block, hydrocarbon_blocks
314
+ )
315
+ if verdict is FailureReason.OK:
316
+ topology_candidates.append(TopologyActivationAction().apply(state))
317
+ else:
318
+ report.record_failure(verdict)
319
+
320
+ kept = _dedup(
321
+ noop_candidates + topology_candidates + anchor_candidates + block_candidates
322
+ )[:max_neighbors]
323
+
324
+ seen = set(kept)
325
+ for candidate in substitution_candidates:
326
+ if len(kept) >= max_neighbors:
327
+ break
328
+ if candidate not in seen:
329
+ seen.add(candidate)
330
+ kept.append(candidate)
331
+
332
+ report.n_candidates = len(kept)
333
+ report.by_family.update(
334
+ {
335
+ "noop": len(noop_candidates),
336
+ "topology_activation": len(topology_candidates),
337
+ "anchor_assignment": len(anchor_candidates),
338
+ "block_assignment": len(block_candidates),
339
+ "ncaa_substitution": max(0, len(kept) - len(_dedup(
340
+ noop_candidates + topology_candidates + anchor_candidates + block_candidates
341
+ )[:max_neighbors])),
342
+ }
343
+ )
344
+ return kept
345
+
346
+
347
+ def _dedup(states: list[StapleState]) -> list[StapleState]:
348
+ """Order-preserving dedup, matching the lactam generator's helper."""
349
+ seen: set[StapleState] = set()
350
+ out: list[StapleState] = []
351
+ for state in states:
352
+ if state not in seen:
353
+ seen.add(state)
354
+ out.append(state)
355
+ return out
356
+
357
+
358
+ class HydrocarbonTransitionGraph:
359
+ """Drop-in graph for the hydrocarbon branch.
360
+
361
+ Mirrors :class:`staplebridge.graph.transition_graph.TransitionGraph`'s
362
+ interface so the trainer and decoders can use it unchanged, while routing to
363
+ the hydrocarbon generator. The lactam graph class is untouched.
364
+ """
365
+
366
+ def __init__(self, catalog: list[BuildingBlock], max_neighbors: int = 128) -> None:
367
+ self.catalog = catalog
368
+ self.max_neighbors = max_neighbors
369
+ self.last_report: ActionGenerationReport | None = None
370
+
371
+ def neighbors(
372
+ self, state: StapleState, protected_positions: list[int]
373
+ ) -> list[StapleState]:
374
+ """Hydrocarbon neighbours of ``state``; diagnostics land in ``last_report``."""
375
+ report = ActionGenerationReport()
376
+ out = enumerate_hydrocarbon_neighbors(
377
+ state,
378
+ catalog=self.catalog,
379
+ protected_positions=protected_positions,
380
+ max_neighbors=self.max_neighbors,
381
+ report=report,
382
+ )
383
+ self.last_report = report
384
+ return out
staplebridge/hydrocarbon/catalog.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon (RCM) staple catalog.
2
+
3
+ Separate from :func:`staplebridge.data.catalog.default_catalog`, which still
4
+ returns exactly one block (``STAPLE_LACTAM``) and is not touched here. No
5
+ hydrocarbon block is ever registered into the lactam catalog, and no block here
6
+ carries the ``STAPLE_LACTAM`` id.
7
+
8
+ Supported topologies
9
+ --------------------
10
+ ========================= ========= ======= ===================================
11
+ block pair spacing status
12
+ ========================= ========= ======= ===================================
13
+ ``STAPLE_HC_S5S5_I4`` S5-S5 i,i+4 enabled
14
+ ``STAPLE_HC_R8S5_I7`` R8-S5 i,i+7 enabled
15
+ ``STAPLE_HC_R5S8_I7`` R5-S8 i,i+7 disabled by default (low frequency)
16
+ ========================= ========= ======= ===================================
17
+
18
+ The two enabled topologies are the dominant modes in the observed data
19
+ (``analysis/hydrocarbon_endpoint_distribution``: S5-S5 at spacing 4 is 221/224
20
+ of its pair, R8-S5 at spacing 7 is 100/103). R5-S8/i,i+7 has only 7 observations
21
+ and is therefore an opt-in extension rather than a default.
22
+
23
+ Deliberately unsupported for now: S3/R3 anchors (zero observations), any other
24
+ spacing, and double staples (more than one anchor pair).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Final
30
+
31
+ from staplebridge.data.schemas import BuildingBlock
32
+
33
+ #: ``chemistry_class`` marker for every hydrocarbon block. Distinct from the
34
+ #: lactam blocks' ``"stapled"`` so the two can never be confused by class alone.
35
+ HYDROCARBON_CHEMISTRY_CLASS: Final[str] = "hydrocarbon_stapled"
36
+
37
+ BLOCK_S5S5_I4: Final[str] = "STAPLE_HC_S5S5_I4"
38
+ BLOCK_R8S5_I7: Final[str] = "STAPLE_HC_R8S5_I7"
39
+ BLOCK_R5S8_I7: Final[str] = "STAPLE_HC_R5S8_I7"
40
+
41
+ #: Enabled unless a config opts in to the extensions.
42
+ DEFAULT_ENABLED_BLOCKS: Final[tuple[str, ...]] = (BLOCK_S5S5_I4, BLOCK_R8S5_I7)
43
+
44
+ #: Low-frequency topologies, off unless explicitly requested.
45
+ OPTIONAL_BLOCKS: Final[tuple[str, ...]] = (BLOCK_R5S8_I7,)
46
+
47
+ #: Anchor monomers this catalog can place.
48
+ SUPPORTED_ANCHOR_TOKENS: Final[tuple[str, ...]] = ("S5", "R8", "R5", "S8")
49
+
50
+ #: Anchors that exist in StaPep syntax but are not supported here.
51
+ UNSUPPORTED_ANCHOR_TOKENS: Final[tuple[str, ...]] = ("S3", "R3")
52
+
53
+ #: Cα(i)-Cα(j) windows. i,i+4 spans one helical turn and i,i+7 spans two, so the
54
+ #: longer staple gets the wider, longer-distance window.
55
+ _CA_WINDOW_I4: Final[tuple[float, float]] = (4.5, 7.5)
56
+ _CA_WINDOW_I7: Final[tuple[float, float]] = (8.5, 13.0)
57
+
58
+
59
+ def _block(
60
+ block_id: str,
61
+ name: str,
62
+ i_aa: str,
63
+ j_aa: str,
64
+ spacing: int,
65
+ ca_window: tuple[float, float],
66
+ cost_score: float,
67
+ spps_score: float,
68
+ sa_score: float,
69
+ ) -> BuildingBlock:
70
+ """Build one hydrocarbon block with a single (pair, spacing) topology.
71
+
72
+ Each block pins exactly one spacing, so ``motif['spacings']`` has length 1.
73
+ That is what keeps the action generator from proposing an i,i+4 R8-S5 staple
74
+ or any other combination the catalog does not describe.
75
+ """
76
+ return BuildingBlock(
77
+ block_id=block_id,
78
+ name=name,
79
+ chemistry_class=HYDROCARBON_CHEMISTRY_CLASS,
80
+ synthetic_accessibility_score=sa_score,
81
+ cost_score=cost_score,
82
+ spps_score=spps_score,
83
+ motif={
84
+ "i_aa": [i_aa],
85
+ "j_aa": [j_aa],
86
+ "spacings": [spacing],
87
+ # Extra descriptive keys, ignored by the generic motif matcher but
88
+ # read by the hydrocarbon action generator and the endpoint prior.
89
+ "anchor_pair_ordered": f"{i_aa}-{j_aa}",
90
+ "anchor_pair_unordered": "-".join(sorted((i_aa, j_aa))),
91
+ "staple_chemistry": "hydrocarbon_rcm",
92
+ },
93
+ ca_window=ca_window,
94
+ )
95
+
96
+
97
+ def hydrocarbon_catalog(
98
+ include_optional: bool = False,
99
+ enabled_blocks: list[str] | tuple[str, ...] | None = None,
100
+ ) -> list[BuildingBlock]:
101
+ """Return the hydrocarbon building blocks.
102
+
103
+ Args:
104
+ include_optional: also return the low-frequency R5-S8/i,i+7 extension.
105
+ enabled_blocks: explicit allow-list of block ids. When given it wins
106
+ over ``include_optional``.
107
+
108
+ Returns:
109
+ Blocks in a stable order. Never includes any lactam block.
110
+
111
+ Raises:
112
+ ValueError: if ``enabled_blocks`` names an unknown block id, so a typo
113
+ cannot silently yield an empty or partial catalog.
114
+ """
115
+ all_blocks = {
116
+ BLOCK_S5S5_I4: _block(
117
+ BLOCK_S5S5_I4,
118
+ "S5-S5 hydrocarbon staple (i, i+4)",
119
+ i_aa="S5",
120
+ j_aa="S5",
121
+ spacing=4,
122
+ ca_window=_CA_WINDOW_I4,
123
+ cost_score=0.55,
124
+ spps_score=0.65,
125
+ sa_score=0.80,
126
+ ),
127
+ BLOCK_R8S5_I7: _block(
128
+ BLOCK_R8S5_I7,
129
+ "R8-S5 hydrocarbon staple (i, i+7)",
130
+ i_aa="R8",
131
+ j_aa="S5",
132
+ spacing=7,
133
+ ca_window=_CA_WINDOW_I7,
134
+ cost_score=0.70,
135
+ spps_score=0.55,
136
+ sa_score=0.70,
137
+ ),
138
+ BLOCK_R5S8_I7: _block(
139
+ BLOCK_R5S8_I7,
140
+ "R5-S8 hydrocarbon staple (i, i+7), low-frequency extension",
141
+ i_aa="R5",
142
+ j_aa="S8",
143
+ spacing=7,
144
+ ca_window=_CA_WINDOW_I7,
145
+ cost_score=0.80,
146
+ spps_score=0.50,
147
+ sa_score=0.60,
148
+ ),
149
+ }
150
+
151
+ if enabled_blocks is not None:
152
+ requested = list(enabled_blocks)
153
+ unknown = [b for b in requested if b not in all_blocks]
154
+ if unknown:
155
+ raise ValueError(
156
+ f"unknown hydrocarbon block id(s): {unknown}; "
157
+ f"known: {sorted(all_blocks)}"
158
+ )
159
+ order = list(DEFAULT_ENABLED_BLOCKS) + list(OPTIONAL_BLOCKS)
160
+ return [all_blocks[b] for b in order if b in set(requested)]
161
+
162
+ selected = list(DEFAULT_ENABLED_BLOCKS)
163
+ if include_optional:
164
+ selected += list(OPTIONAL_BLOCKS)
165
+ return [all_blocks[b] for b in selected]
166
+
167
+
168
+ def hydrocarbon_catalog_from_config(config: dict | None) -> list[BuildingBlock]:
169
+ """Build the catalog from a ``hydrocarbon.catalog`` config section."""
170
+ section = dict((config or {}).get("catalog") or {})
171
+ return hydrocarbon_catalog(
172
+ include_optional=bool(section.get("include_optional", False)),
173
+ enabled_blocks=section.get("enabled_blocks"),
174
+ )
175
+
176
+
177
+ def is_hydrocarbon_block(block: BuildingBlock | None) -> bool:
178
+ """True when ``block`` came from this catalog."""
179
+ return bool(block is not None and block.chemistry_class == HYDROCARBON_CHEMISTRY_CLASS)
180
+
181
+
182
+ def block_topology(block: BuildingBlock) -> tuple[str, int]:
183
+ """Return ``(ordered_pair, spacing)`` for a hydrocarbon block."""
184
+ motif = block.motif or {}
185
+ spacings = motif.get("spacings") or []
186
+ if len(spacings) != 1:
187
+ raise ValueError(
188
+ f"hydrocarbon block {block.block_id} must pin exactly one spacing, "
189
+ f"got {spacings!r}"
190
+ )
191
+ i_aa = (motif.get("i_aa") or [""])[0]
192
+ j_aa = (motif.get("j_aa") or [""])[0]
193
+ return f"{i_aa}-{j_aa}", int(spacings[0])
194
+
195
+
196
+ def supported_topologies(blocks: list[BuildingBlock]) -> list[tuple[str, int]]:
197
+ """All ``(ordered_pair, spacing)`` topologies the given blocks allow."""
198
+ return [block_topology(b) for b in blocks]
staplebridge/hydrocarbon/curriculum.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon demonstration paths (curriculum).
2
+
3
+ The lactam curriculum
4
+ (:class:`staplebridge.training.curriculum_sampler.CurriculumTrajectorySampler`,
5
+ which wraps ``propose_minimal_lactam_motif_edits``) is untouched and still
6
+ defaults to ``block_id="STAPLE_LACTAM"``. This module is the hydrocarbon
7
+ counterpart and never emits a lactam block.
8
+
9
+ A demonstration path installs the two anchor monomers with the fewest possible
10
+ substitutions, then assigns the anchor pair, then activates the topology:
11
+
12
+ linear -> [ncAA substitution]* -> anchor assign -> topology activation
13
+
14
+ Plans are ranked so that sites already carrying the right monomer are preferred
15
+ (zero edits beats one edit), and every rejected plan carries a
16
+ :class:`~staplebridge.hydrocarbon.actions.FailureReason`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections import Counter
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+
25
+ from staplebridge.chemistry.actions import (
26
+ AnchorAssignAction,
27
+ NcAASubstitutionAction,
28
+ TopologyActivationAction,
29
+ )
30
+ from staplebridge.chemistry.protected import can_edit_position
31
+ from staplebridge.chemistry.state import StapleState
32
+ from staplebridge.data.schemas import BuildingBlock
33
+ from staplebridge.hydrocarbon.actions import (
34
+ FailureReason,
35
+ validate_hydrocarbon_staple,
36
+ )
37
+ from staplebridge.hydrocarbon.catalog import block_topology, is_hydrocarbon_block
38
+
39
+
40
+ @dataclass
41
+ class HydrocarbonStaplePlan:
42
+ """One concrete plan for building a hydrocarbon staple."""
43
+
44
+ block_id: str
45
+ anchor_pair: tuple[int, int]
46
+ ordered_pair: str
47
+ spacing: int
48
+ substitutions: list[tuple[int, str]] = field(default_factory=list)
49
+
50
+ @property
51
+ def n_edits(self) -> int:
52
+ """Number of ncAA substitutions this plan requires."""
53
+ return len(self.substitutions)
54
+
55
+ def as_dict(self) -> dict[str, Any]:
56
+ """JSON-serialisable view."""
57
+ return {
58
+ "block_id": self.block_id,
59
+ "anchor_pair": list(self.anchor_pair),
60
+ "ordered_pair": self.ordered_pair,
61
+ "spacing": self.spacing,
62
+ "substitutions": [[p, t] for p, t in self.substitutions],
63
+ "n_edits": self.n_edits,
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class HydrocarbonCurriculumConfig:
69
+ """Config for the hydrocarbon curriculum.
70
+
71
+ ``block_id`` is deliberately absent: the block is chosen per plan from the
72
+ hydrocarbon catalog, so a lactam id cannot leak in through config.
73
+ """
74
+
75
+ enabled: bool = False
76
+ max_anchor_edits: int = 2
77
+ num_trajectories_per_lead: int = 4
78
+ prefer_existing_anchors: bool = True
79
+ protect_positions: bool = True
80
+ require_valid_terminal: bool = True
81
+
82
+
83
+ @dataclass
84
+ class HydrocarbonCurriculumDiagnostics:
85
+ """Bookkeeping mirroring the lactam curriculum's diagnostics shape."""
86
+
87
+ trajectory_count: int = 0
88
+ valid_terminal_count: int = 0
89
+ num_edits_acc: list[int] = field(default_factory=list)
90
+ failure_reasons: Counter = field(default_factory=Counter)
91
+ leads_seen: int = 0
92
+ leads_with_plan: int = 0
93
+
94
+ def as_dict(self) -> dict[str, Any]:
95
+ """JSON-serialisable view."""
96
+ mean_edits = (
97
+ sum(self.num_edits_acc) / len(self.num_edits_acc)
98
+ if self.num_edits_acc
99
+ else 0.0
100
+ )
101
+ return {
102
+ "trajectory_count": int(self.trajectory_count),
103
+ "valid_terminal_count": int(self.valid_terminal_count),
104
+ "mean_num_edits": float(mean_edits),
105
+ "leads_seen": int(self.leads_seen),
106
+ "leads_with_plan": int(self.leads_with_plan),
107
+ "failure_reasons": {k: int(v) for k, v in sorted(self.failure_reasons.items())},
108
+ }
109
+
110
+
111
+ def propose_hydrocarbon_staple_plans(
112
+ tokens: list[str],
113
+ catalog: list[BuildingBlock],
114
+ protected_positions: list[int] | None = None,
115
+ max_anchor_edits: int = 2,
116
+ diagnostics: HydrocarbonCurriculumDiagnostics | None = None,
117
+ ) -> list[HydrocarbonStaplePlan]:
118
+ """Enumerate minimal-edit plans for every catalog topology.
119
+
120
+ Args:
121
+ tokens: current monomer tokens.
122
+ catalog: hydrocarbon blocks; non-hydrocarbon blocks are skipped.
123
+ protected_positions: positions that must not be edited.
124
+ max_anchor_edits: reject plans needing more substitutions than this.
125
+ diagnostics: optional sink for failure reasons.
126
+
127
+ Returns:
128
+ Plans sorted by edit count ascending, then by position, so the caller can
129
+ take the cheapest demonstrations first.
130
+ """
131
+ protected = list(protected_positions or [])
132
+ plans: list[HydrocarbonStaplePlan] = []
133
+
134
+ for block in catalog:
135
+ if not is_hydrocarbon_block(block):
136
+ if diagnostics is not None:
137
+ diagnostics.failure_reasons[FailureReason.NON_HYDROCARBON_BLOCK.value] += 1
138
+ continue
139
+
140
+ ordered_pair, spacing = block_topology(block)
141
+ i_token, j_token = ordered_pair.split("-")
142
+
143
+ for i in range(len(tokens)):
144
+ j = i + spacing
145
+ if j >= len(tokens):
146
+ continue
147
+
148
+ substitutions: list[tuple[int, str]] = []
149
+ blocked = False
150
+ for position, wanted in ((i, i_token), (j, j_token)):
151
+ if tokens[position].upper() == wanted:
152
+ continue
153
+ if not can_edit_position(position, protected):
154
+ if diagnostics is not None:
155
+ diagnostics.failure_reasons[
156
+ FailureReason.ANCHOR_POSITION_PROTECTED.value
157
+ ] += 1
158
+ blocked = True
159
+ break
160
+ substitutions.append((position, wanted))
161
+ if blocked:
162
+ continue
163
+
164
+ if len(substitutions) > max_anchor_edits:
165
+ if diagnostics is not None:
166
+ diagnostics.failure_reasons["exceeds_max_anchor_edits"] += 1
167
+ continue
168
+
169
+ # Reject up front any plan whose terminal state would not be a legal
170
+ # single staple. The common case: the lead already carries anchor
171
+ # monomers elsewhere, so installing this pair would leave more than
172
+ # two anchors in the chain (a double staple, which is out of scope).
173
+ # Validating here rather than at build time keeps the invalid plan
174
+ # out of the ranked list instead of raising deep inside path
175
+ # construction.
176
+ projected = list(tokens)
177
+ for position, wanted in substitutions:
178
+ projected[position] = wanted
179
+ verdict = validate_hydrocarbon_staple(projected, (i, j), block, catalog)
180
+ if verdict is not FailureReason.OK:
181
+ if diagnostics is not None:
182
+ diagnostics.failure_reasons[verdict.value] += 1
183
+ continue
184
+
185
+ plans.append(
186
+ HydrocarbonStaplePlan(
187
+ block_id=block.block_id,
188
+ anchor_pair=(i, j),
189
+ ordered_pair=ordered_pair,
190
+ spacing=spacing,
191
+ substitutions=substitutions,
192
+ )
193
+ )
194
+
195
+ return rank_hydrocarbon_plans(plans)
196
+
197
+
198
+ def rank_hydrocarbon_plans(
199
+ plans: list[HydrocarbonStaplePlan],
200
+ ) -> list[HydrocarbonStaplePlan]:
201
+ """Cheapest-first ordering: fewest edits, then earliest anchor, then block id."""
202
+ return sorted(
203
+ plans,
204
+ key=lambda p: (p.n_edits, p.anchor_pair[0], p.anchor_pair[1], p.block_id),
205
+ )
206
+
207
+
208
+ def build_hydrocarbon_demonstration_path(
209
+ initial: StapleState,
210
+ plan: HydrocarbonStaplePlan,
211
+ catalog: list[BuildingBlock],
212
+ ) -> list[StapleState]:
213
+ """Materialise ``plan`` as a state path from ``initial`` to a stapled terminal.
214
+
215
+ Returns:
216
+ The full path including ``initial``. The last state has
217
+ ``topology == "stapled"``.
218
+
219
+ Raises:
220
+ ValueError: if the resulting terminal state would not be a legal
221
+ hydrocarbon staple, so an invalid demonstration is never emitted.
222
+ """
223
+ path = [initial.copy()]
224
+ current = initial
225
+
226
+ for position, monomer in plan.substitutions:
227
+ current = NcAASubstitutionAction(position, monomer).apply(current)
228
+ path.append(current)
229
+
230
+ block = next((b for b in catalog if b.block_id == plan.block_id), None)
231
+ if block is None:
232
+ raise ValueError(f"plan references unknown block {plan.block_id!r}")
233
+
234
+ current = AnchorAssignAction(*plan.anchor_pair).apply(current)
235
+ current.block_id = plan.block_id
236
+ path.append(current)
237
+
238
+ verdict = validate_hydrocarbon_staple(
239
+ current.sequence_tokens, plan.anchor_pair, block, catalog
240
+ )
241
+ if verdict is not FailureReason.OK:
242
+ raise ValueError(
243
+ f"plan for block {plan.block_id} at {plan.anchor_pair} is invalid: "
244
+ f"{verdict.value}"
245
+ )
246
+
247
+ current = TopologyActivationAction().apply(current)
248
+ path.append(current)
249
+ return path
staplebridge/hydrocarbon/data/pair_spacing_probabilities.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Versioned pair/spacing subset used by the default plan-aware reference.",
3
+ "source": "analysis/hydrocarbon_endpoint_distribution",
4
+ "probabilities_by_version": {
5
+ "sequence_deduplicated": {
6
+ "alpha": 1.0,
7
+ "total": 317,
8
+ "full_support_size": 10,
9
+ "support": [
10
+ "S5-S5|4",
11
+ "R8-S5|7"
12
+ ],
13
+ "categories": {
14
+ "S5-S5|4": {
15
+ "count": 210,
16
+ "raw_probability": 0.6624605678233438,
17
+ "laplace_smoothed_probability": 0.6452599388379205
18
+ },
19
+ "R8-S5|7": {
20
+ "count": 93,
21
+ "raw_probability": 0.29337539432176657,
22
+ "laplace_smoothed_probability": 0.2874617737003058
23
+ }
24
+ }
25
+ }
26
+ },
27
+ "usage_note": "Empirical structural prior; not a trained model and does not use permeability labels."
28
+ }
staplebridge/hydrocarbon/endpoint_prior.py ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Empirical hydrocarbon endpoint prior.
2
+
3
+ Reads the frequency tables produced by
4
+ ``analysis/hydrocarbon_endpoint_distribution`` and turns them into an energy
5
+ term. Base form:
6
+
7
+ E_pair = -log p(pair, spacing)
8
+
9
+ Optional, config-gated additions: peptide length, relative anchor position and
10
+ local sequence context around the anchors.
11
+
12
+ Two properties this prior deliberately has:
13
+
14
+ * **It never sees the permeability label.** The upstream endpoint definition is
15
+ purely structural (tokenizes cleanly, exactly two hydrocarbon anchors), so
16
+ this is a prior over *what real stapled designs look like*, not over what is
17
+ reported permeable. No classifier is trained or consulted.
18
+ * **It is hydrocarbon-only.** It is consumed exclusively by
19
+ :mod:`staplebridge.hydrocarbon.terminal_energy`; the lactam terminal energy in
20
+ ``BridgeTrainer.terminal_energy`` is not reachable from here, whether the prior
21
+ is on or off.
22
+
23
+ Smoothed probabilities are preferred over raw ones so that a legal-but-unobserved
24
+ topology gets finite energy instead of ``inf``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import math
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any, Final
34
+
35
+ from staplebridge.chemistry.state import StapleState
36
+ from staplebridge.data.schemas import BuildingBlock
37
+ from staplebridge.hydrocarbon.catalog import block_topology, is_hydrocarbon_block
38
+ from staplebridge.hydrocarbon.tokenizer import is_anchor_token
39
+
40
+ #: Default location of the empirical tables, relative to the repository root.
41
+ DEFAULT_PRIOR_DIR: Final[str] = (
42
+ "analysis/hydrocarbon_endpoint_distribution/outputs/priors"
43
+ )
44
+
45
+ #: Which deduplication version to read. ``raw_records`` keeps replicate rows;
46
+ #: ``sequence_deduplicated`` / ``molecule_deduplicated`` collapse them.
47
+ DEFAULT_DEDUP_VERSION: Final[str] = "sequence_deduplicated"
48
+
49
+ #: Energy assigned when a topology is absent even from the smoothed table. Large
50
+ #: but finite, so an unsupported design is strongly penalised without producing
51
+ #: ``inf`` and poisoning downstream arithmetic.
52
+ FALLBACK_ENERGY: Final[float] = 25.0
53
+
54
+
55
+ class PriorFilesMissingError(FileNotFoundError):
56
+ """Raised when the empirical prior directory or a required file is absent."""
57
+
58
+
59
+ @dataclass
60
+ class EndpointPriorConfig:
61
+ """Config for :class:`EmpiricalHydrocarbonEndpointPrior`.
62
+
63
+ Only ``E_pair`` is on by default. The optional terms are additive and each
64
+ has its own weight, so they can be enabled one at a time.
65
+ """
66
+
67
+ enabled: bool = False
68
+ prior_dir: str = DEFAULT_PRIOR_DIR
69
+ dedup_version: str = DEFAULT_DEDUP_VERSION
70
+ use_smoothed: bool = True
71
+
72
+ weight_pair: float = 1.0
73
+ use_length: bool = False
74
+ weight_length: float = 0.5
75
+ use_relative_position: bool = False
76
+ weight_relative_position: float = 0.5
77
+ use_local_context: bool = False
78
+ weight_local_context: float = 0.25
79
+ local_context_window: int = 2
80
+
81
+ #: Cap on any single component, keeping one missing bin from dominating.
82
+ max_component_energy: float = FALLBACK_ENERGY
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: dict[str, Any] | None) -> "EndpointPriorConfig":
86
+ """Build from a ``hydrocarbon.endpoint_prior`` config section."""
87
+ section = dict(data or {})
88
+ cfg = cls()
89
+ for key, value in section.items():
90
+ if hasattr(cfg, key):
91
+ current = getattr(cfg, key)
92
+ if isinstance(current, bool):
93
+ setattr(cfg, key, bool(value))
94
+ elif isinstance(current, float):
95
+ setattr(cfg, key, float(value))
96
+ elif isinstance(current, int) and not isinstance(current, bool):
97
+ setattr(cfg, key, int(value))
98
+ else:
99
+ setattr(cfg, key, value)
100
+ return cfg
101
+
102
+
103
+ @dataclass
104
+ class EndpointEnergyBreakdown:
105
+ """Per-component decomposition of one endpoint prior evaluation."""
106
+
107
+ e_pair: float = 0.0
108
+ e_length: float = 0.0
109
+ e_relative_position: float = 0.0
110
+ e_local_context: float = 0.0
111
+ total: float = 0.0
112
+ pair_key: str | None = None
113
+ status: str = "ok"
114
+ details: dict[str, Any] = field(default_factory=dict)
115
+
116
+ def as_dict(self) -> dict[str, Any]:
117
+ """JSON-serialisable view."""
118
+ return {
119
+ "E_endpoint_pair": float(self.e_pair),
120
+ "E_endpoint_length": float(self.e_length),
121
+ "E_endpoint_relative_position": float(self.e_relative_position),
122
+ "E_endpoint_local_context": float(self.e_local_context),
123
+ "E_endpoint_total": float(self.total),
124
+ "endpoint_pair_key": self.pair_key,
125
+ "endpoint_prior_status": self.status,
126
+ **({"endpoint_prior_details": self.details} if self.details else {}),
127
+ }
128
+
129
+
130
+ class EmpiricalHydrocarbonEndpointPrior:
131
+ """Energy over hydrocarbon endpoint realism, from observed frequencies.
132
+
133
+ Args:
134
+ config: prior configuration.
135
+ root: repository root used to resolve a relative ``prior_dir``.
136
+
137
+ Raises:
138
+ PriorFilesMissingError: if the prior is enabled but its files are absent.
139
+ An enabled-but-broken prior fails loudly rather than silently
140
+ scoring zero.
141
+ """
142
+
143
+ def __init__(
144
+ self, config: EndpointPriorConfig | None = None, root: Path | None = None
145
+ ) -> None:
146
+ self.cfg = config or EndpointPriorConfig()
147
+ self._root = Path(root) if root is not None else _repository_root()
148
+ self._pair_spacing: dict[str, dict[str, Any]] = {}
149
+ self._length_probabilities: dict[int, float] = {}
150
+ self._position_histogram: dict[str, Any] = {}
151
+ self._local_context: dict[str, Any] = {}
152
+ self._loaded = False
153
+ self._load_failure: str | None = None
154
+
155
+ if self.cfg.enabled:
156
+ self.load()
157
+
158
+ # -- loading ---------------------------------------------------------
159
+
160
+ @property
161
+ def prior_dir(self) -> Path:
162
+ """Resolved directory holding the empirical JSON tables."""
163
+ candidate = Path(self.cfg.prior_dir)
164
+ return candidate if candidate.is_absolute() else self._root / candidate
165
+
166
+ @property
167
+ def is_active(self) -> bool:
168
+ """True when the prior is enabled and its tables loaded."""
169
+ return bool(self.cfg.enabled and self._loaded)
170
+
171
+ def load(self) -> None:
172
+ """Read the empirical tables from :attr:`prior_dir`."""
173
+ directory = self.prior_dir
174
+ if not directory.is_dir():
175
+ raise PriorFilesMissingError(
176
+ f"hydrocarbon endpoint prior is enabled but {directory} does not "
177
+ "exist. The main StapleBridge configuration keeps this prior "
178
+ "disabled (hydrocarbon.endpoint_prior.enabled=false); the "
179
+ "empirical endpoint evidence enters via "
180
+ "hydrocarbon.plan_reference.mode_prior instead."
181
+ )
182
+
183
+ pair_file = directory / "pair_spacing_probabilities.json"
184
+ if not pair_file.is_file():
185
+ raise PriorFilesMissingError(f"missing required prior file: {pair_file}")
186
+ self._pair_spacing = self._read_probability_file(pair_file)
187
+
188
+ if self.cfg.use_length:
189
+ self._length_probabilities = self._read_length(
190
+ directory / "length_distribution.json"
191
+ )
192
+ if self.cfg.use_relative_position:
193
+ self._position_histogram = _read_json(
194
+ directory / "relative_anchor_position_histogram.json"
195
+ )
196
+ if self.cfg.use_local_context:
197
+ self._local_context = _read_json(directory / "local_context_counts.json")
198
+
199
+ self._loaded = True
200
+
201
+ def _read_probability_file(self, path: Path) -> dict[str, dict[str, Any]]:
202
+ """Extract the selected dedup version's category table."""
203
+ payload = _read_json(path)
204
+ versions = payload.get("probabilities_by_version") or {}
205
+ version = self.cfg.dedup_version
206
+ if version not in versions:
207
+ available = sorted(versions)
208
+ raise PriorFilesMissingError(
209
+ f"dedup version {version!r} not in {path.name}; available: {available}"
210
+ )
211
+ return dict(versions[version].get("categories") or {})
212
+
213
+ def _read_length(self, path: Path) -> dict[int, float]:
214
+ """Normalise the unbinned length counts into a probability table."""
215
+ payload = _read_json(path)
216
+ counts = (payload.get("unbinned") or {}).get(self.cfg.dedup_version) or {}
217
+ total = sum(int(v) for v in counts.values())
218
+ if total <= 0:
219
+ return {}
220
+ # Add-one smoothing over the observed support so a nearby unobserved
221
+ # length is penalised rather than treated as impossible.
222
+ support = len(counts)
223
+ return {
224
+ int(k): (int(v) + 1.0) / (total + support)
225
+ for k, v in counts.items()
226
+ }
227
+
228
+ # -- scoring ---------------------------------------------------------
229
+
230
+ def _probability(self, key: str) -> float | None:
231
+ """Look up ``key`` in the pair x spacing table."""
232
+ entry = self._pair_spacing.get(key)
233
+ if entry is None:
234
+ return None
235
+ field_name = (
236
+ "laplace_smoothed_probability" if self.cfg.use_smoothed else "raw_probability"
237
+ )
238
+ value = entry.get(field_name)
239
+ return None if value is None else float(value)
240
+
241
+ def score_endpoint(
242
+ self,
243
+ state: StapleState,
244
+ block: BuildingBlock | None,
245
+ context: dict[str, Any] | None = None,
246
+ ) -> EndpointEnergyBreakdown:
247
+ """Compute the endpoint prior energy for a terminal hydrocarbon state.
248
+
249
+ Returns a zero breakdown with an explanatory ``status`` when the prior is
250
+ disabled or the state is not a scorable hydrocarbon endpoint, so callers
251
+ can always add ``total`` unconditionally.
252
+ """
253
+ del context
254
+ breakdown = EndpointEnergyBreakdown()
255
+
256
+ if not self.cfg.enabled:
257
+ breakdown.status = "disabled"
258
+ return breakdown
259
+ if not self._loaded:
260
+ breakdown.status = "not_loaded"
261
+ return breakdown
262
+ if block is None or not is_hydrocarbon_block(block):
263
+ breakdown.status = "not_hydrocarbon_block"
264
+ return breakdown
265
+ if state.anchor_pair is None:
266
+ breakdown.status = "no_anchor_pair"
267
+ return breakdown
268
+
269
+ tokens = state.sequence_tokens
270
+ i, j = int(state.anchor_pair[0]), int(state.anchor_pair[1])
271
+ if not (0 <= i < len(tokens)) or not (0 <= j < len(tokens)):
272
+ breakdown.status = "anchor_out_of_range"
273
+ return breakdown
274
+
275
+ # Read the pair off the *state*, not the block, so a mislabelled block
276
+ # cannot silently score as its intended topology.
277
+ ordered_pair = f"{tokens[i].upper()}-{tokens[j].upper()}"
278
+ spacing = j - i
279
+ key = f"{ordered_pair}|{spacing}"
280
+ breakdown.pair_key = key
281
+
282
+ probability = self._probability(key)
283
+ if probability is None or probability <= 0.0:
284
+ breakdown.e_pair = float(self.cfg.max_component_energy)
285
+ breakdown.status = "pair_spacing_unobserved"
286
+ breakdown.details["catalog_topology"] = "-".join(
287
+ str(x) for x in block_topology(block)
288
+ )
289
+ else:
290
+ breakdown.e_pair = min(
291
+ -math.log(probability), float(self.cfg.max_component_energy)
292
+ )
293
+
294
+ breakdown.e_pair *= float(self.cfg.weight_pair)
295
+
296
+ if self.cfg.use_length:
297
+ breakdown.e_length = self.cfg.weight_length * self._length_energy(len(tokens))
298
+ if self.cfg.use_relative_position:
299
+ breakdown.e_relative_position = (
300
+ self.cfg.weight_relative_position
301
+ * self._relative_position_energy(i, j, len(tokens))
302
+ )
303
+ if self.cfg.use_local_context:
304
+ breakdown.e_local_context = (
305
+ self.cfg.weight_local_context
306
+ * self._local_context_energy(tokens, (i, j), ordered_pair)
307
+ )
308
+
309
+ breakdown.total = float(
310
+ breakdown.e_pair
311
+ + breakdown.e_length
312
+ + breakdown.e_relative_position
313
+ + breakdown.e_local_context
314
+ )
315
+ return breakdown
316
+
317
+ def _length_energy(self, length: int) -> float:
318
+ """-log p(peptide length)."""
319
+ if not self._length_probabilities:
320
+ return 0.0
321
+ probability = self._length_probabilities.get(int(length))
322
+ if probability is None or probability <= 0.0:
323
+ return float(self.cfg.max_component_energy)
324
+ return min(-math.log(probability), float(self.cfg.max_component_energy))
325
+
326
+ def _relative_position_energy(self, i: int, j: int, length: int) -> float:
327
+ """Mean -log p over the two anchors' relative positions."""
328
+ histogram = self._position_histogram.get("all_anchors") or {}
329
+ probabilities = histogram.get("probabilities") or []
330
+ edges = self._position_histogram.get("bin_edges") or []
331
+ if not probabilities or len(edges) < 2 or length < 2:
332
+ return 0.0
333
+
334
+ n_bins = len(probabilities)
335
+ total = 0.0
336
+ for index in (i, j):
337
+ relative = index / (length - 1)
338
+ bin_index = min(int(relative * n_bins), n_bins - 1)
339
+ probability = float(probabilities[bin_index])
340
+ if probability <= 0.0:
341
+ total += float(self.cfg.max_component_energy)
342
+ else:
343
+ total += min(
344
+ -math.log(probability), float(self.cfg.max_component_energy)
345
+ )
346
+ return total / 2.0
347
+
348
+ def _local_context_energy(
349
+ self, tokens: list[str], anchor_pair: tuple[int, int], ordered_pair: str
350
+ ) -> float:
351
+ """Mean -log p of the residues flanking each anchor.
352
+
353
+ Uses the pair-specific table when the observed pair has one, otherwise
354
+ the pooled ``all_anchors`` table.
355
+ """
356
+ groups = self._local_context.get("groups") or {}
357
+ group = groups.get(f"pair_{ordered_pair}") or groups.get("all_anchors")
358
+ if not group:
359
+ return 0.0
360
+ offsets = group.get("offsets") or {}
361
+ padding = self._local_context.get("padding_tokens") or {}
362
+ n_pad = padding.get("n_terminal", "<NTERM>")
363
+ c_pad = padding.get("c_terminal", "<CTERM>")
364
+
365
+ window = int(self.cfg.local_context_window)
366
+ energies: list[float] = []
367
+ for anchor in anchor_pair:
368
+ for offset in range(-window, window + 1):
369
+ if offset == 0:
370
+ continue
371
+ entry = offsets.get(str(offset))
372
+ if not entry:
373
+ continue
374
+ frequencies = entry.get("frequencies") or {}
375
+ target = anchor + offset
376
+ if target < 0:
377
+ token = n_pad
378
+ elif target >= len(tokens):
379
+ token = c_pad
380
+ else:
381
+ token = tokens[target].upper()
382
+ probability = float(frequencies.get(token, 0.0))
383
+ if probability <= 0.0:
384
+ # Back off to a floor rather than +inf: an unseen flank is
385
+ # unusual, not impossible.
386
+ total_observed = max(int(entry.get("total", 0)), 1)
387
+ probability = 1.0 / (total_observed + 1.0)
388
+ energies.append(
389
+ min(-math.log(probability), float(self.cfg.max_component_energy))
390
+ )
391
+ return sum(energies) / len(energies) if energies else 0.0
392
+
393
+ # -- introspection ---------------------------------------------------
394
+
395
+ def describe(self) -> dict[str, Any]:
396
+ """Summary of what was loaded, for logging and audits."""
397
+ return {
398
+ "enabled": bool(self.cfg.enabled),
399
+ "loaded": bool(self._loaded),
400
+ "prior_dir": str(self.prior_dir),
401
+ "dedup_version": self.cfg.dedup_version,
402
+ "use_smoothed": bool(self.cfg.use_smoothed),
403
+ "n_pair_spacing_categories": len(self._pair_spacing),
404
+ "n_length_bins": len(self._length_probabilities),
405
+ "components": {
406
+ "pair": True,
407
+ "length": bool(self.cfg.use_length),
408
+ "relative_position": bool(self.cfg.use_relative_position),
409
+ "local_context": bool(self.cfg.use_local_context),
410
+ },
411
+ "uses_permeability_label": False,
412
+ "is_trained_classifier": False,
413
+ }
414
+
415
+ def observed_topologies(self) -> dict[str, float]:
416
+ """Observed ``PAIR|SPACING`` keys mapped to their energies, cheapest first."""
417
+ out: dict[str, float] = {}
418
+ for key in self._pair_spacing:
419
+ probability = self._probability(key)
420
+ if probability and probability > 0.0:
421
+ out[key] = min(
422
+ -math.log(probability), float(self.cfg.max_component_energy)
423
+ )
424
+ return dict(sorted(out.items(), key=lambda kv: kv[1]))
425
+
426
+
427
+ def count_anchor_monomers(tokens: list[str]) -> int:
428
+ """Number of hydrocarbon anchor monomers present in ``tokens``."""
429
+ return sum(1 for t in tokens if is_anchor_token(t))
430
+
431
+
432
+ def _read_json(path: Path) -> dict[str, Any]:
433
+ """Read one JSON file, with a clear error when it is missing."""
434
+ if not path.is_file():
435
+ raise PriorFilesMissingError(f"missing required prior file: {path}")
436
+ with path.open("r", encoding="utf-8") as handle:
437
+ return json.load(handle)
438
+
439
+
440
+ def _repository_root() -> Path:
441
+ """Repository root, derived from this file's location."""
442
+ return Path(__file__).resolve().parents[2]
staplebridge/hydrocarbon/exact_sb_cache.py ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent cache for the deterministic part of the Exact-SB plan target.
2
+
3
+ For a fixed lead and a fixed configuration, everything on the right-hand side of
4
+
5
+ q*(p|x) ∝ q_ref(p|x) · exp[-beta · E_T(x, p)]
6
+
7
+ is deterministic: the legal-plan enumeration, ``q_ref``, the PeptiVerse-backed
8
+ terminal energies, and therefore ``log q*`` itself. Only ``q_theta`` changes as
9
+ the plan head trains. This module persists the deterministic half so each epoch
10
+ recomputes just ``q_theta`` and then ``KL(q*||q_theta)``.
11
+
12
+ Deliberately **not** cached:
13
+
14
+ * ``q_theta`` — it is a function of the live model weights. Caching or freezing
15
+ it would silently stop training the plan head. Nothing in this module reads,
16
+ writes, or accepts ``q_theta``.
17
+ * Anything used for candidate ranking, decoding, or the legacy validation
18
+ metrics. The cache only replays plan order, ``reference_logp``, terminal
19
+ energies and ``log q*``; every consumer recomputes ``q_theta`` itself.
20
+
21
+ Correctness rests on the fingerprint: any change to the lead, catalog,
22
+ empirical prior, ``exact_sb_beta``, terminal-energy/property configuration,
23
+ PeptiVerse model set, or geometry/edit settings produces a different
24
+ fingerprint, so a stale entry is never served. Entries also store the plan
25
+ signatures they were built from and are rejected if the live enumeration
26
+ disagrees, which catches drift the fingerprint alone would miss.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import inspect
33
+ import json
34
+ import os
35
+ import sqlite3
36
+ import threading
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+ from typing import Any, Iterable, Sequence
40
+
41
+ import torch
42
+
43
+ from staplebridge.hydrocarbon.curriculum import HydrocarbonStaplePlan
44
+
45
+ #: Bumped whenever the stored payload's meaning changes. Old rows then miss.
46
+ CACHE_SCHEMA_VERSION = 1
47
+
48
+
49
+ def _stable_json(payload: Any) -> str:
50
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
51
+
52
+
53
+ def _sha256(text: str) -> str:
54
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
55
+
56
+
57
+ def plan_signature(plan: HydrocarbonStaplePlan) -> str:
58
+ """Identity of a plan: everything that changes its terminal state."""
59
+ return _stable_json(
60
+ [
61
+ str(plan.block_id),
62
+ [int(plan.anchor_pair[0]), int(plan.anchor_pair[1])],
63
+ str(plan.ordered_pair),
64
+ int(plan.spacing),
65
+ [[int(position), str(monomer)] for position, monomer in plan.substitutions],
66
+ ]
67
+ )
68
+
69
+
70
+ def plan_signatures(plans: Sequence[HydrocarbonStaplePlan]) -> list[str]:
71
+ return [plan_signature(plan) for plan in plans]
72
+
73
+
74
+ def _directory_digest(root: Path, suffixes: tuple[str, ...]) -> str:
75
+ """Digest of (relative path, size, mtime) for weight files under ``root``.
76
+
77
+ Content hashing 400 MB of PeptiVerse weights on every run would cost more
78
+ than the cache saves, so identity is (path, size, mtime-ns). Touching or
79
+ swapping a weight file therefore invalidates the cache.
80
+ """
81
+ if not root.is_dir():
82
+ return f"missing:{root}"
83
+ entries: list[tuple[str, int, int]] = []
84
+ for path in sorted(root.rglob("*")):
85
+ if not path.is_file() or path.suffix.lower() not in suffixes:
86
+ continue
87
+ stat = path.stat()
88
+ entries.append((str(path.relative_to(root)), int(stat.st_size), int(stat.st_mtime_ns)))
89
+ return _sha256(_stable_json(entries))
90
+
91
+
92
+ def _prior_digest(prior_dir: Path) -> str:
93
+ if not prior_dir.is_dir():
94
+ return f"missing:{prior_dir}"
95
+ entries = []
96
+ for path in sorted(prior_dir.rglob("*")):
97
+ if path.is_file() and path.suffix.lower() in (".json", ".csv", ".tsv", ".yaml", ".yml"):
98
+ stat = path.stat()
99
+ entries.append((str(path.relative_to(prior_dir)), int(stat.st_size), int(stat.st_mtime_ns)))
100
+ return _sha256(_stable_json(entries))
101
+
102
+
103
+ def build_fingerprint(
104
+ config: dict[str, Any],
105
+ *,
106
+ catalog: Iterable[Any],
107
+ repo_root: Path | None = None,
108
+ ) -> dict[str, Any]:
109
+ """Everything ``log q*`` depends on, other than the lead itself.
110
+
111
+ Any mismatch in any component invalidates cached entries automatically,
112
+ because the fingerprint hash is part of every row's key.
113
+ """
114
+ repo_root = repo_root or Path.cwd()
115
+ hydro = dict(config.get("hydrocarbon") or {})
116
+ plan_control = dict(hydro.get("plan_control") or {})
117
+ terminal = dict(hydro.get("terminal_energy") or {})
118
+ property_cfg = dict(terminal.get("property") or {})
119
+ predictor = dict(config.get("property_predictor") or {})
120
+ priors = dict(config.get("reference_priors") or {})
121
+ peptide_prior = dict(priors.get("peptide") or {})
122
+ plan_reference = dict(hydro.get("plan_reference") or {})
123
+ mode_prior = dict(plan_reference.get("mode_prior") or {})
124
+ training = dict(config.get("training") or {})
125
+
126
+ # --- catalog identity -------------------------------------------------
127
+ catalog_entries = []
128
+ for block in catalog:
129
+ catalog_entries.append(
130
+ {
131
+ "block_id": getattr(block, "block_id", None),
132
+ "name": getattr(block, "name", None),
133
+ "chemistry_class": getattr(block, "chemistry_class", None),
134
+ "motif": getattr(block, "motif", None),
135
+ "ca_window": getattr(block, "ca_window", None),
136
+ "cost_score": getattr(block, "cost_score", None),
137
+ "spps_score": getattr(block, "spps_score", None),
138
+ }
139
+ )
140
+
141
+ prior_dir = mode_prior.get("prior_dir")
142
+ components = {
143
+ "schema_version": CACHE_SCHEMA_VERSION,
144
+ # --- exact-SB target definition ---
145
+ "exact_sb_beta": float(plan_control.get("exact_sb_beta", 1.0)),
146
+ "exact_sb_objective": bool(plan_control.get("exact_sb_objective", False)),
147
+ # Hard constraints define the support on which q_ref, q*, and q_theta
148
+ # are conditioned. Version this explicitly so pre-mask cache rows can
149
+ # never collide with post-mask targets.
150
+ "property_free_hard_plan_predicate": "v1",
151
+ "hard_plan_geometry_eps": float(
152
+ config.get("eps_geom", training.get("eps_geom", 2.5))
153
+ ),
154
+ # --- catalog / empirical prior version ---
155
+ "catalog": catalog_entries,
156
+ "catalog_config": dict(hydro.get("catalog") or {}),
157
+ "mode_prior": mode_prior,
158
+ "mode_prior_digest": _prior_digest(
159
+ (repo_root / str(prior_dir)) if prior_dir else repo_root / "__missing__"
160
+ ),
161
+ "plan_reference_enabled": bool(plan_reference.get("enabled", False)),
162
+ "plan_reference_bias": dict(plan_reference.get("bias") or {}),
163
+ "factorized_plan_reference": bool(
164
+ (hydro.get("reference") or {}).get("factorized_plan_reference", False)
165
+ ),
166
+ # --- terminal energy / property config ---
167
+ "terminal_energy": {
168
+ key: terminal[key] for key in sorted(terminal) if key != "property"
169
+ },
170
+ "property": property_cfg,
171
+ "endpoint_prior": dict(hydro.get("endpoint_prior") or {}),
172
+ # These base-terminal coefficients feed E_T through base_terminal_factory.
173
+ "base_terminal_coefficients": {
174
+ key: training.get(key)
175
+ for key in ("lambda_close", "lambda_edit", "lambda_cost", "infeasible_penalty")
176
+ },
177
+ # --- PeptiVerse model / config ---
178
+ "peptiverse": {
179
+ key: predictor.get(key)
180
+ for key in (
181
+ "backend", "mode", "strict", "enable_fallback",
182
+ "allow_wt_token_fallback", "uncertainty", "offline",
183
+ "peptiverse_root", "classifier_weight_root", "manifest_path",
184
+ )
185
+ },
186
+ "peptiverse_manifest_digest": (
187
+ _sha256(Path(str(predictor["manifest_path"])).read_text())
188
+ if predictor.get("manifest_path") and Path(str(predictor["manifest_path"])).is_file()
189
+ else "missing_manifest"
190
+ ),
191
+ "peptiverse_weights_digest": _directory_digest(
192
+ Path(str(predictor.get("classifier_weight_root", ""))) / "training_classifiers",
193
+ (".pt", ".json", ".joblib", ".bin", ".safetensors", ".txt"),
194
+ ),
195
+ # --- reference priors (ESM-2 identity affects q_ref via the sampler) ---
196
+ "peptide_prior": {
197
+ key: peptide_prior.get(key)
198
+ for key in ("backend", "model_name_or_path", "temperature", "offline",
199
+ "strict_runtime", "ncaa_policy")
200
+ },
201
+ "anchor_prior": dict(priors.get("anchor") or {}),
202
+ "block_prior": dict(priors.get("block") or {}),
203
+ "reference_energy": dict(config.get("reference") or {}),
204
+ # --- geometry / edit settings ---
205
+ "geometry": dict(hydro.get("geometry") or {}),
206
+ "edit_constraints": dict(config.get("edit_constraints") or {}),
207
+ "curriculum_limits": {
208
+ key: (hydro.get("curriculum") or {}).get(key)
209
+ for key in ("max_anchor_edits", "prefer_existing_anchors", "protect_positions",
210
+ "require_valid_terminal")
211
+ },
212
+ "actions": dict(hydro.get("actions") or {}),
213
+ "max_neighbors": config.get("max_neighbors"),
214
+ "chemistry": config.get("chemistry"),
215
+ }
216
+ return {"hash": _sha256(_stable_json(components)), "components": components}
217
+
218
+
219
+ def lead_key(lead: Any) -> str:
220
+ """Lead identity *and* content, so an edited sequence cannot reuse a row."""
221
+ return _stable_json(
222
+ {
223
+ "example_id": str(getattr(lead, "example_id", "")),
224
+ "linear_sequence": str(getattr(lead, "linear_sequence", "")),
225
+ "protected_positions": sorted(int(p) for p in (getattr(lead, "protected_positions", None) or [])),
226
+ # peptide_ca participates in the geometry term of E_T.
227
+ "peptide_ca": (getattr(lead, "target_context", None) or {}).get("peptide_ca"),
228
+ }
229
+ )
230
+
231
+
232
+ @dataclass
233
+ class ExactSBTargetEntry:
234
+ """The deterministic half of the Exact-SB target for one lead."""
235
+
236
+ plan_signatures: list[str]
237
+ reference_logp: list[float]
238
+ terminal_energies: list[float]
239
+ log_q_star: list[float]
240
+ target_support_mask: list[bool] | None = None
241
+ joint_plan_count: int | None = None
242
+
243
+ def as_json(self) -> str:
244
+ payload: dict[str, Any] = {
245
+ "plan_signatures": self.plan_signatures,
246
+ "reference_logp": self.reference_logp,
247
+ "terminal_energies": self.terminal_energies,
248
+ "log_q_star": self.log_q_star,
249
+ }
250
+ # Preserve the byte-level shape of historical false-flag cache rows.
251
+ if self.target_support_mask is not None or self.joint_plan_count is not None:
252
+ payload["target_support_mask"] = self.target_support_mask
253
+ payload["joint_plan_count"] = self.joint_plan_count
254
+ return _stable_json(payload)
255
+
256
+ @classmethod
257
+ def from_json(cls, text: str) -> "ExactSBTargetEntry":
258
+ payload = json.loads(text)
259
+ return cls(
260
+ plan_signatures=list(payload["plan_signatures"]),
261
+ reference_logp=[float(v) for v in payload["reference_logp"]],
262
+ terminal_energies=[float(v) for v in payload["terminal_energies"]],
263
+ log_q_star=[float(v) for v in payload["log_q_star"]],
264
+ target_support_mask=(
265
+ [bool(v) for v in payload["target_support_mask"]]
266
+ if payload.get("target_support_mask") is not None
267
+ else None
268
+ ),
269
+ joint_plan_count=(
270
+ int(payload["joint_plan_count"])
271
+ if payload.get("joint_plan_count") is not None
272
+ else None
273
+ ),
274
+ )
275
+
276
+
277
+ @dataclass
278
+ class ExactSBCacheStats:
279
+ hits: int = 0
280
+ misses: int = 0
281
+ signature_mismatches: int = 0
282
+ writes: int = 0
283
+ plans_recomputed: int = 0
284
+ plans_served_from_cache: int = 0
285
+ energy_calls_saved: int = 0
286
+
287
+ def as_dict(self) -> dict[str, Any]:
288
+ total = self.hits + self.misses
289
+ return {
290
+ "hits": self.hits,
291
+ "misses": self.misses,
292
+ "lookups": total,
293
+ "hit_rate": (self.hits / total) if total else None,
294
+ "signature_mismatches": self.signature_mismatches,
295
+ "writes": self.writes,
296
+ "plans_recomputed": self.plans_recomputed,
297
+ "plans_served_from_cache": self.plans_served_from_cache,
298
+ "terminal_energy_calls_saved": self.energy_calls_saved,
299
+ }
300
+
301
+
302
+ class ExactSBTargetCache:
303
+ """SQLite-backed store for per-lead Exact-SB targets.
304
+
305
+ SQLite (WAL, one row per lead) is used rather than one file per lead so a
306
+ 4020-lead run does not create 4020 files, and so concurrent readers during
307
+ resume are safe. ``read_only=True`` gives a disabled/A-B arm that never
308
+ writes.
309
+ """
310
+
311
+ def __init__(
312
+ self,
313
+ path: Path | str | None,
314
+ fingerprint: dict[str, Any],
315
+ *,
316
+ enabled: bool = True,
317
+ read_only: bool = False,
318
+ ) -> None:
319
+ self.enabled = bool(enabled and path is not None)
320
+ self.read_only = bool(read_only)
321
+ self.fingerprint_hash = str(fingerprint["hash"])
322
+ self.fingerprint_components = fingerprint.get("components", {})
323
+ self.path = Path(path) if path is not None else None
324
+ self.stats = ExactSBCacheStats()
325
+ self._lock = threading.Lock()
326
+ self._connection: sqlite3.Connection | None = None
327
+ if self.enabled:
328
+ self._open()
329
+
330
+ # -- storage ---------------------------------------------------------
331
+ def _open(self) -> None:
332
+ assert self.path is not None
333
+ self.path.parent.mkdir(parents=True, exist_ok=True)
334
+ self._connection = sqlite3.connect(str(self.path), check_same_thread=False)
335
+ self._connection.execute("PRAGMA journal_mode=WAL")
336
+ self._connection.execute("PRAGMA synchronous=NORMAL")
337
+ self._connection.executescript(
338
+ """
339
+ CREATE TABLE IF NOT EXISTS exact_sb_targets (
340
+ fingerprint TEXT NOT NULL,
341
+ lead_key TEXT NOT NULL,
342
+ payload TEXT NOT NULL,
343
+ n_plans INTEGER NOT NULL,
344
+ PRIMARY KEY (fingerprint, lead_key)
345
+ );
346
+ CREATE TABLE IF NOT EXISTS fingerprints (
347
+ fingerprint TEXT PRIMARY KEY,
348
+ components TEXT NOT NULL
349
+ );
350
+ """
351
+ )
352
+ if not self.read_only:
353
+ self._connection.execute(
354
+ "INSERT OR REPLACE INTO fingerprints (fingerprint, components) VALUES (?, ?)",
355
+ (self.fingerprint_hash, _stable_json(self.fingerprint_components)),
356
+ )
357
+ self._connection.commit()
358
+
359
+ def close(self) -> None:
360
+ with self._lock:
361
+ if self._connection is not None:
362
+ self._connection.commit()
363
+ self._connection.close()
364
+ self._connection = None
365
+
366
+ # -- lookup / store --------------------------------------------------
367
+ def get(self, lead: Any, plans: Sequence[HydrocarbonStaplePlan]) -> ExactSBTargetEntry | None:
368
+ """Return the cached target, or ``None`` on miss or plan drift."""
369
+ if not self.enabled or self._connection is None:
370
+ self.stats.misses += 1
371
+ return None
372
+ key = lead_key(lead)
373
+ with self._lock:
374
+ row = self._connection.execute(
375
+ "SELECT payload FROM exact_sb_targets WHERE fingerprint = ? AND lead_key = ?",
376
+ (self.fingerprint_hash, key),
377
+ ).fetchone()
378
+ if row is None:
379
+ self.stats.misses += 1
380
+ return None
381
+ entry = ExactSBTargetEntry.from_json(row[0])
382
+ # Defence in depth: even on a fingerprint match, the live enumeration
383
+ # must produce exactly the same plans in the same order.
384
+ if entry.plan_signatures != plan_signatures(plans):
385
+ self.stats.signature_mismatches += 1
386
+ self.stats.misses += 1
387
+ return None
388
+ self.stats.hits += 1
389
+ self.stats.plans_served_from_cache += len(entry.plan_signatures)
390
+ self.stats.energy_calls_saved += len(entry.plan_signatures)
391
+ return entry
392
+
393
+ def put(self, lead: Any, entry: ExactSBTargetEntry) -> None:
394
+ if not self.enabled or self.read_only or self._connection is None:
395
+ return
396
+ with self._lock:
397
+ self._connection.execute(
398
+ "INSERT OR REPLACE INTO exact_sb_targets "
399
+ "(fingerprint, lead_key, payload, n_plans) VALUES (?, ?, ?, ?)",
400
+ (
401
+ self.fingerprint_hash,
402
+ lead_key(lead),
403
+ entry.as_json(),
404
+ len(entry.plan_signatures),
405
+ ),
406
+ )
407
+ self._connection.commit()
408
+ self.stats.writes += 1
409
+
410
+ # -- diagnostics -----------------------------------------------------
411
+ def disk_bytes(self) -> int:
412
+ if self.path is None or not self.path.is_file():
413
+ return 0
414
+ total = self.path.stat().st_size
415
+ for suffix in ("-wal", "-shm"):
416
+ side = self.path.with_name(self.path.name + suffix)
417
+ if side.is_file():
418
+ total += side.stat().st_size
419
+ return int(total)
420
+
421
+ def row_count(self) -> int:
422
+ if not self.enabled or self._connection is None:
423
+ return 0
424
+ with self._lock:
425
+ return int(
426
+ self._connection.execute(
427
+ "SELECT COUNT(*) FROM exact_sb_targets WHERE fingerprint = ?",
428
+ (self.fingerprint_hash,),
429
+ ).fetchone()[0]
430
+ )
431
+
432
+ def describe(self) -> dict[str, Any]:
433
+ return {
434
+ "enabled": self.enabled,
435
+ "read_only": self.read_only,
436
+ "path": None if self.path is None else str(self.path),
437
+ "fingerprint": self.fingerprint_hash,
438
+ "rows_for_fingerprint": self.row_count(),
439
+ "disk_bytes": self.disk_bytes(),
440
+ **self.stats.as_dict(),
441
+ }
442
+
443
+
444
+ def cache_path_from_config(config: dict[str, Any], repo_root: Path | None = None) -> Path | None:
445
+ """Resolve ``hydrocarbon.plan_control.exact_sb_cache.path``; ``None`` = off."""
446
+ plan_control = dict(((config.get("hydrocarbon") or {}).get("plan_control") or {}))
447
+ section = dict(plan_control.get("exact_sb_cache") or {})
448
+ if not section or not bool(section.get("enabled", False)):
449
+ return None
450
+ raw = str(section.get("path") or "outputs/cache/exact_sb_targets.sqlite")
451
+ path = Path(raw)
452
+ if not path.is_absolute():
453
+ path = (repo_root or Path.cwd()) / path
454
+ return path
455
+
456
+
457
+ def energy_only_from_config(config: dict[str, Any]) -> bool:
458
+ """Read ``hydrocarbon.plan_control.exact_sb_cache.energy_only`` (default on).
459
+
460
+ Off gives the original all-property scalar construction, which the A/B
461
+ benchmark uses as the reference arm.
462
+ """
463
+ plan_control = dict(((config.get("hydrocarbon") or {}).get("plan_control") or {}))
464
+ section = dict(plan_control.get("exact_sb_cache") or {})
465
+ return bool(section.get("energy_only", True))
466
+
467
+
468
+ def build_cache_from_config(
469
+ config: dict[str, Any],
470
+ *,
471
+ catalog: Iterable[Any],
472
+ repo_root: Path | None = None,
473
+ read_only: bool = False,
474
+ override_path: Path | None = None,
475
+ ) -> ExactSBTargetCache:
476
+ """Construct the cache declared by ``config`` (disabled when absent)."""
477
+ catalog = list(catalog)
478
+ fingerprint = build_fingerprint(config, catalog=catalog, repo_root=repo_root)
479
+ path = override_path if override_path is not None else cache_path_from_config(config, repo_root)
480
+ return ExactSBTargetCache(path, fingerprint, enabled=path is not None, read_only=read_only)
481
+
482
+
483
+ def _accepts_energy_only(energy_fn: Any) -> bool:
484
+ """Whether ``energy_fn`` takes the ``energy_only`` keyword."""
485
+ target = energy_fn.__call__ if not inspect.isfunction(energy_fn) else energy_fn
486
+ try:
487
+ signature = inspect.signature(target)
488
+ except (TypeError, ValueError):
489
+ return False
490
+ parameters = signature.parameters
491
+ if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
492
+ return True
493
+ return "energy_only" in parameters
494
+
495
+
496
+ def resolve_exact_sb_target(
497
+ *,
498
+ lead: Any,
499
+ plans: Sequence[HydrocarbonStaplePlan],
500
+ reference_log_probabilities: torch.Tensor,
501
+ beta: float,
502
+ energy_fn: Any,
503
+ initial_state: Any,
504
+ build_terminal: Any,
505
+ cache: ExactSBTargetCache | None,
506
+ energy_only: bool = False,
507
+ scorer: Any = None,
508
+ scorer_config: Any = None,
509
+ ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
510
+ """Return ``(terminal_energies, log_q_star, info)`` for one lead.
511
+
512
+ On a cache hit the PeptiVerse-backed terminal energies are replayed from
513
+ disk. On a miss they are computed exactly as before and then stored.
514
+ ``log q*`` is always produced by the shared
515
+ :func:`exact_sb_target_log_probabilities`, so the cached and recomputed
516
+ paths cannot diverge in definition.
517
+
518
+ ``energy_only`` restricts property prediction to the properties that reach
519
+ the terminal energy and batch-prefetches their SMILES across all of this
520
+ lead's plans. It is a pure speedup: the energies, and therefore ``q*``, are
521
+ unchanged, so a cache row written by either path is interchangeable. It
522
+ requires ``scorer``/``scorer_config`` and an ``energy_fn`` accepting the
523
+ ``energy_only`` keyword; without them the original scalar path runs.
524
+ """
525
+ # Imported here to keep plan_control free of a dependency on this module.
526
+ from staplebridge.hydrocarbon.plan_control import (
527
+ exact_sb_target_log_probabilities,
528
+ )
529
+ from staplebridge.hydrocarbon.property_energy import required_energy_properties
530
+
531
+ device = reference_log_probabilities.device
532
+ dtype = reference_log_probabilities.dtype
533
+ if scorer_config is None:
534
+ scorer_config = getattr(energy_fn, "property_cfg", None)
535
+ joint_enabled = bool(
536
+ getattr(scorer_config, "enable_joint_perm_halflife_support", False)
537
+ )
538
+
539
+ def support_info(
540
+ mask_values: list[bool] | None, joint_count: int | None
541
+ ) -> dict[str, Any]:
542
+ if not joint_enabled:
543
+ return {
544
+ "joint_perm_halflife_support_enabled": False,
545
+ "target_support_mask": None,
546
+ "joint_plan_count": None,
547
+ "joint_nonempty": None,
548
+ "joint_fallback": None,
549
+ "q_star_support_size": len(plans),
550
+ }
551
+ count = int(joint_count or 0)
552
+ fallback = count == 0
553
+ effective = None if fallback else list(mask_values or [])
554
+ return {
555
+ "joint_perm_halflife_support_enabled": True,
556
+ "target_support_mask": effective,
557
+ "joint_plan_count": count,
558
+ "joint_nonempty": not fallback,
559
+ "joint_fallback": fallback,
560
+ "q_star_support_size": count if count else len(plans),
561
+ }
562
+
563
+ entry = None if cache is None else cache.get(lead, plans)
564
+ if entry is not None:
565
+ if joint_enabled and entry.joint_plan_count is None:
566
+ # Backward-compatible payloads do not carry enough information to
567
+ # reconstruct a strict target support. A matching new-arm
568
+ # fingerprint should make this impossible, but fail closed.
569
+ entry = None
570
+ if entry is not None:
571
+ energies = torch.tensor(entry.terminal_energies, dtype=dtype, device=device)
572
+ mask = (
573
+ torch.tensor(entry.target_support_mask, dtype=torch.bool, device=device)
574
+ if joint_enabled
575
+ and int(entry.joint_plan_count or 0) > 0
576
+ and entry.target_support_mask is not None
577
+ else None
578
+ )
579
+ # Recomputed from the cached energies rather than trusting the stored
580
+ # log_q_star blindly; the stored copy is then verified against it.
581
+ log_q_star = exact_sb_target_log_probabilities(
582
+ reference_log_probabilities,
583
+ energies,
584
+ beta,
585
+ target_support_mask=mask,
586
+ )
587
+ stored = torch.tensor(entry.log_q_star, dtype=dtype, device=device)
588
+ finite = torch.isfinite(log_q_star) & torch.isfinite(stored)
589
+ max_drift = (
590
+ float((log_q_star[finite] - stored[finite]).abs().max().item())
591
+ if bool(finite.any().item())
592
+ else 0.0
593
+ )
594
+ if not torch.equal(torch.isneginf(log_q_star), torch.isneginf(stored)):
595
+ max_drift = float("inf")
596
+ return energies, log_q_star, {
597
+ "source": "cache",
598
+ "log_q_star_drift": max_drift,
599
+ **support_info(entry.target_support_mask, entry.joint_plan_count),
600
+ }
601
+
602
+ energies_list: list[float] = []
603
+ terminals = [build_terminal(initial_state, plan) for plan in plans]
604
+
605
+ # Energy-only + batched prefetch. Both are pure accelerations: the prefetch
606
+ # only warms caches, and energy_only skips predictions that are provably not
607
+ # summed into the energy. When either is unavailable the loop below is the
608
+ # original scalar path, so the stored energies are the same either way.
609
+ prefetch_info: dict[str, Any] = {}
610
+ if energy_only:
611
+ # Only the hydrocarbon terminal energy accepts the keyword and carries a
612
+ # scorer; any other callable (tests, lactam-style stubs) keeps the
613
+ # original scalar path rather than being handed an argument it rejects.
614
+ if not _accepts_energy_only(energy_fn):
615
+ energy_only = False
616
+ if energy_only:
617
+ # HydrocarbonTerminalEnergy carries both; taking them from energy_fn
618
+ # keeps the two knobs in sync with the energy that will actually run.
619
+ if scorer is None:
620
+ scorer = getattr(energy_fn, "property_scorer", None)
621
+ if scorer_config is None:
622
+ scorer_config = getattr(energy_fn, "property_cfg", None)
623
+ if scorer is None:
624
+ energy_only = False
625
+ if energy_only and scorer is not None:
626
+ properties = required_energy_properties(scorer_config) if scorer_config else ()
627
+ if properties:
628
+ smiles: list[str] = []
629
+ for terminal in terminals:
630
+ try:
631
+ smiles.extend(scorer.energy_only_smiles(terminal))
632
+ except Exception: # noqa: BLE001
633
+ # An unscorable terminal is handled by the energy itself
634
+ # (topology gate); nothing to prefetch for it.
635
+ continue
636
+ if smiles:
637
+ prefetch_info = scorer.prefetch(properties, smiles)
638
+
639
+ joint_mask_values: list[bool] = []
640
+ for terminal in terminals:
641
+ if energy_only:
642
+ energy, terms = energy_fn(initial_state, terminal, lead, energy_only=True)
643
+ else:
644
+ energy, terms = energy_fn(initial_state, terminal, lead)
645
+ energies_list.append(float(energy))
646
+ if joint_enabled:
647
+ if "hydrocarbon_joint_perm_halflife_condition" not in terms:
648
+ raise RuntimeError(
649
+ "joint Exact-SB support enabled but terminal energy did not "
650
+ "report the joint condition"
651
+ )
652
+ joint_mask_values.append(
653
+ bool(terms["hydrocarbon_joint_perm_halflife_condition"])
654
+ )
655
+ energies = torch.tensor(energies_list, dtype=dtype, device=device)
656
+ joint_count = sum(joint_mask_values) if joint_enabled else None
657
+ target_mask = (
658
+ torch.tensor(joint_mask_values, dtype=torch.bool, device=device)
659
+ if joint_enabled and int(joint_count or 0) > 0
660
+ else None
661
+ )
662
+ log_q_star = exact_sb_target_log_probabilities(
663
+ reference_log_probabilities,
664
+ energies,
665
+ beta,
666
+ target_support_mask=target_mask,
667
+ )
668
+ if cache is not None:
669
+ cache.stats.plans_recomputed += len(plans)
670
+ cache.put(
671
+ lead,
672
+ ExactSBTargetEntry(
673
+ plan_signatures=plan_signatures(plans),
674
+ reference_logp=[float(v) for v in reference_log_probabilities.detach().cpu().tolist()],
675
+ terminal_energies=energies_list,
676
+ log_q_star=[float(v) for v in log_q_star.detach().cpu().tolist()],
677
+ target_support_mask=(
678
+ list(joint_mask_values) if joint_enabled else None
679
+ ),
680
+ joint_plan_count=joint_count,
681
+ ),
682
+ )
683
+ return energies, log_q_star, {
684
+ "source": "computed",
685
+ "log_q_star_drift": 0.0,
686
+ "energy_only": bool(energy_only),
687
+ "prefetch": prefetch_info,
688
+ **support_info(
689
+ list(joint_mask_values) if joint_enabled else None,
690
+ joint_count,
691
+ ),
692
+ }
staplebridge/hydrocarbon/factorized_plan_reference.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Topology-mass-preserving hydrocarbon plan reference.
2
+
3
+ This module is hydrocarbon-only. It does not import or modify the lactam
4
+ catalog, decoder, property model, SMILES builder, plan space, or loss.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from staplebridge.chemistry.state import StapleState
14
+ from staplebridge.data.schemas import BuildingBlock
15
+ from staplebridge.hydrocarbon.curriculum import (
16
+ HydrocarbonStaplePlan,
17
+ build_hydrocarbon_demonstration_path,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class FactorizedPlanReferenceConfig:
23
+ """Configuration read from ``hydrocarbon.reference``.
24
+
25
+ Defaults were preregistered without property labels from the component
26
+ scale audit: MotifSupportAnchorPrior/geometry is primary and frozen ESM2
27
+ delta is only a weak regularizer. CatalogBlockPrior is a legality check and
28
+ diagnostic, not a soft ranking term.
29
+ """
30
+
31
+ enabled: bool = False
32
+ geometry_coefficient: float = 1.0
33
+ esm2_coefficient: float = 0.1
34
+ temperature: float = 1.0
35
+ block_legality_only: bool = True
36
+
37
+ @classmethod
38
+ def from_config(
39
+ cls, root_cfg: dict[str, Any] | None
40
+ ) -> "FactorizedPlanReferenceConfig":
41
+ root_cfg = dict(root_cfg or {})
42
+ hydro = dict(root_cfg.get("hydrocarbon") or {})
43
+ section = dict(hydro.get("reference") or {})
44
+ within = dict(section.get("within_mode") or {})
45
+ return cls(
46
+ enabled=bool(section.get("factorized_plan_reference", False)),
47
+ geometry_coefficient=float(within.get("geometry_coefficient", 1.0)),
48
+ esm2_coefficient=float(within.get("esm2_coefficient", 0.1)),
49
+ temperature=max(float(within.get("temperature", 1.0)), 1e-8),
50
+ block_legality_only=bool(within.get("block_legality_only", True)),
51
+ )
52
+
53
+ def describe(self) -> dict[str, Any]:
54
+ return {
55
+ "factorized_plan_reference": bool(self.enabled),
56
+ "geometry_coefficient": float(self.geometry_coefficient),
57
+ "esm2_coefficient": float(self.esm2_coefficient),
58
+ "temperature": float(self.temperature),
59
+ "block_legality_only": bool(self.block_legality_only),
60
+ "uses_property_labels": False,
61
+ "normalization": "softmax separately within each feasible mode",
62
+ }
63
+
64
+
65
+ class FactorizedPlanReference:
66
+ """Compute ``q_mode(mode|lead) * q_within(plan|lead,mode)``.
67
+
68
+ The supplied ``mode_prior`` owns the StaPep probability and empirical
69
+ beta. Within-mode components are normalized separately, so they cannot
70
+ change the total mass assigned to a topology.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ mode_prior: Any,
76
+ catalog: list[BuildingBlock],
77
+ peptide_prior: Any,
78
+ anchor_prior: Any,
79
+ block_prior: Any,
80
+ config: FactorizedPlanReferenceConfig,
81
+ ) -> None:
82
+ self.mode_prior = mode_prior
83
+ self.catalog = list(catalog)
84
+ self.catalog_index = {block.block_id: block for block in catalog}
85
+ self.peptide_prior = peptide_prior
86
+ self.anchor_prior = anchor_prior
87
+ self.block_prior = block_prior
88
+ self.cfg = config
89
+ self.last_diagnostics: list[dict[str, Any]] = []
90
+
91
+ @staticmethod
92
+ def _softmax(values: list[float]) -> list[float]:
93
+ if not values:
94
+ return []
95
+ peak = max(values)
96
+ exponentials = [math.exp(value - peak) for value in values]
97
+ total = sum(exponentials)
98
+ if total <= 0.0 or not math.isfinite(total):
99
+ return [1.0 / len(values)] * len(values)
100
+ probabilities = [value / total for value in exponentials]
101
+ if len(probabilities) > 1:
102
+ probabilities[-1] = 1.0 - sum(probabilities[:-1])
103
+ return probabilities
104
+
105
+ def weights(
106
+ self,
107
+ initial: StapleState,
108
+ plans: list[HydrocarbonStaplePlan],
109
+ context: dict[str, Any] | None = None,
110
+ ) -> list[float]:
111
+ """Return normalized factorized probabilities in ``plans`` order."""
112
+ if not plans:
113
+ self.last_diagnostics = []
114
+ return []
115
+
116
+ context = dict(context or {})
117
+ terminals = [
118
+ build_hydrocarbon_demonstration_path(initial, plan, self.catalog)[-1]
119
+ for plan in plans
120
+ ]
121
+ esm2_scores = self.peptide_prior.batch_score_transitions(
122
+ initial, terminals, context
123
+ )
124
+
125
+ feasible_modes: list[tuple[str, int]] = []
126
+ for plan in plans:
127
+ mode = (plan.ordered_pair, plan.spacing)
128
+ if mode not in feasible_modes:
129
+ feasible_modes.append(mode)
130
+ tilted = [self.mode_prior.tilted_weight(mode) for mode in feasible_modes]
131
+ tilted_total = sum(tilted)
132
+ if tilted_total <= 0.0:
133
+ self.last_diagnostics = []
134
+ return [0.0] * len(plans)
135
+ q_mode = {
136
+ mode: weight / tilted_total for mode, weight in zip(feasible_modes, tilted)
137
+ }
138
+ if len(feasible_modes) > 1:
139
+ q_mode[feasible_modes[-1]] = 1.0 - sum(
140
+ q_mode[mode] for mode in feasible_modes[:-1]
141
+ )
142
+
143
+ logits: list[float] = []
144
+ diagnostics: list[dict[str, Any]] = []
145
+ for plan, terminal, esm2_score in zip(plans, terminals, esm2_scores):
146
+ block = self.catalog_index.get(plan.block_id)
147
+ if block is None:
148
+ anchor_score = float("-inf")
149
+ block_score = float("-inf")
150
+ anchor_components: dict[str, float] = {}
151
+ legal = False
152
+ else:
153
+ anchor_context = dict(context)
154
+ anchor_context["return_components"] = True
155
+ anchor_score = float(
156
+ self.anchor_prior.score_anchor(
157
+ terminal.sequence_tokens, plan.anchor_pair, anchor_context
158
+ )
159
+ )
160
+ anchor_components = dict(anchor_context.get("_components") or {})
161
+ block_score = float(
162
+ self.block_prior.score_block(
163
+ terminal.sequence_tokens, plan.anchor_pair, block, context
164
+ )
165
+ )
166
+ legal = math.isfinite(anchor_score) and math.isfinite(block_score)
167
+
168
+ logit = (
169
+ self.cfg.geometry_coefficient * anchor_score
170
+ + self.cfg.esm2_coefficient * float(esm2_score)
171
+ ) / self.cfg.temperature
172
+ if not legal:
173
+ logit = float("-inf")
174
+ logits.append(float(logit))
175
+ diagnostics.append(
176
+ {
177
+ "mode": f"{plan.ordered_pair}/i,i+{plan.spacing}",
178
+ "anchor_pair": list(plan.anchor_pair),
179
+ "block_id": plan.block_id,
180
+ "stapep_probability": self.mode_prior.probability(
181
+ (plan.ordered_pair, plan.spacing)
182
+ ),
183
+ "stapep_tilted_weight": self.mode_prior.tilted_weight(
184
+ (plan.ordered_pair, plan.spacing)
185
+ ),
186
+ "q_mode_target": q_mode[(plan.ordered_pair, plan.spacing)],
187
+ "anchor_score_raw": anchor_score,
188
+ "anchor_components": anchor_components,
189
+ "esm2_delta_raw": float(esm2_score),
190
+ "block_score_diagnostic_only": block_score,
191
+ "block_legal": bool(legal),
192
+ "within_mode_logit": float(logit),
193
+ }
194
+ )
195
+
196
+ weights = [0.0] * len(plans)
197
+ for mode in feasible_modes:
198
+ indices = [
199
+ index
200
+ for index, plan in enumerate(plans)
201
+ if (plan.ordered_pair, plan.spacing) == mode
202
+ ]
203
+ mode_logits = [logits[index] for index in indices]
204
+ finite = [math.isfinite(value) for value in mode_logits]
205
+ if not any(finite):
206
+ continue
207
+ masked = [value if ok else -1e30 for value, ok in zip(mode_logits, finite)]
208
+ within = self._softmax(masked)
209
+ for local_index, plan_index in enumerate(indices):
210
+ diagnostics[plan_index]["q_within_mode"] = float(within[local_index])
211
+ weights[plan_index] = q_mode[mode] * within[local_index]
212
+ if len(indices) > 1:
213
+ weights[indices[-1]] = q_mode[mode] - sum(
214
+ weights[index] for index in indices[:-1]
215
+ )
216
+
217
+ for index, weight in enumerate(weights):
218
+ diagnostics[index]["q_ref"] = float(weight)
219
+ self.last_diagnostics = diagnostics
220
+ return weights
staplebridge/hydrocarbon/geometry.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon geometry oracle.
2
+
3
+ The lactam oracles in :mod:`staplebridge.oracles.geometry` (``MockGeometryOracle``,
4
+ ``CPComposerStapledOracle``) are **not modified**. This is a separate oracle for
5
+ the hydrocarbon branch, because the two chemistries have genuinely different
6
+ feasibility criteria:
7
+
8
+ * lactam K-D/E closes at i,i+3 / i,i+4 with a 4.0-6.5 A Cα window;
9
+ * hydrocarbon RCM staples span one helical turn (i,i+4) or two (i,i+7), so the
10
+ i,i+7 window sits much further out.
11
+
12
+ ``ctype`` here additionally requires that the anchor residues really are the
13
+ anchor monomers the block names, which the lactam oracle has no notion of.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from typing import Any
20
+
21
+ from staplebridge.data.schemas import BuildingBlock
22
+ from staplebridge.hydrocarbon.actions import FailureReason, validate_hydrocarbon_staple
23
+ from staplebridge.hydrocarbon.catalog import is_hydrocarbon_block
24
+ from staplebridge.oracles.base import GeometryOracleBase
25
+
26
+
27
+ def _ca_distance(
28
+ coords: list[tuple[float, float, float]] | None, i: int, j: int
29
+ ) -> float | None:
30
+ """Cα(i)-Cα(j) distance, or ``None`` when coordinates are unavailable."""
31
+ if coords is None or i < 0 or j < 0 or i >= len(coords) or j >= len(coords):
32
+ return None
33
+ xi, yi, zi = coords[i]
34
+ xj, yj, zj = coords[j]
35
+ return math.sqrt((xi - xj) ** 2 + (yi - yj) ** 2 + (zi - zj) ** 2)
36
+
37
+
38
+ class HydrocarbonGeometryOracle(GeometryOracleBase):
39
+ """Feasibility for hydrocarbon staples.
40
+
41
+ Args:
42
+ catalog: the hydrocarbon blocks in play, used for topology validation.
43
+ sentinel_cgeom: penalty returned when the staple is not even
44
+ type-feasible. Matches the lactam oracles' convention of a large
45
+ sentinel so that stage-aware geometry can detect it.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ catalog: list[BuildingBlock] | None = None,
51
+ sentinel_cgeom: float = 10.0,
52
+ ) -> None:
53
+ self.catalog = list(catalog or [])
54
+ self.sentinel_cgeom = float(sentinel_cgeom)
55
+
56
+ def ctype(
57
+ self,
58
+ sequence: list[str],
59
+ anchor_pair: tuple[int, int] | None,
60
+ block: BuildingBlock | None,
61
+ *,
62
+ peptide_ca: Any = None,
63
+ ) -> bool:
64
+ """True when ``(sequence, anchor_pair, block)`` is a legal hydrocarbon staple.
65
+
66
+ Sequence-level only; the Cα window is scored by :meth:`cgeom`.
67
+ """
68
+ del peptide_ca
69
+ if block is None or not is_hydrocarbon_block(block):
70
+ return False
71
+ catalog = self.catalog or [block]
72
+ verdict = validate_hydrocarbon_staple(sequence, anchor_pair, block, catalog)
73
+ return verdict is FailureReason.OK
74
+
75
+ def cgeom(
76
+ self,
77
+ sequence: list[str],
78
+ anchor_pair: tuple[int, int] | None,
79
+ block: BuildingBlock | None,
80
+ *,
81
+ peptide_ca: Any = None,
82
+ ) -> float:
83
+ """Distance of Cα(i)-Cα(j) from the block's allowed window, in Å.
84
+
85
+ Returns ``0.0`` inside the window. Without coordinates, falls back to a
86
+ sequence-only verdict: ``0.0`` when type-feasible, else the sentinel.
87
+ """
88
+ if not self.ctype(sequence, anchor_pair, block):
89
+ return self.sentinel_cgeom
90
+ assert anchor_pair is not None and block is not None # ctype guarantees
91
+
92
+ i, j = anchor_pair
93
+ distance = _ca_distance(peptide_ca, i, j)
94
+ if distance is None:
95
+ return 0.0
96
+
97
+ low, high = block.ca_window
98
+ if distance < low:
99
+ return float(low - distance)
100
+ if distance > high:
101
+ return float(distance - high)
102
+ return 0.0
staplebridge/hydrocarbon/monomers.py ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon monomer library.
2
+
3
+ Self-contained SMILES fragments for building hydrocarbon-stapled peptides. This
4
+ module is **additive and hydrocarbon-only**: the lactam path has no SMILES
5
+ builder at all, so nothing here replaces or shadows existing behaviour.
6
+
7
+ Fragment convention
8
+ -------------------
9
+ Every residue is stored as a *backbone-open* fragment with two named attachment
10
+ points, written so that concatenating them forms a normal peptide chain:
11
+
12
+ N-terminus of residue k <-- amide bond --> C-terminus of residue k-1
13
+
14
+ Each entry carries the fragment as an explicit SMILES with a dummy atom at each
15
+ end (``[*:1]`` for the incoming amide nitrogen side, ``[*:2]`` for the outgoing
16
+ carbonyl side). Bonds are then formed programmatically in
17
+ :mod:`staplebridge.hydrocarbon.smiles_builder` rather than by string
18
+ concatenation, because string splicing cannot keep ring-closure digits or
19
+ stereo-bond parities consistent across a macrocyclisation.
20
+
21
+ Stereochemistry
22
+ ---------------
23
+ All natural residues are L (CIP *S* at Cα, except cysteine which is *R* by the
24
+ standard CIP quirk, and glycine which is achiral). In this module's fragment
25
+ ordering — ``[*:1]`` first, then N, Cα, then the carbonyl — the verified tags are:
26
+
27
+ in-chain L residue ``[*:1]N[C@@H](R)C(=O)[*:2]`` -> CIP S
28
+ S5 anchor (α-Me, (S)) ``[*:1]N[C@@](C)(CCCC=C)C(=O)[*:2]`` -> CIP S
29
+ R8 anchor (α-Me, (R)) ``[*:1]N[C@](C)(CCCCCCC=C)C(=O)[*:2]`` -> CIP R
30
+
31
+ A ``@``/``@@`` symbol denotes a parity over the neighbour list *in written
32
+ order*, so the same symbol means different absolute configurations depending on
33
+ where the attachment dummies sit. Reasoning about these tags by analogy is
34
+ therefore unreliable, and every tag in this module is instead machine-checked by
35
+ :func:`verify_monomer_stereochemistry` against
36
+ ``rdkit.Chem.rdCIPLabeler.AssignCIPLabels``. That check is exercised by the
37
+ tests; it caught all four anchors being inverted during development, and it
38
+ would catch a future edit that reintroduces the same slip.
39
+
40
+ Anchor naming follows the StaPep convention used throughout this branch:
41
+
42
+ ========== ============================ ========= =======================
43
+ token monomer Cα config olefin tether
44
+ ========== ============================ ========= =======================
45
+ ``S5`` (S)-α-Me-α-(4-pentenyl)Gly S ``CCCC=C`` (4 C + ene)
46
+ ``R8`` (R)-α-Me-α-(7-octenyl)Gly R ``CCCCCCC=C`` (7 C + ene)
47
+ ``R5`` (R)-α-Me-α-(4-pentenyl)Gly R ``CCCC=C``
48
+ ``S8`` (S)-α-Me-α-(7-octenyl)Gly S ``CCCCCCC=C``
49
+ ========== ============================ ========= =======================
50
+
51
+ ``S5``/``R8`` are the two anchors the enabled catalog uses; ``R5``/``S8`` exist
52
+ here only so the default-off ``R5-S8/i,i+7`` extension can be built when
53
+ explicitly requested.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ from dataclasses import dataclass
59
+ from typing import Final
60
+
61
+ #: Dummy-atom map number marking the amide-nitrogen (N-terminal) attachment.
62
+ ATTACH_N: Final[int] = 1
63
+ #: Dummy-atom map number marking the carbonyl-carbon (C-terminal) attachment.
64
+ ATTACH_C: Final[int] = 2
65
+
66
+
67
+ class UnknownMonomerError(KeyError):
68
+ """Raised when a token has no fragment in this library."""
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Monomer:
73
+ """One residue fragment.
74
+
75
+ Attributes:
76
+ token: canonical monomer token (``"A"``, ``"S5"``, ``"AIB"``, ...).
77
+ name: human-readable name.
78
+ smiles: backbone-open fragment carrying ``[*:1]`` and ``[*:2]``.
79
+ is_anchor: True for the olefin-bearing staple anchors.
80
+ olefin_carbons: number of carbons in the tether *including* both alkene
81
+ carbons. ``S5`` has 5, ``R8`` has 8. Zero for non-anchors.
82
+ cip_code: expected CIP label at Cα, or ``None`` when achiral.
83
+ three_letter: conventional three-letter code, for reporting.
84
+ """
85
+
86
+ token: str
87
+ name: str
88
+ smiles: str
89
+ is_anchor: bool = False
90
+ olefin_carbons: int = 0
91
+ cip_code: str | None = None
92
+ three_letter: str = ""
93
+
94
+
95
+ def _residue(
96
+ token: str,
97
+ name: str,
98
+ side_chain: str,
99
+ three_letter: str,
100
+ cip_code: str | None = "S",
101
+ ) -> Monomer:
102
+ """Build a standard L-α-amino-acid fragment from its side chain.
103
+
104
+ ``side_chain`` is spliced into ``[*:1]N[C@@H](<side_chain>)C(=O)[*:2]``.
105
+ Glycine passes ``side_chain=""`` and uses the achiral ``CH2`` form.
106
+ """
107
+ if side_chain:
108
+ smiles = f"[*:{ATTACH_N}]N[C@@H]({side_chain})C(=O)[*:{ATTACH_C}]"
109
+ else:
110
+ smiles = f"[*:{ATTACH_N}]NCC(=O)[*:{ATTACH_C}]"
111
+ return Monomer(
112
+ token=token,
113
+ name=name,
114
+ smiles=smiles,
115
+ cip_code=cip_code,
116
+ three_letter=three_letter,
117
+ )
118
+
119
+
120
+ def _anchor(
121
+ token: str,
122
+ name: str,
123
+ tether: str,
124
+ olefin_carbons: int,
125
+ chirality: str,
126
+ cip_code: str,
127
+ ) -> Monomer:
128
+ """Build an α-methyl α-alkenyl glycine anchor fragment.
129
+
130
+ ``chirality`` is the raw ``@``/``@@`` tag; the resulting CIP label is
131
+ asserted against ``cip_code`` by :func:`verify_monomer_stereochemistry`
132
+ rather than trusted.
133
+ """
134
+ return Monomer(
135
+ token=token,
136
+ name=name,
137
+ smiles=(
138
+ f"[*:{ATTACH_N}]N[C{chirality}](C)({tether})C(=O)[*:{ATTACH_C}]"
139
+ ),
140
+ is_anchor=True,
141
+ olefin_carbons=olefin_carbons,
142
+ cip_code=cip_code,
143
+ three_letter=token,
144
+ )
145
+
146
+
147
+ #: Olefin tether for the "5" series: 4 saturated carbons then a terminal alkene.
148
+ TETHER_PENTENYL: Final[str] = "CCCC=C"
149
+ #: Olefin tether for the "8" series: 7 saturated carbons then a terminal alkene.
150
+ TETHER_OCTENYL: Final[str] = "CCCCCCC=C"
151
+
152
+ #: The 20 proteinogenic residues, L configuration.
153
+ #:
154
+ #: Cysteine is CIP *R* despite being L — the sulfur outranks the carboxyl, so
155
+ #: the same spatial arrangement earns the opposite descriptor. Encoding it as
156
+ #: ``"S"`` would make :func:`verify_monomer_stereochemistry` fail correctly, so
157
+ #: the exception is recorded here rather than special-cased downstream.
158
+ NATURAL_MONOMERS: Final[dict[str, Monomer]] = {
159
+ "A": _residue("A", "L-alanine", "C", "Ala"),
160
+ "R": _residue("R", "L-arginine", "CCCNC(N)=N", "Arg"),
161
+ "N": _residue("N", "L-asparagine", "CC(N)=O", "Asn"),
162
+ "D": _residue("D", "L-aspartic acid", "CC(=O)O", "Asp"),
163
+ "C": _residue("C", "L-cysteine", "CS", "Cys", cip_code="R"),
164
+ "Q": _residue("Q", "L-glutamine", "CCC(N)=O", "Gln"),
165
+ "E": _residue("E", "L-glutamic acid", "CCC(=O)O", "Glu"),
166
+ "G": _residue("G", "glycine", "", "Gly", cip_code=None),
167
+ "H": _residue("H", "L-histidine", "Cc1c[nH]cn1", "His"),
168
+ "I": _residue("I", "L-isoleucine", "[C@@H](C)CC", "Ile"),
169
+ "L": _residue("L", "L-leucine", "CC(C)C", "Leu"),
170
+ "K": _residue("K", "L-lysine", "CCCCN", "Lys"),
171
+ "M": _residue("M", "L-methionine", "CCSC", "Met"),
172
+ "F": _residue("F", "L-phenylalanine", "Cc1ccccc1", "Phe"),
173
+ "P": _residue("P", "L-proline", "", "Pro", cip_code="S"),
174
+ "S": _residue("S", "L-serine", "CO", "Ser"),
175
+ "T": _residue("T", "L-threonine", "[C@H](C)O", "Thr"),
176
+ "W": _residue("W", "L-tryptophan", "Cc1c[nH]c2ccccc12", "Trp"),
177
+ "Y": _residue("Y", "L-tyrosine", "Cc1ccc(O)cc1", "Tyr"),
178
+ "V": _residue("V", "L-valine", "C(C)C", "Val"),
179
+ }
180
+
181
+ # Proline is a secondary amine whose side chain closes back onto the backbone
182
+ # nitrogen, so the generic template cannot express it. Written out explicitly.
183
+ NATURAL_MONOMERS["P"] = Monomer(
184
+ token="P",
185
+ name="L-proline",
186
+ smiles=f"[*:{ATTACH_N}]N1[C@@H](CCC1)C(=O)[*:{ATTACH_C}]",
187
+ cip_code="S",
188
+ three_letter="Pro",
189
+ )
190
+
191
+ #: The hydrocarbon staple anchors.
192
+ #:
193
+ #: The ``@``/``@@`` tags below are in the *fragment's* atom ordering
194
+ #: (``[*:1]N[C?](C)(tether)C(=O)[*:2]``), where ``@@`` is (S) and ``@`` is (R).
195
+ #: That is the opposite of the bare ``N[C?](C)(tether)C(=O)NC`` ordering, which
196
+ #: is exactly the kind of slip :func:`verify_monomer_stereochemistry` exists to
197
+ #: catch — it did catch it during development, on all four anchors at once.
198
+ ANCHOR_MONOMERS: Final[dict[str, Monomer]] = {
199
+ "S5": _anchor(
200
+ "S5",
201
+ "(S)-2-(4-pentenyl)alanine",
202
+ TETHER_PENTENYL,
203
+ olefin_carbons=5,
204
+ chirality="@@",
205
+ cip_code="S",
206
+ ),
207
+ "R8": _anchor(
208
+ "R8",
209
+ "(R)-2-(7-octenyl)alanine",
210
+ TETHER_OCTENYL,
211
+ olefin_carbons=8,
212
+ chirality="@",
213
+ cip_code="R",
214
+ ),
215
+ "R5": _anchor(
216
+ "R5",
217
+ "(R)-2-(4-pentenyl)alanine",
218
+ TETHER_PENTENYL,
219
+ olefin_carbons=5,
220
+ chirality="@",
221
+ cip_code="R",
222
+ ),
223
+ "S8": _anchor(
224
+ "S8",
225
+ "(S)-2-(7-octenyl)alanine",
226
+ TETHER_OCTENYL,
227
+ olefin_carbons=8,
228
+ chirality="@@",
229
+ cip_code="S",
230
+ ),
231
+ }
232
+
233
+ #: Non-anchor non-natural monomers the branch tokenizer already accepts.
234
+ OTHER_MONOMERS: Final[dict[str, Monomer]] = {
235
+ "AIB": Monomer(
236
+ token="AIB",
237
+ name="2-aminoisobutyric acid",
238
+ smiles=f"[*:{ATTACH_N}]NC(C)(C)C(=O)[*:{ATTACH_C}]",
239
+ cip_code=None,
240
+ three_letter="Aib",
241
+ ),
242
+ "NLE": Monomer(
243
+ token="NLE",
244
+ name="L-norleucine",
245
+ smiles=f"[*:{ATTACH_N}]N[C@@H](CCCC)C(=O)[*:{ATTACH_C}]",
246
+ cip_code="S",
247
+ three_letter="Nle",
248
+ ),
249
+ }
250
+
251
+ #: Every monomer this library can place.
252
+ MONOMER_LIBRARY: Final[dict[str, Monomer]] = {
253
+ **NATURAL_MONOMERS,
254
+ **ANCHOR_MONOMERS,
255
+ **OTHER_MONOMERS,
256
+ }
257
+
258
+ # -- terminal capping --------------------------------------------------------
259
+
260
+ #: Free N-terminus: the attachment dummy simply becomes a hydrogen.
261
+ N_TERM_FREE: Final[str] = "free_amine"
262
+ #: Acetylated N-terminus (``Ac-``).
263
+ N_TERM_ACETYL: Final[str] = "acetyl"
264
+ #: Free C-terminus carboxylic acid.
265
+ C_TERM_ACID: Final[str] = "free_acid"
266
+ #: C-terminal primary amide (``-NH2``).
267
+ C_TERM_AMIDE: Final[str] = "amide"
268
+
269
+ #: Cap fragments, each with a single attachment dummy.
270
+ N_TERMINAL_CAPS: Final[dict[str, str | None]] = {
271
+ N_TERM_FREE: None,
272
+ N_TERM_ACETYL: f"CC(=O)[*:{ATTACH_N}]",
273
+ }
274
+ C_TERMINAL_CAPS: Final[dict[str, str | None]] = {
275
+ C_TERM_ACID: f"O[*:{ATTACH_C}]",
276
+ C_TERM_AMIDE: f"N[*:{ATTACH_C}]",
277
+ }
278
+
279
+
280
+ def get_monomer(token: str) -> Monomer:
281
+ """Look up one monomer by token.
282
+
283
+ Raises:
284
+ UnknownMonomerError: if the token is not in the library. Guessing a
285
+ fragment would silently emit the wrong molecule.
286
+ """
287
+ key = token.upper()
288
+ monomer = MONOMER_LIBRARY.get(key)
289
+ if monomer is None:
290
+ raise UnknownMonomerError(
291
+ f"no SMILES fragment for monomer {token!r}; known tokens: "
292
+ f"{sorted(MONOMER_LIBRARY)}"
293
+ )
294
+ return monomer
295
+
296
+
297
+ def is_anchor_monomer(token: str) -> bool:
298
+ """True when ``token`` is one of the olefin-bearing staple anchors."""
299
+ return token.upper() in ANCHOR_MONOMERS
300
+
301
+
302
+ def anchor_olefin_carbons(token: str) -> int:
303
+ """Tether carbon count for an anchor token, including both alkene carbons."""
304
+ return get_monomer(token).olefin_carbons
305
+
306
+
307
+ def staple_carbon_count(i_token: str, j_token: str) -> int:
308
+ """Carbons in the closed staple bridge after RCM.
309
+
310
+ Ring-closing metathesis joins the two terminal alkenes and expels ethene, so
311
+ two carbons — one from each partner's terminal ``=CH2`` — leave the molecule:
312
+
313
+ S5 + S5 -> 5 + 5 - 2 = 8 bridge carbons
314
+ R8 + S5 -> 8 + 5 - 2 = 11 bridge carbons
315
+
316
+ Those counts are what make the observed macrocycle sizes add up (13 backbone
317
+ atoms + 8 = 21 for i,i+4; 22 + 11 = 33 for i,i+7), which is the arithmetic
318
+ used to identify the reference topologies in the StaPep table.
319
+ """
320
+ return anchor_olefin_carbons(i_token) + anchor_olefin_carbons(j_token) - 2
321
+
322
+
323
+ def expected_macrocycle_size(spacing: int, i_token: str, j_token: str) -> int:
324
+ """Ring size of the stapled macrocycle.
325
+
326
+ The ring runs Cα(i) -> backbone -> Cα(j) -> staple bridge -> back to Cα(i).
327
+ Between Cα(i) and Cα(j) there are ``3 * spacing - 1`` intervening backbone
328
+ atoms (each residue contributes N, Cα, C'), so including both Cα atoms the
329
+ backbone arc is ``3 * spacing + 1`` atoms. Adding the bridge gives the ring.
330
+ """
331
+ return 3 * spacing + 1 + staple_carbon_count(i_token, j_token)
332
+
333
+
334
+ def verify_monomer_stereochemistry() -> dict[str, dict[str, str | None]]:
335
+ """Round-trip every chiral monomer through RDKit's CIP labeller.
336
+
337
+ Each fragment is capped into a small Ac-Xaa-NHMe model peptide (the dummy
338
+ atoms cannot themselves be sanitised into a stereo-perceivable molecule) and
339
+ the observed CIP label at Cα is compared with the declared
340
+ :attr:`Monomer.cip_code`.
341
+
342
+ Returns:
343
+ ``{token: {"expected": ..., "observed": ..., "status": ...}}``. Status is
344
+ ``"ok"``, ``"mismatch"``, ``"achiral"`` or ``"unparseable"``.
345
+
346
+ This exists so the hand-written ``@``/``@@`` tags are machine-checked. A
347
+ silently inverted anchor would otherwise produce a molecule that parses,
348
+ sanitises and looks entirely reasonable while being the wrong enantiomer.
349
+ """
350
+ from rdkit import Chem
351
+ from rdkit.Chem import rdCIPLabeler
352
+
353
+ results: dict[str, dict[str, str | None]] = {}
354
+ for token, monomer in MONOMER_LIBRARY.items():
355
+ model = monomer.smiles.replace(f"[*:{ATTACH_N}]", "CC(=O)").replace(
356
+ f"[*:{ATTACH_C}]", "NC"
357
+ )
358
+ mol = Chem.MolFromSmiles(model)
359
+ if mol is None:
360
+ results[token] = {
361
+ "expected": monomer.cip_code,
362
+ "observed": None,
363
+ "status": "unparseable",
364
+ "model_smiles": model,
365
+ }
366
+ continue
367
+
368
+ Chem.AssignStereochemistry(mol, cleanIt=True, force=True)
369
+ rdCIPLabeler.AssignCIPLabels(mol)
370
+ labels = [
371
+ atom.GetProp("_CIPCode")
372
+ for atom in mol.GetAtoms()
373
+ if atom.HasProp("_CIPCode")
374
+ ]
375
+
376
+ if monomer.cip_code is None:
377
+ status = "achiral" if not labels else "mismatch"
378
+ observed = labels[0] if labels else None
379
+ else:
380
+ # Ile and Thr carry a second stereocentre in the side chain; the Cα
381
+ # label is the one being checked, so require it to be present rather
382
+ # than requiring exactly one label overall.
383
+ observed = labels[0] if labels else None
384
+ status = "ok" if monomer.cip_code in labels else "mismatch"
385
+
386
+ results[token] = {
387
+ "expected": monomer.cip_code,
388
+ "observed": observed,
389
+ "all_labels": ",".join(labels) if labels else None,
390
+ "status": status,
391
+ "model_smiles": model,
392
+ }
393
+ return results
394
+
395
+
396
+ def describe_library() -> dict[str, object]:
397
+ """Summary of the library, for reports and audits."""
398
+ return {
399
+ "n_monomers": len(MONOMER_LIBRARY),
400
+ "n_natural": len(NATURAL_MONOMERS),
401
+ "n_anchors": len(ANCHOR_MONOMERS),
402
+ "n_other": len(OTHER_MONOMERS),
403
+ "anchor_tokens": sorted(ANCHOR_MONOMERS),
404
+ "anchor_olefin_carbons": {
405
+ token: monomer.olefin_carbons
406
+ for token, monomer in sorted(ANCHOR_MONOMERS.items())
407
+ },
408
+ "n_terminal_caps": sorted(N_TERMINAL_CAPS),
409
+ "c_terminal_caps": sorted(C_TERMINAL_CAPS),
410
+ "staple_bridge_carbons": {
411
+ "S5-S5": staple_carbon_count("S5", "S5"),
412
+ "R8-S5": staple_carbon_count("R8", "S5"),
413
+ "R5-S8": staple_carbon_count("R5", "S8"),
414
+ },
415
+ "expected_macrocycle_size": {
416
+ "S5-S5/i,i+4": expected_macrocycle_size(4, "S5", "S5"),
417
+ "R8-S5/i,i+7": expected_macrocycle_size(7, "R8", "S5"),
418
+ },
419
+ }
staplebridge/hydrocarbon/plan_control.py ADDED
@@ -0,0 +1,962 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon-only learnable control over empirical staple plans.
2
+
3
+ The empirical distribution remains the reference measure. When enabled, a
4
+ small MLP tilts that measure before the existing plan-conditioned action policy
5
+ runs. Nothing in this module is imported by the lactam training path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import random
12
+ from collections import OrderedDict
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ import torch
17
+ from torch import nn
18
+
19
+ from staplebridge.chemistry.edit_distance import weighted_edit_distance
20
+ from staplebridge.chemistry.edit_metrics import edit_diagnostics
21
+ from staplebridge.chemistry.state import StapleState
22
+ from staplebridge.hydrocarbon.actions import FailureReason, validate_hydrocarbon_staple
23
+ from staplebridge.hydrocarbon.catalog import hydrocarbon_catalog_from_config
24
+ from staplebridge.hydrocarbon.curriculum import (
25
+ HydrocarbonStaplePlan,
26
+ build_hydrocarbon_demonstration_path,
27
+ )
28
+ from staplebridge.hydrocarbon.plan_reference import (
29
+ OFF_PLAN_ANCHOR,
30
+ OFF_PLAN_SUBSTITUTION,
31
+ OFF_PLAN_TOPOLOGY,
32
+ ON_PLAN_ANCHOR_ASSIGN,
33
+ ON_PLAN_BLOCK_ASSIGN,
34
+ ON_PLAN_FIRST_ANCHOR,
35
+ ON_PLAN_LABELS,
36
+ ON_PLAN_SECOND_ANCHOR,
37
+ ON_PLAN_TOPOLOGY,
38
+ PlanAwareReferenceSampler,
39
+ PlanAwareTrajectory,
40
+ PlanProgress,
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class HydrocarbonPlanControlConfig:
46
+ """Configuration for the optional plan-level control head."""
47
+
48
+ enabled: bool = True
49
+ plans_per_lead: int = 4
50
+ hidden_dim: int = 32
51
+ loss_weight: float = 1.0
52
+ target_temperature: float = 0.10
53
+ # False preserves the historical sampled-plan Penetrance CE exactly.
54
+ # True opts into the lead-local coarse-grained SB objective.
55
+ exact_sb_objective: bool = False
56
+ exact_sb_beta: float = 1.0
57
+ # Opt-in stable representation repair. The representation is independent
58
+ # of which plan-level objective (Exact-SB or Legacy) supervises the head.
59
+ # False preserves old V1 checkpoints bit-for-bit.
60
+ plan_encoder_v2: bool = False
61
+ validation_all_plans: bool = True
62
+
63
+ @classmethod
64
+ def from_config(cls, root_config: dict[str, Any] | None) -> "HydrocarbonPlanControlConfig":
65
+ section = dict(((root_config or {}).get("hydrocarbon") or {}).get("plan_control") or {})
66
+ cfg = cls()
67
+ for key, value in section.items():
68
+ if not hasattr(cfg, key):
69
+ continue
70
+ current = getattr(cfg, key)
71
+ if isinstance(current, bool):
72
+ setattr(cfg, key, bool(value))
73
+ elif isinstance(current, int):
74
+ setattr(cfg, key, int(value))
75
+ else:
76
+ setattr(cfg, key, float(value))
77
+ if cfg.plans_per_lead < 2:
78
+ raise ValueError("hydrocarbon.plan_control.plans_per_lead must be >= 2")
79
+ if (
80
+ cfg.hidden_dim < 1
81
+ or cfg.loss_weight < 0.0
82
+ or cfg.target_temperature <= 0.0
83
+ or cfg.exact_sb_beta <= 0.0
84
+ ):
85
+ raise ValueError("invalid hydrocarbon.plan_control numeric setting")
86
+ return cfg
87
+
88
+
89
+ def property_free_candidate_constraints(
90
+ *,
91
+ state: StapleState,
92
+ initial_state: StapleState,
93
+ lead: Any,
94
+ plan: HydrocarbonStaplePlan,
95
+ catalog: list[Any],
96
+ catalog_index: dict[str, Any],
97
+ geometry: Any,
98
+ config: dict[str, Any],
99
+ ) -> dict[str, Any]:
100
+ """One property-free hard predicate shared by train/valid/test.
101
+
102
+ No model or PeptiVerse value participates. The predicate covers exact
103
+ plan completion plus chemistry, topology, geometry, edit budget, sequence
104
+ identity, and protected-position constraints.
105
+ """
106
+ block = catalog_index.get(state.block_id) if state.block_id else None
107
+ anchor_pair = None if state.anchor_pair is None else tuple(state.anchor_pair)
108
+ verdict = validate_hydrocarbon_staple(
109
+ state.sequence_tokens, anchor_pair, block, catalog
110
+ )
111
+ chemistry_valid = verdict is FailureReason.OK
112
+ stapled = state.topology == "stapled"
113
+ peptide_ca = (lead.target_context or {}).get("peptide_ca")
114
+ cgeom = float(
115
+ geometry.cgeom(
116
+ state.sequence_tokens, anchor_pair, block, peptide_ca=peptide_ca
117
+ )
118
+ )
119
+ eps_geom = float(
120
+ config.get(
121
+ "eps_geom", (config.get("training") or {}).get("eps_geom", 2.5)
122
+ )
123
+ )
124
+ geometry_feasible = bool(
125
+ geometry.ctype(
126
+ state.sequence_tokens, anchor_pair, block, peptide_ca=peptide_ca
127
+ )
128
+ and cgeom <= eps_geom
129
+ )
130
+ edits = edit_diagnostics(
131
+ state,
132
+ initial_state,
133
+ protected_positions=list(lead.protected_positions or []),
134
+ catalog=catalog_index,
135
+ )
136
+ edit_cfg = dict(config.get("edit_constraints") or {})
137
+ within_edit_budget = bool(
138
+ float(edits["edit_distance"])
139
+ <= float(edit_cfg.get("max_edit_budget", float("inf")))
140
+ )
141
+ sequence_identity_ok = bool(
142
+ float(edits["sequence_identity"])
143
+ >= float(edit_cfg.get("min_sequence_identity", 0.0))
144
+ )
145
+ protected_positions_ok = bool(
146
+ edit_cfg.get("allow_protected_edits", False)
147
+ or not edits["protected_edit_violation"]
148
+ )
149
+ exact_completion = bool(completes_committed_plan(state, plan))
150
+ strict_feasible = bool(
151
+ chemistry_valid
152
+ and stapled
153
+ and geometry_feasible
154
+ and exact_completion
155
+ and within_edit_budget
156
+ and sequence_identity_ok
157
+ and protected_positions_ok
158
+ )
159
+ ordered_failures = [
160
+ ("chemistry_invalid", chemistry_valid),
161
+ ("not_stapled", stapled),
162
+ ("geometry_infeasible", geometry_feasible),
163
+ ("inexact_committed_plan_completion", exact_completion),
164
+ ("edit_budget_exceeded", within_edit_budget),
165
+ ("sequence_identity_below_minimum", sequence_identity_ok),
166
+ ("protected_position_violation", protected_positions_ok),
167
+ ]
168
+ failure_reasons = [name for name, passed in ordered_failures if not passed]
169
+ return {
170
+ "chemistry_valid": chemistry_valid,
171
+ "stapled": stapled,
172
+ "topology_status": verdict.value,
173
+ "geometry_cgeom": cgeom,
174
+ "geometry_eps": eps_geom,
175
+ "geometry_feasible": geometry_feasible,
176
+ "exact_committed_plan_completion": exact_completion,
177
+ "edit_distance": float(edits["edit_distance"]),
178
+ "weighted_edit_distance": float(
179
+ weighted_edit_distance(state, initial_state)
180
+ ),
181
+ "sequence_identity": float(edits["sequence_identity"]),
182
+ "protected_edit_violation": bool(edits["protected_edit_violation"]),
183
+ "within_edit_budget": within_edit_budget,
184
+ "sequence_identity_constraint_satisfied": sequence_identity_ok,
185
+ "protected_position_constraint_satisfied": protected_positions_ok,
186
+ "edit_constraints_satisfied": bool(
187
+ within_edit_budget and sequence_identity_ok and protected_positions_ok
188
+ ),
189
+ "strict_feasible": strict_feasible,
190
+ "hard_plan_failure_reasons": failure_reasons,
191
+ "hard_plan_primary_failure_reason": (
192
+ failure_reasons[0] if failure_reasons else None
193
+ ),
194
+ }
195
+
196
+
197
+ def property_free_hard_plan_support(
198
+ *,
199
+ initial_state: StapleState,
200
+ lead: Any,
201
+ plans: list[HydrocarbonStaplePlan],
202
+ catalog: list[Any],
203
+ catalog_index: dict[str, Any],
204
+ geometry: Any,
205
+ config: dict[str, Any],
206
+ ) -> tuple[list[int], list[dict[str, Any]]]:
207
+ """Return strict-support indices and auditable per-plan verdicts."""
208
+ support: list[int] = []
209
+ verdicts: list[dict[str, Any]] = []
210
+ for index, plan in enumerate(plans):
211
+ try:
212
+ terminal = build_hydrocarbon_demonstration_path(
213
+ initial_state, plan, catalog
214
+ )[-1]
215
+ verdict = property_free_candidate_constraints(
216
+ state=terminal,
217
+ initial_state=initial_state,
218
+ lead=lead,
219
+ plan=plan,
220
+ catalog=catalog,
221
+ catalog_index=catalog_index,
222
+ geometry=geometry,
223
+ config=config,
224
+ )
225
+ except (KeyError, ValueError) as exc:
226
+ verdict = {
227
+ "chemistry_valid": False,
228
+ "stapled": False,
229
+ "geometry_feasible": False,
230
+ "exact_committed_plan_completion": False,
231
+ "edit_constraints_satisfied": False,
232
+ "strict_feasible": False,
233
+ "hard_plan_failure_reasons": ["plan_materialization_failed"],
234
+ "hard_plan_primary_failure_reason": "plan_materialization_failed",
235
+ "hard_plan_error": f"{type(exc).__name__}: {exc}",
236
+ }
237
+ verdicts.append(verdict)
238
+ if verdict["strict_feasible"]:
239
+ support.append(index)
240
+ return support, verdicts
241
+
242
+
243
+ def mask_and_renormalize_plan_log_probabilities(
244
+ log_probabilities: torch.Tensor,
245
+ support_indices: list[int],
246
+ ) -> torch.Tensor:
247
+ """Hard-mask q_theta and renormalize on property-free strict support."""
248
+ if log_probabilities.ndim != 1:
249
+ raise ValueError("plan log probabilities must be one-dimensional")
250
+ if not support_indices:
251
+ raise ValueError("cannot renormalize q_theta on empty strict support")
252
+ indices = torch.tensor(
253
+ support_indices, dtype=torch.long, device=log_probabilities.device
254
+ )
255
+ if int(indices.min().item()) < 0 or int(indices.max().item()) >= int(
256
+ log_probabilities.numel()
257
+ ):
258
+ raise IndexError("strict-support index outside plan distribution")
259
+ if int(torch.unique(indices).numel()) != len(support_indices):
260
+ raise ValueError("strict-support indices must be unique")
261
+ masked = torch.full_like(log_probabilities, -torch.inf)
262
+ masked[indices] = log_probabilities[indices]
263
+ return masked - torch.logsumexp(masked, dim=0)
264
+
265
+
266
+ class HydrocarbonPlanHead(nn.Module):
267
+ """Two-layer MLP that tilts empirical plan probabilities."""
268
+
269
+ PLAN_FEATURE_DIM = 8
270
+
271
+ def __init__(self, lead_embedding_dim: int, hidden_dim: int = 32) -> None:
272
+ super().__init__()
273
+ self.lead_embedding_dim = int(lead_embedding_dim)
274
+ self.net = nn.Sequential(
275
+ nn.Linear(self.lead_embedding_dim + self.PLAN_FEATURE_DIM, hidden_dim),
276
+ nn.SiLU(),
277
+ nn.Linear(hidden_dim, 1),
278
+ )
279
+ # Exact empirical q_ref at initialization. Any later disagreement is
280
+ # learned rather than an arbitrary random tilt.
281
+ nn.init.zeros_(self.net[-1].weight)
282
+ nn.init.zeros_(self.net[-1].bias)
283
+
284
+ @staticmethod
285
+ def plan_features(
286
+ plans: list[HydrocarbonStaplePlan],
287
+ lead_length: int,
288
+ device: torch.device,
289
+ ) -> torch.Tensor:
290
+ denom = float(max(lead_length - 1, 1))
291
+ rows: list[list[float]] = []
292
+ for plan in plans:
293
+ i, j = plan.anchor_pair
294
+ is_s5 = float(plan.ordered_pair == "S5-S5")
295
+ is_r8 = float(plan.ordered_pair == "R8-S5")
296
+ rows.append(
297
+ [
298
+ is_s5,
299
+ is_r8,
300
+ i / denom,
301
+ j / denom,
302
+ (0.5 * (i + j)) / denom,
303
+ plan.spacing / denom,
304
+ plan.n_edits / 2.0,
305
+ lead_length / 32.0,
306
+ ]
307
+ )
308
+ return torch.tensor(rows, dtype=torch.float32, device=device)
309
+
310
+ def forward(
311
+ self,
312
+ lead_embedding: torch.Tensor,
313
+ plans: list[HydrocarbonStaplePlan],
314
+ lead_length: int,
315
+ ) -> torch.Tensor:
316
+ if not plans:
317
+ return torch.empty(0, device=lead_embedding.device)
318
+ if lead_embedding.ndim == 1:
319
+ lead_embedding = lead_embedding.unsqueeze(0)
320
+ features = self.plan_features(plans, lead_length, lead_embedding.device)
321
+ expanded = lead_embedding.expand(len(plans), -1)
322
+ return self.net(torch.cat([expanded, features], dim=-1)).squeeze(-1)
323
+
324
+
325
+
326
+ class FrozenESM2PlanFeatureEncoder:
327
+ """Stable local features from the frozen ESM2 prior's vector cache.
328
+
329
+ The existing peptide prior stores the complete 20-AA masked-token
330
+ log-probability vector for ``(sequence, position)``. Reusing those vectors
331
+ gives a deterministic contextual feature at every anchor/local position,
332
+ avoids registering the 650M model in the head, and makes cache-hit probes
333
+ require no ESM2 forward at all. Missing vectors still go through the same
334
+ frozen strict ESM2 provider and are cached there.
335
+ """
336
+
337
+ VECTOR_DIM = 20
338
+ RAW_FEATURE_COUNT = 11
339
+ _AA_ORDER = "ACDEFGHIKLMNPQRSTVWY"
340
+
341
+ def __init__(self, esm2_prior: Any, cache_size: int = 65536) -> None:
342
+ if esm2_prior is None:
343
+ raise ValueError("plan_encoder_v2 requires the real frozen ESM2 prior")
344
+ get_vectors = getattr(esm2_prior, "_get_vectors", None)
345
+ if not callable(get_vectors):
346
+ raise TypeError("plan_encoder_v2 requires ESM2DeltaPeptidePrior")
347
+ self.esm2_prior = esm2_prior
348
+ self.hidden_dim = self.VECTOR_DIM
349
+ self.cache_size = max(int(cache_size), 1)
350
+ self._cache: "OrderedDict[str, torch.Tensor]" = OrderedDict()
351
+ self.cache_hits = 0
352
+ self.cache_misses = 0
353
+ self.forward_batches = 0
354
+
355
+ def _canonicalize(self, sequence_tokens: list[str]) -> str:
356
+ canonicalize = getattr(self.esm2_prior, "_canonicalize", None)
357
+ if not callable(canonicalize):
358
+ raise RuntimeError("ESM2 prior lacks canonical-surrogate conversion")
359
+ sequence, _ = canonicalize(list(sequence_tokens))
360
+ if len(sequence) != len(sequence_tokens):
361
+ raise RuntimeError("ESM2 surrogate changed peptide length")
362
+ return sequence
363
+
364
+ def _put_cache(self, sequence: str, tokens: torch.Tensor) -> None:
365
+ self._cache[sequence] = tokens.detach().to(device="cpu", dtype=torch.float16)
366
+ self._cache.move_to_end(sequence)
367
+ while len(self._cache) > self.cache_size:
368
+ self._cache.popitem(last=False)
369
+
370
+ def _encode_missing(self, sequences: list[str]) -> None:
371
+ if not sequences:
372
+ return
373
+ requests = [
374
+ (sequence, position)
375
+ for sequence in sequences
376
+ for position in range(len(sequence))
377
+ ]
378
+ vectors = self.esm2_prior._get_vectors(requests)
379
+ for sequence in sequences:
380
+ rows = [vectors.get((sequence, position)) for position in range(len(sequence))]
381
+ if any(row is None or len(row) != self.VECTOR_DIM for row in rows):
382
+ raise RuntimeError("ESM2 local-vector cache returned incomplete features")
383
+ self._put_cache(sequence, torch.tensor(rows, dtype=torch.float32))
384
+
385
+ def _fetch_many(
386
+ self, sequences: list[str]
387
+ ) -> dict[str, torch.Tensor]:
388
+ unique = list(dict.fromkeys(sequences))
389
+ missing: list[str] = []
390
+ for sequence in unique:
391
+ if sequence in self._cache:
392
+ self.cache_hits += 1
393
+ self._cache.move_to_end(sequence)
394
+ else:
395
+ self.cache_misses += 1
396
+ missing.append(sequence)
397
+ self._encode_missing(missing)
398
+ return {sequence: self._cache[sequence] for sequence in unique}
399
+
400
+ @classmethod
401
+ def _one_hot(cls, token: str) -> torch.Tensor:
402
+ row = torch.zeros(cls.VECTOR_DIM, dtype=torch.float16)
403
+ token = str(token).upper()
404
+ if token in cls._AA_ORDER:
405
+ row[cls._AA_ORDER.index(token)] = 1.0
406
+ return row
407
+
408
+ @staticmethod
409
+ def _local_context(tokens: torch.Tensor, position: int, radius: int) -> torch.Tensor:
410
+ indices = [
411
+ index
412
+ for index in range(
413
+ max(0, position - radius), min(tokens.shape[0], position + radius + 1)
414
+ )
415
+ if index != position
416
+ ]
417
+ if not indices:
418
+ return tokens[position]
419
+ return tokens[indices].mean(dim=0)
420
+
421
+ def context_features(
422
+ self,
423
+ sequence_tokens: list[str],
424
+ plans: list[HydrocarbonStaplePlan],
425
+ device: torch.device,
426
+ ) -> torch.Tensor:
427
+ if not plans:
428
+ return torch.empty(
429
+ (0, self.RAW_FEATURE_COUNT, self.hidden_dim),
430
+ dtype=torch.float32,
431
+ device=device,
432
+ )
433
+ base_sequence = self._canonicalize(sequence_tokens)
434
+ cached = self._fetch_many([base_sequence])
435
+ base_tokens = cached[base_sequence]
436
+ base_pooled = base_tokens.mean(dim=0)
437
+ rows: list[torch.Tensor] = []
438
+ for plan in plans:
439
+ i, j = (int(plan.anchor_pair[0]), int(plan.anchor_pair[1]))
440
+ if i < 0 or j >= base_tokens.shape[0]:
441
+ raise ValueError("plan anchor outside ESM2 token representation")
442
+ h_i = base_tokens[i]
443
+ h_j = base_tokens[j]
444
+ rows.append(
445
+ torch.stack(
446
+ [
447
+ base_pooled,
448
+ h_i,
449
+ h_j,
450
+ h_i - h_j,
451
+ h_i * h_j,
452
+ self._local_context(base_tokens, i, 2),
453
+ self._local_context(base_tokens, j, 2),
454
+ self._local_context(base_tokens, i, 3),
455
+ self._local_context(base_tokens, j, 3),
456
+ self._one_hot(sequence_tokens[i]),
457
+ self._one_hot(sequence_tokens[j]),
458
+ ],
459
+ dim=0,
460
+ )
461
+ )
462
+ return torch.stack(rows, dim=0).to(device=device, dtype=torch.float32)
463
+
464
+ def prefetch(self, sequences: list[list[str]]) -> None:
465
+ """Warm stable base-sequence representations in efficient batches."""
466
+ canonical = [self._canonicalize(tokens) for tokens in sequences]
467
+ self._fetch_many(canonical)
468
+
469
+ def diagnostics(self) -> dict[str, int]:
470
+ return {
471
+ "cache_entries": len(self._cache),
472
+ "cache_hits": self.cache_hits,
473
+ "cache_misses": self.cache_misses,
474
+ "forward_batches": self.forward_batches,
475
+ }
476
+
477
+
478
+ class PlanGeometryFeatureEncoder:
479
+ """Deterministic plan geometry available identically at train/inference."""
480
+
481
+ FEATURE_DIM = 8
482
+
483
+ def __init__(self, ca_windows: dict[str, tuple[float, float]]) -> None:
484
+ self.ca_windows = {
485
+ str(block_id): (float(window[0]), float(window[1]))
486
+ for block_id, window in ca_windows.items()
487
+ }
488
+
489
+ def features(
490
+ self,
491
+ plans: list[HydrocarbonStaplePlan],
492
+ peptide_ca: list[tuple[float, float, float]] | list[list[float]],
493
+ device: torch.device,
494
+ ) -> torch.Tensor:
495
+ if peptide_ca is None:
496
+ raise ValueError("plan_encoder_v2 requires peptide_ca")
497
+ rows: list[list[float]] = []
498
+ for plan in plans:
499
+ i, j = (int(plan.anchor_pair[0]), int(plan.anchor_pair[1]))
500
+ if i < 0 or j < 0 or i >= len(peptide_ca) or j >= len(peptide_ca):
501
+ raise ValueError("plan anchor outside peptide_ca")
502
+ if plan.block_id not in self.ca_windows:
503
+ raise ValueError(f"missing geometry window for {plan.block_id}")
504
+ xi, yi, zi = (float(value) for value in peptide_ca[i])
505
+ xj, yj, zj = (float(value) for value in peptide_ca[j])
506
+ distance = math.sqrt(
507
+ (xi - xj) ** 2 + (yi - yj) ** 2 + (zi - zj) ** 2
508
+ )
509
+ low, high = self.ca_windows[plan.block_id]
510
+ below = max(low - distance, 0.0)
511
+ above = max(distance - high, 0.0)
512
+ center = 0.5 * (low + high)
513
+ rows.append(
514
+ [
515
+ 1.0,
516
+ distance / 20.0,
517
+ low / 20.0,
518
+ high / 20.0,
519
+ below / 10.0,
520
+ above / 10.0,
521
+ float(below == 0.0 and above == 0.0),
522
+ (distance - center) / 10.0,
523
+ ]
524
+ )
525
+ return torch.tensor(rows, dtype=torch.float32, device=device)
526
+
527
+
528
+ class HydrocarbonPlanHeadV2(nn.Module):
529
+ """Exact-SB plan head with stable frozen sequence + geometry features.
530
+
531
+ ``lead_embedding`` remains in the call signature solely for V1/V2 API and
532
+ checkpoint orchestration compatibility. V2 deliberately ignores it: that
533
+ embedding belongs to the fine policy encoder and moves under the path loss.
534
+ """
535
+
536
+ PROJECTION_DIM = 32
537
+
538
+ def __init__(
539
+ self,
540
+ lead_embedding_dim: int,
541
+ hidden_dim: int,
542
+ feature_encoder: FrozenESM2PlanFeatureEncoder,
543
+ geometry_encoder: PlanGeometryFeatureEncoder,
544
+ ) -> None:
545
+ super().__init__()
546
+ self.lead_embedding_dim = int(lead_embedding_dim)
547
+ self.feature_encoder = feature_encoder
548
+ self.geometry_encoder = geometry_encoder
549
+ self.feature_projection = nn.Linear(
550
+ feature_encoder.hidden_dim, self.PROJECTION_DIM
551
+ )
552
+ input_dim = (
553
+ HydrocarbonPlanHead.PLAN_FEATURE_DIM
554
+ + feature_encoder.RAW_FEATURE_COUNT * self.PROJECTION_DIM
555
+ + geometry_encoder.FEATURE_DIM
556
+ )
557
+ self.net = nn.Sequential(
558
+ nn.Linear(input_dim, hidden_dim),
559
+ nn.SiLU(),
560
+ nn.Linear(hidden_dim, 1),
561
+ )
562
+ nn.init.zeros_(self.net[-1].weight)
563
+ nn.init.zeros_(self.net[-1].bias)
564
+
565
+ def forward(
566
+ self,
567
+ lead_embedding: torch.Tensor,
568
+ plans: list[HydrocarbonStaplePlan],
569
+ lead_length: int,
570
+ sequence_tokens: list[str],
571
+ peptide_ca: list[tuple[float, float, float]] | list[list[float]],
572
+ ) -> torch.Tensor:
573
+ if not plans:
574
+ return torch.empty(0, device=lead_embedding.device)
575
+ # The fine-policy embedding is intentionally excluded from V2.
576
+ device = lead_embedding.device
577
+ old_features = HydrocarbonPlanHead.plan_features(
578
+ plans, lead_length, device
579
+ )
580
+ context = self.feature_encoder.context_features(
581
+ sequence_tokens, plans, device
582
+ )
583
+ projected = self.feature_projection(context).flatten(start_dim=1)
584
+ geometry = self.geometry_encoder.features(plans, peptide_ca, device)
585
+ return self.net(
586
+ torch.cat([old_features, projected, geometry], dim=-1)
587
+ ).squeeze(-1)
588
+
589
+ def build_hydrocarbon_plan_head(
590
+ root_config: dict[str, Any] | None,
591
+ lead_embedding_dim: int,
592
+ device: torch.device | str,
593
+ *,
594
+ esm2_prior: Any = None,
595
+ ) -> HydrocarbonPlanHead | HydrocarbonPlanHeadV2 | None:
596
+ """Construct the selected head; disabled and V1 paths retain zero V2 cost."""
597
+ cfg = HydrocarbonPlanControlConfig.from_config(root_config)
598
+ if not cfg.enabled:
599
+ return None
600
+ if not cfg.plan_encoder_v2:
601
+ return HydrocarbonPlanHead(lead_embedding_dim, cfg.hidden_dim).to(device)
602
+ feature_encoder = FrozenESM2PlanFeatureEncoder(esm2_prior)
603
+ catalog = hydrocarbon_catalog_from_config(
604
+ dict(((root_config or {}).get("hydrocarbon") or {}))
605
+ )
606
+ geometry_encoder = PlanGeometryFeatureEncoder(
607
+ {block.block_id: tuple(block.ca_window) for block in catalog}
608
+ )
609
+ return HydrocarbonPlanHeadV2(
610
+ lead_embedding_dim, cfg.hidden_dim, feature_encoder, geometry_encoder
611
+ ).to(device)
612
+
613
+
614
+ def empirical_log_probabilities(weights: list[float], device: torch.device) -> torch.Tensor:
615
+ values = torch.tensor(weights, dtype=torch.float32, device=device)
616
+ if values.numel() == 0 or float(values.sum().item()) <= 0.0:
617
+ raise ValueError("empirical plan weights must have positive mass")
618
+ values = values / values.sum()
619
+ return torch.log(values.clamp_min(torch.finfo(values.dtype).tiny))
620
+
621
+
622
+ def controlled_plan_log_probabilities(
623
+ head: HydrocarbonPlanHead | HydrocarbonPlanHeadV2,
624
+ lead_embedding: torch.Tensor,
625
+ plans: list[HydrocarbonStaplePlan],
626
+ empirical_weights: list[float],
627
+ lead_length: int,
628
+ *,
629
+ sequence_tokens: list[str] | None = None,
630
+ peptide_ca: list[tuple[float, float, float]] | list[list[float]] | None = None,
631
+ ) -> torch.Tensor:
632
+ reference = empirical_log_probabilities(empirical_weights, lead_embedding.device)
633
+ if isinstance(head, HydrocarbonPlanHeadV2):
634
+ if sequence_tokens is None:
635
+ raise ValueError("plan_encoder_v2 requires sequence_tokens")
636
+ if peptide_ca is None:
637
+ raise ValueError("plan_encoder_v2 requires peptide_ca")
638
+ scores = head(
639
+ lead_embedding.detach(), plans, lead_length, list(sequence_tokens), peptide_ca
640
+ )
641
+ else:
642
+ # This is intentionally the historical expression, unchanged.
643
+ scores = head(lead_embedding.detach(), plans, lead_length)
644
+ return torch.log_softmax(reference + scores, dim=0)
645
+
646
+
647
+ def best_committed_plan_trajectory(
648
+ decoded: list[tuple[StapleState, float, int]],
649
+ plan: HydrocarbonStaplePlan,
650
+ ) -> tuple[StapleState, float, int] | None:
651
+ """Return the highest-probability trajectory completing the selected plan."""
652
+ for item in decoded:
653
+ state = item[0]
654
+ if completes_committed_plan(state, plan):
655
+ return item
656
+ return None
657
+
658
+
659
+ def completes_committed_plan(
660
+ state: StapleState, plan: HydrocarbonStaplePlan
661
+ ) -> bool:
662
+ """The single exact-completion predicate shared by train and inference."""
663
+ return bool(
664
+ state.topology == "stapled"
665
+ and state.anchor_pair is not None
666
+ and tuple(state.anchor_pair) == tuple(plan.anchor_pair)
667
+ and state.block_id == plan.block_id
668
+ )
669
+
670
+
671
+ def hierarchical_plan_ranking_enabled(root_config: dict[str, Any] | None) -> bool:
672
+ """Return the hydrocarbon-only plan-first decoding switch (default: on)."""
673
+ decode = dict(((root_config or {}).get("hydrocarbon") or {}).get("decode") or {})
674
+ return bool(decode.get("hierarchical_plan_ranking", True))
675
+
676
+
677
+ def select_plan_and_trajectory(
678
+ plan_log_probabilities: torch.Tensor,
679
+ decoded_by_plan: list[tuple[Any, float, int] | None],
680
+ *,
681
+ hierarchical: bool,
682
+ ) -> tuple[float, int, Any, float, int]:
683
+ """Select a plan first, then its best plan-conditioned trajectory.
684
+
685
+ In hierarchical mode raw path probability never participates in a
686
+ comparison between plans. ``hierarchical=False`` preserves the historical
687
+ ``log q(plan) + log P(path | plan)`` ranking exactly for ablations.
688
+ """
689
+ if len(decoded_by_plan) != int(plan_log_probabilities.numel()):
690
+ raise ValueError("decoded plan count must match plan probabilities")
691
+ if not decoded_by_plan:
692
+ raise ValueError("no plans to decode")
693
+
694
+ if hierarchical:
695
+ plan_index = int(torch.argmax(plan_log_probabilities).item())
696
+ decoded = decoded_by_plan[plan_index]
697
+ if decoded is None:
698
+ raise RuntimeError("top-ranked hydrocarbon plan has no decoded trajectory")
699
+ terminal, path_logp, path_length = decoded
700
+ # Retain the joint value for logging only. It is not a selection score.
701
+ joint = float(plan_log_probabilities[plan_index].item()) + float(path_logp)
702
+ return joint, plan_index, terminal, float(path_logp), int(path_length)
703
+
704
+ candidates = []
705
+ for plan_index, decoded in enumerate(decoded_by_plan):
706
+ if decoded is None:
707
+ continue
708
+ terminal, path_logp, path_length = decoded
709
+ joint = float(plan_log_probabilities[plan_index].item()) + float(path_logp)
710
+ candidates.append((joint, plan_index, terminal, float(path_logp), int(path_length)))
711
+ if not candidates:
712
+ raise RuntimeError("no decoded hydrocarbon plans")
713
+ return max(candidates, key=lambda item: item[0])
714
+
715
+
716
+ def sample_distinct_plans(
717
+ plans: list[HydrocarbonStaplePlan],
718
+ empirical_weights: list[float],
719
+ count: int,
720
+ rng: random.Random,
721
+ ) -> list[HydrocarbonStaplePlan]:
722
+ """Weighted sampling without replacement from empirical plan support."""
723
+ remaining = list(zip(plans, empirical_weights))
724
+ selected: list[HydrocarbonStaplePlan] = []
725
+ for _ in range(min(int(count), len(remaining))):
726
+ total = sum(max(float(weight), 0.0) for _, weight in remaining)
727
+ if total <= 0.0:
728
+ break
729
+ threshold = rng.random() * total
730
+ cumulative = 0.0
731
+ chosen = len(remaining) - 1
732
+ for index, (_, weight) in enumerate(remaining):
733
+ cumulative += max(float(weight), 0.0)
734
+ if cumulative >= threshold:
735
+ chosen = index
736
+ break
737
+ plan, _ = remaining.pop(chosen)
738
+ selected.append(plan)
739
+ return selected
740
+
741
+
742
+ def sample_committed_plan_trajectory(
743
+ sampler: PlanAwareReferenceSampler,
744
+ init_state: StapleState,
745
+ plan: HydrocarbonStaplePlan,
746
+ protected_positions: list[int],
747
+ context: dict[str, Any],
748
+ horizon: int,
749
+ early_stop: bool = True,
750
+ ) -> PlanAwareTrajectory:
751
+ """Use the existing plan-aware action kernel with a caller-supplied plan."""
752
+ states = [init_state]
753
+ labels: list[str] = []
754
+ current = init_state
755
+ progress = PlanProgress(plan_selected=True)
756
+ no_neighbor = False
757
+ for _ in range(horizon):
758
+ candidates = sampler.graph.neighbors(
759
+ current, protected_positions=protected_positions
760
+ )
761
+ if not candidates:
762
+ no_neighbor = True
763
+ break
764
+ nxt, label = sampler.kernel.sample_next(
765
+ current, candidates, plan, context=context
766
+ )
767
+ states.append(nxt)
768
+ labels.append(label)
769
+ progress.n_actions += 1
770
+ if label in ON_PLAN_LABELS:
771
+ progress.n_on_plan_actions += 1
772
+ if label == ON_PLAN_FIRST_ANCHOR:
773
+ progress.first_anchor_installed = True
774
+ elif label == ON_PLAN_SECOND_ANCHOR:
775
+ progress.second_anchor_installed = True
776
+ elif label == ON_PLAN_ANCHOR_ASSIGN:
777
+ progress.anchor_assigned = True
778
+ elif label == ON_PLAN_BLOCK_ASSIGN:
779
+ progress.block_assigned = True
780
+ elif label in (ON_PLAN_TOPOLOGY, OFF_PLAN_TOPOLOGY):
781
+ progress.topology_activated = True
782
+ elif label == OFF_PLAN_SUBSTITUTION:
783
+ progress.n_off_plan_substitutions += 1
784
+ elif label == OFF_PLAN_ANCHOR:
785
+ progress.n_off_plan_anchor_selections += 1
786
+ current = nxt
787
+ if early_stop and current.topology == "stapled":
788
+ break
789
+ progress.plan_completed = completes_committed_plan(current, plan)
790
+ return PlanAwareTrajectory(
791
+ states=states,
792
+ plan=plan,
793
+ progress=progress,
794
+ action_labels=labels,
795
+ no_neighbor=no_neighbor,
796
+ )
797
+
798
+
799
+ def plan_entropy(log_probabilities: torch.Tensor) -> torch.Tensor:
800
+ probabilities = log_probabilities.exp()
801
+ return -(probabilities * log_probabilities).sum()
802
+
803
+
804
+ def penetrance_target_weights(values: list[float], temperature: float, device: torch.device) -> torch.Tensor:
805
+ scores = torch.tensor(values, dtype=torch.float32, device=device)
806
+ return torch.softmax(scores / float(temperature), dim=0)
807
+
808
+
809
+ def plan_level_loss(
810
+ controlled_log_probabilities: torch.Tensor,
811
+ sampled_indices: list[int],
812
+ penetrance_values: list[float],
813
+ temperature: float,
814
+ ) -> torch.Tensor:
815
+ if not sampled_indices:
816
+ return controlled_log_probabilities.sum() * 0.0
817
+ target = penetrance_target_weights(
818
+ penetrance_values, temperature, controlled_log_probabilities.device
819
+ )
820
+ index = torch.tensor(sampled_indices, dtype=torch.long, device=controlled_log_probabilities.device)
821
+ return -(target * controlled_log_probabilities[index]).sum()
822
+
823
+
824
+ def configured_plan_level_objective(
825
+ controlled_log_probabilities: torch.Tensor,
826
+ sampled_indices: list[int],
827
+ penetrance_values: list[float],
828
+ legacy_temperature: float,
829
+ *,
830
+ exact_sb_objective: bool = False,
831
+ reference_log_probabilities: torch.Tensor | None = None,
832
+ terminal_energies: torch.Tensor | None = None,
833
+ exact_sb_beta: float = 1.0,
834
+ target_support_mask: torch.Tensor | None = None,
835
+ ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None]:
836
+ """Dispatch without perturbing the historical false branch.
837
+
838
+ Returns ``(optimized_loss, direct_forward_objective, log_q_star)``.
839
+ """
840
+ if not exact_sb_objective:
841
+ return (
842
+ plan_level_loss(
843
+ controlled_log_probabilities,
844
+ sampled_indices,
845
+ penetrance_values,
846
+ legacy_temperature,
847
+ ),
848
+ None,
849
+ None,
850
+ )
851
+ if reference_log_probabilities is None or terminal_energies is None:
852
+ raise ValueError("exact-SB requires q_ref and all-plan terminal energies")
853
+ return exact_sb_plan_objectives(
854
+ controlled_log_probabilities,
855
+ reference_log_probabilities,
856
+ terminal_energies,
857
+ exact_sb_beta,
858
+ target_support_mask=target_support_mask,
859
+ )
860
+
861
+
862
+ def exact_sb_target_log_probabilities(
863
+ reference_log_probabilities: torch.Tensor,
864
+ terminal_energies: torch.Tensor,
865
+ beta: float,
866
+ *,
867
+ target_support_mask: torch.Tensor | None = None,
868
+ ) -> torch.Tensor:
869
+ """Lead-local Gibbs target, optionally conditioned on a strict support.
870
+
871
+ A false mask entry receives ``log q* = -inf`` and therefore probability
872
+ exactly zero. Callers must pass ``None`` for the empty-set fallback; this
873
+ preserves the historical permeability-only computation bit-for-bit.
874
+ """
875
+ if reference_log_probabilities.ndim != 1 or terminal_energies.ndim != 1:
876
+ raise ValueError("exact-SB plan tensors must be one-dimensional for one lead")
877
+ if reference_log_probabilities.shape != terminal_energies.shape:
878
+ raise ValueError("q_ref and terminal energies must cover the same plans")
879
+ if reference_log_probabilities.numel() == 0:
880
+ raise ValueError("exact-SB requires at least one legal plan")
881
+ if float(beta) <= 0.0:
882
+ raise ValueError("exact-SB beta must be positive")
883
+ energies = terminal_energies.to(
884
+ device=reference_log_probabilities.device,
885
+ dtype=reference_log_probabilities.dtype,
886
+ )
887
+ logits = reference_log_probabilities - float(beta) * energies
888
+ if target_support_mask is not None:
889
+ if target_support_mask.shape != logits.shape:
890
+ raise ValueError("target support mask must cover the same plans as q*")
891
+ mask = target_support_mask.to(device=logits.device, dtype=torch.bool)
892
+ if not bool(mask.any().item()):
893
+ raise ValueError("target support mask must be non-empty; use None for fallback")
894
+ logits = logits.masked_fill(~mask, float("-inf"))
895
+ return torch.log_softmax(logits, dim=0)
896
+
897
+
898
+ def exact_sb_plan_objectives(
899
+ controlled_log_probabilities: torch.Tensor,
900
+ reference_log_probabilities: torch.Tensor,
901
+ terminal_energies: torch.Tensor,
902
+ beta: float,
903
+ *,
904
+ target_support_mask: torch.Tensor | None = None,
905
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
906
+ """Return ``KL(q*||q_theta)``, direct SB objective, and ``log q*``.
907
+
908
+ The reverse target KL is optimized. The direct forward variational
909
+ objective is returned only as a consistency diagnostic.
910
+ """
911
+ if controlled_log_probabilities.ndim != 1:
912
+ raise ValueError("q_theta must be one-dimensional for one lead")
913
+ if controlled_log_probabilities.shape != reference_log_probabilities.shape:
914
+ raise ValueError("q_theta and q_ref must cover the same legal plans")
915
+ log_q_star = exact_sb_target_log_probabilities(
916
+ reference_log_probabilities,
917
+ terminal_energies,
918
+ beta,
919
+ target_support_mask=target_support_mask,
920
+ )
921
+ q_star = log_q_star.exp().detach()
922
+ support = (
923
+ torch.ones_like(q_star, dtype=torch.bool)
924
+ if target_support_mask is None
925
+ else target_support_mask.to(device=q_star.device, dtype=torch.bool)
926
+ )
927
+ reverse_kl = torch.sum(
928
+ q_star[support]
929
+ * (log_q_star[support].detach() - controlled_log_probabilities[support])
930
+ )
931
+ energies = terminal_energies.to(
932
+ device=controlled_log_probabilities.device,
933
+ dtype=controlled_log_probabilities.dtype,
934
+ )
935
+ if target_support_mask is None:
936
+ # Historical false/fallback branch: keep its arithmetic untouched.
937
+ q_theta = controlled_log_probabilities.exp()
938
+ forward_objective = torch.sum(
939
+ q_theta
940
+ * (
941
+ controlled_log_probabilities
942
+ - reference_log_probabilities
943
+ + float(beta) * energies
944
+ )
945
+ )
946
+ else:
947
+ # Diagnostic-only conditional forward objective. q_theta is
948
+ # renormalized on the same strict support because KL(q_theta||q*) over
949
+ # the full plan set would be infinite whenever q_theta has outside
950
+ # mass. The trained objective remains reverse_kl above.
951
+ theta_log = torch.log_softmax(controlled_log_probabilities[support], dim=0)
952
+ ref_log = torch.log_softmax(reference_log_probabilities[support], dim=0)
953
+ theta = theta_log.exp()
954
+ forward_objective = torch.sum(
955
+ theta * (theta_log - ref_log + float(beta) * energies[support])
956
+ )
957
+ return reverse_kl, forward_objective, log_q_star
958
+
959
+
960
+ def describe_plan(plan: HydrocarbonStaplePlan) -> str:
961
+ i, j = plan.anchor_pair
962
+ return f"{plan.ordered_pair}/i,i+{plan.spacing}@{i}:{j}"
staplebridge/hydrocarbon/plan_reference.py ADDED
@@ -0,0 +1,932 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plan-aware empirical hydrocarbon reference process.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ The hard-only reference in :mod:`staplebridge.hydrocarbon.actions` +
6
+ :class:`staplebridge.reference.kernel.ReferenceKernel` reaches a stapled terminal
7
+ on only ~21% of rollouts. The measured cause is **anchor overshoot**, not a
8
+ scoring problem: the action generator offers an anchor-monomer substitution at
9
+ almost every editable position, each individually legal, so an unguided walk
10
+ installs 5-7 anchor monomers. ``validate_hydrocarbon_staple`` then returns
11
+ ``DOUBLE_STAPLE_UNSUPPORTED``, anchor assignment is never offered, and the
12
+ trajectory dead-ends with ``no_anchor_pair``. On a 32-lead probe, 98 of 101
13
+ failures had >2 anchors installed and no anchor pair.
14
+
15
+ The fix is to commit to a *whole staple plan* before walking, then bias the walk
16
+ toward finishing that plan:
17
+
18
+ 1. enumerate every legal plan on the lead (S5-S5/i,i+4 and R8-S5/i,i+7);
19
+ 2. filter on protected positions, anchor conflicts, edit budget and catalog;
20
+ 3. draw one plan from q(plan | x) ∝ p_empirical(mode)^beta / n_mode(x);
21
+ 4. bias the per-step kernel toward first anchor -> second anchor ->
22
+ anchor/block assign -> topology activation for *that* plan;
23
+ 5. downweight substitutions and anchor re-selection unrelated to the plan.
24
+
25
+ The ``1 / n_mode(x)`` factor is the point of step 3: i,i+4 admits more anchor
26
+ positions than i,i+7 on the same lead (8 vs 5 on a 12-mer), so weighting plans
27
+ by the raw mode probability would amplify i,i+4 purely by opportunity count.
28
+ Dividing by the per-lead legal-plan count of that mode makes the *mode* mass
29
+ exactly ``p^beta`` and the choice *within* a mode uniform.
30
+
31
+ The empirical mode prior is consumed **once, here, at plan selection**. It is
32
+ deliberately not multiplied into every action and not re-counted in the terminal
33
+ energy; ``configs/hydrocarbon_empirical_reference.yaml`` therefore sets
34
+ ``endpoint_prior.weight_pair: 0.0`` so the same table cannot be charged twice.
35
+
36
+ Isolation
37
+ ---------
38
+ Additive and hydrocarbon-only. Nothing here is imported by the lactam path:
39
+ :class:`staplebridge.reference.kernel.ReferenceKernel`,
40
+ :class:`staplebridge.reference.sampler.ReferenceTrajectorySampler`,
41
+ ``staplebridge.graph.neighbors`` and ``BridgeTrainer`` are wrapped, never
42
+ modified. The original hydrocarbon hard-only reference stays reachable exactly
43
+ as before, so it remains available as the ablation baseline.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import math
50
+ import random
51
+ from dataclasses import dataclass, field
52
+ from pathlib import Path
53
+ from typing import Any, Final
54
+
55
+ import torch
56
+
57
+ from staplebridge.chemistry.state import StapleState
58
+ from staplebridge.data.schemas import BuildingBlock
59
+ from staplebridge.hydrocarbon.catalog import block_topology, is_hydrocarbon_block
60
+ from staplebridge.hydrocarbon.curriculum import (
61
+ HydrocarbonStaplePlan,
62
+ propose_hydrocarbon_staple_plans,
63
+ )
64
+ from staplebridge.hydrocarbon.factorized_plan_reference import (
65
+ FactorizedPlanReference,
66
+ FactorizedPlanReferenceConfig,
67
+ )
68
+ from staplebridge.hydrocarbon.tokenizer import is_anchor_token
69
+ from staplebridge.reference.kernel import ReferenceKernel
70
+
71
+ #: Versioned subset of the generated empirical priors needed for plan
72
+ #: selection. Keeping it in the package makes defaults work in a clean clone;
73
+ #: the complete analysis output remains optional and generated.
74
+ DEFAULT_MODE_PRIOR_DIR: Final[str] = "staplebridge/hydrocarbon/data"
75
+
76
+ #: Structural cost ``weighted_edit_distance`` charges for any completed staple:
77
+ #: anchor 1.0 + topology 0.5 + block 0.5. A plan's terminal weighted edit
78
+ #: distance is therefore ``n_edits + 2.0``, which is what the edit budget filter
79
+ #: has to compare against.
80
+ STAPLE_STRUCTURAL_EDIT_COST: Final[float] = 2.0
81
+
82
+ # -- action labels, relative to the committed plan ---------------------------
83
+ ON_PLAN_FIRST_ANCHOR: Final[str] = "on_plan_first_anchor"
84
+ ON_PLAN_SECOND_ANCHOR: Final[str] = "on_plan_second_anchor"
85
+ ON_PLAN_ANCHOR_ASSIGN: Final[str] = "on_plan_anchor_assign"
86
+ ON_PLAN_BLOCK_ASSIGN: Final[str] = "on_plan_block_assign"
87
+ ON_PLAN_TOPOLOGY: Final[str] = "on_plan_topology_activation"
88
+ OFF_PLAN_TOPOLOGY: Final[str] = "off_plan_topology_activation"
89
+ OFF_PLAN_SUBSTITUTION: Final[str] = "off_plan_substitution"
90
+ OFF_PLAN_ANCHOR: Final[str] = "off_plan_anchor_selection"
91
+ OFF_PLAN_BLOCK: Final[str] = "off_plan_block_assign"
92
+ PLAN_NOOP: Final[str] = "noop"
93
+
94
+ #: Labels that count as progress on the committed plan.
95
+ ON_PLAN_LABELS: Final[frozenset[str]] = frozenset(
96
+ {
97
+ ON_PLAN_FIRST_ANCHOR,
98
+ ON_PLAN_SECOND_ANCHOR,
99
+ ON_PLAN_ANCHOR_ASSIGN,
100
+ ON_PLAN_BLOCK_ASSIGN,
101
+ ON_PLAN_TOPOLOGY,
102
+ }
103
+ )
104
+
105
+
106
+ class PlanSelectionError(RuntimeError):
107
+ """Raised when the empirical mode prior cannot be loaded."""
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Empirical mode prior
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ @dataclass
116
+ class ModePriorConfig:
117
+ """Config for :class:`EmpiricalModePrior`.
118
+
119
+ Only the modes the catalog actually supports are kept, and their
120
+ probabilities are renormalised over that restricted support. Without the
121
+ renormalisation the ``beta`` exponent would act on a distribution whose mass
122
+ partly sits on topologies the hard catalog forbids.
123
+ """
124
+
125
+ prior_dir: str = DEFAULT_MODE_PRIOR_DIR
126
+ dedup_version: str = "sequence_deduplicated"
127
+ use_smoothed: bool = True
128
+ #: Temperature on the empirical mode probabilities: ``p^beta``. 1.0 follows
129
+ #: the data exactly, 0.0 is uniform over modes.
130
+ beta: float = 0.75
131
+ #: Floor for a catalog mode absent from the table, so an enabled topology is
132
+ #: never assigned probability zero.
133
+ unobserved_probability: float = 1e-3
134
+
135
+ @classmethod
136
+ def from_dict(cls, data: dict[str, Any] | None) -> "ModePriorConfig":
137
+ """Build from a ``hydrocarbon.plan_reference.mode_prior`` section."""
138
+ cfg = cls()
139
+ for key, value in dict(data or {}).items():
140
+ if not hasattr(cfg, key):
141
+ continue
142
+ current = getattr(cfg, key)
143
+ if isinstance(current, bool):
144
+ setattr(cfg, key, bool(value))
145
+ elif isinstance(current, float):
146
+ setattr(cfg, key, float(value))
147
+ else:
148
+ setattr(cfg, key, value)
149
+ return cfg
150
+
151
+
152
+ class EmpiricalModePrior:
153
+ """``p_empirical(mode)`` over the catalog's ``(pair, spacing)`` topologies.
154
+
155
+ Args:
156
+ catalog: the hydrocarbon blocks in play. Defines the support.
157
+ config: prior configuration.
158
+ root: repository root used to resolve a relative ``prior_dir``.
159
+
160
+ Raises:
161
+ PlanSelectionError: if the empirical table is missing or names no
162
+ catalog mode. Failing loudly beats silently falling back to uniform,
163
+ because "plan-aware *empirical* reference" would then be a misnomer.
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ catalog: list[BuildingBlock],
169
+ config: ModePriorConfig | None = None,
170
+ root: Path | None = None,
171
+ ) -> None:
172
+ self.cfg = config or ModePriorConfig()
173
+ self._root = Path(root) if root is not None else Path(__file__).resolve().parents[2]
174
+ self.modes: list[tuple[str, int]] = [
175
+ block_topology(b) for b in catalog if is_hydrocarbon_block(b)
176
+ ]
177
+ self._raw: dict[tuple[str, int], float] = {}
178
+ self._probabilities: dict[tuple[str, int], float] = {}
179
+ self._load()
180
+
181
+ @property
182
+ def prior_dir(self) -> Path:
183
+ """Resolved directory holding the empirical JSON tables."""
184
+ candidate = Path(self.cfg.prior_dir)
185
+ return candidate if candidate.is_absolute() else self._root / candidate
186
+
187
+ def _load(self) -> None:
188
+ """Read ``pair_spacing_probabilities.json`` and restrict to the catalog."""
189
+ path = self.prior_dir / "pair_spacing_probabilities.json"
190
+ if not path.is_file():
191
+ raise PlanSelectionError(
192
+ f"plan-aware reference needs the empirical mode table at {path}. "
193
+ "It ships with this release at "
194
+ "staplebridge/hydrocarbon/data/pair_spacing_probabilities.json; "
195
+ "check hydrocarbon.plan_reference.mode_prior.prior_dir."
196
+ )
197
+ with path.open("r", encoding="utf-8") as handle:
198
+ payload = json.load(handle)
199
+ versions = payload.get("probabilities_by_version") or {}
200
+ if self.cfg.dedup_version not in versions:
201
+ raise PlanSelectionError(
202
+ f"dedup version {self.cfg.dedup_version!r} not in {path.name}; "
203
+ f"available: {sorted(versions)}"
204
+ )
205
+ categories = dict(versions[self.cfg.dedup_version].get("categories") or {})
206
+ field_name = (
207
+ "laplace_smoothed_probability" if self.cfg.use_smoothed else "raw_probability"
208
+ )
209
+
210
+ for pair, spacing in self.modes:
211
+ entry = categories.get(f"{pair}|{spacing}") or {}
212
+ value = entry.get(field_name)
213
+ self._raw[(pair, spacing)] = (
214
+ float(self.cfg.unobserved_probability)
215
+ if value is None or float(value) <= 0.0
216
+ else float(value)
217
+ )
218
+
219
+ total = sum(self._raw.values())
220
+ if total <= 0.0:
221
+ raise PlanSelectionError(
222
+ f"no catalog mode has positive empirical probability in {path.name}; "
223
+ f"catalog modes: {self.modes}"
224
+ )
225
+ self._probabilities = {k: v / total for k, v in self._raw.items()}
226
+
227
+ def probability(self, mode: tuple[str, int]) -> float:
228
+ """Renormalised ``p_empirical(mode)``; 0.0 for a non-catalog mode."""
229
+ return float(self._probabilities.get(mode, 0.0))
230
+
231
+ def tilted_weight(self, mode: tuple[str, int]) -> float:
232
+ """``p_empirical(mode) ** beta``, the weight used at plan selection."""
233
+ probability = self.probability(mode)
234
+ return 0.0 if probability <= 0.0 else probability ** float(self.cfg.beta)
235
+
236
+ def describe(self) -> dict[str, Any]:
237
+ """Summary for logging and audits."""
238
+ return {
239
+ "prior_dir": str(self.prior_dir),
240
+ "dedup_version": self.cfg.dedup_version,
241
+ "use_smoothed": bool(self.cfg.use_smoothed),
242
+ "beta": float(self.cfg.beta),
243
+ "modes": [f"{p}/i,i+{s}" for p, s in self.modes],
244
+ "p_empirical": {
245
+ f"{p}/i,i+{s}": self.probability((p, s)) for p, s in self.modes
246
+ },
247
+ "p_tilted": {
248
+ f"{p}/i,i+{s}": self.tilted_weight((p, s)) for p, s in self.modes
249
+ },
250
+ "uses_permeability_label": False,
251
+ "is_trained_classifier": False,
252
+ "consumed": "once, at plan selection",
253
+ }
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Plan enumeration, filtering and selection
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ @dataclass
262
+ class PlanFilterConfig:
263
+ """Feasibility filters applied to enumerated plans."""
264
+
265
+ #: Reject plans needing more anchor substitutions than this.
266
+ max_anchor_edits: int = 2
267
+ #: Terminal weighted-edit-distance ceiling (``edit_constraints.max_edit_budget``).
268
+ max_edit_budget: float = 6.0
269
+ #: Minimum surviving sequence identity (``edit_constraints.min_sequence_identity``).
270
+ min_sequence_identity: float = 0.60
271
+
272
+ @classmethod
273
+ def from_config(
274
+ cls, hydro_cfg: dict[str, Any] | None, root_cfg: dict[str, Any] | None
275
+ ) -> "PlanFilterConfig":
276
+ """Read the curriculum and edit-constraint sections of a full config."""
277
+ curriculum = dict((hydro_cfg or {}).get("curriculum") or {})
278
+ edits = dict((root_cfg or {}).get("edit_constraints") or {})
279
+ return cls(
280
+ max_anchor_edits=int(curriculum.get("max_anchor_edits", 2)),
281
+ max_edit_budget=float(edits.get("max_edit_budget", 6.0)),
282
+ min_sequence_identity=float(edits.get("min_sequence_identity", 0.60)),
283
+ )
284
+
285
+
286
+ @dataclass
287
+ class PlanEnumerationReport:
288
+ """Why plans were rejected, and what the surviving mode mix looks like.
289
+
290
+ Every counter accumulates, so one report can be threaded through a whole
291
+ batch of leads. ``n_enumerated`` and ``n_kept`` are therefore totals over all
292
+ enumeration calls, not per-lead values — mixing the two conventions in one
293
+ object would make the per-mode counts unreadable against them.
294
+ """
295
+
296
+ n_calls: int = 0
297
+ n_enumerated: int = 0
298
+ n_kept: int = 0
299
+ rejected: dict[str, int] = field(default_factory=dict)
300
+ per_mode_counts: dict[str, int] = field(default_factory=dict)
301
+
302
+ def reject(self, reason: str) -> None:
303
+ """Tally one rejection."""
304
+ self.rejected[reason] = self.rejected.get(reason, 0) + 1
305
+
306
+ def as_dict(self) -> dict[str, Any]:
307
+ """JSON-serialisable view, with per-call means alongside the totals."""
308
+ calls = max(self.n_calls, 1)
309
+ return {
310
+ "n_calls": int(self.n_calls),
311
+ "n_enumerated_total": int(self.n_enumerated),
312
+ "n_kept_total": int(self.n_kept),
313
+ "mean_enumerated_per_lead": float(self.n_enumerated / calls),
314
+ "mean_kept_per_lead": float(self.n_kept / calls),
315
+ "rejected": dict(sorted(self.rejected.items())),
316
+ "per_mode_counts": dict(sorted(self.per_mode_counts.items())),
317
+ }
318
+
319
+
320
+ def enumerate_legal_plans(
321
+ tokens: list[str],
322
+ catalog: list[BuildingBlock],
323
+ protected_positions: list[int] | None = None,
324
+ filters: PlanFilterConfig | None = None,
325
+ report: PlanEnumerationReport | None = None,
326
+ ) -> list[HydrocarbonStaplePlan]:
327
+ """Every legal staple plan on ``tokens``, after feasibility filtering.
328
+
329
+ Delegates catalog/protected/anchor-conflict/double-staple filtering to
330
+ :func:`~staplebridge.hydrocarbon.curriculum.propose_hydrocarbon_staple_plans`
331
+ (so the plan-aware reference and the curriculum oracle agree on what is
332
+ legal by construction), then applies the edit-budget and sequence-identity
333
+ constraints the curriculum does not check.
334
+
335
+ Returns:
336
+ Plans in the curriculum's cheapest-first order.
337
+ """
338
+ filters = filters or PlanFilterConfig()
339
+ report = report if report is not None else PlanEnumerationReport()
340
+
341
+ plans = propose_hydrocarbon_staple_plans(
342
+ tokens,
343
+ catalog,
344
+ protected_positions=protected_positions,
345
+ max_anchor_edits=filters.max_anchor_edits,
346
+ )
347
+ report.n_calls += 1
348
+ report.n_enumerated += len(plans)
349
+
350
+ kept: list[HydrocarbonStaplePlan] = []
351
+ for plan in plans:
352
+ # Terminal weighted edit distance the plan would incur, including the
353
+ # fixed structural cost of closing a staple.
354
+ projected_edit = float(plan.n_edits) + STAPLE_STRUCTURAL_EDIT_COST
355
+ if projected_edit > filters.max_edit_budget:
356
+ report.reject("edit_budget_exhausted")
357
+ continue
358
+ identity = 1.0 - (plan.n_edits / len(tokens)) if tokens else 0.0
359
+ if identity < filters.min_sequence_identity:
360
+ report.reject("below_min_sequence_identity")
361
+ continue
362
+ kept.append(plan)
363
+ mode = f"{plan.ordered_pair}/i,i+{plan.spacing}"
364
+ report.per_mode_counts[mode] = report.per_mode_counts.get(mode, 0) + 1
365
+
366
+ report.n_kept += len(kept)
367
+ return kept
368
+
369
+
370
+ def plan_selection_weights(
371
+ plans: list[HydrocarbonStaplePlan], mode_prior: EmpiricalModePrior
372
+ ) -> list[float]:
373
+ """``q(plan | x) ∝ p_empirical(mode)^beta / n_mode(x)``, unnormalised.
374
+
375
+ Dividing by ``n_mode(x)`` — the number of legal plans of that mode *on this
376
+ lead* — is what keeps i,i+4 from being amplified simply because it has more
377
+ admissible anchor positions than i,i+7. The resulting mode marginal is
378
+ exactly ``p^beta`` and the within-mode choice is uniform.
379
+ """
380
+ counts: dict[tuple[str, int], int] = {}
381
+ for plan in plans:
382
+ key = (plan.ordered_pair, plan.spacing)
383
+ counts[key] = counts.get(key, 0) + 1
384
+
385
+ weights: list[float] = []
386
+ for plan in plans:
387
+ key = (plan.ordered_pair, plan.spacing)
388
+ n_mode = counts[key]
389
+ weights.append(mode_prior.tilted_weight(key) / float(n_mode) if n_mode else 0.0)
390
+ return weights
391
+
392
+
393
+ def select_plan(
394
+ plans: list[HydrocarbonStaplePlan],
395
+ mode_prior: EmpiricalModePrior,
396
+ rng: random.Random,
397
+ ) -> HydrocarbonStaplePlan | None:
398
+ """Draw one plan from ``q(plan | x)``.
399
+
400
+ Returns ``None`` when there is no legal plan, or when every legal plan's mode
401
+ has zero empirical weight.
402
+ """
403
+ if not plans:
404
+ return None
405
+ weights = plan_selection_weights(plans, mode_prior)
406
+ total = sum(weights)
407
+ if total <= 0.0:
408
+ return None
409
+ threshold = rng.random() * total
410
+ cumulative = 0.0
411
+ for plan, weight in zip(plans, weights):
412
+ cumulative += weight
413
+ if cumulative >= threshold:
414
+ return plan
415
+ return plans[-1]
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Plan-conditional action labelling and biasing
420
+ # ---------------------------------------------------------------------------
421
+
422
+
423
+ @dataclass
424
+ class PlanBiasConfig:
425
+ """Log-space bonuses applied to the reference pmf, per plan-relative label.
426
+
427
+ Positive values favour an action, negative values suppress it. The four
428
+ on-plan structural bonuses increase along the build order (first anchor ->
429
+ second anchor -> assign -> activate) so that a partially built plan is
430
+ always pulled forward rather than left to compete with a fresh restart.
431
+
432
+ The off-plan substitution penalty is the load-bearing one: the action
433
+ generator offers an anchor substitution at nearly every editable position,
434
+ and unguided that is what installs a third anchor and kills the trajectory.
435
+ """
436
+
437
+ first_anchor: float = 3.0
438
+ second_anchor: float = 3.5
439
+ anchor_assign: float = 4.0
440
+ block_assign: float = 4.0
441
+ topology_activation: float = 4.5
442
+ #: Closing a different pair contradicts the committed plan and strict
443
+ #: hierarchical inference. Keep it a failure, not an alternative positive.
444
+ off_plan_topology_activation: float = -4.5
445
+ off_plan_substitution: float = -3.0
446
+ off_plan_anchor_selection: float = -3.0
447
+ off_plan_block_assign: float = -1.0
448
+ noop: float = -1.0
449
+
450
+ @classmethod
451
+ def from_dict(cls, data: dict[str, Any] | None) -> "PlanBiasConfig":
452
+ """Build from a ``hydrocarbon.plan_reference.bias`` section."""
453
+ cfg = cls()
454
+ for key, value in dict(data or {}).items():
455
+ if hasattr(cfg, key):
456
+ setattr(cfg, key, float(value))
457
+ return cfg
458
+
459
+ def as_dict(self) -> dict[str, float]:
460
+ """Label -> bonus mapping used by the kernel."""
461
+ return {
462
+ ON_PLAN_FIRST_ANCHOR: self.first_anchor,
463
+ ON_PLAN_SECOND_ANCHOR: self.second_anchor,
464
+ ON_PLAN_ANCHOR_ASSIGN: self.anchor_assign,
465
+ ON_PLAN_BLOCK_ASSIGN: self.block_assign,
466
+ ON_PLAN_TOPOLOGY: self.topology_activation,
467
+ OFF_PLAN_TOPOLOGY: self.off_plan_topology_activation,
468
+ OFF_PLAN_SUBSTITUTION: self.off_plan_substitution,
469
+ OFF_PLAN_ANCHOR: self.off_plan_anchor_selection,
470
+ OFF_PLAN_BLOCK: self.off_plan_block_assign,
471
+ PLAN_NOOP: self.noop,
472
+ }
473
+
474
+
475
+ def plan_positions_satisfied(
476
+ tokens: list[str], plan: HydrocarbonStaplePlan
477
+ ) -> tuple[bool, bool]:
478
+ """Whether the plan's ``i`` and ``j`` anchor monomers are already installed."""
479
+ i, j = plan.anchor_pair
480
+ i_token, j_token = plan.ordered_pair.split("-")
481
+ have_i = 0 <= i < len(tokens) and tokens[i].upper() == i_token
482
+ have_j = 0 <= j < len(tokens) and tokens[j].upper() == j_token
483
+ return have_i, have_j
484
+
485
+
486
+ def classify_against_plan(
487
+ state: StapleState, candidate: StapleState, plan: HydrocarbonStaplePlan
488
+ ) -> str:
489
+ """Label the transition ``state -> candidate`` relative to ``plan``.
490
+
491
+ Checked in the same order the build proceeds, so a composite transition
492
+ (the action generator sets ``block_id`` in the same step as the anchor
493
+ assignment) is attributed to its most advanced effect.
494
+ """
495
+ plan_i, plan_j = plan.anchor_pair
496
+ i_token, j_token = plan.ordered_pair.split("-")
497
+
498
+ # -- topology activation --------------------------------------------
499
+ if state.topology != candidate.topology:
500
+ if candidate.topology != "stapled":
501
+ return PLAN_NOOP
502
+ on_plan = (
503
+ candidate.anchor_pair is not None
504
+ and tuple(candidate.anchor_pair) == (plan_i, plan_j)
505
+ and candidate.block_id == plan.block_id
506
+ )
507
+ return ON_PLAN_TOPOLOGY if on_plan else OFF_PLAN_TOPOLOGY
508
+
509
+ # -- sequence edit ---------------------------------------------------
510
+ if state.sequence_tokens != candidate.sequence_tokens:
511
+ changed = [
512
+ position
513
+ for position in range(min(len(state.sequence_tokens), len(candidate.sequence_tokens)))
514
+ if state.sequence_tokens[position] != candidate.sequence_tokens[position]
515
+ ]
516
+ if len(changed) != 1:
517
+ return OFF_PLAN_SUBSTITUTION
518
+ position = changed[0]
519
+ installed = candidate.sequence_tokens[position].upper()
520
+ wanted = (
521
+ i_token if position == plan_i else j_token if position == plan_j else None
522
+ )
523
+ if wanted is None or installed != wanted:
524
+ return OFF_PLAN_SUBSTITUTION
525
+ # Ordering is by *progress*, not by index: whichever of the two plan
526
+ # anchors lands first is the "first anchor" install.
527
+ have_i, have_j = plan_positions_satisfied(state.sequence_tokens, plan)
528
+ return (
529
+ ON_PLAN_SECOND_ANCHOR if (have_i or have_j) else ON_PLAN_FIRST_ANCHOR
530
+ )
531
+
532
+ # -- anchor selection ------------------------------------------------
533
+ if state.anchor_pair != candidate.anchor_pair:
534
+ if (
535
+ candidate.anchor_pair is not None
536
+ and tuple(candidate.anchor_pair) == (plan_i, plan_j)
537
+ and candidate.block_id in (None, plan.block_id)
538
+ ):
539
+ return ON_PLAN_ANCHOR_ASSIGN
540
+ return OFF_PLAN_ANCHOR
541
+
542
+ # -- block assignment ------------------------------------------------
543
+ if state.block_id != candidate.block_id:
544
+ if (
545
+ candidate.block_id == plan.block_id
546
+ and candidate.anchor_pair is not None
547
+ and tuple(candidate.anchor_pair) == (plan_i, plan_j)
548
+ ):
549
+ return ON_PLAN_BLOCK_ASSIGN
550
+ return OFF_PLAN_BLOCK
551
+
552
+ return PLAN_NOOP
553
+
554
+
555
+ class PlanAwareReferenceKernel:
556
+ """Reference kernel that conditions on a committed staple plan.
557
+
558
+ Wraps an unmodified :class:`~staplebridge.reference.kernel.ReferenceKernel`:
559
+ the base pmf (peptide/anchor/block priors, cost, geometry, action-progress,
560
+ group normalisation, substitution downweight) is computed exactly as today,
561
+ then reweighted by ``exp(bonus(label))`` and renormalised. With no plan
562
+ committed, or with all bonuses at zero, this is the base kernel.
563
+
564
+ Reweighting in probability space rather than editing the base logits keeps
565
+ the two kernels directly comparable for the ablation: the only difference is
566
+ a plan-conditional multiplicative factor.
567
+ """
568
+
569
+ def __init__(
570
+ self, base_kernel: ReferenceKernel, bias: PlanBiasConfig | None = None
571
+ ) -> None:
572
+ self.base_kernel = base_kernel
573
+ self.bias = bias or PlanBiasConfig()
574
+ self._bonuses = self.bias.as_dict()
575
+
576
+ def labels(
577
+ self,
578
+ state: StapleState,
579
+ candidates: list[StapleState],
580
+ plan: HydrocarbonStaplePlan | None,
581
+ ) -> list[str]:
582
+ """Plan-relative label for each candidate."""
583
+ if plan is None:
584
+ return [PLAN_NOOP] * len(candidates)
585
+ return [classify_against_plan(state, c, plan) for c in candidates]
586
+
587
+ def plan_probs(
588
+ self,
589
+ state: StapleState,
590
+ candidates: list[StapleState],
591
+ plan: HydrocarbonStaplePlan | None,
592
+ context: dict[str, Any] | None = None,
593
+ ) -> tuple[torch.Tensor, list[str]]:
594
+ """Plan-conditional pmf over ``candidates``, plus their labels."""
595
+ probs = self.base_kernel.reference_probs(state, candidates, context=context)
596
+ if plan is None:
597
+ return probs, [PLAN_NOOP] * len(candidates)
598
+
599
+ labels = self.labels(state, candidates, plan)
600
+ factors = torch.tensor(
601
+ [math.exp(self._bonuses.get(label, 0.0)) for label in labels],
602
+ dtype=torch.float32,
603
+ )
604
+ tilted = probs * factors
605
+ total = float(tilted.sum().item())
606
+ if total <= 0.0:
607
+ # Every candidate had zero base mass; fall back rather than emit a
608
+ # degenerate pmf that ``torch.multinomial`` would reject.
609
+ return probs, labels
610
+ return tilted / total, labels
611
+
612
+ def sample_next(
613
+ self,
614
+ state: StapleState,
615
+ candidates: list[StapleState],
616
+ plan: HydrocarbonStaplePlan | None,
617
+ context: dict[str, Any] | None = None,
618
+ ) -> tuple[StapleState, str]:
619
+ """Draw one candidate from the plan-conditional pmf."""
620
+ probs, labels = self.plan_probs(state, candidates, plan, context=context)
621
+ index = int(torch.multinomial(probs, num_samples=1).item())
622
+ return candidates[index], labels[index]
623
+
624
+
625
+ # ---------------------------------------------------------------------------
626
+ # Plan-aware trajectory sampler
627
+ # ---------------------------------------------------------------------------
628
+
629
+
630
+ @dataclass
631
+ class PlanProgress:
632
+ """Which stages of the committed plan a trajectory actually reached.
633
+
634
+ Recorded per stage rather than as a single success flag, because when the
635
+ stapled rate disappoints the question is always *which* stage lost the
636
+ trajectory.
637
+ """
638
+
639
+ plan_selected: bool = False
640
+ first_anchor_installed: bool = False
641
+ second_anchor_installed: bool = False
642
+ anchor_assigned: bool = False
643
+ block_assigned: bool = False
644
+ topology_activated: bool = False
645
+ plan_completed: bool = False
646
+ n_on_plan_actions: int = 0
647
+ n_off_plan_substitutions: int = 0
648
+ n_off_plan_anchor_selections: int = 0
649
+ n_actions: int = 0
650
+
651
+ @property
652
+ def unrelated_substitution_rate(self) -> float:
653
+ """Share of this trajectory's actions that were off-plan substitutions."""
654
+ return (
655
+ self.n_off_plan_substitutions / self.n_actions if self.n_actions else 0.0
656
+ )
657
+
658
+ def as_dict(self) -> dict[str, Any]:
659
+ """JSON-serialisable view."""
660
+ return {
661
+ "plan_selected": bool(self.plan_selected),
662
+ "first_anchor_installed": bool(self.first_anchor_installed),
663
+ "second_anchor_installed": bool(self.second_anchor_installed),
664
+ "anchor_assigned": bool(self.anchor_assigned),
665
+ "block_assigned": bool(self.block_assigned),
666
+ "topology_activated": bool(self.topology_activated),
667
+ "plan_completed": bool(self.plan_completed),
668
+ "n_on_plan_actions": int(self.n_on_plan_actions),
669
+ "n_off_plan_substitutions": int(self.n_off_plan_substitutions),
670
+ "n_off_plan_anchor_selections": int(self.n_off_plan_anchor_selections),
671
+ "n_actions": int(self.n_actions),
672
+ "unrelated_substitution_rate": float(self.unrelated_substitution_rate),
673
+ }
674
+
675
+
676
+ @dataclass
677
+ class PlanAwareTrajectory:
678
+ """One plan-aware rollout."""
679
+
680
+ states: list[StapleState]
681
+ plan: HydrocarbonStaplePlan | None
682
+ progress: PlanProgress
683
+ action_labels: list[str] = field(default_factory=list)
684
+ no_plan_reason: str | None = None
685
+ #: True when the rollout stopped because the graph offered no neighbour.
686
+ no_neighbor: bool = False
687
+
688
+
689
+ class PlanAwareReferenceSampler:
690
+ """Reference sampler that commits to a plan, then completes it.
691
+
692
+ Args:
693
+ graph: the hydrocarbon transition graph (unmodified).
694
+ kernel: the plan-conditional kernel.
695
+ mode_prior: empirical mode prior, consumed once per trajectory.
696
+ filters: plan feasibility filters.
697
+ seed: base seed for plan selection, kept separate from the global torch
698
+ RNG so plan draws are reproducible independently of the pmf draws.
699
+ """
700
+
701
+ def __init__(
702
+ self,
703
+ graph: Any,
704
+ kernel: PlanAwareReferenceKernel,
705
+ mode_prior: EmpiricalModePrior,
706
+ filters: PlanFilterConfig | None = None,
707
+ seed: int = 42,
708
+ factorized_reference: FactorizedPlanReference | None = None,
709
+ ) -> None:
710
+ self.graph = graph
711
+ self.kernel = kernel
712
+ self.mode_prior = mode_prior
713
+ self.filters = filters or PlanFilterConfig()
714
+ self.factorized_reference = factorized_reference
715
+ self._rng = random.Random(seed)
716
+
717
+ @property
718
+ def factorized_plan_reference_enabled(self) -> bool:
719
+ return self.factorized_reference is not None
720
+
721
+ def plan_selection_weights(
722
+ self,
723
+ initial: StapleState,
724
+ plans: list[HydrocarbonStaplePlan],
725
+ context: dict[str, Any] | None = None,
726
+ ) -> list[float]:
727
+ """Active plan-reference weights, with a bit-exact legacy branch."""
728
+ if self.factorized_reference is None:
729
+ return plan_selection_weights(plans, self.mode_prior)
730
+ return self.factorized_reference.weights(initial, plans, context)
731
+
732
+ def select_plan(
733
+ self,
734
+ initial: StapleState,
735
+ plans: list[HydrocarbonStaplePlan],
736
+ context: dict[str, Any] | None = None,
737
+ ) -> HydrocarbonStaplePlan | None:
738
+ if not plans:
739
+ return None
740
+ weights = self.plan_selection_weights(initial, plans, context)
741
+ total = sum(weights)
742
+ if total <= 0.0:
743
+ return None
744
+ threshold = self._rng.random() * total
745
+ cumulative = 0.0
746
+ for plan, weight in zip(plans, weights):
747
+ cumulative += weight
748
+ if cumulative >= threshold:
749
+ return plan
750
+ return plans[-1]
751
+
752
+ def sample_trajectory(
753
+ self,
754
+ init_state: StapleState,
755
+ protected_positions: list[int],
756
+ context: dict[str, Any],
757
+ horizon: int,
758
+ early_stop: bool = True,
759
+ report: PlanEnumerationReport | None = None,
760
+ ) -> PlanAwareTrajectory:
761
+ """Select a plan for ``init_state``, then walk toward completing it."""
762
+ plans = enumerate_legal_plans(
763
+ init_state.sequence_tokens,
764
+ self.graph.catalog,
765
+ protected_positions=protected_positions,
766
+ filters=self.filters,
767
+ report=report,
768
+ )
769
+ plan = self.select_plan(init_state, plans, context)
770
+ progress = PlanProgress(plan_selected=plan is not None)
771
+ if plan is None:
772
+ reason = "no_legal_plan" if not plans else "no_mode_weight"
773
+ return PlanAwareTrajectory(
774
+ states=[init_state], plan=None, progress=progress, no_plan_reason=reason
775
+ )
776
+
777
+ states = [init_state]
778
+ labels: list[str] = []
779
+ current = init_state
780
+ no_neighbor = False
781
+
782
+ for _ in range(horizon):
783
+ candidates = self.graph.neighbors(
784
+ current, protected_positions=protected_positions
785
+ )
786
+ if not candidates:
787
+ no_neighbor = True
788
+ break
789
+ nxt, label = self.kernel.sample_next(
790
+ current, candidates, plan, context=context
791
+ )
792
+ states.append(nxt)
793
+ labels.append(label)
794
+
795
+ progress.n_actions += 1
796
+ if label in ON_PLAN_LABELS:
797
+ progress.n_on_plan_actions += 1
798
+ if label == ON_PLAN_FIRST_ANCHOR:
799
+ progress.first_anchor_installed = True
800
+ elif label == ON_PLAN_SECOND_ANCHOR:
801
+ progress.second_anchor_installed = True
802
+ elif label == ON_PLAN_ANCHOR_ASSIGN:
803
+ progress.anchor_assigned = True
804
+ elif label == ON_PLAN_BLOCK_ASSIGN:
805
+ progress.block_assigned = True
806
+ elif label == ON_PLAN_TOPOLOGY:
807
+ progress.topology_activated = True
808
+ elif label == OFF_PLAN_TOPOLOGY:
809
+ progress.topology_activated = True
810
+ elif label == OFF_PLAN_SUBSTITUTION:
811
+ progress.n_off_plan_substitutions += 1
812
+ elif label == OFF_PLAN_ANCHOR:
813
+ progress.n_off_plan_anchor_selections += 1
814
+
815
+ current = nxt
816
+ if early_stop and current.topology == "stapled":
817
+ break
818
+
819
+ # The anchor assignment is composite (it sets block_id in the same
820
+ # transition), so credit block assignment from the terminal state rather
821
+ # than requiring a separate labelled step.
822
+ if current.block_id == plan.block_id and tuple(
823
+ current.anchor_pair or (-1, -1)
824
+ ) == plan.anchor_pair:
825
+ progress.block_assigned = True
826
+ have_i, have_j = plan_positions_satisfied(current.sequence_tokens, plan)
827
+ if have_i and have_j:
828
+ progress.first_anchor_installed = True
829
+ progress.second_anchor_installed = True
830
+ elif have_i or have_j:
831
+ progress.first_anchor_installed = True
832
+
833
+ progress.plan_completed = bool(
834
+ current.topology == "stapled"
835
+ and current.anchor_pair is not None
836
+ and tuple(current.anchor_pair) == plan.anchor_pair
837
+ and current.block_id == plan.block_id
838
+ )
839
+
840
+ return PlanAwareTrajectory(
841
+ states=states,
842
+ plan=plan,
843
+ progress=progress,
844
+ action_labels=labels,
845
+ no_neighbor=no_neighbor,
846
+ )
847
+
848
+ def sample_batch(
849
+ self,
850
+ init_state: StapleState,
851
+ protected_positions: list[int],
852
+ context: dict[str, Any],
853
+ horizon: int,
854
+ n: int,
855
+ report: PlanEnumerationReport | None = None,
856
+ ) -> list[PlanAwareTrajectory]:
857
+ """``n`` independent plan-aware rollouts from ``init_state``."""
858
+ return [
859
+ self.sample_trajectory(
860
+ init_state,
861
+ protected_positions=protected_positions,
862
+ context=context,
863
+ horizon=horizon,
864
+ report=report,
865
+ )
866
+ for _ in range(n)
867
+ ]
868
+
869
+
870
+ @dataclass
871
+ class PlanReferenceConfig:
872
+ """Full config for the plan-aware reference, from a ``hydrocarbon`` section."""
873
+
874
+ enabled: bool = False
875
+ mode_prior: ModePriorConfig = field(default_factory=ModePriorConfig)
876
+ bias: PlanBiasConfig = field(default_factory=PlanBiasConfig)
877
+ filters: PlanFilterConfig = field(default_factory=PlanFilterConfig)
878
+ factorized: FactorizedPlanReferenceConfig = field(
879
+ default_factory=FactorizedPlanReferenceConfig
880
+ )
881
+
882
+ @classmethod
883
+ def from_config(cls, root_cfg: dict[str, Any] | None) -> "PlanReferenceConfig":
884
+ """Read ``hydrocarbon.plan_reference`` plus the shared edit constraints."""
885
+ root_cfg = dict(root_cfg or {})
886
+ hydro_cfg = dict(root_cfg.get("hydrocarbon") or {})
887
+ section = dict(hydro_cfg.get("plan_reference") or {})
888
+ return cls(
889
+ enabled=bool(section.get("enabled", False)),
890
+ mode_prior=ModePriorConfig.from_dict(section.get("mode_prior")),
891
+ bias=PlanBiasConfig.from_dict(section.get("bias")),
892
+ filters=PlanFilterConfig.from_config(hydro_cfg, root_cfg),
893
+ factorized=FactorizedPlanReferenceConfig.from_config(root_cfg),
894
+ )
895
+
896
+
897
+ def build_plan_aware_sampler(
898
+ graph: Any,
899
+ base_kernel: ReferenceKernel,
900
+ root_cfg: dict[str, Any] | None,
901
+ seed: int = 42,
902
+ root: Path | None = None,
903
+ ) -> tuple[PlanAwareReferenceSampler, PlanReferenceConfig]:
904
+ """Assemble the plan-aware sampler from a full config mapping."""
905
+ cfg = PlanReferenceConfig.from_config(root_cfg)
906
+ mode_prior = EmpiricalModePrior(graph.catalog, cfg.mode_prior, root=root)
907
+ kernel = PlanAwareReferenceKernel(base_kernel, cfg.bias)
908
+ factorized_reference = None
909
+ if cfg.factorized.enabled:
910
+ energy = base_kernel.energy_model
911
+ factorized_reference = FactorizedPlanReference(
912
+ mode_prior=mode_prior,
913
+ catalog=graph.catalog,
914
+ peptide_prior=energy.peptide_prior,
915
+ anchor_prior=energy.anchor_prior,
916
+ block_prior=energy.block_prior,
917
+ config=cfg.factorized,
918
+ )
919
+ sampler = PlanAwareReferenceSampler(
920
+ graph,
921
+ kernel,
922
+ mode_prior,
923
+ filters=cfg.filters,
924
+ seed=seed,
925
+ factorized_reference=factorized_reference,
926
+ )
927
+ return sampler, cfg
928
+
929
+
930
+ def count_anchor_monomers(tokens: list[str]) -> int:
931
+ """Number of hydrocarbon anchor monomers in ``tokens``."""
932
+ return sum(1 for t in tokens if is_anchor_token(t))
staplebridge/hydrocarbon/plan_validation.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exact-SB validation diagnostics for the hydrocarbon plan-control head.
2
+
3
+ The lead-local Gibbs target is computed with exactly the training definition,
4
+ by delegating to :func:`configured_plan_level_objective` with
5
+ ``exact_sb_objective=True``. Nothing here participates in candidate
6
+ generation, plan selection, or reranking: ``q*`` is a diagnostic and a
7
+ model-selection signal only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import Counter
13
+ from typing import Any, Sequence
14
+
15
+ import torch
16
+
17
+ from staplebridge.hydrocarbon.plan_control import (
18
+ HydrocarbonPlanControlConfig,
19
+ configured_plan_level_objective,
20
+ )
21
+
22
+ # The validation metrics this module contributes. They are additive: no
23
+ # existing validation metric is replaced or removed.
24
+ Q_STAR_VALIDATION_METRICS = (
25
+ "q_star_vs_q_theta_kl",
26
+ "q_star_top1_agreement",
27
+ "q_star_spearman",
28
+ "q_star_entropy",
29
+ "q_theta_entropy",
30
+ "exact_sb_forward_objective",
31
+ )
32
+
33
+ S5_MODE = "S5-S5/i,i+4"
34
+ R8_MODE = "R8-S5/i,i+7"
35
+
36
+
37
+ def exact_sb_validation_enabled(root_config: dict[str, Any] | None) -> bool:
38
+ """Only ``exact_sb_objective`` **and** ``validation_all_plans`` opt in."""
39
+ cfg = HydrocarbonPlanControlConfig.from_config(root_config)
40
+ return bool(cfg.enabled and cfg.exact_sb_objective and cfg.validation_all_plans)
41
+
42
+
43
+ def _average_ranks(values: torch.Tensor) -> torch.Tensor:
44
+ """Ascending ranks with ties averaged, as Spearman's rho requires."""
45
+ order = torch.argsort(values)
46
+ sorted_values = values[order]
47
+ ranks = torch.empty_like(sorted_values, dtype=torch.float64)
48
+ start = 0
49
+ for index in range(1, sorted_values.numel() + 1):
50
+ if index == sorted_values.numel() or sorted_values[index] != sorted_values[start]:
51
+ ranks[start:index] = 0.5 * float(start + index - 1) + 1.0
52
+ start = index
53
+ out = torch.empty_like(ranks)
54
+ out[order] = ranks
55
+ return out
56
+
57
+
58
+ def spearman_correlation(left: torch.Tensor, right: torch.Tensor) -> float | None:
59
+ """Rank correlation, or ``None`` when it is undefined for this lead.
60
+
61
+ Undefined means fewer than two plans, or one side constant (every plan
62
+ tied), in which case there is no ordering to agree or disagree with.
63
+ """
64
+ if left.shape != right.shape:
65
+ raise ValueError("spearman inputs must cover the same plans")
66
+ if left.numel() < 2:
67
+ return None
68
+ left_ranks = _average_ranks(left.detach().to(dtype=torch.float64).flatten())
69
+ right_ranks = _average_ranks(right.detach().to(dtype=torch.float64).flatten())
70
+ left_centered = left_ranks - left_ranks.mean()
71
+ right_centered = right_ranks - right_ranks.mean()
72
+ denominator = float(
73
+ torch.sqrt((left_centered**2).sum() * (right_centered**2).sum()).item()
74
+ )
75
+ if denominator <= 0.0:
76
+ return None
77
+ return float((left_centered * right_centered).sum().item() / denominator)
78
+
79
+
80
+ def lead_q_star_diagnostics(
81
+ controlled_log_probabilities: torch.Tensor,
82
+ reference_log_probabilities: torch.Tensor,
83
+ terminal_energies: torch.Tensor,
84
+ beta: float,
85
+ plan_modes: Sequence[str] | None = None,
86
+ target_support_mask: torch.Tensor | None = None,
87
+ ) -> dict[str, Any]:
88
+ """Per-lead ``q*`` diagnostics over the hard-supported legal plans.
89
+
90
+ ``q*`` and the forward objective come from the same
91
+ :func:`configured_plan_level_objective` call the training loop uses, so a
92
+ validation KL can never drift from the trained objective's definition.
93
+ """
94
+ reverse_kl, forward_objective, log_q_star = configured_plan_level_objective(
95
+ controlled_log_probabilities,
96
+ [],
97
+ [],
98
+ # Unused by the exact-SB branch; passed only to satisfy the shared
99
+ # signature. Any positive value leaves the exact-SB result untouched.
100
+ 1.0,
101
+ exact_sb_objective=True,
102
+ reference_log_probabilities=reference_log_probabilities,
103
+ terminal_energies=terminal_energies,
104
+ exact_sb_beta=beta,
105
+ target_support_mask=target_support_mask,
106
+ )
107
+ if forward_objective is None or log_q_star is None:
108
+ raise RuntimeError("exact-SB validation diagnostics require the exact-SB branch")
109
+ q_star = log_q_star.exp()
110
+ q_theta = controlled_log_probabilities.exp()
111
+ star_top = int(torch.argmax(log_q_star).item())
112
+ theta_top = int(torch.argmax(controlled_log_probabilities).item())
113
+ if target_support_mask is None:
114
+ # Historical false/fallback branch is deliberately bit-for-bit.
115
+ q_star_entropy = -(q_star * log_q_star).sum()
116
+ else:
117
+ q_star_entropy = -torch.where(
118
+ q_star > 0.0, q_star * log_q_star, torch.zeros_like(q_star)
119
+ ).sum()
120
+ result: dict[str, Any] = {
121
+ "n_plans": int(controlled_log_probabilities.numel()),
122
+ "q_star_vs_q_theta_kl": float(reverse_kl.detach().cpu().item()),
123
+ "exact_sb_forward_objective": float(forward_objective.detach().cpu().item()),
124
+ "q_star_entropy": float(q_star_entropy.detach().cpu().item()),
125
+ "q_theta_entropy": float(
126
+ (-(q_theta * controlled_log_probabilities).sum()).detach().cpu().item()
127
+ ),
128
+ "q_star_top1_index": star_top,
129
+ "q_theta_top1_index": theta_top,
130
+ "q_star_top1_agreement": bool(star_top == theta_top),
131
+ "q_star_spearman": spearman_correlation(
132
+ log_q_star.detach(), controlled_log_probabilities.detach()
133
+ ),
134
+ "exact_sb_beta": float(beta),
135
+ "q_star_support_size": int(
136
+ target_support_mask.to(dtype=torch.bool).sum().item()
137
+ if target_support_mask is not None
138
+ else controlled_log_probabilities.numel()
139
+ ),
140
+ }
141
+ if plan_modes is not None:
142
+ if len(plan_modes) != controlled_log_probabilities.numel():
143
+ raise ValueError("plan_modes must cover the same plans as q* and q_theta")
144
+ for distribution_name, probabilities in (
145
+ ("q_star", q_star),
146
+ ("q_theta", q_theta),
147
+ ):
148
+ for metric_name, mode in (
149
+ ("s5_s5_i4", S5_MODE),
150
+ ("r8_s5_i7", R8_MODE),
151
+ ):
152
+ indices = [index for index, value in enumerate(plan_modes) if value == mode]
153
+ mass = (
154
+ float(probabilities[indices].sum().detach().cpu().item())
155
+ if indices
156
+ else 0.0
157
+ )
158
+ result[f"{distribution_name}_{metric_name}_probability_mass"] = mass
159
+ result["q_star_top1_mode"] = str(plan_modes[star_top])
160
+ result["q_theta_top1_mode"] = str(plan_modes[theta_top])
161
+ return result
162
+
163
+
164
+ def _mean(values: Sequence[float]) -> float | None:
165
+ return float(sum(values) / len(values)) if values else None
166
+
167
+
168
+ def aggregate_q_star_diagnostics(rows: Sequence[dict[str, Any]]) -> dict[str, Any]:
169
+ """Average per-lead diagnostics into the validation summary metrics."""
170
+ spearmans = [
171
+ float(row["q_star_spearman"])
172
+ for row in rows
173
+ if row.get("q_star_spearman") is not None
174
+ ]
175
+ result: dict[str, Any] = {
176
+ "q_star_vs_q_theta_kl": _mean([float(row["q_star_vs_q_theta_kl"]) for row in rows]),
177
+ "q_star_top1_agreement": _mean(
178
+ [float(bool(row["q_star_top1_agreement"])) for row in rows]
179
+ ),
180
+ "q_star_spearman": _mean(spearmans),
181
+ "q_star_entropy": _mean([float(row["q_star_entropy"]) for row in rows]),
182
+ "q_theta_entropy": _mean(
183
+ [float(row["q_theta_entropy"]) for row in rows if row.get("q_theta_entropy") is not None]
184
+ ),
185
+ "exact_sb_forward_objective": _mean(
186
+ [float(row["exact_sb_forward_objective"]) for row in rows]
187
+ ),
188
+ "q_star_leads_scored": len(rows),
189
+ "q_star_plans_scored": int(sum(int(row["n_plans"]) for row in rows)),
190
+ "q_star_spearman_defined_leads": len(spearmans),
191
+ # Stated explicitly because it is the invariant this module must hold:
192
+ # diagnostics never touch the decoded candidate set or its ranking.
193
+ "q_star_used_for_candidate_reranking": False,
194
+ }
195
+ for distribution_name in ("q_star", "q_theta"):
196
+ for metric_name in ("s5_s5_i4", "r8_s5_i7"):
197
+ key = f"{distribution_name}_{metric_name}_probability_mass"
198
+ values = [float(row[key]) for row in rows if row.get(key) is not None]
199
+ if values:
200
+ result[key] = _mean(values)
201
+ top1_key = f"{distribution_name}_top1_mode"
202
+ modes = Counter(str(row[top1_key]) for row in rows if row.get(top1_key))
203
+ if modes:
204
+ result[f"{distribution_name}_top1_counts"] = dict(modes)
205
+ result[f"{distribution_name}_top1_mix"] = {
206
+ mode: count / len(rows) for mode, count in modes.items()
207
+ }
208
+ result[f"{distribution_name}_s5_s5_i4_top1_rate"] = (
209
+ modes.get(S5_MODE, 0) / len(rows)
210
+ )
211
+ result[f"{distribution_name}_r8_s5_i7_top1_rate"] = (
212
+ modes.get(R8_MODE, 0) / len(rows)
213
+ )
214
+ return result
staplebridge/hydrocarbon/property_energy.py ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strict PeptiVerse SMILES scoring for hydrocarbon terminal states.
2
+
3
+ Hydrocarbon-only: no lactam module imports this file. A legal terminal is
4
+ converted to the neutral canonical linear precursor and both E/Z products.
5
+ Penetrance remains the primary objective. Optional lead-relative preservation
6
+ adds either the legacy Toxicity/Solubility/Half-life hinges or the independent
7
+ Half-life-only hinge without changing the property-free feasible support. A
8
+ third independent switch exposes a permeability/half-life condition used only
9
+ to mask the Exact-SB teacher support; it never changes terminal energy itself.
10
+
11
+ Two scoring depths exist:
12
+
13
+ * :meth:`HydrocarbonPropertyScorer.score` — every property on every SMILES.
14
+ This is what validation, candidate ranking and final evaluation consume, and
15
+ it is unchanged.
16
+ * :meth:`HydrocarbonPropertyScorer.score_energy_only` — only the properties
17
+ :func:`required_energy_properties` says actually reach the terminal energy,
18
+ and only on the E/Z products whose mean the energy reads. Used when the
19
+ caller discards the info dict, as the Exact-SB ``q*`` construction does.
20
+ The resulting energy is identical because the skipped predictions are
21
+ provably not summed into it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ from dataclasses import dataclass
28
+ from typing import Any, Protocol, Sequence
29
+
30
+ from rdkit import Chem
31
+
32
+ from staplebridge.chemistry.state import StapleState
33
+ from staplebridge.hydrocarbon.smiles_builder import (
34
+ OlefinGeometry,
35
+ StapleSmilesPlan,
36
+ _assemble_linear,
37
+ build_stapled_smiles,
38
+ )
39
+ from staplebridge.hydrocarbon.monomers import C_TERM_ACID, N_TERM_FREE
40
+
41
+
42
+ class StrictSmilesPredictor(Protocol):
43
+ """Minimal PeptiVerse wrapper interface used by the scorer."""
44
+
45
+ available: bool
46
+
47
+ def predict_property(
48
+ self,
49
+ property_key: str,
50
+ input_str: str,
51
+ mode: str = "wt",
52
+ uncertainty: bool | None = None,
53
+ ) -> dict[str, Any]:
54
+ ...
55
+
56
+ def predict_property_batch(
57
+ self,
58
+ property_key: str,
59
+ input_strs: Sequence[str],
60
+ mode: str = "wt",
61
+ uncertainty: bool | None = None,
62
+ ) -> list[dict[str, Any]]:
63
+ """Same contract as :meth:`predict_property`, in input order."""
64
+ ...
65
+
66
+
67
+ @dataclass
68
+ class HydrocarbonPropertyEnergyConfig:
69
+ """Property objective and optional lead-relative preservation penalties.
70
+
71
+ Every developability field defaults to an inert value behind
72
+ ``enable_developability_constraints=False``. This preserves the historical
73
+ permeability-only energy and its exact PeptiVerse call set.
74
+ """
75
+
76
+ enabled: bool = False
77
+ penetrance_weight: float = 5.0
78
+ toxicity_guard_enabled: bool = False
79
+ toxicity_threshold: float = 0.49
80
+ toxicity_guard_weight: float = 10.0
81
+ enable_developability_constraints: bool = False
82
+ developability_lambda_toxicity: float = 1.0
83
+ developability_lambda_solubility: float = 1.0
84
+ developability_lambda_halflife: float = 1.0
85
+ developability_normalization_toxicity_scale: float = 1.0
86
+ developability_normalization_solubility_scale: float = 1.0
87
+ developability_normalization_halflife_scale: float = 1.0
88
+ developability_normalization_artifact: str = ""
89
+ enable_halflife_preservation: bool = False
90
+ enable_joint_perm_halflife_support: bool = False
91
+ halflife_preservation_lambda: float = 1.0
92
+ halflife_preservation_scale: float = 1.0
93
+ halflife_preservation_scale_method: str = ""
94
+ halflife_preservation_train_sample_count: float = 0.0
95
+ halflife_preservation_normalization_artifact: str = ""
96
+
97
+ @classmethod
98
+ def from_dict(
99
+ cls, data: dict[str, Any] | None
100
+ ) -> "HydrocarbonPropertyEnergyConfig":
101
+ cfg = cls()
102
+ for key, value in dict(data or {}).items():
103
+ if not hasattr(cfg, key):
104
+ continue
105
+ current = getattr(cfg, key)
106
+ if isinstance(current, bool):
107
+ converted = bool(value)
108
+ elif isinstance(current, str):
109
+ converted = str(value)
110
+ else:
111
+ converted = float(value)
112
+ setattr(cfg, key, converted)
113
+ if cfg.enable_developability_constraints:
114
+ scales = {
115
+ "toxicity": cfg.developability_normalization_toxicity_scale,
116
+ "solubility": cfg.developability_normalization_solubility_scale,
117
+ "halflife": cfg.developability_normalization_halflife_scale,
118
+ }
119
+ invalid = {
120
+ key: value
121
+ for key, value in scales.items()
122
+ if not math.isfinite(float(value)) or float(value) <= 0.0
123
+ }
124
+ if invalid:
125
+ raise ValueError(
126
+ "developability normalization scales must be finite and > 0: "
127
+ f"{invalid}"
128
+ )
129
+ weights = {
130
+ "toxicity": cfg.developability_lambda_toxicity,
131
+ "solubility": cfg.developability_lambda_solubility,
132
+ "halflife": cfg.developability_lambda_halflife,
133
+ }
134
+ invalid_weights = {
135
+ key: value
136
+ for key, value in weights.items()
137
+ if not math.isfinite(float(value)) or float(value) < 0.0
138
+ }
139
+ if invalid_weights:
140
+ raise ValueError(
141
+ "developability lambdas must be finite and >= 0: "
142
+ f"{invalid_weights}"
143
+ )
144
+ if cfg.enable_halflife_preservation:
145
+ if (
146
+ not math.isfinite(float(cfg.halflife_preservation_scale))
147
+ or float(cfg.halflife_preservation_scale) <= 0.0
148
+ ):
149
+ raise ValueError(
150
+ "halflife preservation scale must be finite and > 0: "
151
+ f"{cfg.halflife_preservation_scale}"
152
+ )
153
+ if (
154
+ not math.isfinite(float(cfg.halflife_preservation_lambda))
155
+ or float(cfg.halflife_preservation_lambda) < 0.0
156
+ ):
157
+ raise ValueError(
158
+ "halflife preservation lambda must be finite and >= 0: "
159
+ f"{cfg.halflife_preservation_lambda}"
160
+ )
161
+ if (
162
+ not math.isfinite(
163
+ float(cfg.halflife_preservation_train_sample_count)
164
+ )
165
+ or float(cfg.halflife_preservation_train_sample_count) < 2.0
166
+ ):
167
+ raise ValueError(
168
+ "halflife preservation train sample count must be finite and >= 2"
169
+ )
170
+ if cfg.enable_joint_perm_halflife_support and (
171
+ cfg.enable_developability_constraints or cfg.enable_halflife_preservation
172
+ ):
173
+ raise ValueError(
174
+ "joint permeability/half-life support requires both legacy "
175
+ "developability and half-life soft-preservation flags to be false"
176
+ )
177
+ return cfg
178
+
179
+ def describe(self) -> dict[str, Any]:
180
+ return {
181
+ "enabled": bool(self.enabled),
182
+ "penetrance_weight": float(self.penetrance_weight),
183
+ "toxicity_guard_enabled": bool(self.toxicity_guard_enabled),
184
+ "toxicity_threshold": float(self.toxicity_threshold),
185
+ "toxicity_guard_weight": float(self.toxicity_guard_weight),
186
+ "enable_developability_constraints": bool(
187
+ self.enable_developability_constraints
188
+ ),
189
+ "developability_lambda_toxicity": float(
190
+ self.developability_lambda_toxicity
191
+ ),
192
+ "developability_lambda_solubility": float(
193
+ self.developability_lambda_solubility
194
+ ),
195
+ "developability_lambda_halflife": float(
196
+ self.developability_lambda_halflife
197
+ ),
198
+ "developability_normalization": {
199
+ "toxicity_scale": float(
200
+ self.developability_normalization_toxicity_scale
201
+ ),
202
+ "solubility_scale": float(
203
+ self.developability_normalization_solubility_scale
204
+ ),
205
+ "halflife_scale": float(
206
+ self.developability_normalization_halflife_scale
207
+ ),
208
+ "artifact": self.developability_normalization_artifact,
209
+ },
210
+ "enable_halflife_preservation": bool(
211
+ self.enable_halflife_preservation
212
+ ),
213
+ "enable_joint_perm_halflife_support": bool(
214
+ self.enable_joint_perm_halflife_support
215
+ ),
216
+ "joint_perm_halflife_support": {
217
+ "condition": "delta_permeability > 0 and delta_halflife >= 0",
218
+ "empty_set_fallback": "permeability-only Full q_star",
219
+ "changes_terminal_energy": False,
220
+ },
221
+ "halflife_preservation": {
222
+ "lambda": float(self.halflife_preservation_lambda),
223
+ "scale": float(self.halflife_preservation_scale),
224
+ "scale_method": self.halflife_preservation_scale_method,
225
+ "train_sample_count": int(
226
+ self.halflife_preservation_train_sample_count
227
+ ),
228
+ "normalization_artifact": (
229
+ self.halflife_preservation_normalization_artifact
230
+ ),
231
+ "penalty": "lambda * max(0, -delta_halflife / scale)",
232
+ },
233
+ "input_convention": "neutral_canonical",
234
+ "product_geometries": ["E", "Z"],
235
+ "product_aggregation": "mean",
236
+ "ez_uncertainty": "absolute_difference",
237
+ "energy_properties": list(required_energy_properties(self)),
238
+ "monitor_only": (
239
+ ["hemolysis"]
240
+ if self.enable_developability_constraints
241
+ else (
242
+ ["toxicity", "hemolysis"]
243
+ if (
244
+ self.enable_halflife_preservation
245
+ or self.enable_joint_perm_halflife_support
246
+ )
247
+ else ["toxicity", "hemolysis", "halflife"]
248
+ )
249
+ ),
250
+ "excluded": (
251
+ ["binding_affinity"]
252
+ if self.enable_developability_constraints
253
+ else ["solubility", "binding_affinity"]
254
+ ),
255
+ }
256
+
257
+
258
+ class HydrocarbonPropertyScoringError(RuntimeError):
259
+ """Construction/backend/mode/non-finite failure in strict scoring."""
260
+
261
+
262
+ def required_energy_properties(
263
+ cfg: HydrocarbonPropertyEnergyConfig,
264
+ ) -> tuple[str, ...]:
265
+ """Properties needed by terminal energy or the Exact-SB target support.
266
+
267
+ Mirrors :func:`property_energy_terms` exactly: it reads
268
+ ``permeability_penetrance`` unconditionally and ``toxicity`` only when the
269
+ guard is on. Anything else the scorer reports is a monitor, so omitting it
270
+ cannot move the energy. Keep this in lockstep with
271
+ :func:`property_energy_terms` — the equivalence test asserts they agree.
272
+ """
273
+ if not cfg.enabled:
274
+ return ()
275
+ required = ["permeability_penetrance"]
276
+ if cfg.toxicity_guard_enabled:
277
+ required.append("toxicity")
278
+ if cfg.enable_developability_constraints:
279
+ required.extend(("toxicity", "solubility", "halflife"))
280
+ if cfg.enable_halflife_preservation:
281
+ required.append("halflife")
282
+ if cfg.enable_joint_perm_halflife_support:
283
+ required.append("halflife")
284
+ return tuple(dict.fromkeys(required))
285
+
286
+
287
+ def required_original_lead_properties(
288
+ cfg: HydrocarbonPropertyEnergyConfig,
289
+ ) -> tuple[str, ...]:
290
+ """Original-lead scores needed by the configured terminal objective.
291
+
292
+ The historical path requests penetrance only. The developability path also
293
+ needs all three preservation baselines and retains penetrance for the
294
+ existing delta-PV evaluator.
295
+ """
296
+ required = ["permeability_penetrance"]
297
+ if cfg.enable_developability_constraints:
298
+ required.extend(("toxicity", "solubility", "halflife"))
299
+ if cfg.enable_halflife_preservation:
300
+ required.append("halflife")
301
+ if cfg.enable_joint_perm_halflife_support:
302
+ required.append("halflife")
303
+ return tuple(dict.fromkeys(required))
304
+
305
+
306
+ def full_scoring_properties(
307
+ cfg: HydrocarbonPropertyEnergyConfig,
308
+ ) -> tuple[str, ...]:
309
+ """Properties recorded by full validation/ranking for this arm."""
310
+ if cfg.enable_developability_constraints:
311
+ return HydrocarbonPropertyScorer.ALL_PROPERTIES + ("solubility",)
312
+ return HydrocarbonPropertyScorer.ALL_PROPERTIES
313
+
314
+
315
+ class HydrocarbonPropertyScorer:
316
+ """Score a legal hydrocarbon terminal with strict PeptiVerse."""
317
+
318
+ ENERGY_PROPERTIES = ("permeability_penetrance", "toxicity")
319
+ MONITOR_PROPERTIES = ("hemolysis", "halflife")
320
+ ALL_PROPERTIES = ENERGY_PROPERTIES + MONITOR_PROPERTIES
321
+
322
+ def __init__(
323
+ self,
324
+ predictor: StrictSmilesPredictor,
325
+ original_linear_cache: dict[tuple[Any, ...], dict[str, Any]] | None = None,
326
+ ) -> None:
327
+ if not getattr(predictor, "available", False):
328
+ raise HydrocarbonPropertyScoringError(
329
+ "strict scorer requires available PeptiVerse"
330
+ )
331
+ self.predictor = predictor
332
+ self._cache: dict[tuple[Any, ...], dict[str, Any]] = {}
333
+ self._original_linear_cache = (
334
+ {} if original_linear_cache is None else original_linear_cache
335
+ )
336
+ self.cache_hits = 0
337
+ self.cache_misses = 0
338
+ self.original_linear_cache_hits = 0
339
+ self.original_linear_cache_misses = 0
340
+
341
+ @staticmethod
342
+ def _canonical_neutral(smiles: str) -> str:
343
+ mol = Chem.MolFromSmiles(smiles)
344
+ if mol is None:
345
+ raise HydrocarbonPropertyScoringError("RDKit could not parse built SMILES")
346
+ charge = sum(atom.GetFormalCharge() for atom in mol.GetAtoms())
347
+ if charge != 0:
348
+ raise HydrocarbonPropertyScoringError(
349
+ f"neutral input convention violated: formal charge={charge}"
350
+ )
351
+ return Chem.MolToSmiles(mol, isomericSmiles=True, canonical=True)
352
+
353
+ def _predict(self, property_key: str, smiles: str) -> float:
354
+ result = self.predictor.predict_property(
355
+ property_key, input_str=smiles, mode="smiles", uncertainty=False
356
+ )
357
+ backend = result.get("backend_used")
358
+ mode = result.get("mode")
359
+ try:
360
+ score = float(result["score"])
361
+ except (KeyError, TypeError, ValueError) as exc:
362
+ raise HydrocarbonPropertyScoringError(
363
+ f"{property_key}: missing numeric score in {result!r}"
364
+ ) from exc
365
+ if backend != "peptiverse":
366
+ raise HydrocarbonPropertyScoringError(
367
+ f"{property_key}: forbidden backend/fallback {backend!r}"
368
+ )
369
+ if mode != "smiles":
370
+ raise HydrocarbonPropertyScoringError(
371
+ f"{property_key}: forbidden mode {mode!r}"
372
+ )
373
+ if not math.isfinite(score):
374
+ raise HydrocarbonPropertyScoringError(
375
+ f"{property_key}: non-finite score {score!r}"
376
+ )
377
+ return score
378
+
379
+ def prefetch(
380
+ self, properties: Sequence[str], smiles: Sequence[str]
381
+ ) -> dict[str, int]:
382
+ """Batch-predict ``properties`` over ``smiles`` to warm the cache.
383
+
384
+ Purely a warming step: the results land in the predictor's own
385
+ memo, so the subsequent per-terminal ``_predict`` calls hit it and
386
+ return the same floats they would have computed one at a time. Callers
387
+ that lack a batch-capable predictor are served by the scalar loop, so
388
+ this is always safe to call.
389
+ """
390
+ batch = getattr(self.predictor, "predict_property_batch", None)
391
+ unique = list(dict.fromkeys(s for s in smiles if s))
392
+ if not unique or not callable(batch):
393
+ return {"properties": len(properties), "smiles": len(unique), "batched": 0}
394
+ for prop in properties:
395
+ batch(prop, unique, mode="smiles", uncertainty=False)
396
+ return {
397
+ "properties": len(properties),
398
+ "smiles": len(unique),
399
+ "batched": len(properties) * len(unique),
400
+ }
401
+
402
+ def original_linear_smiles(self, sequence_tokens: Sequence[str]) -> str:
403
+ """Build the same neutral canonical unedited-lead SMILES as production."""
404
+ tokens = tuple(str(token).upper() for token in sequence_tokens)
405
+ linear_rw, _ = _assemble_linear(list(tokens), N_TERM_FREE, C_TERM_ACID)
406
+ linear_mol = linear_rw.GetMol()
407
+ Chem.SanitizeMol(linear_mol)
408
+ return self._canonical_neutral(
409
+ Chem.MolToSmiles(linear_mol, isomericSmiles=True)
410
+ )
411
+
412
+ def score_original_linear(
413
+ self,
414
+ sequence_tokens: list[str],
415
+ *,
416
+ lead_key: str | None = None,
417
+ properties: Sequence[str] | None = None,
418
+ ) -> dict[str, Any]:
419
+ """Score the unedited original lead once, independently of endpoint plan."""
420
+ tokens = tuple(str(token).upper() for token in sequence_tokens)
421
+ key = (lead_key, tokens)
422
+ requested = tuple(properties or ("permeability_penetrance",))
423
+ info = dict(self._original_linear_cache.get(key, {}))
424
+ missing = [
425
+ prop
426
+ for prop in requested
427
+ if f"hydrocarbon_{prop}_original_linear" not in info
428
+ ]
429
+ if not missing:
430
+ self.original_linear_cache_hits += 1
431
+ return info
432
+
433
+ smiles = str(info.get("hydrocarbon_original_linear_smiles") or "")
434
+ if not smiles:
435
+ smiles = self.original_linear_smiles(tokens)
436
+ info["hydrocarbon_original_linear_smiles"] = smiles
437
+ for prop in missing:
438
+ info[f"hydrocarbon_{prop}_original_linear"] = self._predict(prop, smiles)
439
+ self.original_linear_cache_misses += 1
440
+ self._original_linear_cache[key] = dict(info)
441
+ return dict(info)
442
+
443
+ def _build_smiles(self, terminal: StapleState) -> tuple[str, str, str]:
444
+ """Return ``(linear, product_E, product_Z)`` neutral canonical SMILES."""
445
+ pair = None if terminal.anchor_pair is None else tuple(terminal.anchor_pair)
446
+ if pair is None:
447
+ raise HydrocarbonPropertyScoringError("terminal has no anchor pair")
448
+ plan = StapleSmilesPlan.from_tokens(terminal.sequence_tokens, pair)
449
+ built_e = build_stapled_smiles(
450
+ terminal.sequence_tokens, plan, olefin_geometry=OlefinGeometry.E, strict=True
451
+ )
452
+ built_z = build_stapled_smiles(
453
+ terminal.sequence_tokens, plan, olefin_geometry=OlefinGeometry.Z, strict=True
454
+ )
455
+ return (
456
+ self._canonical_neutral(built_e.linear_smiles),
457
+ self._canonical_neutral(built_e.stapled_smiles),
458
+ self._canonical_neutral(built_z.stapled_smiles),
459
+ )
460
+
461
+ @staticmethod
462
+ def _state_key(terminal: StapleState) -> tuple[Any, ...]:
463
+ pair = None if terminal.anchor_pair is None else tuple(terminal.anchor_pair)
464
+ return (tuple(terminal.sequence_tokens), pair, terminal.block_id, terminal.topology)
465
+
466
+ def energy_only_smiles(self, terminal: StapleState) -> tuple[str, str]:
467
+ """The E/Z product SMILES the terminal energy needs, nothing else.
468
+
469
+ Exposed so a caller can collect SMILES across many plans and prefetch
470
+ them through :meth:`StrictSmilesPredictor.predict_property_batch`
471
+ before scoring.
472
+ """
473
+ _, product_e, product_z = self._build_smiles(terminal)
474
+ return product_e, product_z
475
+
476
+ def score_energy_only(
477
+ self, terminal: StapleState, properties: Sequence[str]
478
+ ) -> dict[str, Any]:
479
+ """Score only ``properties``, and only on the E/Z products.
480
+
481
+ Returns just the ``*_product_mean`` keys that
482
+ :func:`property_energy_terms` reads. The linear precursor and the
483
+ monitor properties are deliberately not predicted: neither is summed
484
+ into the energy, so skipping them leaves it bit-for-bit identical while
485
+ removing most of the PeptiVerse work.
486
+ """
487
+ key = ("energy_only", tuple(properties), self._state_key(terminal))
488
+ if key in self._cache:
489
+ self.cache_hits += 1
490
+ return dict(self._cache[key])
491
+
492
+ product_e, product_z = self.energy_only_smiles(terminal)
493
+ info: dict[str, Any] = {
494
+ "hydrocarbon_property_status": "scored_energy_only",
495
+ "hydrocarbon_property_backend": "peptiverse",
496
+ "hydrocarbon_property_mode": "smiles",
497
+ "hydrocarbon_property_input_convention": "neutral_canonical",
498
+ }
499
+ for prop in properties:
500
+ e_score = self._predict(prop, product_e)
501
+ z_score = self._predict(prop, product_z)
502
+ info[f"hydrocarbon_{prop}_product_mean"] = 0.5 * (e_score + z_score)
503
+
504
+ self.cache_misses += 1
505
+ self._cache[key] = dict(info)
506
+ return info
507
+
508
+ def score(
509
+ self, terminal: StapleState, properties: Sequence[str] | None = None
510
+ ) -> dict[str, Any]:
511
+ """Return namespaced linear/E/Z metrics for a terminal state."""
512
+ selected_properties = tuple(properties or self.ALL_PROPERTIES)
513
+ key = (
514
+ self._state_key(terminal)
515
+ if properties is None
516
+ else ("full", selected_properties, self._state_key(terminal))
517
+ )
518
+ if key in self._cache:
519
+ self.cache_hits += 1
520
+ return dict(self._cache[key])
521
+
522
+ linear, product_e, product_z = self._build_smiles(terminal)
523
+
524
+ info: dict[str, Any] = {
525
+ "hydrocarbon_property_status": "scored",
526
+ "hydrocarbon_property_backend": "peptiverse",
527
+ "hydrocarbon_property_mode": "smiles",
528
+ "hydrocarbon_property_input_convention": "neutral_canonical",
529
+ "hydrocarbon_linear_smiles": linear,
530
+ "hydrocarbon_plan_precursor_smiles": linear,
531
+ "hydrocarbon_product_E_smiles": product_e,
532
+ "hydrocarbon_product_Z_smiles": product_z,
533
+ }
534
+ for prop in selected_properties:
535
+ linear_score = self._predict(prop, linear)
536
+ e_score = self._predict(prop, product_e)
537
+ z_score = self._predict(prop, product_z)
538
+ mean_score = 0.5 * (e_score + z_score)
539
+ prefix = f"hydrocarbon_{prop}"
540
+ info.update(
541
+ {
542
+ f"{prefix}_linear": linear_score,
543
+ f"{prefix}_plan_precursor": linear_score,
544
+ f"{prefix}_E": e_score,
545
+ f"{prefix}_Z": z_score,
546
+ f"{prefix}_product_mean": mean_score,
547
+ f"{prefix}_E_minus_Z": e_score - z_score,
548
+ f"{prefix}_EZ_abs_diff": abs(e_score - z_score),
549
+ f"{prefix}_delta_product_linear": mean_score - linear_score,
550
+ f"{prefix}_delta_vs_plan_precursor": mean_score - linear_score,
551
+ }
552
+ )
553
+
554
+ self.cache_misses += 1
555
+ self._cache[key] = dict(info)
556
+ return info
557
+
558
+
559
+ def property_energy_terms(
560
+ scores: dict[str, Any], cfg: HydrocarbonPropertyEnergyConfig
561
+ ) -> tuple[float, dict[str, Any]]:
562
+ """Convert scores to Penetrance objective plus optional toxicity guard."""
563
+ if not cfg.enabled:
564
+ return 0.0, {
565
+ "hydrocarbon_property_energy": 0.0,
566
+ "hydrocarbon_penetrance_energy": 0.0,
567
+ "hydrocarbon_toxicity_guard_energy": 0.0,
568
+ "hydrocarbon_toxicity_violation": False,
569
+ }
570
+
571
+ def score(key: str) -> float:
572
+ value = float(scores[key])
573
+ if not math.isfinite(value):
574
+ raise HydrocarbonPropertyScoringError(f"non-finite property energy input {key}={value}")
575
+ return value
576
+
577
+ penetrance = score("hydrocarbon_permeability_penetrance_product_mean")
578
+ penetrance_energy = float(cfg.penetrance_weight) * (1.0 - penetrance)
579
+ if cfg.toxicity_guard_enabled:
580
+ # Energy-bearing, so the score must be present regardless of caller.
581
+ toxicity = score("hydrocarbon_toxicity_product_mean")
582
+ over_threshold = toxicity > float(cfg.toxicity_threshold)
583
+ toxicity_energy = float(cfg.toxicity_guard_weight) * max(
584
+ 0.0, toxicity - float(cfg.toxicity_threshold)
585
+ )
586
+ else:
587
+ # Guard off: toxicity contributes 0.0 to the energy, so an energy-only
588
+ # caller may legitimately not have predicted it. Report it when it is
589
+ # there (unchanged for every full-scoring caller) and stay silent when
590
+ # it is not, rather than forcing a prediction the energy never reads.
591
+ raw_toxicity = scores.get("hydrocarbon_toxicity_product_mean")
592
+ over_threshold = (
593
+ float(raw_toxicity) > float(cfg.toxicity_threshold)
594
+ if raw_toxicity is not None
595
+ else False
596
+ )
597
+ toxicity_energy = 0.0
598
+ violation = bool(cfg.toxicity_guard_enabled and over_threshold)
599
+
600
+ developability_terms: dict[str, Any] = {}
601
+ developability_energy = 0.0
602
+ if cfg.enable_developability_constraints:
603
+ developability_terms = {
604
+ "hydrocarbon_developability_constraints_enabled": True,
605
+ "hydrocarbon_developability_toxicity_energy": 0.0,
606
+ "hydrocarbon_developability_solubility_energy": 0.0,
607
+ "hydrocarbon_developability_halflife_energy": 0.0,
608
+ "hydrocarbon_developability_energy": 0.0,
609
+ }
610
+ deltas: dict[str, float] = {}
611
+ penalties: dict[str, float] = {}
612
+ definitions = {
613
+ "toxicity": (
614
+ cfg.developability_lambda_toxicity,
615
+ cfg.developability_normalization_toxicity_scale,
616
+ 1.0,
617
+ ),
618
+ "solubility": (
619
+ cfg.developability_lambda_solubility,
620
+ cfg.developability_normalization_solubility_scale,
621
+ -1.0,
622
+ ),
623
+ "halflife": (
624
+ cfg.developability_lambda_halflife,
625
+ cfg.developability_normalization_halflife_scale,
626
+ -1.0,
627
+ ),
628
+ }
629
+ for prop, (weight, scale, adverse_sign) in definitions.items():
630
+ product = score(f"hydrocarbon_{prop}_product_mean")
631
+ original = score(f"hydrocarbon_{prop}_original_linear")
632
+ delta = product - original
633
+ normalized_hinge = max(0.0, adverse_sign * delta) / float(scale)
634
+ penalty = float(weight) * normalized_hinge
635
+ deltas[prop] = delta
636
+ penalties[prop] = penalty
637
+ developability_terms[f"hydrocarbon_{prop}_delta_vs_original_lead"] = delta
638
+ developability_terms[
639
+ f"hydrocarbon_developability_{prop}_normalized_hinge"
640
+ ] = normalized_hinge
641
+ developability_terms[
642
+ f"hydrocarbon_developability_{prop}_energy"
643
+ ] = penalty
644
+ developability_energy = float(sum(penalties.values()))
645
+ developability_terms["hydrocarbon_developability_energy"] = (
646
+ developability_energy
647
+ )
648
+
649
+ halflife_preservation_terms: dict[str, Any] = {}
650
+ halflife_preservation_energy = 0.0
651
+ if cfg.enable_halflife_preservation:
652
+ half_product = score("hydrocarbon_halflife_product_mean")
653
+ half_original = score("hydrocarbon_halflife_original_linear")
654
+ delta_half = half_product - half_original
655
+ normalized_hinge = max(0.0, -delta_half) / float(
656
+ cfg.halflife_preservation_scale
657
+ )
658
+ halflife_preservation_energy = (
659
+ float(cfg.halflife_preservation_lambda) * normalized_hinge
660
+ )
661
+ halflife_preservation_terms = {
662
+ "hydrocarbon_halflife_preservation_enabled": True,
663
+ "hydrocarbon_halflife_delta_vs_original_lead": delta_half,
664
+ "hydrocarbon_halflife_preservation_normalized_hinge": normalized_hinge,
665
+ "hydrocarbon_halflife_preservation_energy": halflife_preservation_energy,
666
+ }
667
+
668
+ # This condition is consumed by the plan-level Exact-SB target builder.
669
+ # It is deliberately absent from ``total``: inside the allowed set q*
670
+ # remains permeability-only, and an empty set falls back exactly.
671
+ joint_support_terms: dict[str, Any] = {}
672
+ if cfg.enable_joint_perm_halflife_support:
673
+ half_product = score("hydrocarbon_halflife_product_mean")
674
+ half_original = score("hydrocarbon_halflife_original_linear")
675
+ perm_original = score("hydrocarbon_permeability_penetrance_original_linear")
676
+ delta_half = half_product - half_original
677
+ delta_perm = penetrance - perm_original
678
+ joint_support_terms = {
679
+ "hydrocarbon_joint_perm_halflife_support_enabled": True,
680
+ "hydrocarbon_joint_delta_permeability_vs_original_lead": delta_perm,
681
+ "hydrocarbon_joint_delta_halflife_vs_original_lead": delta_half,
682
+ "hydrocarbon_joint_perm_halflife_condition": bool(
683
+ delta_perm > 0.0 and delta_half >= 0.0
684
+ ),
685
+ }
686
+
687
+ total = (
688
+ penetrance_energy
689
+ + toxicity_energy
690
+ + developability_energy
691
+ + halflife_preservation_energy
692
+ )
693
+ return total, {
694
+ "hydrocarbon_property_energy": float(total),
695
+ "hydrocarbon_penetrance_energy": float(penetrance_energy),
696
+ "hydrocarbon_toxicity_guard_energy": float(toxicity_energy),
697
+ "hydrocarbon_toxicity_threshold": float(cfg.toxicity_threshold),
698
+ "hydrocarbon_toxicity_over_threshold": bool(over_threshold),
699
+ "hydrocarbon_toxicity_violation": violation,
700
+ **developability_terms,
701
+ **halflife_preservation_terms,
702
+ **joint_support_terms,
703
+ }
staplebridge/hydrocarbon/smiles_builder.py ADDED
@@ -0,0 +1,1071 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon-stapled product SMILES builder.
2
+
3
+ Builds, from a linear sequence plus a staple plan:
4
+
5
+ linear precursor the uncyclised peptide with both olefin tethers intact
6
+ stapled product the RCM macrocycle, ethene expelled, C=C bond formed
7
+
8
+ Both are produced as real molecular graphs. The stapled product is **not** the
9
+ linear precursor with a label attached: a carbon-carbon bond is created between
10
+ the two tether alkene carbons, the two terminal ``=CH2`` groups are deleted, and
11
+ the remaining bond is set to double. :func:`validate_stapled_product` then
12
+ re-derives the crosslink from the graph and refuses to certify a molecule whose
13
+ macrocycle is not actually closed, so a topology flag alone cannot pass.
14
+
15
+ Chemistry
16
+ ---------
17
+ Ring-closing metathesis joins two terminal alkenes and releases ethene:
18
+
19
+ R-CH=CH2 + R'-CH=CH2 -> R-CH=CH-R' + CH2=CH2
20
+
21
+ So each partner contributes its tether minus one carbon, and the resulting
22
+ bridge holds ``n_i + n_j - 2`` carbons. The default product is the *E* (trans)
23
+ alkene, which is the major RCM product for these staples; ``olefin_geometry``
24
+ can request *Z* or an unspecified double bond instead.
25
+
26
+ Isolation
27
+ ---------
28
+ Additive and hydrocarbon-only. The lactam path has no SMILES builder, so nothing
29
+ here overrides existing behaviour, and no lactam module is imported.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field
35
+ from enum import Enum
36
+ from typing import Any, Final
37
+
38
+ from rdkit import Chem, RDLogger
39
+ from rdkit.Chem import Descriptors, rdCIPLabeler, rdMolDescriptors
40
+
41
+ from staplebridge.hydrocarbon.catalog import block_topology, is_hydrocarbon_block
42
+ from staplebridge.hydrocarbon.monomers import (
43
+ ATTACH_C,
44
+ ATTACH_N,
45
+ C_TERM_ACID,
46
+ C_TERM_AMIDE,
47
+ C_TERMINAL_CAPS,
48
+ N_TERM_ACETYL,
49
+ N_TERM_FREE,
50
+ N_TERMINAL_CAPS,
51
+ UnknownMonomerError,
52
+ expected_macrocycle_size,
53
+ get_monomer,
54
+ is_anchor_monomer,
55
+ staple_carbon_count,
56
+ )
57
+
58
+ RDLogger.DisableLog("rdApp.*")
59
+
60
+ #: Exactly two anchors define a single staple; see
61
+ #: :data:`staplebridge.hydrocarbon.actions.REQUIRED_ANCHOR_COUNT`.
62
+ REQUIRED_ANCHOR_COUNT: Final[int] = 2
63
+
64
+ #: (pair, spacing) topologies this builder will close, mirroring the enabled
65
+ #: catalog. ``R5-S8/i,i+7`` is present but must be opted into explicitly.
66
+ SUPPORTED_TOPOLOGIES: Final[frozenset[tuple[str, int]]] = frozenset(
67
+ {("S5-S5", 4), ("R8-S5", 7)}
68
+ )
69
+ OPTIONAL_TOPOLOGIES: Final[frozenset[tuple[str, int]]] = frozenset({("R5-S8", 7)})
70
+
71
+
72
+ class OlefinGeometry(str, Enum):
73
+ """Requested geometry of the product double bond."""
74
+
75
+ E = "E"
76
+ Z = "Z"
77
+ UNSPECIFIED = "unspecified"
78
+
79
+
80
+ class BuildFailure(str, Enum):
81
+ """Explicit reasons a build or validation was rejected."""
82
+
83
+ OK = "ok"
84
+ UNKNOWN_MONOMER = "unknown_monomer"
85
+ SEQUENCE_TOO_SHORT = "sequence_too_short"
86
+ ANCHOR_OUT_OF_RANGE = "anchor_out_of_range"
87
+ ANCHOR_NOT_OLEFINIC = "anchor_not_olefinic"
88
+ WRONG_ANCHOR_COUNT = "wrong_anchor_count"
89
+ THIRD_ANCHOR_PRESENT = "third_anchor_present"
90
+ DOUBLE_STAPLE_UNSUPPORTED = "double_staple_unsupported"
91
+ UNSUPPORTED_PAIR_SPACING = "unsupported_pair_spacing_combination"
92
+ ANCHOR_TOKEN_MISMATCH = "anchor_token_mismatch"
93
+ SANITIZATION_FAILED = "sanitization_failed"
94
+ VALENCE_ERROR = "valence_error"
95
+ MULTIPLE_FRAGMENTS = "multiple_fragments"
96
+ NO_CROSSLINK_BOND = "no_crosslink_bond"
97
+ MACROCYCLE_NOT_CLOSED = "macrocycle_not_closed"
98
+ UNEXPECTED_RING_SIZE = "unexpected_ring_size"
99
+ LINEAR_EQUALS_PRODUCT = "linear_equals_product"
100
+ RESIDUAL_TERMINAL_ALKENE = "residual_terminal_alkene"
101
+ UNKNOWN_CAP = "unknown_terminal_cap"
102
+
103
+
104
+ class SmilesBuildError(ValueError):
105
+ """Raised when a hydrocarbon SMILES build cannot be completed.
106
+
107
+ Carries the specific :class:`BuildFailure` so callers can distinguish an
108
+ illegal request (third anchor, unsupported spacing) from a chemistry bug.
109
+ """
110
+
111
+ def __init__(self, reason: BuildFailure, message: str) -> None:
112
+ super().__init__(f"{reason.value}: {message}")
113
+ self.reason = reason
114
+ self.message = message
115
+
116
+
117
+ @dataclass
118
+ class StapleSmilesPlan:
119
+ """The minimum a builder needs: which two positions to join, and with what.
120
+
121
+ Deliberately decoupled from
122
+ :class:`staplebridge.hydrocarbon.curriculum.HydrocarbonStaplePlan` so the
123
+ builder can be driven directly from a sequence in tests and audits, without
124
+ constructing a reference-process plan. :meth:`from_hydrocarbon_plan` converts.
125
+ """
126
+
127
+ anchor_pair: tuple[int, int]
128
+ ordered_pair: str
129
+ spacing: int
130
+ block_id: str | None = None
131
+
132
+ @classmethod
133
+ def from_hydrocarbon_plan(cls, plan: Any) -> "StapleSmilesPlan":
134
+ """Convert a curriculum/reference ``HydrocarbonStaplePlan``."""
135
+ return cls(
136
+ anchor_pair=tuple(plan.anchor_pair),
137
+ ordered_pair=plan.ordered_pair,
138
+ spacing=int(plan.spacing),
139
+ block_id=getattr(plan, "block_id", None),
140
+ )
141
+
142
+ @classmethod
143
+ def from_block(cls, block: Any, anchor_pair: tuple[int, int]) -> "StapleSmilesPlan":
144
+ """Derive the plan from a hydrocarbon catalog block."""
145
+ if not is_hydrocarbon_block(block):
146
+ raise SmilesBuildError(
147
+ BuildFailure.UNSUPPORTED_PAIR_SPACING,
148
+ f"block {getattr(block, 'block_id', block)!r} is not a hydrocarbon block",
149
+ )
150
+ pair, spacing = block_topology(block)
151
+ return cls(
152
+ anchor_pair=tuple(anchor_pair),
153
+ ordered_pair=pair,
154
+ spacing=spacing,
155
+ block_id=block.block_id,
156
+ )
157
+
158
+ @classmethod
159
+ def from_tokens(cls, tokens: list[str], anchor_pair: tuple[int, int]) -> "StapleSmilesPlan":
160
+ """Read the plan off the sequence itself."""
161
+ i, j = int(anchor_pair[0]), int(anchor_pair[1])
162
+ if not (0 <= i < len(tokens)) or not (0 <= j < len(tokens)):
163
+ raise SmilesBuildError(
164
+ BuildFailure.ANCHOR_OUT_OF_RANGE,
165
+ f"anchor pair {(i, j)} outside sequence of length {len(tokens)}",
166
+ )
167
+ return cls(
168
+ anchor_pair=(i, j),
169
+ ordered_pair=f"{tokens[i].upper()}-{tokens[j].upper()}",
170
+ spacing=j - i,
171
+ )
172
+
173
+ @property
174
+ def mode(self) -> str:
175
+ """Display label, e.g. ``"S5-S5/i,i+4"``."""
176
+ return f"{self.ordered_pair}/i,i+{self.spacing}"
177
+
178
+
179
+ @dataclass
180
+ class AtomMapping:
181
+ """Where each residue's atoms ended up in the built molecule.
182
+
183
+ Indices refer to the *stapled product* unless stated otherwise. This is what
184
+ makes the crosslink auditable: the validator re-finds the C=C bond from
185
+ :attr:`crosslink_carbons` rather than trusting that the build succeeded.
186
+ """
187
+
188
+ #: residue index -> its atom indices in the product
189
+ residue_atoms: dict[int, list[int]] = field(default_factory=dict)
190
+ #: residue index -> its Cα atom index
191
+ alpha_carbons: dict[int, int] = field(default_factory=dict)
192
+ #: residue index -> backbone amide N atom index
193
+ backbone_nitrogens: dict[int, int] = field(default_factory=dict)
194
+ #: residue index -> backbone carbonyl C atom index
195
+ carbonyl_carbons: dict[int, int] = field(default_factory=dict)
196
+ #: the two carbons joined by the new C-C bond, (i-side, j-side)
197
+ crosslink_carbons: tuple[int, int] | None = None
198
+ #: every atom of the staple bridge, N-to-C order
199
+ staple_atoms: list[int] = field(default_factory=list)
200
+ #: atom indices deleted as expelled ethene, in the *linear* numbering
201
+ expelled_atoms: list[int] = field(default_factory=list)
202
+
203
+ def as_dict(self) -> dict[str, Any]:
204
+ """JSON-serialisable view."""
205
+ return {
206
+ "residue_atoms": {str(k): v for k, v in sorted(self.residue_atoms.items())},
207
+ "alpha_carbons": {str(k): v for k, v in sorted(self.alpha_carbons.items())},
208
+ "backbone_nitrogens": {
209
+ str(k): v for k, v in sorted(self.backbone_nitrogens.items())
210
+ },
211
+ "carbonyl_carbons": {
212
+ str(k): v for k, v in sorted(self.carbonyl_carbons.items())
213
+ },
214
+ "crosslink_carbons": (
215
+ list(self.crosslink_carbons) if self.crosslink_carbons else None
216
+ ),
217
+ "staple_atoms": list(self.staple_atoms),
218
+ "expelled_atoms_linear_numbering": list(self.expelled_atoms),
219
+ }
220
+
221
+
222
+ @dataclass
223
+ class StapledSmilesResult:
224
+ """Everything one build produced."""
225
+
226
+ sequence_tokens: list[str]
227
+ plan: StapleSmilesPlan
228
+ linear_smiles: str
229
+ stapled_smiles: str
230
+ linear_canonical: str
231
+ stapled_canonical: str
232
+ atom_mapping: AtomMapping
233
+ n_terminus: str
234
+ c_terminus: str
235
+ olefin_geometry: str
236
+ #: Per-anchor CIP labels observed in the product.
237
+ anchor_cip: dict[int, str | None] = field(default_factory=dict)
238
+ linear_formula: str = ""
239
+ stapled_formula: str = ""
240
+ linear_mw: float = 0.0
241
+ stapled_mw: float = 0.0
242
+ macrocycle_size: int | None = None
243
+ expected_macrocycle_size: int | None = None
244
+ staple_bridge_carbons: int = 0
245
+ validation: dict[str, Any] = field(default_factory=dict)
246
+
247
+ @property
248
+ def is_valid(self) -> bool:
249
+ """True when every strict validation check passed."""
250
+ return bool(self.validation.get("valid"))
251
+
252
+ def as_dict(self) -> dict[str, Any]:
253
+ """JSON-serialisable view."""
254
+ return {
255
+ "sequence": "-".join(self.sequence_tokens),
256
+ "n_residues": len(self.sequence_tokens),
257
+ "mode": self.plan.mode,
258
+ "anchor_pair": list(self.plan.anchor_pair),
259
+ "spacing": self.plan.spacing,
260
+ "block_id": self.plan.block_id,
261
+ "n_terminus": self.n_terminus,
262
+ "c_terminus": self.c_terminus,
263
+ "olefin_geometry": self.olefin_geometry,
264
+ "linear_smiles": self.linear_smiles,
265
+ "stapled_smiles": self.stapled_smiles,
266
+ "linear_canonical": self.linear_canonical,
267
+ "stapled_canonical": self.stapled_canonical,
268
+ "linear_formula": self.linear_formula,
269
+ "stapled_formula": self.stapled_formula,
270
+ "linear_mw": round(float(self.linear_mw), 4),
271
+ "stapled_mw": round(float(self.stapled_mw), 4),
272
+ "mw_loss_vs_linear": round(float(self.linear_mw - self.stapled_mw), 4),
273
+ "macrocycle_size": self.macrocycle_size,
274
+ "expected_macrocycle_size": self.expected_macrocycle_size,
275
+ "staple_bridge_carbons": self.staple_bridge_carbons,
276
+ "anchor_cip": {str(k): v for k, v in sorted(self.anchor_cip.items())},
277
+ "atom_mapping": self.atom_mapping.as_dict(),
278
+ "validation": self.validation,
279
+ "linear_differs_from_product": self.linear_canonical != self.stapled_canonical,
280
+ }
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Assembly
285
+ # ---------------------------------------------------------------------------
286
+
287
+
288
+ def _fragment_mol(smiles: str) -> Chem.Mol:
289
+ """Parse one fragment without sanitising (dummy atoms defeat valence checks)."""
290
+ mol = Chem.MolFromSmiles(smiles, sanitize=False)
291
+ if mol is None:
292
+ raise SmilesBuildError(
293
+ BuildFailure.UNKNOWN_MONOMER, f"fragment SMILES did not parse: {smiles!r}"
294
+ )
295
+ return mol
296
+
297
+
298
+ def _find_dummy(mol: Chem.Mol, map_number: int) -> int:
299
+ """Index of the dummy atom carrying ``map_number``."""
300
+ for atom in mol.GetAtoms():
301
+ if atom.GetAtomicNum() == 0 and atom.GetAtomMapNum() == map_number:
302
+ return atom.GetIdx()
303
+ raise SmilesBuildError(
304
+ BuildFailure.UNKNOWN_MONOMER,
305
+ f"fragment has no [*:{map_number}] attachment point",
306
+ )
307
+
308
+
309
+ def _validate_request(
310
+ tokens: list[str], plan: StapleSmilesPlan, include_optional: bool
311
+ ) -> None:
312
+ """Reject an illegal build request before any atom is placed.
313
+
314
+ Every check here is a *hard* failure with a named reason. In particular a
315
+ third anchor monomer anywhere in the chain is rejected as a double staple,
316
+ which is what stops a silently-wrong molecule from being emitted for a
317
+ sequence the catalog cannot express.
318
+ """
319
+ if len(tokens) < 2:
320
+ raise SmilesBuildError(
321
+ BuildFailure.SEQUENCE_TOO_SHORT,
322
+ f"need at least 2 residues, got {len(tokens)}",
323
+ )
324
+
325
+ for token in tokens:
326
+ try:
327
+ get_monomer(token)
328
+ except UnknownMonomerError as exc:
329
+ raise SmilesBuildError(BuildFailure.UNKNOWN_MONOMER, str(exc)) from exc
330
+
331
+ i, j = plan.anchor_pair
332
+ if not (0 <= i < len(tokens)) or not (0 <= j < len(tokens)):
333
+ raise SmilesBuildError(
334
+ BuildFailure.ANCHOR_OUT_OF_RANGE,
335
+ f"anchor pair {(i, j)} outside sequence of length {len(tokens)}",
336
+ )
337
+ if i == j:
338
+ raise SmilesBuildError(
339
+ BuildFailure.WRONG_ANCHOR_COUNT, f"anchor pair must be distinct, got {(i, j)}"
340
+ )
341
+
342
+ # Both named positions must actually carry olefinic anchors.
343
+ for position in (i, j):
344
+ if not is_anchor_monomer(tokens[position]):
345
+ raise SmilesBuildError(
346
+ BuildFailure.ANCHOR_NOT_OLEFINIC,
347
+ f"position {position} carries {tokens[position]!r}, which has no "
348
+ "olefin tether to metathesise",
349
+ )
350
+
351
+ # A third anchor would give a double staple, which is out of scope.
352
+ anchor_positions = [k for k, t in enumerate(tokens) if is_anchor_monomer(t)]
353
+ if len(anchor_positions) != REQUIRED_ANCHOR_COUNT:
354
+ reason = (
355
+ BuildFailure.THIRD_ANCHOR_PRESENT
356
+ if len(anchor_positions) > REQUIRED_ANCHOR_COUNT
357
+ else BuildFailure.WRONG_ANCHOR_COUNT
358
+ )
359
+ raise SmilesBuildError(
360
+ reason,
361
+ f"a single staple needs exactly {REQUIRED_ANCHOR_COUNT} anchor "
362
+ f"monomers, found {len(anchor_positions)} at {anchor_positions}",
363
+ )
364
+ if set(anchor_positions) != {i, j}:
365
+ raise SmilesBuildError(
366
+ BuildFailure.DOUBLE_STAPLE_UNSUPPORTED,
367
+ f"anchors at {anchor_positions} do not match the requested pair {(i, j)}",
368
+ )
369
+
370
+ # The declared pair must match what the sequence says, so a mislabelled plan
371
+ # cannot be built as though it were its intended topology.
372
+ observed_pair = f"{tokens[i].upper()}-{tokens[j].upper()}"
373
+ if observed_pair != plan.ordered_pair:
374
+ raise SmilesBuildError(
375
+ BuildFailure.ANCHOR_TOKEN_MISMATCH,
376
+ f"plan declares {plan.ordered_pair!r} but sequence carries "
377
+ f"{observed_pair!r} at {(i, j)}",
378
+ )
379
+ if plan.spacing != j - i:
380
+ raise SmilesBuildError(
381
+ BuildFailure.ANCHOR_TOKEN_MISMATCH,
382
+ f"plan declares spacing {plan.spacing} but anchors are {j - i} apart",
383
+ )
384
+
385
+ allowed = set(SUPPORTED_TOPOLOGIES)
386
+ if include_optional:
387
+ allowed |= set(OPTIONAL_TOPOLOGIES)
388
+ if (plan.ordered_pair, plan.spacing) not in allowed:
389
+ raise SmilesBuildError(
390
+ BuildFailure.UNSUPPORTED_PAIR_SPACING,
391
+ f"topology {plan.mode} is not supported; enabled: "
392
+ f"{sorted(f'{p}/i,i+{s}' for p, s in allowed)}",
393
+ )
394
+
395
+
396
+ def _assemble_linear(
397
+ tokens: list[str], n_terminus: str, c_terminus: str
398
+ ) -> tuple[Chem.RWMol, AtomMapping]:
399
+ """Build the linear peptide graph, recording per-residue atom indices."""
400
+ if n_terminus not in N_TERMINAL_CAPS:
401
+ raise SmilesBuildError(
402
+ BuildFailure.UNKNOWN_CAP,
403
+ f"unknown N-terminal cap {n_terminus!r}; known: {sorted(N_TERMINAL_CAPS)}",
404
+ )
405
+ if c_terminus not in C_TERMINAL_CAPS:
406
+ raise SmilesBuildError(
407
+ BuildFailure.UNKNOWN_CAP,
408
+ f"unknown C-terminal cap {c_terminus!r}; known: {sorted(C_TERMINAL_CAPS)}",
409
+ )
410
+
411
+ combined = Chem.RWMol()
412
+ mapping = AtomMapping()
413
+ # residue index -> (its N-side dummy idx, its C-side dummy idx) in `combined`
414
+ dummies: dict[int, tuple[int, int]] = {}
415
+
416
+ for residue_index, token in enumerate(tokens):
417
+ monomer = get_monomer(token)
418
+ fragment = _fragment_mol(monomer.smiles)
419
+ offset = combined.GetNumAtoms()
420
+ combined.InsertMol(fragment)
421
+
422
+ indices = [offset + a for a in range(fragment.GetNumAtoms())]
423
+ mapping.residue_atoms[residue_index] = indices
424
+ dummies[residue_index] = (
425
+ offset + _find_dummy(fragment, ATTACH_N),
426
+ offset + _find_dummy(fragment, ATTACH_C),
427
+ )
428
+
429
+ # The backbone is N -> Cα -> C'(=O), so read them off the dummies'
430
+ # neighbours rather than by position, which side chains would shift.
431
+ n_dummy, c_dummy = dummies[residue_index]
432
+ nitrogen = next(
433
+ nbr.GetIdx()
434
+ for nbr in combined.GetAtomWithIdx(n_dummy).GetNeighbors()
435
+ if nbr.GetAtomicNum() == 7
436
+ )
437
+ carbonyl = next(
438
+ nbr.GetIdx()
439
+ for nbr in combined.GetAtomWithIdx(c_dummy).GetNeighbors()
440
+ if nbr.GetAtomicNum() == 6
441
+ )
442
+ alpha = next(
443
+ nbr.GetIdx()
444
+ for nbr in combined.GetAtomWithIdx(nitrogen).GetNeighbors()
445
+ if nbr.GetAtomicNum() == 6 and nbr.GetIdx() != n_dummy
446
+ and combined.GetBondBetweenAtoms(nbr.GetIdx(), carbonyl) is not None
447
+ )
448
+ mapping.backbone_nitrogens[residue_index] = nitrogen
449
+ mapping.carbonyl_carbons[residue_index] = carbonyl
450
+ mapping.alpha_carbons[residue_index] = alpha
451
+
452
+ # -- peptide bonds ---------------------------------------------------
453
+ for residue_index in range(len(tokens) - 1):
454
+ upstream_c = mapping.carbonyl_carbons[residue_index]
455
+ downstream_n = mapping.backbone_nitrogens[residue_index + 1]
456
+ combined.AddBond(upstream_c, downstream_n, Chem.BondType.SINGLE)
457
+
458
+ # -- terminal caps ---------------------------------------------------
459
+ # Cap atoms belong to no residue of their own, so they are attributed to the
460
+ # terminal residue they modify. Leaving them untagged would make
461
+ # ``residue_atoms`` an incomplete cover of the molecule, which is exactly the
462
+ # kind of silent gap that lets an index-based check drift.
463
+ n_cap = N_TERMINAL_CAPS[n_terminus]
464
+ if n_cap is not None:
465
+ fragment = _fragment_mol(n_cap)
466
+ offset = combined.GetNumAtoms()
467
+ combined.InsertMol(fragment)
468
+ cap_atoms = [offset + a for a in range(fragment.GetNumAtoms())]
469
+ cap_dummy = offset + _find_dummy(fragment, ATTACH_N)
470
+ cap_anchor = next(
471
+ nbr.GetIdx() for nbr in combined.GetAtomWithIdx(cap_dummy).GetNeighbors()
472
+ )
473
+ combined.AddBond(cap_anchor, mapping.backbone_nitrogens[0], Chem.BondType.SINGLE)
474
+ combined.RemoveAtom(cap_dummy)
475
+ mapping.residue_atoms[0].extend(a for a in cap_atoms if a != cap_dummy)
476
+
477
+ c_cap = C_TERMINAL_CAPS[c_terminus]
478
+ if c_cap is not None:
479
+ fragment = _fragment_mol(c_cap)
480
+ offset = combined.GetNumAtoms()
481
+ combined.InsertMol(fragment)
482
+ cap_atoms = [offset + a for a in range(fragment.GetNumAtoms())]
483
+ cap_dummy = offset + _find_dummy(fragment, ATTACH_C)
484
+ cap_anchor = next(
485
+ nbr.GetIdx() for nbr in combined.GetAtomWithIdx(cap_dummy).GetNeighbors()
486
+ )
487
+ combined.AddBond(
488
+ mapping.carbonyl_carbons[len(tokens) - 1], cap_anchor, Chem.BondType.SINGLE
489
+ )
490
+ combined.RemoveAtom(cap_dummy)
491
+ mapping.residue_atoms[len(tokens) - 1].extend(
492
+ a for a in cap_atoms if a != cap_dummy
493
+ )
494
+
495
+ # -- strip the remaining attachment dummies --------------------------
496
+ # Index arithmetic is deliberately avoided here. Cap attachment above
497
+ # already removed atoms, so a single "subtract the removals below me" pass
498
+ # over the original indices would be wrong — and was, silently, for the
499
+ # residues after the first cap. Instead every atom of interest is tagged
500
+ # with a durable property before any deletion, and the indices are re-read
501
+ # from the finished molecule afterwards.
502
+ for residue_index, indices in mapping.residue_atoms.items():
503
+ for atom_index in indices:
504
+ atom = combined.GetAtomWithIdx(atom_index)
505
+ if atom.GetAtomicNum() != 0:
506
+ atom.SetIntProp("residue_index", residue_index)
507
+ for residue_index, atom_index in mapping.alpha_carbons.items():
508
+ combined.GetAtomWithIdx(atom_index).SetIntProp("alpha_of", residue_index)
509
+ for residue_index, atom_index in mapping.backbone_nitrogens.items():
510
+ combined.GetAtomWithIdx(atom_index).SetIntProp("amide_n_of", residue_index)
511
+ for residue_index, atom_index in mapping.carbonyl_carbons.items():
512
+ combined.GetAtomWithIdx(atom_index).SetIntProp("carbonyl_of", residue_index)
513
+
514
+ for index in sorted(
515
+ (atom.GetIdx() for atom in combined.GetAtoms() if atom.GetAtomicNum() == 0),
516
+ reverse=True,
517
+ ):
518
+ combined.RemoveAtom(index)
519
+
520
+ return combined, _mapping_from_props(combined)
521
+
522
+
523
+ def _smiles_output_index(mol: Chem.Mol) -> dict[int, int]:
524
+ """Map working atom index -> position in the most recent SMILES output.
525
+
526
+ ``MolToSmiles`` records its traversal as the private ``_smilesAtomOutputOrder``
527
+ property, whose value at position ``k`` is the working index emitted there.
528
+ Inverting it gives what the mapping needs. The property is stored as a string
529
+ (``"[2,1,0,...,]"``) and is not surfaced by ``GetPropsAsDict``, so it has to
530
+ be read and parsed explicitly.
531
+
532
+ Returns an empty dict when the property is absent, in which case the caller
533
+ leaves the mapping in working-molecule order rather than corrupting it.
534
+ """
535
+ if not mol.HasProp("_smilesAtomOutputOrder"):
536
+ return {}
537
+ raw = mol.GetProp("_smilesAtomOutputOrder").strip()
538
+ inner = raw.strip("[]").rstrip(",")
539
+ if not inner:
540
+ return {}
541
+ try:
542
+ order = [int(part) for part in inner.split(",") if part.strip() != ""]
543
+ except ValueError:
544
+ return {}
545
+ return {old: new for new, old in enumerate(order)}
546
+
547
+
548
+ def _remap_mapping(mapping: AtomMapping, index_map: dict[int, int]) -> AtomMapping:
549
+ """Rewrite every recorded index through ``index_map``.
550
+
551
+ Used to convert working-molecule indices into emitted-SMILES order, so the
552
+ published mapping is valid against a re-parse of the published string.
553
+ """
554
+
555
+ def convert(old: int) -> int:
556
+ return index_map.get(old, old)
557
+
558
+ return AtomMapping(
559
+ residue_atoms={
560
+ residue: sorted(convert(a) for a in atoms)
561
+ for residue, atoms in mapping.residue_atoms.items()
562
+ },
563
+ alpha_carbons={k: convert(v) for k, v in mapping.alpha_carbons.items()},
564
+ backbone_nitrogens={
565
+ k: convert(v) for k, v in mapping.backbone_nitrogens.items()
566
+ },
567
+ carbonyl_carbons={k: convert(v) for k, v in mapping.carbonyl_carbons.items()},
568
+ crosslink_carbons=(
569
+ (convert(mapping.crosslink_carbons[0]), convert(mapping.crosslink_carbons[1]))
570
+ if mapping.crosslink_carbons
571
+ else None
572
+ ),
573
+ staple_atoms=[convert(a) for a in mapping.staple_atoms],
574
+ expelled_atoms=list(mapping.expelled_atoms),
575
+ )
576
+
577
+
578
+ def _mapping_from_props(mol: Chem.Mol) -> AtomMapping:
579
+ """Rebuild the atom mapping by reading the durable tags off ``mol``.
580
+
581
+ Reading the finished molecule is the only reliable way to get these indices:
582
+ any bookkeeping that tracks them through a sequence of ``RemoveAtom`` calls
583
+ has to model RDKit's renumbering exactly, and gets it wrong as soon as two
584
+ removals interleave with the recorded positions.
585
+ """
586
+ mapping = AtomMapping()
587
+ for atom in mol.GetAtoms():
588
+ index = atom.GetIdx()
589
+ if atom.HasProp("residue_index"):
590
+ mapping.residue_atoms.setdefault(
591
+ atom.GetIntProp("residue_index"), []
592
+ ).append(index)
593
+ if atom.HasProp("alpha_of"):
594
+ mapping.alpha_carbons[atom.GetIntProp("alpha_of")] = index
595
+ if atom.HasProp("amide_n_of"):
596
+ mapping.backbone_nitrogens[atom.GetIntProp("amide_n_of")] = index
597
+ if atom.HasProp("carbonyl_of"):
598
+ mapping.carbonyl_carbons[atom.GetIntProp("carbonyl_of")] = index
599
+ return mapping
600
+
601
+
602
+ def _terminal_alkene_carbons(
603
+ mol: Chem.Mol, residue_atoms: list[int], alpha_carbon: int
604
+ ) -> tuple[int, int]:
605
+ """Locate one anchor's tether alkene, as ``(inner_CH, terminal_CH2)``.
606
+
607
+ Identified structurally rather than by index arithmetic: the terminal ``CH2``
608
+ is the alkene carbon with exactly one heavy neighbour, and it must sit on the
609
+ side chain of this residue, reachable from Cα without crossing the backbone.
610
+ """
611
+ candidates: list[tuple[int, int]] = []
612
+ owned = set(residue_atoms)
613
+ for bond in mol.GetBonds():
614
+ if bond.GetBondType() != Chem.BondType.DOUBLE:
615
+ continue
616
+ begin, end = bond.GetBeginAtom(), bond.GetEndAtom()
617
+ if begin.GetAtomicNum() != 6 or end.GetAtomicNum() != 6:
618
+ continue
619
+ if begin.GetIdx() not in owned or end.GetIdx() not in owned:
620
+ continue
621
+ for inner, terminal in ((begin, end), (end, begin)):
622
+ heavy = [n for n in terminal.GetNeighbors() if n.GetAtomicNum() > 1]
623
+ if len(heavy) == 1 and terminal.GetTotalNumHs() == 2:
624
+ candidates.append((inner.GetIdx(), terminal.GetIdx()))
625
+
626
+ if not candidates:
627
+ raise SmilesBuildError(
628
+ BuildFailure.ANCHOR_NOT_OLEFINIC,
629
+ f"no terminal alkene found on the residue whose Cα is atom {alpha_carbon}",
630
+ )
631
+ if len(candidates) > 1:
632
+ raise SmilesBuildError(
633
+ BuildFailure.ANCHOR_NOT_OLEFINIC,
634
+ f"ambiguous tether: {len(candidates)} terminal alkenes on the residue "
635
+ f"whose Cα is atom {alpha_carbon}",
636
+ )
637
+ return candidates[0]
638
+
639
+
640
+ def _bridge_atoms(mol: Chem.Mol, start: int, end: int, blocked: set[int]) -> list[int]:
641
+ """Shortest path from ``start`` to ``end`` avoiding ``blocked`` atoms."""
642
+ from collections import deque
643
+
644
+ queue = deque([[start]])
645
+ seen = {start}
646
+ while queue:
647
+ path = queue.popleft()
648
+ if path[-1] == end:
649
+ return path
650
+ for neighbour in mol.GetAtomWithIdx(path[-1]).GetNeighbors():
651
+ index = neighbour.GetIdx()
652
+ if index in seen or index in blocked:
653
+ continue
654
+ seen.add(index)
655
+ queue.append(path + [index])
656
+ return []
657
+
658
+
659
+ # ---------------------------------------------------------------------------
660
+ # Public builder
661
+ # ---------------------------------------------------------------------------
662
+
663
+
664
+ def build_stapled_smiles(
665
+ sequence_tokens: list[str],
666
+ plan: StapleSmilesPlan,
667
+ n_terminus: str = N_TERM_FREE,
668
+ c_terminus: str = C_TERM_ACID,
669
+ olefin_geometry: OlefinGeometry | str = OlefinGeometry.E,
670
+ include_optional_topologies: bool = False,
671
+ strict: bool = True,
672
+ ) -> StapledSmilesResult:
673
+ """Build the linear precursor and the RCM-stapled product.
674
+
675
+ Args:
676
+ sequence_tokens: monomer tokens, e.g. ``["A", "S5", "L", "K", "A", "S5"]``.
677
+ plan: which positions to staple, and with which topology.
678
+ n_terminus: ``"free_amine"`` or ``"acetyl"``.
679
+ c_terminus: ``"free_acid"`` or ``"amide"``.
680
+ olefin_geometry: geometry of the product double bond.
681
+ include_optional_topologies: also allow ``R5-S8/i,i+7``.
682
+ strict: raise when validation fails, instead of returning an invalid
683
+ result with the reasons recorded.
684
+
685
+ Returns:
686
+ A :class:`StapledSmilesResult` whose ``stapled_smiles`` is a genuinely
687
+ cyclised molecule.
688
+
689
+ Raises:
690
+ SmilesBuildError: for an illegal request, or (when ``strict``) a product
691
+ that fails validation.
692
+ """
693
+ tokens = [t.upper() for t in sequence_tokens]
694
+ geometry = OlefinGeometry(olefin_geometry)
695
+ _validate_request(tokens, plan, include_optional_topologies)
696
+
697
+ i, j = plan.anchor_pair
698
+ linear_rw, mapping = _assemble_linear(tokens, n_terminus, c_terminus)
699
+
700
+ linear_mol = linear_rw.GetMol()
701
+ try:
702
+ Chem.SanitizeMol(linear_mol)
703
+ except Exception as exc:
704
+ raise SmilesBuildError(
705
+ BuildFailure.SANITIZATION_FAILED,
706
+ f"linear precursor failed sanitization: {type(exc).__name__}: {exc}",
707
+ ) from exc
708
+
709
+ inner_i, terminal_i = _terminal_alkene_carbons(
710
+ linear_mol, mapping.residue_atoms[i], mapping.alpha_carbons[i]
711
+ )
712
+ inner_j, terminal_j = _terminal_alkene_carbons(
713
+ linear_mol, mapping.residue_atoms[j], mapping.alpha_carbons[j]
714
+ )
715
+
716
+ # -- ring-closing metathesis -----------------------------------------
717
+ # Join the two inner alkene carbons, delete both terminal CH2 groups as the
718
+ # expelled ethene, and make the surviving bond a double bond.
719
+ product = Chem.RWMol(linear_mol)
720
+ product.AddBond(inner_i, inner_j, Chem.BondType.DOUBLE)
721
+ # Tag the crosslink carbons before deleting anything, for the same reason the
722
+ # linear assembly does: the deletions renumber the atoms above them.
723
+ product.GetAtomWithIdx(inner_i).SetIntProp("crosslink_side", 0)
724
+ product.GetAtomWithIdx(inner_j).SetIntProp("crosslink_side", 1)
725
+ for index in sorted((terminal_i, terminal_j), reverse=True):
726
+ product.RemoveAtom(index)
727
+
728
+ product_mapping = _mapping_from_props(product)
729
+ crosslink: dict[int, int] = {}
730
+ for atom in product.GetAtoms():
731
+ if atom.HasProp("crosslink_side"):
732
+ crosslink[atom.GetIntProp("crosslink_side")] = atom.GetIdx()
733
+ if set(crosslink) != {0, 1}:
734
+ raise SmilesBuildError(
735
+ BuildFailure.NO_CROSSLINK_BOND,
736
+ f"crosslink carbons were lost during ethene removal: found {crosslink}",
737
+ )
738
+ product_mapping.crosslink_carbons = (crosslink[0], crosslink[1])
739
+ product_mapping.expelled_atoms = sorted((terminal_i, terminal_j))
740
+
741
+ stapled_mol = product.GetMol()
742
+ try:
743
+ Chem.SanitizeMol(stapled_mol)
744
+ except Exception as exc:
745
+ raise SmilesBuildError(
746
+ BuildFailure.SANITIZATION_FAILED,
747
+ f"stapled product failed sanitization: {type(exc).__name__}: {exc}",
748
+ ) from exc
749
+
750
+ # Re-read the mapping from the *sanitized* molecule. The tags survive both
751
+ # ``GetMol()`` and sanitization, and this is the molecule whose numbering the
752
+ # emitted SMILES will carry, so reading here is what keeps the published
753
+ # indices valid for a caller who re-parses the string.
754
+ product_mapping = _mapping_from_props(stapled_mol)
755
+ crosslink = {}
756
+ for atom in stapled_mol.GetAtoms():
757
+ if atom.HasProp("crosslink_side"):
758
+ crosslink[atom.GetIntProp("crosslink_side")] = atom.GetIdx()
759
+ if set(crosslink) != {0, 1}:
760
+ raise SmilesBuildError(
761
+ BuildFailure.NO_CROSSLINK_BOND,
762
+ f"crosslink carbons were lost during sanitization: found {crosslink}",
763
+ )
764
+ product_mapping.crosslink_carbons = (crosslink[0], crosslink[1])
765
+ product_mapping.expelled_atoms = sorted((terminal_i, terminal_j))
766
+
767
+ # -- olefin geometry -------------------------------------------------
768
+ cross_i, cross_j = product_mapping.crosslink_carbons
769
+ bond = stapled_mol.GetBondBetweenAtoms(cross_i, cross_j)
770
+ if bond is None:
771
+ raise SmilesBuildError(
772
+ BuildFailure.NO_CROSSLINK_BOND,
773
+ f"crosslink bond between atoms {cross_i} and {cross_j} is absent after build",
774
+ )
775
+ if geometry is OlefinGeometry.UNSPECIFIED:
776
+ bond.SetStereo(Chem.BondStereo.STEREONONE)
777
+ else:
778
+ # Stereo atoms must be named for the parity to mean anything: pick the
779
+ # ring-side neighbour on each end, so E/Z refers to the macrocycle.
780
+ ref_i = next(
781
+ (
782
+ n.GetIdx()
783
+ for n in stapled_mol.GetAtomWithIdx(cross_i).GetNeighbors()
784
+ if n.GetIdx() != cross_j
785
+ ),
786
+ None,
787
+ )
788
+ ref_j = next(
789
+ (
790
+ n.GetIdx()
791
+ for n in stapled_mol.GetAtomWithIdx(cross_j).GetNeighbors()
792
+ if n.GetIdx() != cross_i
793
+ ),
794
+ None,
795
+ )
796
+ if ref_i is not None and ref_j is not None:
797
+ bond.SetStereoAtoms(ref_i, ref_j)
798
+ bond.SetStereo(
799
+ Chem.BondStereo.STEREOE
800
+ if geometry is OlefinGeometry.E
801
+ else Chem.BondStereo.STEREOZ
802
+ )
803
+ # Setting the stereo descriptor alone is not enough for a double bond
804
+ # inside a ring: RDKit stores it on the bond but omits the ``/``
805
+ # ``\`` markers when writing SMILES, so E and Z would serialise to
806
+ # the identical string. This call materialises the neighbour bond
807
+ # directions that the writer actually reads.
808
+ Chem.SetDoubleBondNeighborDirections(stapled_mol)
809
+
810
+ if geometry is OlefinGeometry.UNSPECIFIED:
811
+ Chem.AssignStereochemistry(stapled_mol, cleanIt=True, force=True)
812
+ else:
813
+ # cleanIt=True would discard the ring-bond stereo just installed, so
814
+ # only assign what is missing.
815
+ Chem.AssignStereochemistry(stapled_mol, cleanIt=False, force=False)
816
+
817
+ # -- staple bridge atoms, read off the graph -------------------------
818
+ backbone_block = set(product_mapping.backbone_nitrogens.values()) | set(
819
+ product_mapping.carbonyl_carbons.values()
820
+ )
821
+ bridge = _bridge_atoms(
822
+ stapled_mol,
823
+ product_mapping.alpha_carbons[i],
824
+ product_mapping.alpha_carbons[j],
825
+ blocked=backbone_block,
826
+ )
827
+ product_mapping.staple_atoms = bridge
828
+
829
+ # CIP labels and ring sizes are read while the mapping still holds working
830
+ # indices, since both queries address ``stapled_mol``.
831
+ anchor_cip: dict[int, str | None] = {}
832
+ rdCIPLabeler.AssignCIPLabels(stapled_mol)
833
+ for position in (i, j):
834
+ atom = stapled_mol.GetAtomWithIdx(product_mapping.alpha_carbons[position])
835
+ anchor_cip[position] = (
836
+ atom.GetProp("_CIPCode") if atom.HasProp("_CIPCode") else None
837
+ )
838
+
839
+ ring_sizes = [
840
+ len(ring)
841
+ for ring in stapled_mol.GetRingInfo().AtomRings()
842
+ if cross_i in ring and cross_j in ring
843
+ ]
844
+
845
+ linear_smiles = Chem.MolToSmiles(linear_mol, isomericSmiles=True)
846
+ stapled_smiles = Chem.MolToSmiles(stapled_mol, isomericSmiles=True)
847
+
848
+ # Re-express the mapping in the *emitted SMILES* atom order.
849
+ #
850
+ # ``MolToSmiles`` chooses its own traversal root and order, so an index that
851
+ # is correct for ``stapled_mol`` points at an unrelated atom once a caller
852
+ # does ``MolFromSmiles(stapled_smiles)``. That silent mismatch is worse than
853
+ # useless for an atom-mapping API, so the published indices are converted
854
+ # into output order here using RDKit's own output-order record, and the
855
+ # tests assert them against a freshly re-parsed molecule.
856
+ output_index = _smiles_output_index(stapled_mol)
857
+ if output_index:
858
+ product_mapping = _remap_mapping(product_mapping, output_index)
859
+
860
+ result = StapledSmilesResult(
861
+ sequence_tokens=tokens,
862
+ plan=plan,
863
+ linear_smiles=linear_smiles,
864
+ stapled_smiles=stapled_smiles,
865
+ linear_canonical=Chem.MolToSmiles(linear_mol, isomericSmiles=False),
866
+ stapled_canonical=Chem.MolToSmiles(stapled_mol, isomericSmiles=False),
867
+ atom_mapping=product_mapping,
868
+ n_terminus=n_terminus,
869
+ c_terminus=c_terminus,
870
+ olefin_geometry=geometry.value,
871
+ anchor_cip=anchor_cip,
872
+ linear_formula=rdMolDescriptors.CalcMolFormula(linear_mol),
873
+ stapled_formula=rdMolDescriptors.CalcMolFormula(stapled_mol),
874
+ linear_mw=float(Descriptors.MolWt(linear_mol)),
875
+ stapled_mw=float(Descriptors.MolWt(stapled_mol)),
876
+ macrocycle_size=min(ring_sizes) if ring_sizes else None,
877
+ expected_macrocycle_size=expected_macrocycle_size(
878
+ plan.spacing, tokens[i], tokens[j]
879
+ ),
880
+ staple_bridge_carbons=staple_carbon_count(tokens[i], tokens[j]),
881
+ )
882
+ # Validated against the re-parsed product, not the working molecule: the
883
+ # published mapping is in emitted-SMILES order, and checking it against the
884
+ # string a caller would actually receive is the only check that proves the
885
+ # published indices are usable.
886
+ result.validation = validate_stapled_product(result)
887
+
888
+ if strict and not result.is_valid:
889
+ raise SmilesBuildError(
890
+ BuildFailure(result.validation["first_failure"]),
891
+ f"stapled product failed validation: {result.validation['failures']}",
892
+ )
893
+ return result
894
+
895
+
896
+ def build_from_sequence(
897
+ sequence: str,
898
+ anchor_pair: tuple[int, int] | None = None,
899
+ **kwargs: Any,
900
+ ) -> StapledSmilesResult:
901
+ """Convenience wrapper: tokenize ``sequence`` and staple its two anchors.
902
+
903
+ When ``anchor_pair`` is omitted the two anchor monomers present in the
904
+ sequence are used, which is what the StaPep audit needs.
905
+ """
906
+ from staplebridge.hydrocarbon.tokenizer import tokenize_sequence
907
+
908
+ tokens = tokenize_sequence(sequence)
909
+ if anchor_pair is None:
910
+ positions = [k for k, t in enumerate(tokens) if is_anchor_monomer(t)]
911
+ if len(positions) != REQUIRED_ANCHOR_COUNT:
912
+ raise SmilesBuildError(
913
+ BuildFailure.WRONG_ANCHOR_COUNT,
914
+ f"sequence {sequence!r} has {len(positions)} anchor monomers at "
915
+ f"{positions}; need exactly {REQUIRED_ANCHOR_COUNT}",
916
+ )
917
+ anchor_pair = (positions[0], positions[1])
918
+ plan = StapleSmilesPlan.from_tokens(tokens, anchor_pair)
919
+ return build_stapled_smiles(tokens, plan, **kwargs)
920
+
921
+
922
+ # ---------------------------------------------------------------------------
923
+ # Strict validation
924
+ # ---------------------------------------------------------------------------
925
+
926
+
927
+ def validate_stapled_product(
928
+ result: StapledSmilesResult,
929
+ linear_mol: Chem.Mol | None = None,
930
+ stapled_mol: Chem.Mol | None = None,
931
+ ) -> dict[str, Any]:
932
+ """Strictly validate a built product, re-deriving the crosslink from the graph.
933
+
934
+ Checks, each recorded by name:
935
+
936
+ 1. both SMILES re-parse and sanitize;
937
+ 2. no valence errors;
938
+ 3. each is a single connected molecule;
939
+ 4. exactly two anchor residues, and the crosslink joins *their* tethers;
940
+ 5. the crosslink bond exists, is C-C, and is a double bond;
941
+ 6. the crosslink lies on a ring, and that ring has the arithmetically
942
+ expected size;
943
+ 7. no terminal ``CH2=`` remains (both tethers were consumed);
944
+ 8. the linear and stapled canonical SMILES differ.
945
+
946
+ Check 6 is what makes a topology label insufficient: the ring is found in the
947
+ re-parsed product graph, so a molecule that was merely flagged as stapled has
948
+ no ring to find and fails here.
949
+ """
950
+ failures: list[str] = []
951
+ details: dict[str, Any] = {}
952
+
953
+ reparsed_linear = Chem.MolFromSmiles(result.linear_smiles)
954
+ reparsed_stapled = Chem.MolFromSmiles(result.stapled_smiles)
955
+ details["linear_reparsed"] = reparsed_linear is not None
956
+ details["stapled_reparsed"] = reparsed_stapled is not None
957
+ if reparsed_linear is None or reparsed_stapled is None:
958
+ failures.append(BuildFailure.SANITIZATION_FAILED.value)
959
+ return _verdict(failures, details)
960
+
961
+ for label, mol in (("linear", reparsed_linear), ("stapled", reparsed_stapled)):
962
+ problems = Chem.DetectChemistryProblems(mol)
963
+ if problems:
964
+ failures.append(BuildFailure.VALENCE_ERROR.value)
965
+ details[f"{label}_chemistry_problems"] = [p.Message() for p in problems]
966
+
967
+ linear_fragments = len(Chem.GetMolFrags(reparsed_linear))
968
+ stapled_fragments = len(Chem.GetMolFrags(reparsed_stapled))
969
+ details["linear_fragment_count"] = linear_fragments
970
+ details["stapled_fragment_count"] = stapled_fragments
971
+ if linear_fragments != 1 or stapled_fragments != 1:
972
+ failures.append(BuildFailure.MULTIPLE_FRAGMENTS.value)
973
+
974
+ # -- anchors ---------------------------------------------------------
975
+ anchor_positions = [
976
+ k for k, t in enumerate(result.sequence_tokens) if is_anchor_monomer(t)
977
+ ]
978
+ details["anchor_positions"] = anchor_positions
979
+ if len(anchor_positions) != REQUIRED_ANCHOR_COUNT:
980
+ failures.append(BuildFailure.WRONG_ANCHOR_COUNT.value)
981
+
982
+ # -- crosslink bond, in the working molecule -------------------------
983
+ working = stapled_mol if stapled_mol is not None else reparsed_stapled
984
+ crosslink = result.atom_mapping.crosslink_carbons
985
+ details["crosslink_carbons"] = list(crosslink) if crosslink else None
986
+ if crosslink is None:
987
+ failures.append(BuildFailure.NO_CROSSLINK_BOND.value)
988
+ return _verdict(failures, details)
989
+
990
+ cross_i, cross_j = crosslink
991
+ bond = working.GetBondBetweenAtoms(cross_i, cross_j)
992
+ if bond is None:
993
+ failures.append(BuildFailure.NO_CROSSLINK_BOND.value)
994
+ return _verdict(failures, details)
995
+
996
+ both_carbon = (
997
+ working.GetAtomWithIdx(cross_i).GetAtomicNum() == 6
998
+ and working.GetAtomWithIdx(cross_j).GetAtomicNum() == 6
999
+ )
1000
+ details["crosslink_is_carbon_carbon"] = both_carbon
1001
+ details["crosslink_bond_type"] = str(bond.GetBondType())
1002
+ if not both_carbon:
1003
+ failures.append(BuildFailure.NO_CROSSLINK_BOND.value)
1004
+ if bond.GetBondType() != Chem.BondType.DOUBLE:
1005
+ failures.append(BuildFailure.NO_CROSSLINK_BOND.value)
1006
+
1007
+ # -- the crosslink must close a real macrocycle -----------------------
1008
+ details["crosslink_in_ring"] = bool(bond.IsInRing())
1009
+ if not bond.IsInRing():
1010
+ failures.append(BuildFailure.MACROCYCLE_NOT_CLOSED.value)
1011
+ else:
1012
+ rings = [
1013
+ len(ring)
1014
+ for ring in working.GetRingInfo().AtomRings()
1015
+ if cross_i in ring and cross_j in ring
1016
+ ]
1017
+ observed = min(rings) if rings else None
1018
+ details["macrocycle_size"] = observed
1019
+ details["expected_macrocycle_size"] = result.expected_macrocycle_size
1020
+ if observed != result.expected_macrocycle_size:
1021
+ failures.append(BuildFailure.UNEXPECTED_RING_SIZE.value)
1022
+
1023
+ # -- both tethers consumed -------------------------------------------
1024
+ residual = working.GetSubstructMatches(Chem.MolFromSmarts("[CX3H2]=[CX3]"))
1025
+ details["residual_terminal_alkenes"] = len(residual)
1026
+ if residual:
1027
+ failures.append(BuildFailure.RESIDUAL_TERMINAL_ALKENE.value)
1028
+
1029
+ # -- the product is genuinely not the precursor ----------------------
1030
+ differs = result.linear_canonical != result.stapled_canonical
1031
+ details["linear_differs_from_product"] = differs
1032
+ if not differs:
1033
+ failures.append(BuildFailure.LINEAR_EQUALS_PRODUCT.value)
1034
+
1035
+ # Ethene (C2H4, 28.05) leaves, so the product must be lighter.
1036
+ details["mw_loss_vs_linear"] = round(result.linear_mw - result.stapled_mw, 4)
1037
+ return _verdict(failures, details)
1038
+
1039
+
1040
+ def _verdict(failures: list[str], details: dict[str, Any]) -> dict[str, Any]:
1041
+ """Package a validation outcome."""
1042
+ unique = sorted(set(failures))
1043
+ return {
1044
+ "valid": not unique,
1045
+ "failures": unique,
1046
+ "first_failure": unique[0] if unique else BuildFailure.OK.value,
1047
+ "n_checks_failed": len(unique),
1048
+ **details,
1049
+ }
1050
+
1051
+
1052
+ def describe_builder() -> dict[str, Any]:
1053
+ """Summary of builder capabilities, for reports."""
1054
+ return {
1055
+ "supported_topologies": sorted(f"{p}/i,i+{s}" for p, s in SUPPORTED_TOPOLOGIES),
1056
+ "optional_topologies": sorted(f"{p}/i,i+{s}" for p, s in OPTIONAL_TOPOLOGIES),
1057
+ "n_terminal_caps": sorted(N_TERMINAL_CAPS),
1058
+ "c_terminal_caps": sorted(C_TERMINAL_CAPS),
1059
+ "olefin_geometries": [g.value for g in OlefinGeometry],
1060
+ "rcm_expels": "ethene (C2H4)",
1061
+ "validation_checks": [
1062
+ "sanitization",
1063
+ "valence",
1064
+ "single_fragment",
1065
+ "exactly_two_anchors",
1066
+ "crosslink_bond_is_CC_double",
1067
+ "crosslink_closes_macrocycle_of_expected_size",
1068
+ "no_residual_terminal_alkene",
1069
+ "linear_differs_from_product",
1070
+ ],
1071
+ }
staplebridge/hydrocarbon/terminal_energy.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hydrocarbon terminal energy.
2
+
3
+ ``BridgeTrainer.terminal_energy`` is the lactam terminal energy and is **not
4
+ modified**. This module wraps it additively for the hydrocarbon branch:
5
+
6
+ E_hydrocarbon = E_base + E_endpoint_prior + E_hydrocarbon_property
7
+
8
+ ``E_base`` is computed by delegating to the caller's existing terminal-energy
9
+ callable, so the geometry, edit-distance, property-penalty and cost terms behave
10
+ exactly as they do today. Hydrocarbon-specific endpoint-prior and strict SMILES property terms are both
11
+ optional. With neither enabled the total degrades to ``E_base`` bit-for-bit.
12
+ The property scorer is never imported or constructed by the lactam path.
13
+
14
+ Because this wrapper is only constructed on the hydrocarbon path, the lactam
15
+ terminal energy is unchanged whether the prior is on or off.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Any, Callable, Protocol
22
+
23
+ from staplebridge.chemistry.state import StapleState
24
+ from staplebridge.data.schemas import BuildingBlock, LeadExample
25
+ from staplebridge.hydrocarbon.actions import (
26
+ FailureReason,
27
+ validate_hydrocarbon_staple,
28
+ )
29
+ from staplebridge.hydrocarbon.endpoint_prior import (
30
+ EmpiricalHydrocarbonEndpointPrior,
31
+ EndpointPriorConfig,
32
+ )
33
+ from staplebridge.hydrocarbon.property_energy import (
34
+ full_scoring_properties,
35
+ HydrocarbonPropertyEnergyConfig,
36
+ HydrocarbonPropertyScorer,
37
+ property_energy_terms,
38
+ required_energy_properties,
39
+ required_original_lead_properties,
40
+ )
41
+
42
+
43
+ class TerminalEnergyFn(Protocol):
44
+ """The signature of ``BridgeTrainer.terminal_energy``."""
45
+
46
+ def __call__(
47
+ self, z0: StapleState, zt: StapleState, lead: LeadExample
48
+ ) -> tuple[float, dict[str, Any]]:
49
+ ...
50
+
51
+
52
+ @dataclass
53
+ class HydrocarbonTerminalEnergyConfig:
54
+ """Config for the hydrocarbon terminal energy wrapper."""
55
+
56
+ #: Penalty when a terminal state is not a legal hydrocarbon staple. Applied
57
+ #: on top of whatever the base energy already charges for infeasibility.
58
+ invalid_topology_penalty: float = 10.0
59
+ #: Also charge the penalty when the terminal state never got stapled.
60
+ penalize_unstapled: bool = True
61
+
62
+
63
+ class HydrocarbonTerminalEnergy:
64
+ """Additive hydrocarbon terminal energy.
65
+
66
+ Args:
67
+ base_terminal_energy: the existing (lactam-style) terminal energy
68
+ callable, typically ``trainer.terminal_energy``. Delegated to
69
+ unchanged.
70
+ catalog: hydrocarbon blocks, used to validate the terminal topology.
71
+ endpoint_prior: the empirical prior. When ``None`` or disabled, this
72
+ class adds nothing to the base energy.
73
+ config: wrapper configuration.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ base_terminal_energy: TerminalEnergyFn | Callable[..., tuple[float, dict[str, Any]]],
79
+ catalog: list[BuildingBlock],
80
+ endpoint_prior: EmpiricalHydrocarbonEndpointPrior | None = None,
81
+ config: HydrocarbonTerminalEnergyConfig | None = None,
82
+ property_scorer: HydrocarbonPropertyScorer | None = None,
83
+ property_config: HydrocarbonPropertyEnergyConfig | None = None,
84
+ ) -> None:
85
+ self._base = base_terminal_energy
86
+ self.catalog = list(catalog)
87
+ self.catalog_index = {b.block_id: b for b in self.catalog}
88
+ self.endpoint_prior = endpoint_prior or EmpiricalHydrocarbonEndpointPrior()
89
+ self.cfg = config or HydrocarbonTerminalEnergyConfig()
90
+ self.property_scorer = property_scorer
91
+ self.property_cfg = property_config or HydrocarbonPropertyEnergyConfig()
92
+
93
+ def __call__(
94
+ self, z0: StapleState, zt: StapleState, lead: LeadExample, *, energy_only: bool = False
95
+ ) -> tuple[float, dict[str, Any]]:
96
+ """Compute the hydrocarbon terminal energy for ``zt``.
97
+
98
+ With ``energy_only=True`` the property scorer predicts only the
99
+ properties that reach the energy (see
100
+ :func:`required_energy_properties`), so the returned ``info`` carries
101
+ just those and the monitors are absent. The float energy is unchanged.
102
+ Callers that read ``info`` for logging, validation or ranking must
103
+ leave ``energy_only`` at its default.
104
+ """
105
+ base_energy, info = self._base(z0, zt, lead)
106
+ energy = float(base_energy)
107
+ info = dict(info)
108
+ info["chemistry"] = "hydrocarbon"
109
+ info["base_terminal_energy"] = float(base_energy)
110
+
111
+ block = self.catalog_index.get(zt.block_id) if zt.block_id else None
112
+ verdict = validate_hydrocarbon_staple(
113
+ zt.sequence_tokens,
114
+ None if zt.anchor_pair is None else tuple(zt.anchor_pair),
115
+ block,
116
+ self.catalog,
117
+ )
118
+ info["hydrocarbon_topology_status"] = verdict.value
119
+
120
+ topology_ok = verdict is FailureReason.OK
121
+ stapled = zt.topology == "stapled"
122
+ penalty = 0.0
123
+ if not topology_ok or (self.cfg.penalize_unstapled and not stapled):
124
+ penalty = float(self.cfg.invalid_topology_penalty)
125
+ energy += penalty
126
+ info["hydrocarbon_invalid_topology_penalty"] = penalty
127
+
128
+ breakdown = self.endpoint_prior.score_endpoint(zt, block)
129
+ energy += float(breakdown.total)
130
+ info.update(breakdown.as_dict())
131
+
132
+ info["hydrocarbon_endpoint_pass"] = bool(topology_ok and stapled)
133
+
134
+ if self.property_scorer is not None and topology_ok and stapled:
135
+ if energy_only:
136
+ property_scores = self.property_scorer.score_energy_only(
137
+ zt, required_energy_properties(self.property_cfg)
138
+ )
139
+ else:
140
+ # Preserve the historical full-scoring call exactly when the
141
+ # total switch is off; only the developability arm adds the
142
+ # solubility head to validation/ranking records.
143
+ if self.property_cfg.enable_developability_constraints:
144
+ property_scores = self.property_scorer.score(
145
+ zt, full_scoring_properties(self.property_cfg)
146
+ )
147
+ else:
148
+ property_scores = self.property_scorer.score(zt)
149
+ if (
150
+ self.property_cfg.enable_developability_constraints
151
+ or self.property_cfg.enable_halflife_preservation
152
+ or self.property_cfg.enable_joint_perm_halflife_support
153
+ ):
154
+ lead_key = (
155
+ str(getattr(lead, "example_id"))
156
+ if getattr(lead, "example_id", None) is not None
157
+ else None
158
+ )
159
+ original_scores = self.property_scorer.score_original_linear(
160
+ z0.sequence_tokens,
161
+ lead_key=lead_key,
162
+ properties=required_original_lead_properties(self.property_cfg),
163
+ )
164
+ property_scores.update(original_scores)
165
+ info.update(property_scores)
166
+ property_energy, property_terms = property_energy_terms(
167
+ property_scores, self.property_cfg
168
+ )
169
+ energy += float(property_energy)
170
+ info.update(property_terms)
171
+ else:
172
+ reason = (
173
+ "disabled"
174
+ if self.property_scorer is None
175
+ else f"not_scored:{verdict.value}"
176
+ )
177
+ info["hydrocarbon_property_status"] = reason
178
+ info["hydrocarbon_property_energy"] = 0.0
179
+ info["hydrocarbon_toxicity_violation"] = False
180
+
181
+ info["terminal_energy"] = float(energy)
182
+ return float(energy), info
183
+
184
+ def describe(self) -> dict[str, Any]:
185
+ """Summary for logging and audits."""
186
+ return {
187
+ "chemistry": "hydrocarbon",
188
+ "catalog_blocks": [b.block_id for b in self.catalog],
189
+ "invalid_topology_penalty": float(self.cfg.invalid_topology_penalty),
190
+ "endpoint_prior": self.endpoint_prior.describe(),
191
+ "property_energy": self.property_cfg.describe(),
192
+ }
193
+
194
+
195
+ def build_hydrocarbon_terminal_energy(
196
+ base_terminal_energy: TerminalEnergyFn,
197
+ catalog: list[BuildingBlock],
198
+ hydrocarbon_cfg: dict[str, Any] | None,
199
+ root: Any = None,
200
+ property_scorer: HydrocarbonPropertyScorer | None = None,
201
+ ) -> HydrocarbonTerminalEnergy:
202
+ """Build the wrapper from a ``hydrocarbon`` config section.
203
+
204
+ The prior is only constructed as enabled when the config says so, so a
205
+ config without an ``endpoint_prior`` block yields base-energy behaviour.
206
+ """
207
+ section = dict(hydrocarbon_cfg or {})
208
+ prior_cfg = EndpointPriorConfig.from_dict(section.get("endpoint_prior"))
209
+ prior = EmpiricalHydrocarbonEndpointPrior(prior_cfg, root=root)
210
+
211
+ terminal_section = dict(section.get("terminal_energy") or {})
212
+ cfg = HydrocarbonTerminalEnergyConfig()
213
+ for key, value in terminal_section.items():
214
+ if hasattr(cfg, key):
215
+ current = getattr(cfg, key)
216
+ setattr(cfg, key, bool(value) if isinstance(current, bool) else float(value))
217
+
218
+ return HydrocarbonTerminalEnergy(
219
+ base_terminal_energy=base_terminal_energy,
220
+ catalog=catalog,
221
+ endpoint_prior=prior,
222
+ config=cfg,
223
+ property_scorer=property_scorer,
224
+ property_config=HydrocarbonPropertyEnergyConfig.from_dict(
225
+ terminal_section.get("property")
226
+ ),
227
+ )
staplebridge/hydrocarbon/tokenizer.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-character monomer tokenizer for the hydrocarbon branch.
2
+
3
+ The lactam path builds states with ``StapleState.from_sequence``, which is
4
+ ``list(seq)`` - one character per residue. That is correct for K/D/E lactam and
5
+ is left untouched. Hydrocarbon anchors are two characters (``S5``, ``R8``), so
6
+ this module provides a *separate* tokenizing constructor. Lactam never calls it.
7
+
8
+ Model-vocabulary projection
9
+ ---------------------------
10
+ ``staplebridge.data.vocab.ALL_TOKENS`` has 24 entries and
11
+ :class:`~staplebridge.models.embeddings.TokenMLPEncoder` sizes its embedding as
12
+ ``nn.Embedding(len(TOKEN_TO_ID), ...)``. Appending ``S5``/``R8`` to that list
13
+ would change the embedding matrix shape and break loading of every existing
14
+ checkpoint. So this module does **not** touch the vocab. Instead it projects
15
+ hydrocarbon monomers onto the ncAA tokens the vocab already carries:
16
+
17
+ S3, S5, S8 -> "X" (X is already the StaPep alias for S5)
18
+ R3, R5, R8 -> "B"
19
+ Aib -> "X"
20
+ Nle -> "B"
21
+
22
+ The projection is only applied where a tensor of token ids is needed. The
23
+ authoritative state keeps the true monomer tokens, so anchor typing, catalog
24
+ matching and the endpoint prior all see ``S5``/``R8`` exactly.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Final
30
+
31
+ from staplebridge.chemistry.state import StapleState
32
+ from staplebridge.data.vocab import TOKEN_TO_ID
33
+
34
+ NATURAL_AA: Final[frozenset[str]] = frozenset("ACDEFGHIKLMNPQRSTVWY")
35
+
36
+ #: Hydrocarbon anchor monomers recognised by the tokenizer. Longest-first so a
37
+ #: greedy scan consumes ``S5`` before it can mistake ``S`` for serine.
38
+ HYDROCARBON_ANCHOR_TOKENS: Final[tuple[str, ...]] = (
39
+ "S3",
40
+ "S5",
41
+ "S8",
42
+ "R3",
43
+ "R5",
44
+ "R8",
45
+ )
46
+
47
+ #: Non-anchor non-natural monomers the tokenizer accepts as whole segments.
48
+ OTHER_MONOMER_TOKENS: Final[tuple[str, ...]] = ("Aib", "AIB", "Nle", "NLE")
49
+
50
+ #: Terminal modifications: no residue index, no contribution to length.
51
+ N_TERMINAL_MODS: Final[frozenset[str]] = frozenset({"AC"})
52
+ C_TERMINAL_MODS: Final[frozenset[str]] = frozenset({"NH2"})
53
+
54
+ #: Projection onto tokens the existing model vocabulary already contains. See
55
+ #: the module docstring for why the vocab itself is not extended.
56
+ _MODEL_TOKEN_PROJECTION: Final[dict[str, str]] = {
57
+ "S3": "X",
58
+ "S5": "X",
59
+ "S8": "X",
60
+ "R3": "B",
61
+ "R5": "B",
62
+ "R8": "B",
63
+ "AIB": "X",
64
+ "NLE": "B",
65
+ }
66
+
67
+ _MULTI_CHAR: Final[tuple[str, ...]] = tuple(
68
+ sorted(
69
+ {t.upper() for t in HYDROCARBON_ANCHOR_TOKENS + OTHER_MONOMER_TOKENS},
70
+ key=len,
71
+ reverse=True,
72
+ )
73
+ )
74
+
75
+
76
+ class HydrocarbonTokenizationError(ValueError):
77
+ """Raised when a sequence cannot be fully consumed into known monomers."""
78
+
79
+
80
+ def is_anchor_token(token: str) -> bool:
81
+ """True when ``token`` is a hydrocarbon staple anchor monomer."""
82
+ return token.upper() in {t.upper() for t in HYDROCARBON_ANCHOR_TOKENS}
83
+
84
+
85
+ def normalize_monomer(token: str) -> str:
86
+ """Canonicalise one monomer token (``s5`` -> ``S5``, ``Aib`` -> ``AIB``)."""
87
+ upper = token.upper()
88
+ if upper in {t.upper() for t in HYDROCARBON_ANCHOR_TOKENS}:
89
+ return upper
90
+ if upper in {"AIB", "NLE"}:
91
+ return upper
92
+ return upper
93
+
94
+
95
+ def tokenize_sequence(sequence: str) -> list[str]:
96
+ """Tokenize a hydrocarbon-style sequence into monomer tokens.
97
+
98
+ Supports dash-delimited segments, undelimited runs and mixtures, plus
99
+ ``Ac-``/``-NH2`` terminal modifications (which are dropped from the residue
100
+ list because they carry no residue index).
101
+
102
+ Args:
103
+ sequence: e.g. ``"TSFR8EYWALLS5"``, ``"Ac-ISF-R8-ELLDYY-S5-ESGS"``.
104
+
105
+ Returns:
106
+ One canonical monomer token per residue.
107
+
108
+ Raises:
109
+ HydrocarbonTokenizationError: if any part cannot be consumed. Nothing is
110
+ guessed.
111
+ """
112
+ if sequence is None or not str(sequence).strip():
113
+ raise HydrocarbonTokenizationError("empty sequence")
114
+
115
+ text = str(sequence).strip().strip("-")
116
+ segments = [s for s in text.split("-") if s]
117
+ tokens: list[str] = []
118
+
119
+ for position, segment in enumerate(segments):
120
+ upper = segment.upper()
121
+ if upper in N_TERMINAL_MODS and position == 0:
122
+ continue
123
+ if upper in C_TERMINAL_MODS and position == len(segments) - 1:
124
+ continue
125
+ if upper in _MULTI_CHAR:
126
+ tokens.append(normalize_monomer(upper))
127
+ continue
128
+ tokens.extend(_tokenize_run(segment))
129
+
130
+ if not tokens:
131
+ raise HydrocarbonTokenizationError(
132
+ f"sequence {sequence!r} contained no residues"
133
+ )
134
+ return tokens
135
+
136
+
137
+ def _tokenize_run(run: str) -> list[str]:
138
+ """Longest-match scan of one undelimited run."""
139
+ upper = run.upper()
140
+ tokens: list[str] = []
141
+ index = 0
142
+ while index < len(upper):
143
+ for candidate in _MULTI_CHAR:
144
+ if upper.startswith(candidate, index):
145
+ tokens.append(normalize_monomer(candidate))
146
+ index += len(candidate)
147
+ break
148
+ else:
149
+ char = upper[index]
150
+ if char in NATURAL_AA:
151
+ tokens.append(char)
152
+ index += 1
153
+ else:
154
+ raise HydrocarbonTokenizationError(
155
+ f"unrecognized character {char!r} at offset {index} of {run!r}"
156
+ )
157
+ return tokens
158
+
159
+
160
+ def state_from_sequence(sequence: str, **kwargs: object) -> StapleState:
161
+ """Build a :class:`StapleState` whose tokens are hydrocarbon monomers.
162
+
163
+ This is the hydrocarbon counterpart of ``StapleState.from_sequence``. It is
164
+ a separate function precisely so the lactam constructor keeps its exact
165
+ per-character behaviour.
166
+ """
167
+ return StapleState(sequence_tokens=tokenize_sequence(sequence), **kwargs)
168
+
169
+
170
+ def anchor_positions(tokens: list[str]) -> list[int]:
171
+ """Residue indices of the hydrocarbon anchors, N-to-C."""
172
+ return [i for i, t in enumerate(tokens) if is_anchor_token(t)]
173
+
174
+
175
+ def to_model_tokens(tokens: list[str]) -> list[str]:
176
+ """Project monomer tokens onto tokens present in the existing model vocab.
177
+
178
+ Multi-character hydrocarbon monomers are mapped onto the ``X``/``B`` ncAA
179
+ tokens that ``staplebridge.data.vocab`` already defines, so the embedding
180
+ matrix keeps its original size and old checkpoints still load. Natural
181
+ residues pass through unchanged.
182
+ """
183
+ projected: list[str] = []
184
+ for token in tokens:
185
+ upper = token.upper()
186
+ if upper in _MODEL_TOKEN_PROJECTION:
187
+ projected.append(_MODEL_TOKEN_PROJECTION[upper])
188
+ elif token in TOKEN_TO_ID:
189
+ projected.append(token)
190
+ elif upper in TOKEN_TO_ID:
191
+ projected.append(upper)
192
+ else:
193
+ projected.append("<unk>")
194
+ return projected
195
+
196
+
197
+ def to_display_sequence(tokens: list[str]) -> str:
198
+ """Human-readable sequence string, dash-separating multi-character monomers.
199
+
200
+ ``"".join`` would render ``[..., "S5", ...]`` ambiguously against a real
201
+ ``S`` followed by a literal ``5``, so multi-character monomers are set off
202
+ with dashes.
203
+ """
204
+ parts: list[str] = []
205
+ for token in tokens:
206
+ if len(token) > 1:
207
+ parts.append(f"-{token}-")
208
+ else:
209
+ parts.append(token)
210
+ return "".join(parts).replace("--", "-").strip("-")
staplebridge/integrations/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """External integration utilities."""
staplebridge/integrations/peptiverse.py ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ import sys
6
+ import types
7
+ from pathlib import Path
8
+ from typing import Any, Sequence
9
+
10
+ import torch
11
+
12
+
13
+ PROPERTY_ALIASES = {
14
+ "hemolysis": "hemolysis",
15
+ "nf": "nf",
16
+ "non_fouling": "nf",
17
+ "non-fouling": "nf",
18
+ "solubility": "solubility",
19
+ "toxicity": "toxicity",
20
+ "permeability": "permeability_penetrance",
21
+ "permeability_penetrance": "permeability_penetrance",
22
+ "pampa": "permeability_pampa",
23
+ "permeability_pampa": "permeability_pampa",
24
+ "caco2": "permeability_caco2",
25
+ "permeability_caco2": "permeability_caco2",
26
+ "half_life": "halflife",
27
+ "halflife": "halflife",
28
+ "binding_affinity": "binding_affinity",
29
+ }
30
+
31
+
32
+ def _to_finite_float(x: Any) -> float | None:
33
+ try:
34
+ v = float(x)
35
+ except (TypeError, ValueError):
36
+ return None
37
+ if math.isnan(v) or math.isinf(v):
38
+ return None
39
+ return v
40
+
41
+
42
+ def extract_numeric_score(raw: Any, property_key: str) -> float:
43
+ """Pull the canonical numeric score for ``property_key`` out of ``raw``.
44
+
45
+ For ``binding_affinity`` we prefer ``raw["affinity"]`` then ``raw["score"]``.
46
+ For other properties we prefer ``raw["score"]``. Falls back to walking
47
+ other plausible keys if a top-level dict is given. Raises ``ValueError``
48
+ if no finite float can be produced. When the priority keys do not yield a
49
+ value but the fallback dict-walk does, a ``warnings.warn`` is emitted so
50
+ silent mis-extraction is surfaced.
51
+ """
52
+ import warnings as _warnings
53
+
54
+ pkey = (property_key or "").lower()
55
+ candidates: list[Any] = []
56
+ if isinstance(raw, dict):
57
+ if pkey == "binding_affinity":
58
+ candidates += [raw.get("affinity"), raw.get("score")]
59
+ else:
60
+ candidates += [raw.get("score"), raw.get(pkey)]
61
+ candidates += [raw.get("value"), raw.get("prediction"), raw.get("mean")]
62
+ elif isinstance(raw, (list, tuple)) and raw:
63
+ candidates.append(raw[0])
64
+ else:
65
+ candidates.append(raw)
66
+
67
+ for c in candidates:
68
+ v = _to_finite_float(c)
69
+ if v is not None:
70
+ return v
71
+ if isinstance(raw, dict):
72
+ finite_pairs = [(k, _to_finite_float(v)) for k, v in raw.items()]
73
+ finite_pairs = [(k, v) for k, v in finite_pairs if v is not None]
74
+ if finite_pairs:
75
+ if len(finite_pairs) > 1:
76
+ _warnings.warn(
77
+ f"PeptiVerse score extraction for property={property_key!r}: "
78
+ f"no priority key matched; falling back to dict walk over "
79
+ f"{[k for k, _ in finite_pairs]} -> picked first finite value "
80
+ f"{finite_pairs[0][1]} from key {finite_pairs[0][0]!r}. "
81
+ "This may be wrong — verify the predictor output schema.",
82
+ stacklevel=2,
83
+ )
84
+ return finite_pairs[0][1]
85
+ raise ValueError(
86
+ f"Cannot extract finite numeric score for property={property_key!r} from raw={raw!r}"
87
+ )
88
+
89
+
90
+ class PeptiVerseUnavailable(RuntimeError):
91
+ pass
92
+
93
+
94
+ class PeptiVerseWrapper:
95
+ """Local-import wrapper around ChatterjeeLab/PeptiVerse `inference.py`.
96
+
97
+ The HF repo is expected to be cloned to disk at ``peptiverse_root``; we
98
+ insert that path into ``sys.path`` and import ``inference.PeptiVersePredictor``
99
+ directly. No remote HTTP API is used.
100
+
101
+ Parameters
102
+ ----------
103
+ peptiverse_root : path to the local PeptiVerse repo clone.
104
+ manifest_path : best_models.txt path (defaults to ``peptiverse_root/best_models.txt``).
105
+ classifier_weight_root : weight root (defaults to ``peptiverse_root``).
106
+ device : ``"cpu"`` or ``"cuda"``.
107
+ strict : if True, raise on any init error.
108
+ uncertainty : default ``uncertainty`` flag passed to the underlying
109
+ predictor unless overridden per-call.
110
+ cache_enabled : memoize identical (property, mode, input, target, uncertainty)
111
+ calls to avoid re-running the underlying torch models.
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ peptiverse_root: str | Path,
117
+ manifest_path: str | Path | None = None,
118
+ classifier_weight_root: str | Path | None = None,
119
+ device: str | None = None,
120
+ strict: bool = False,
121
+ uncertainty: bool = False,
122
+ cache_enabled: bool = True,
123
+ hf_cache_dir: str | Path | None = None,
124
+ esm_model_name_or_path: str | Path | None = None,
125
+ peptideclm_model_name_or_path: str | Path | None = None,
126
+ chemberta_model_name_or_path: str | Path | None = None,
127
+ offline: bool = False,
128
+ batch_size: int = 32,
129
+ ) -> None:
130
+ self.root = Path(peptiverse_root) if peptiverse_root else None
131
+ self.manifest_path = Path(manifest_path) if manifest_path else None
132
+ self.classifier_weight_root = (
133
+ Path(classifier_weight_root) if classifier_weight_root else None
134
+ )
135
+ self.device = device
136
+ self.strict = strict
137
+ self.uncertainty = bool(uncertainty)
138
+ self.cache_enabled = bool(cache_enabled)
139
+ self.hf_cache_dir = Path(hf_cache_dir) if hf_cache_dir else None
140
+ self.esm_model_name_or_path = (
141
+ Path(esm_model_name_or_path) if esm_model_name_or_path else None
142
+ )
143
+ self.peptideclm_model_name_or_path = (
144
+ Path(peptideclm_model_name_or_path)
145
+ if peptideclm_model_name_or_path
146
+ else None
147
+ )
148
+ self.chemberta_model_name_or_path = (
149
+ Path(chemberta_model_name_or_path)
150
+ if chemberta_model_name_or_path
151
+ else None
152
+ )
153
+ self.offline = bool(offline)
154
+ self.predictor = None
155
+ self.available = False
156
+ self.init_error: str | None = None
157
+ self._cache: dict[tuple, dict[str, Any]] = {}
158
+ self.cache_hits = 0
159
+ self.cache_misses = 0
160
+ self.batch_size = max(1, int(batch_size))
161
+ self.batch_chunks = 0
162
+ self.batch_oom_retries = 0
163
+ self.batch_oom_giveups = 0
164
+ self._init_predictor()
165
+
166
+ @staticmethod
167
+ def _snapshot_from_cache(cache_root: Path, model_id: str) -> Path | None:
168
+ """Resolve a cached Hugging Face repo to a concrete local snapshot."""
169
+ repo = cache_root / "hub" / f"models--{model_id.replace('/', '--')}"
170
+ snapshots = repo / "snapshots"
171
+ ref = repo / "refs" / "main"
172
+ if ref.is_file():
173
+ revision = ref.read_text(encoding="utf-8").strip()
174
+ candidate = snapshots / revision
175
+ if candidate.is_dir():
176
+ return candidate.resolve()
177
+ candidates = sorted(path for path in snapshots.glob("*") if path.is_dir())
178
+ if len(candidates) == 1:
179
+ return candidates[0].resolve()
180
+ return None
181
+
182
+ def _local_hf_models(self) -> dict[str, Path]:
183
+ configured = {
184
+ "esm_name": self.esm_model_name_or_path,
185
+ "clm_name": self.peptideclm_model_name_or_path,
186
+ "chemberta_name": self.chemberta_model_name_or_path,
187
+ }
188
+ model_ids = {
189
+ "esm_name": "facebook/esm2_t33_650M_UR50D",
190
+ "clm_name": "aaronfeller/PeptideCLM-23M-all",
191
+ "chemberta_name": "DeepChem/ChemBERTa-77M-MLM",
192
+ }
193
+ resolved: dict[str, Path] = {}
194
+ for argument, configured_path in configured.items():
195
+ path = configured_path
196
+ if path is None and self.hf_cache_dir is not None:
197
+ path = self._snapshot_from_cache(
198
+ self.hf_cache_dir, model_ids[argument]
199
+ )
200
+ if path is not None:
201
+ path = path.expanduser().resolve()
202
+ if not path.is_dir():
203
+ raise FileNotFoundError(
204
+ f"local PeptiVerse HF model directory missing: {path}"
205
+ )
206
+ resolved[argument] = path
207
+ return resolved
208
+
209
+ def _init_predictor(self) -> None:
210
+ if self.hf_cache_dir is not None:
211
+ cache_root = self.hf_cache_dir.expanduser().resolve()
212
+ # Deliberately override inherited values. A stale user-level HF
213
+ # cache was the source of tokenizer lookup failures in offline
214
+ # runs; this wrapper's configured local model root is authoritative.
215
+ os.environ["HF_HOME"] = str(cache_root)
216
+ os.environ["HUGGINGFACE_HUB_CACHE"] = str(cache_root / "hub")
217
+ os.environ["TRANSFORMERS_CACHE"] = str(cache_root / "hub")
218
+ if self.offline:
219
+ os.environ["HF_HUB_OFFLINE"] = "1"
220
+ os.environ["TRANSFORMERS_OFFLINE"] = "1"
221
+
222
+ if self.root is None or not self.root.exists():
223
+ self.init_error = f"peptiverse_root does not exist: {self.root}"
224
+ if self.strict:
225
+ raise PeptiVerseUnavailable(self.init_error)
226
+ return
227
+ if not (self.root / "inference.py").exists():
228
+ self.init_error = f"inference.py not found under {self.root}"
229
+ if self.strict:
230
+ raise PeptiVerseUnavailable(self.init_error)
231
+ return
232
+
233
+ sys.path.insert(0, str(self.root))
234
+ try:
235
+ # PeptiVerse imports ``tokenizer.my_tokenizers`` from its own
236
+ # source tree. Some environments also install an unrelated
237
+ # top-level ``tokenizer`` package; once that regular package is in
238
+ # sys.modules it shadows PeptiVerse's namespace directory even
239
+ # though the PeptiVerse root is first on sys.path. Bind the local
240
+ # namespace explicitly before importing inference.
241
+ local_tokenizer = self.root / "tokenizer"
242
+ if (local_tokenizer / "my_tokenizers.py").is_file():
243
+ loaded = sys.modules.get("tokenizer")
244
+ loaded_paths = [
245
+ str(Path(value).resolve())
246
+ for value in (getattr(loaded, "__path__", None) or [])
247
+ ]
248
+ if str(local_tokenizer.resolve()) not in loaded_paths:
249
+ for name in list(sys.modules):
250
+ if name == "tokenizer" or name.startswith("tokenizer."):
251
+ del sys.modules[name]
252
+ namespace = types.ModuleType("tokenizer")
253
+ namespace.__path__ = [str(local_tokenizer)] # type: ignore[attr-defined]
254
+ namespace.__package__ = "tokenizer"
255
+ sys.modules["tokenizer"] = namespace
256
+ from inference import PeptiVersePredictor # type: ignore
257
+
258
+ # Several PeptiVerse MAPIE calibration artifacts were serialized
259
+ # by a training script where this estimator lived in __main__.
260
+ # Re-export the identical local inference.py class there before
261
+ # joblib loads those artifacts. Point predictions do not use MAPIE
262
+ # when uncertainty=False, but resolving the class avoids noisy
263
+ # load failures and preserves optional uncertainty availability.
264
+ try:
265
+ from inference import PassthroughRegressor # type: ignore
266
+
267
+ main_module = sys.modules.get("__main__")
268
+ if main_module is not None and not hasattr(
269
+ main_module, "PassthroughRegressor"
270
+ ):
271
+ setattr(
272
+ main_module,
273
+ "PassthroughRegressor",
274
+ PassthroughRegressor,
275
+ )
276
+ except ImportError:
277
+ pass
278
+
279
+ manifest = self.manifest_path or (self.root / "best_models.txt")
280
+ weight_root = self.classifier_weight_root or self.root
281
+ kwargs: dict[str, Any] = {
282
+ "manifest_path": str(manifest),
283
+ "classifier_weight_root": str(weight_root),
284
+ }
285
+ if self.device is not None:
286
+ kwargs["device"] = self.device
287
+ for argument, model_path in self._local_hf_models().items():
288
+ kwargs[argument] = str(model_path)
289
+ self.predictor = PeptiVersePredictor(**kwargs)
290
+ self.available = True
291
+ except Exception as exc: # noqa: BLE001 - surface any import/init failure
292
+ self.init_error = f"{type(exc).__name__}: {exc}"
293
+ self.available = False
294
+ if self.strict:
295
+ raise PeptiVerseUnavailable(self.init_error) from exc
296
+
297
+ @staticmethod
298
+ def normalize_property_name(key: str) -> str:
299
+ return PROPERTY_ALIASES.get(key.lower(), key)
300
+
301
+ @staticmethod
302
+ def extract_numeric_score(raw: Any, property_key: str) -> float:
303
+ return extract_numeric_score(raw, property_key)
304
+
305
+ def _ensure_available(self) -> None:
306
+ if not self.available or self.predictor is None:
307
+ raise PeptiVerseUnavailable(
308
+ self.init_error or "PeptiVerse predictor is not available"
309
+ )
310
+
311
+ def clear_cache(self) -> None:
312
+ self._cache.clear()
313
+ self.cache_hits = 0
314
+ self.cache_misses = 0
315
+
316
+ def _cache_key(
317
+ self, property_key: str, mode: str, input_str: str, target_seq: str, uncertainty: bool
318
+ ) -> tuple:
319
+ return (property_key, mode, input_str, target_seq, bool(uncertainty))
320
+
321
+ def predict_property(
322
+ self,
323
+ property_key: str,
324
+ input_str: str,
325
+ mode: str = "wt",
326
+ uncertainty: bool | None = None,
327
+ ) -> dict[str, Any]:
328
+ """Run a single non-binding property head.
329
+
330
+ Returns ``{"backend_used": "peptiverse", "raw": <dict>, "score": float,
331
+ "property": <std_key>, "mode": <mode>, "cached": bool}``.
332
+ """
333
+ self._ensure_available()
334
+ std_key = self.normalize_property_name(property_key)
335
+ unc = self.uncertainty if uncertainty is None else bool(uncertainty)
336
+ key = self._cache_key(std_key, mode, input_str, "", unc)
337
+ if self.cache_enabled and key in self._cache:
338
+ self.cache_hits += 1
339
+ cached = dict(self._cache[key])
340
+ cached["cached"] = True
341
+ return cached
342
+
343
+ raw = self.predictor.predict_property( # type: ignore[union-attr]
344
+ std_key, mode, input_str, unc
345
+ )
346
+ score = extract_numeric_score(raw, std_key)
347
+ out = {
348
+ "backend_used": "peptiverse",
349
+ "property": std_key,
350
+ "mode": mode,
351
+ "raw": raw,
352
+ "score": float(score),
353
+ "cached": False,
354
+ }
355
+ if self.cache_enabled:
356
+ self._cache[key] = {k: v for k, v in out.items() if k != "cached"}
357
+ self.cache_misses += 1
358
+ return out
359
+
360
+ def predict_property_batch(
361
+ self,
362
+ property_key: str,
363
+ input_strs: Sequence[str],
364
+ mode: str = "wt",
365
+ uncertainty: bool | None = None,
366
+ batch_size: int | None = None,
367
+ ) -> list[dict[str, Any]]:
368
+ """Run one non-binding property head over many inputs.
369
+
370
+ Same per-item contract as :meth:`predict_property`, returned in input
371
+ order. Duplicates and cache hits are computed once. The speedup comes
372
+ from batching the *embedding* forward pass; the heads still see one
373
+ input at a time, so each score is produced by exactly the code path
374
+ :meth:`predict_property` would have used.
375
+
376
+ Batching an embedder changes the padded sequence length, which perturbs
377
+ the pooled embedding at ~1e-7. That is why only the embeddings are
378
+ batched here and the results are cached under the same keys as the
379
+ scalar path: a later scalar call returns the batch-computed value, so a
380
+ run cannot mix the two conventions for the same input. Verify numeric
381
+ agreement for any new property head before relying on it.
382
+
383
+ On CUDA OOM the offending chunk is retried at half the batch size, down
384
+ to scalar, so a long tail of large molecules degrades rather than fails.
385
+ """
386
+ self._ensure_available()
387
+ std_key = self.normalize_property_name(property_key)
388
+ if std_key == "binding_affinity":
389
+ raise ValueError("use predict_binding_affinity for binding affinity")
390
+ unc = self.uncertainty if uncertainty is None else bool(uncertainty)
391
+ inputs = list(input_strs)
392
+ if not inputs:
393
+ return []
394
+
395
+ # Deduplicate while preserving first-seen order; only uncached, unique
396
+ # inputs reach the backend.
397
+ pending: list[str] = []
398
+ seen: set[str] = set()
399
+ for value in inputs:
400
+ key = self._cache_key(std_key, mode, value, "", unc)
401
+ if self.cache_enabled and key in self._cache:
402
+ continue
403
+ if value in seen:
404
+ continue
405
+ seen.add(value)
406
+ pending.append(value)
407
+
408
+ if pending:
409
+ self._embed_batch(std_key, mode, pending, unc, batch_size)
410
+ # Every input now either was cached or has a warm embedding, so this
411
+ # loop is the unmodified scalar path and defines the returned values.
412
+ return [
413
+ self.predict_property(std_key, value, mode=mode, uncertainty=unc)
414
+ for value in inputs
415
+ ]
416
+
417
+ def _embed_batch(
418
+ self,
419
+ std_key: str,
420
+ mode: str,
421
+ pending: Sequence[str],
422
+ uncertainty: bool,
423
+ batch_size: int | None,
424
+ ) -> None:
425
+ """Warm the underlying embedder's cache for ``pending`` in batches."""
426
+ embedder, pooled_kind = self._embedder_for(std_key, mode)
427
+ if embedder is None:
428
+ return
429
+ size = int(batch_size or self.batch_size)
430
+ index = 0
431
+ items = list(pending)
432
+ while index < len(items):
433
+ chunk = items[index : index + size]
434
+ try:
435
+ self._embed_chunk(embedder, pooled_kind, chunk)
436
+ except torch.cuda.OutOfMemoryError:
437
+ torch.cuda.empty_cache()
438
+ if len(chunk) == 1:
439
+ # Nothing left to split; let the scalar path surface it.
440
+ self.batch_oom_giveups += 1
441
+ index += 1
442
+ continue
443
+ size = max(1, len(chunk) // 2)
444
+ self.batch_oom_retries += 1
445
+ continue
446
+ index += len(chunk)
447
+ self.batch_chunks += 1
448
+
449
+ def _embedder_for(self, std_key: str, mode: str) -> tuple[Any, str]:
450
+ """The embedder and pooling kind the head for ``std_key`` will request."""
451
+ predictor = self.predictor
452
+ meta = getattr(predictor, "meta", {}).get((std_key, mode))
453
+ if meta is None:
454
+ return None, ""
455
+ try:
456
+ embedder = predictor._get_embedder(meta["emb_tag"]) # type: ignore[union-attr]
457
+ except (KeyError, ValueError):
458
+ return None, ""
459
+ # torch_ckpt heads consume unpooled token embeddings; everything else
460
+ # consumes the masked mean pool. Mirrors PeptiVersePredictor._get_features.
461
+ return embedder, ("unpooled" if meta.get("kind") == "torch_ckpt" else "pooled")
462
+
463
+ @staticmethod
464
+ def _embed_chunk(embedder: Any, pooled_kind: str, chunk: Sequence[str]) -> None:
465
+ """Forward ``chunk`` once and store per-item results in the embedder cache.
466
+
467
+ Writes into the embedder's own ``_cache_pooled``/``_cache_unpooled`` so
468
+ the scalar ``pooled``/``unpooled`` calls that follow find them. Items
469
+ already cached are skipped.
470
+ """
471
+ cache_attr = "_cache_pooled" if pooled_kind == "pooled" else "_cache_unpooled"
472
+ cache = getattr(embedder, cache_attr, None)
473
+ if cache is None:
474
+ return
475
+ stripped = [str(value).strip() for value in chunk]
476
+ fresh = [value for value in dict.fromkeys(stripped) if value not in cache]
477
+ if not fresh:
478
+ return
479
+
480
+ tokenized = embedder._tokenize(fresh)
481
+ with torch.no_grad():
482
+ hidden = embedder.model(
483
+ input_ids=tokenized["input_ids"],
484
+ attention_mask=tokenized["attention_mask"],
485
+ ).last_hidden_state
486
+ valid = embedder._valid_mask(tokenized["input_ids"], tokenized["attention_mask"])
487
+
488
+ if pooled_kind == "pooled":
489
+ weights = valid.unsqueeze(-1).float()
490
+ pooled = (hidden * weights).sum(dim=1) / weights.sum(dim=1).clamp(min=1e-9)
491
+ for position, value in enumerate(fresh):
492
+ cache[value] = pooled[position : position + 1]
493
+ return
494
+
495
+ for position, value in enumerate(fresh):
496
+ row = valid[position]
497
+ features = hidden[position : position + 1, row, :]
498
+ mask = torch.ones(
499
+ (1, features.shape[1]), dtype=torch.bool, device=features.device
500
+ )
501
+ cache[value] = (features, mask)
502
+
503
+ def predict_binding_affinity(
504
+ self,
505
+ binder_str: str,
506
+ target_seq: str,
507
+ mode: str = "wt",
508
+ uncertainty: bool | None = None,
509
+ ) -> dict[str, Any]:
510
+ """Run the binding-affinity head against a specific protein target."""
511
+ self._ensure_available()
512
+ if not target_seq:
513
+ raise ValueError("predict_binding_affinity requires target_seq")
514
+ unc = self.uncertainty if uncertainty is None else bool(uncertainty)
515
+ key = self._cache_key("binding_affinity", mode, binder_str, target_seq, unc)
516
+ if self.cache_enabled and key in self._cache:
517
+ self.cache_hits += 1
518
+ cached = dict(self._cache[key])
519
+ cached["cached"] = True
520
+ return cached
521
+
522
+ raw = self.predictor.predict_binding_affinity( # type: ignore[union-attr]
523
+ mode, target_seq, binder_str, unc
524
+ )
525
+ score = extract_numeric_score(raw, "binding_affinity")
526
+ out = {
527
+ "backend_used": "peptiverse",
528
+ "property": "binding_affinity",
529
+ "mode": mode,
530
+ "raw": raw,
531
+ "score": float(score),
532
+ "cached": False,
533
+ }
534
+ if self.cache_enabled:
535
+ self._cache[key] = {k: v for k, v in out.items() if k != "cached"}
536
+ self.cache_misses += 1
537
+ return out
staplebridge/oracles/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """"""
staplebridge/oracles/anchor_prior.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from staplebridge.oracles.base import AnchorPriorBase
6
+
7
+
8
+ class MockAnchorPrior(AnchorPriorBase):
9
+ """Anchor scorer.
10
+
11
+ Without strong priors on what makes a "good" anchor, we just lightly
12
+ favor having an anchor at all and penalize touching protected positions.
13
+ Spacing preferences now live in the block.motif (e.g. STAPLE_LACTAM
14
+ accepts spacings 3 and 4), so this prior no longer hardcodes (4, 7).
15
+ """
16
+
17
+ def score_anchor(self, sequence: list[str], anchor_pair: tuple[int, int] | None, context: dict[str, Any] | None = None) -> float:
18
+ del sequence
19
+ if anchor_pair is None:
20
+ return -0.2
21
+ i, j = anchor_pair
22
+ score = 0.5 # mild bias toward having an anchor
23
+ if context and context.get("protected_positions"):
24
+ protected = set(context["protected_positions"])
25
+ if i in protected or j in protected:
26
+ score -= 1.0
27
+ return score
staplebridge/oracles/base.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from staplebridge.chemistry.state import StapleState
7
+ from staplebridge.data.schemas import BuildingBlock
8
+
9
+
10
+ class PeptidePriorBase(ABC):
11
+ @abstractmethod
12
+ def score_transition(self, old_state: StapleState, new_state: StapleState, context: dict[str, Any] | None = None) -> float:
13
+ pass
14
+
15
+ def batch_score_transitions(
16
+ self,
17
+ old_state: StapleState,
18
+ new_states: list[StapleState],
19
+ context: dict[str, Any] | None = None,
20
+ ) -> list[float]:
21
+ """Score N candidate transitions from ``old_state`` at once.
22
+
23
+ Default implementation just loops ``score_transition``; heavyweight
24
+ priors (ESM2) override this so they can share one model forward across
25
+ all candidates. Non-sequence-changing candidates are expected to
26
+ return exactly 0.0.
27
+ """
28
+ return [
29
+ self.score_transition(old_state, new, context) for new in new_states
30
+ ]
31
+
32
+ def prewarm_requests(
33
+ self, pairs: list[tuple[StapleState, list[StapleState]]]
34
+ ) -> None:
35
+ """Prefetch model outputs for many (z, candidates) pairs at once.
36
+
37
+ Default is a noop — heavy priors (ESM2) override this to run one
38
+ batched model forward covering every request across all pairs, so a
39
+ subsequent per-pair ``batch_score_transitions`` call becomes a pure
40
+ cache-lookup.
41
+ """
42
+ del pairs
43
+
44
+
45
+ class AnchorPriorBase(ABC):
46
+ @abstractmethod
47
+ def score_anchor(self, sequence: list[str], anchor_pair: tuple[int, int] | None, context: dict[str, Any] | None = None) -> float:
48
+ pass
49
+
50
+
51
+ class BlockPriorBase(ABC):
52
+ @abstractmethod
53
+ def score_block(
54
+ self,
55
+ sequence: list[str],
56
+ anchor_pair: tuple[int, int] | None,
57
+ block: BuildingBlock | None,
58
+ context: dict[str, Any] | None = None,
59
+ ) -> float:
60
+ pass
61
+
62
+
63
+ class GeometryOracleBase(ABC):
64
+ @abstractmethod
65
+ def ctype(
66
+ self,
67
+ sequence: list[str],
68
+ anchor_pair: tuple[int, int] | None,
69
+ block: BuildingBlock | None,
70
+ *,
71
+ peptide_ca: list[tuple[float, float, float]] | None = None,
72
+ ) -> bool:
73
+ pass
74
+
75
+ @abstractmethod
76
+ def cgeom(
77
+ self,
78
+ sequence: list[str],
79
+ anchor_pair: tuple[int, int] | None,
80
+ block: BuildingBlock | None,
81
+ *,
82
+ peptide_ca: list[tuple[float, float, float]] | None = None,
83
+ ) -> float:
84
+ pass
staplebridge/oracles/block_prior.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from staplebridge.data.schemas import BuildingBlock
6
+ from staplebridge.oracles.base import BlockPriorBase
7
+
8
+
9
+ def _motif_satisfied(seq: list[str], i: int, j: int, motif: dict | None) -> bool:
10
+ if motif is None:
11
+ return True
12
+ spacings = motif.get("spacings")
13
+ if spacings != "any" and spacings is not None and (j - i) not in spacings:
14
+ return False
15
+ if motif.get("i_aa") and seq[i] not in motif["i_aa"]:
16
+ return False
17
+ if motif.get("j_aa") and seq[j] not in motif["j_aa"]:
18
+ return False
19
+ return True
20
+
21
+
22
+ class MockBlockPrior(BlockPriorBase):
23
+ def score_block(
24
+ self,
25
+ sequence: list[str],
26
+ anchor_pair: tuple[int, int] | None,
27
+ block: BuildingBlock | None,
28
+ context: dict[str, Any] | None = None,
29
+ ) -> float:
30
+ del context
31
+ if block is None:
32
+ return -0.3
33
+ if anchor_pair is None:
34
+ return -1.0
35
+ i, j = anchor_pair
36
+ score = 0.0
37
+ score += 0.5 * block.synthetic_accessibility_score
38
+ score -= 0.2 * block.cost_score
39
+ if _motif_satisfied(sequence, i, j, block.motif):
40
+ score += 1.0
41
+ else:
42
+ score -= 1.0
43
+ return score
staplebridge/oracles/catalog_block_prior.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Catalog-informed block prior.
2
+
3
+ Scores a building block conditional on the current sequence and anchor pair
4
+ using **only** catalog metadata — no PeptiVerse, no learned model. Signals:
5
+
6
+ * spacing compatibility — is (j - i) in the block motif's spacings?
7
+ * residue compatibility — are (seq[i], seq[j]) in the block motif's
8
+ (i_aa, j_aa) sets?
9
+ * motif edit distance — how many substitutions to satisfy the motif?
10
+ * synthetic accessibility, SPPS, cost — pulled from BuildingBlock fields.
11
+
12
+ Works with the existing ``BuildingBlock`` schema (motif dict) *and* legacy
13
+ schemas that expose ``allowed_anchor_spacings`` / ``compatible_residue_types``
14
+ as top-level attributes; the schema has retired the latter, but callers may
15
+ inject them for ablations.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ from staplebridge.data.schemas import BuildingBlock
24
+ from staplebridge.oracles.base import BlockPriorBase, GeometryOracleBase
25
+
26
+
27
+ @dataclass
28
+ class CatalogBlockPriorConfig:
29
+ weights: dict[str, float] = field(default_factory=lambda: {
30
+ "spacing": 2.0,
31
+ "residue_compatibility": 2.0,
32
+ "motif_edit_distance": 1.0,
33
+ "synthetic_accessibility": 0.5,
34
+ "spps": 0.5,
35
+ "cost": 0.2,
36
+ })
37
+ max_motif_edits: int = 2
38
+ no_block_penalty: float = 0.3
39
+ no_anchor_penalty: float = 1.0
40
+
41
+
42
+ class CatalogBlockPrior(BlockPriorBase):
43
+ """Score blocks purely from catalog + motif compatibility."""
44
+
45
+ def __init__(
46
+ self,
47
+ catalog: dict[str, BuildingBlock] | None = None,
48
+ geometry_oracle: GeometryOracleBase | None = None,
49
+ cfg: CatalogBlockPriorConfig | None = None,
50
+ ) -> None:
51
+ self.catalog = catalog or {}
52
+ self.geometry_oracle = geometry_oracle # currently unused; kept for
53
+ # symmetry with the anchor prior and for future extensions.
54
+ self.cfg = cfg or CatalogBlockPriorConfig()
55
+
56
+ def _allowed_spacings(self, block: BuildingBlock) -> list[int] | None:
57
+ # Prefer legacy explicit field if present (ablations may set it).
58
+ allowed = getattr(block, "allowed_anchor_spacings", None)
59
+ if allowed:
60
+ return list(allowed)
61
+ motif = block.motif or {}
62
+ spacings = motif.get("spacings")
63
+ if spacings == "any":
64
+ return None # any spacing OK
65
+ return list(spacings) if spacings else None
66
+
67
+ def _residue_ok(self, seq: list[str], i: int, j: int, block: BuildingBlock) -> tuple[bool, int]:
68
+ """Return (both_sides_ok, edit_count_needed_for_motif)."""
69
+ motif = block.motif or {}
70
+ i_aa = motif.get("i_aa")
71
+ j_aa = motif.get("j_aa")
72
+ legacy = getattr(block, "compatible_residue_types", None)
73
+ if not i_aa and isinstance(legacy, dict):
74
+ i_aa = legacy.get("i")
75
+ if not j_aa and isinstance(legacy, dict):
76
+ j_aa = legacy.get("j")
77
+ edits = 0
78
+ i_ok = True
79
+ j_ok = True
80
+ if i_aa and 0 <= i < len(seq):
81
+ i_ok = seq[i] in i_aa
82
+ if not i_ok:
83
+ edits += 1
84
+ if j_aa and 0 <= j < len(seq):
85
+ j_ok = seq[j] in j_aa
86
+ if not j_ok:
87
+ edits += 1
88
+ return (i_ok and j_ok), edits
89
+
90
+ def score_block(
91
+ self,
92
+ sequence: list[str],
93
+ anchor_pair: tuple[int, int] | None,
94
+ block: BuildingBlock | None,
95
+ context: dict[str, Any] | None = None,
96
+ ) -> float:
97
+ del context
98
+ if block is None:
99
+ return -self.cfg.no_block_penalty
100
+ if anchor_pair is None:
101
+ return -self.cfg.no_anchor_penalty
102
+ i, j = anchor_pair
103
+ if i > j:
104
+ i, j = j, i
105
+ w = self.cfg.weights
106
+ score = 0.0
107
+
108
+ # 1. Spacing
109
+ allowed = self._allowed_spacings(block)
110
+ if allowed is None:
111
+ score += 0.5 * float(w.get("spacing", 2.0))
112
+ elif (j - i) in allowed:
113
+ score += float(w.get("spacing", 2.0))
114
+ else:
115
+ # Distance-to-nearest-allowed spacing controls the size of the
116
+ # penalty (adjacent spacings hurt less than far-away ones).
117
+ gap = min(abs((j - i) - a) for a in allowed)
118
+ score -= float(w.get("spacing", 2.0)) * (0.5 + 0.5 * gap)
119
+
120
+ # 2. Residue compatibility + motif edit distance
121
+ residues_ok, edits = self._residue_ok(sequence, i, j, block)
122
+ if residues_ok:
123
+ score += float(w.get("residue_compatibility", 2.0))
124
+ else:
125
+ score -= float(w.get("residue_compatibility", 2.0)) * 0.5
126
+ # motif_edit_distance term: 0 -> +weight, 1 -> +0.4*weight, 2 -> 0,
127
+ # >max -> -weight.
128
+ if edits == 0:
129
+ score += float(w.get("motif_edit_distance", 1.0))
130
+ elif edits == 1:
131
+ score += 0.4 * float(w.get("motif_edit_distance", 1.0))
132
+ elif edits == 2:
133
+ score += 0.0
134
+ elif edits <= self.cfg.max_motif_edits:
135
+ score -= 0.5 * float(w.get("motif_edit_distance", 1.0))
136
+ else:
137
+ score -= float(w.get("motif_edit_distance", 1.0))
138
+
139
+ # 3. Synthetic accessibility (larger better) / SPPS (larger better) / cost (smaller better)
140
+ score += float(w.get("synthetic_accessibility", 0.5)) * float(block.synthetic_accessibility_score)
141
+ score += float(w.get("spps", 0.5)) * float(block.spps_score)
142
+ score -= float(w.get("cost", 0.2)) * float(block.cost_score)
143
+
144
+ return float(score)
staplebridge/oracles/esm2_peptide_prior.py ADDED
@@ -0,0 +1,565 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ESM2-based peptide prior — batched, dedup-friendly.
2
+
3
+ Scores a state transition by the change in ESM2 masked log-prob at the
4
+ sequence positions that changed. Non-sequence transitions (topology / anchor /
5
+ block only) return exactly ``0.0`` and cost nothing.
6
+
7
+ Correctness note (motivating the batching design):
8
+ A substitution changes exactly one position ``p``. To score it we want
9
+ ``lp_new = log P(new_aa | seq_new masked at p)`` and
10
+ ``lp_old = log P(old_aa | seq_old masked at p)``.
11
+ Since ``seq_old`` and ``seq_new`` differ only at ``p`` and we mask ``p``,
12
+ the masked input is *identical* on both sides. One ESM2 forward over the
13
+ masked sequence yields the full 20-AA log-prob vector at ``p``, from
14
+ which we index both ``lp_old`` and ``lp_new``.
15
+
16
+ That means:
17
+ * one ESM2 forward per unique ``(masked_seq_str, pos)`` covers many candidates
18
+ that only differ in the substituted amino acid at ``p``;
19
+ * ``batch_score_transitions`` collects unique requests across an entire
20
+ candidate neighborhood, deduplicates by ``(masked_seq, pos)``, and
21
+ pushes cache misses through **one** batched ESM2 forward pass;
22
+ * the cache stores the full 20-vector, so future single-position lookups
23
+ cost O(1) with no additional forwards.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import os
30
+ import sqlite3
31
+ import struct
32
+ import threading
33
+ from collections import OrderedDict
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from staplebridge.chemistry.state import StapleState
38
+ from staplebridge.oracles.base import PeptidePriorBase
39
+ from staplebridge.utils.profiling import STAGE_TIMER
40
+
41
+
42
+ CANONICAL_AA = "ACDEFGHIKLMNPQRSTVWY"
43
+ _AA_TO_IDX = {a: i for i, a in enumerate(CANONICAL_AA)}
44
+ _DEFAULT_SURROGATE = {"X": "A", "B": "A"}
45
+ _VECTOR_LEN = len(CANONICAL_AA) # 20
46
+ _VECTOR_STRUCT = struct.Struct(f"<{_VECTOR_LEN}f") # 80 bytes
47
+
48
+
49
+ class _SqliteVectorCache:
50
+ """Persistent cache mapping ``(masked_seq, pos) -> 20-AA log-prob vector``.
51
+
52
+ Stores the vector as an 80-byte little-endian float32 blob. Failures never
53
+ propagate — the in-memory OrderedDict continues to satisfy hits.
54
+ """
55
+
56
+ def __init__(self, path: str | None, memory_size: int = 65536, commit_every: int = 1024) -> None:
57
+ self.memory_size = int(memory_size)
58
+ self._mem: "OrderedDict[tuple[str, int], list[float]]" = OrderedDict()
59
+ self._lock = threading.Lock()
60
+ self._db: sqlite3.Connection | None = None
61
+ self.path = path
62
+ # Batched-commit bookkeeping. Vectors are served from ``_mem`` the moment
63
+ # they are ``put`` (before any commit), so deferring the sqlite commit
64
+ # cannot change a single returned value or its byte content -- it only
65
+ # changes *when* rows are made durable. On a crash the at-most
66
+ # ``commit_every`` uncommitted vectors are simply recomputed next run.
67
+ self._commit_every = max(1, int(commit_every))
68
+ self._pending = 0
69
+ if path:
70
+ try:
71
+ p = Path(path)
72
+ p.parent.mkdir(parents=True, exist_ok=True)
73
+ self._db = sqlite3.connect(str(p), check_same_thread=False)
74
+ # WAL + NORMAL: one fsync per commit instead of the default
75
+ # journal's rewrite-and-fsync-per-commit. Combined with batched
76
+ # commits this removes the ~1 s/vector fsync that dominated the
77
+ # cold-cache rollout path.
78
+ self._db.execute("PRAGMA journal_mode=WAL")
79
+ self._db.execute("PRAGMA synchronous=NORMAL")
80
+ self._db.execute(
81
+ "CREATE TABLE IF NOT EXISTS vec_scores "
82
+ "(seq TEXT, pos INTEGER, vec BLOB, "
83
+ "PRIMARY KEY(seq, pos))"
84
+ )
85
+ self._db.commit()
86
+ except Exception:
87
+ self._db = None
88
+
89
+ def get(self, seq: str, pos: int) -> list[float] | None:
90
+ key = (seq, int(pos))
91
+ with self._lock:
92
+ v = self._mem.get(key)
93
+ if v is not None:
94
+ self._mem.move_to_end(key)
95
+ return v
96
+ if self._db is not None:
97
+ try:
98
+ cur = self._db.execute(
99
+ "SELECT vec FROM vec_scores WHERE seq=? AND pos=?",
100
+ (seq, int(pos)),
101
+ )
102
+ row = cur.fetchone()
103
+ if row is not None:
104
+ blob = row[0]
105
+ if isinstance(blob, (bytes, bytearray)) and len(blob) == _VECTOR_STRUCT.size:
106
+ vec = list(_VECTOR_STRUCT.unpack(bytes(blob)))
107
+ self._put_mem(key, vec)
108
+ return vec
109
+ except Exception:
110
+ pass
111
+ return None
112
+
113
+ def put(self, seq: str, pos: int, vec: list[float]) -> None:
114
+ if len(vec) != _VECTOR_LEN:
115
+ return
116
+ key = (seq, int(pos))
117
+ self._put_mem(key, list(vec))
118
+ if self._db is not None:
119
+ try:
120
+ blob = _VECTOR_STRUCT.pack(*[float(x) for x in vec])
121
+ self._db.execute(
122
+ "INSERT OR REPLACE INTO vec_scores(seq, pos, vec) VALUES (?, ?, ?)",
123
+ (seq, int(pos), blob),
124
+ )
125
+ # Defer the commit; flush() (called once per forward batch by
126
+ # _get_vectors) makes the batch durable. A safety cap bounds the
127
+ # unflushed set if a single batch is unusually large.
128
+ self._pending += 1
129
+ if self._pending >= self._commit_every:
130
+ self._db.commit()
131
+ self._pending = 0
132
+ except Exception:
133
+ pass
134
+
135
+ def flush(self) -> None:
136
+ """Commit any rows written since the last commit. Safe to call always."""
137
+ if self._db is not None and self._pending:
138
+ try:
139
+ self._db.commit()
140
+ except Exception:
141
+ pass
142
+ self._pending = 0
143
+
144
+ def _put_mem(self, key: tuple[str, int], val: list[float]) -> None:
145
+ with self._lock:
146
+ self._mem[key] = val
147
+ self._mem.move_to_end(key)
148
+ while len(self._mem) > self.memory_size:
149
+ self._mem.popitem(last=False)
150
+
151
+
152
+ class ESM2DeltaPeptidePrior(PeptidePriorBase):
153
+ """Score transitions by masked-position log-prob under a frozen ESM2.
154
+
155
+ Config surface (mirrors :func:`build_peptide_prior`):
156
+ model_name_or_path : str — local snapshot dir; can be omitted if a
157
+ live ``ESM2FrozenEncoder`` is passed.
158
+ cache_path : str | None — sqlite path for persistent caching.
159
+ Legacy scalar-cache DBs are ignored;
160
+ a new ``vec_scores`` table is used.
161
+ ncaa_policy : dict — {"mode": "canonical_surrogate",
162
+ "surrogates": {...},
163
+ "unknown_penalty": 0.2}
164
+ offline : bool — force offline (default respects env vars).
165
+ device : str — "cpu" | "cuda" | "cuda:N".
166
+ temperature : float — softmax temperature (default 1.0).
167
+ max_batch_size : int — cap for a single ESM2 forward batch (safety
168
+ valve for very large neighborhoods).
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ model_name_or_path: str | None = None,
174
+ *,
175
+ sequence_encoder: Any = None,
176
+ cache_path: str | None = None,
177
+ ncaa_policy: dict[str, Any] | None = None,
178
+ offline: bool = False,
179
+ device: str = "cpu",
180
+ temperature: float = 1.0,
181
+ max_batch_size: int = 128,
182
+ memory_cache_size: int = 65536,
183
+ strict_runtime: bool = False,
184
+ ) -> None:
185
+ self.model_name_or_path = model_name_or_path
186
+ self.device = device
187
+ self.temperature = max(float(temperature), 1e-6)
188
+ pol = dict(ncaa_policy or {})
189
+ self.ncaa_mode = pol.get("mode", "canonical_surrogate")
190
+ self.surrogates = dict(_DEFAULT_SURROGATE)
191
+ self.surrogates.update(pol.get("surrogates") or {})
192
+ self.unknown_penalty = float(pol.get("unknown_penalty", 0.2))
193
+ self.offline = bool(offline)
194
+ self._external_encoder = sequence_encoder
195
+ self.max_batch_size = max(int(max_batch_size), 1)
196
+ self.strict_runtime = bool(strict_runtime)
197
+
198
+ self._cache = _SqliteVectorCache(cache_path, memory_size=memory_cache_size)
199
+ self._model = None
200
+ self._tokenizer = None
201
+ self._vocab_ids: list[int] | None = None # canonical-order lookup
202
+ self._mask_id: int | None = None
203
+ self._init_lock = threading.Lock()
204
+ self._init_error: str | None = None
205
+
206
+ # ------------------------------------------------------------------
207
+ # Lazy model construction
208
+ # ------------------------------------------------------------------
209
+ def _ensure_loaded(self) -> bool:
210
+ if self._model is not None or self._init_error:
211
+ return self._model is not None
212
+ with self._init_lock:
213
+ if self._model is not None:
214
+ return True
215
+ if self._init_error:
216
+ return False
217
+ if self.offline:
218
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
219
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
220
+ try:
221
+ if self._external_encoder is not None:
222
+ self._tokenizer = self._external_encoder.tokenizer
223
+ from transformers import EsmForMaskedLM # type: ignore
224
+
225
+ path = getattr(self._external_encoder, "model_path", None) or self.model_name_or_path
226
+ if path is None:
227
+ raise ValueError(
228
+ "ESM2DeltaPeptidePrior needs a model_name_or_path; "
229
+ "the passed encoder has no `model_path` attribute."
230
+ )
231
+ self._model = EsmForMaskedLM.from_pretrained(path)
232
+ elif self.model_name_or_path:
233
+ from transformers import EsmForMaskedLM, EsmTokenizer # type: ignore
234
+
235
+ self._tokenizer = EsmTokenizer.from_pretrained(self.model_name_or_path)
236
+ self._model = EsmForMaskedLM.from_pretrained(self.model_name_or_path)
237
+ else:
238
+ raise ValueError(
239
+ "ESM2DeltaPeptidePrior: neither sequence_encoder nor "
240
+ "model_name_or_path was provided."
241
+ )
242
+ self._model.eval()
243
+ for p in self._model.parameters():
244
+ p.requires_grad_(False)
245
+ try:
246
+ import torch # type: ignore
247
+
248
+ if self.device.startswith("cuda") and torch.cuda.is_available():
249
+ self._model.to(self.device)
250
+ else:
251
+ self._model.to("cpu")
252
+ except Exception:
253
+ pass
254
+ mask_tok = getattr(self._tokenizer, "mask_token", "<mask>")
255
+ self._mask_id = self._tokenizer.convert_tokens_to_ids(mask_tok)
256
+ self._vocab_ids = [
257
+ int(self._tokenizer.convert_tokens_to_ids(aa))
258
+ for aa in CANONICAL_AA
259
+ ]
260
+ return True
261
+ except Exception as exc:
262
+ self._init_error = f"{type(exc).__name__}: {exc}"
263
+ self._model = None
264
+ if self.strict_runtime:
265
+ raise RuntimeError(
266
+ f"strict ESM2 peptide prior unavailable: {self._init_error}"
267
+ ) from exc
268
+ return False
269
+
270
+ def ensure_available(self) -> None:
271
+ """Eagerly verify the configured frozen model for strict runs."""
272
+ if not self._ensure_loaded():
273
+ raise RuntimeError(f"ESM2 peptide prior unavailable: {self._init_error or 'unknown error'}")
274
+
275
+ # ------------------------------------------------------------------
276
+ # Token surrogate
277
+ # ------------------------------------------------------------------
278
+ def _canonicalize(self, seq: list[str]) -> tuple[str, float]:
279
+ out: list[str] = []
280
+ penalty = 0.0
281
+ for t in seq:
282
+ if isinstance(t, str) and len(t) == 1 and t.upper() in _AA_TO_IDX:
283
+ out.append(t.upper())
284
+ continue
285
+ if self.ncaa_mode == "canonical_surrogate":
286
+ sub = self.surrogates.get(t) or self.surrogates.get(str(t).upper())
287
+ if sub and sub in _AA_TO_IDX:
288
+ out.append(sub)
289
+ continue
290
+ out.append("A")
291
+ penalty += self.unknown_penalty
292
+ return "".join(out), penalty
293
+
294
+ # ------------------------------------------------------------------
295
+ # Batched forward — the whole point of this rewrite
296
+ # ------------------------------------------------------------------
297
+ def _forward_batch(
298
+ self, requests: list[tuple[str, int]]
299
+ ) -> dict[tuple[str, int], list[float]]:
300
+ """Compute the 20-AA log-prob vector at ``pos`` under masked ``seq``.
301
+
302
+ Returns a dict keyed by ``(seq, pos)``. Falls back to a zero-vector on
303
+ any error; the caller then treats it as a benign 0-delta contribution.
304
+ """
305
+ if not requests or not self._ensure_loaded():
306
+ return {}
307
+ try:
308
+ import torch # type: ignore
309
+ except Exception:
310
+ return {}
311
+
312
+ results: dict[tuple[str, int], list[float]] = {}
313
+ # Chunk requests to bound peak activation memory.
314
+ for start in range(0, len(requests), self.max_batch_size):
315
+ chunk = requests[start : start + self.max_batch_size]
316
+ seqs = [seq for seq, _ in chunk]
317
+ try:
318
+ enc = self._tokenizer(
319
+ seqs,
320
+ return_tensors="pt",
321
+ add_special_tokens=True,
322
+ padding=True,
323
+ )
324
+ input_ids = enc["input_ids"].clone()
325
+ attn = enc.get("attention_mask")
326
+ mask_positions: list[int] = []
327
+ valid: list[bool] = []
328
+ for i, (seq, pos) in enumerate(chunk):
329
+ mp = pos + 1 # <cls> shift
330
+ if mp <= 0 or mp >= input_ids.shape[1]:
331
+ mask_positions.append(0)
332
+ valid.append(False)
333
+ continue
334
+ input_ids[i, mp] = int(self._mask_id) # type: ignore[arg-type]
335
+ mask_positions.append(mp)
336
+ valid.append(True)
337
+ device = next(self._model.parameters()).device
338
+ input_ids = input_ids.to(device)
339
+ if attn is not None:
340
+ attn = attn.to(device)
341
+ with STAGE_TIMER.section("esm2_forward_time"):
342
+ with torch.no_grad():
343
+ out = self._model(input_ids=input_ids, attention_mask=attn)
344
+ STAGE_TIMER.bump("esm2_forward_batches")
345
+ STAGE_TIMER.bump("esm2_forward_calls", len(chunk))
346
+ # Gather per-row logits at each row's mask position.
347
+ logits = out.logits # [B, L, V]
348
+ idx = torch.arange(logits.shape[0], device=logits.device)
349
+ mp_tensor = torch.tensor(
350
+ mask_positions, device=logits.device, dtype=torch.long
351
+ )
352
+ pos_logits = logits[idx, mp_tensor] / self.temperature # [B, V]
353
+ logprobs = torch.log_softmax(pos_logits, dim=-1)
354
+ # Column-select the 20 canonical-AA ids in one gather.
355
+ aa_ids = torch.tensor(
356
+ self._vocab_ids or [], device=logprobs.device, dtype=torch.long
357
+ )
358
+ lp20 = logprobs.index_select(dim=-1, index=aa_ids) # [B, 20]
359
+ lp20_cpu = lp20.detach().to("cpu").tolist()
360
+ for i, (key, ok) in enumerate(zip(chunk, valid)):
361
+ if not ok:
362
+ results[key] = [0.0] * _VECTOR_LEN
363
+ else:
364
+ results[key] = list(lp20_cpu[i])
365
+ except Exception as exc:
366
+ if self.strict_runtime:
367
+ raise RuntimeError(f"strict ESM2 peptide-prior forward failed: {exc}") from exc
368
+ # On any failure, degrade gracefully — mark this chunk as zeros.
369
+ for key in chunk:
370
+ results[key] = [0.0] * _VECTOR_LEN
371
+ return results
372
+
373
+ def _get_vectors(
374
+ self, requests: list[tuple[str, int]]
375
+ ) -> dict[tuple[str, int], list[float]]:
376
+ """Return log-prob vectors for each ``(masked_seq, pos)`` request.
377
+
378
+ Cache-hit vectors come from memory/sqlite; cache-miss requests are
379
+ deduped and pushed through a single batched ESM2 forward.
380
+ """
381
+ vectors: dict[tuple[str, int], list[float]] = {}
382
+ misses: list[tuple[str, int]] = []
383
+ seen_miss: set[tuple[str, int]] = set()
384
+ for key in requests:
385
+ if key in vectors:
386
+ continue
387
+ cached = self._cache.get(key[0], key[1])
388
+ if cached is not None:
389
+ STAGE_TIMER.bump("esm2_cache_hit")
390
+ vectors[key] = cached
391
+ else:
392
+ if key not in seen_miss:
393
+ STAGE_TIMER.bump("esm2_cache_miss")
394
+ misses.append(key)
395
+ seen_miss.add(key)
396
+ if misses:
397
+ fresh = self._forward_batch(misses)
398
+ for key, vec in fresh.items():
399
+ self._cache.put(key[0], key[1], vec)
400
+ vectors[key] = vec
401
+ # One commit per forward-batch group rather than one per vector.
402
+ self._cache.flush()
403
+ return vectors
404
+
405
+ # ------------------------------------------------------------------
406
+ # Scoring — single position substitution shortcut
407
+ # ------------------------------------------------------------------
408
+ def _substitution_positions(
409
+ self, old_str: str, new_str: str
410
+ ) -> list[int] | None:
411
+ """Return positions where old and new differ; ``None`` if lengths don't match."""
412
+ if len(old_str) != len(new_str):
413
+ return None
414
+ return [i for i in range(len(new_str)) if old_str[i] != new_str[i]]
415
+
416
+ @staticmethod
417
+ def _mask_at(seq: str, pos: int) -> str:
418
+ # We keep the sequence string as-is; the actual mask token replaces the
419
+ # tokenized input in ``_forward_batch``. What matters is that all
420
+ # candidates masking the same (base_seq, pos) share the same key —
421
+ # base_seq is enough. The seq string carries every non-masked position
422
+ # verbatim.
423
+ return seq
424
+
425
+ # ------------------------------------------------------------------
426
+ # PeptidePriorBase — scalar API
427
+ # ------------------------------------------------------------------
428
+ def score_transition(
429
+ self,
430
+ old_state: StapleState,
431
+ new_state: StapleState,
432
+ context: dict[str, Any] | None = None,
433
+ ) -> float:
434
+ return self.batch_score_transitions(old_state, [new_state], context)[0]
435
+
436
+ def batch_score_transitions(
437
+ self,
438
+ old_state: StapleState,
439
+ new_states: list[StapleState],
440
+ context: dict[str, Any] | None = None,
441
+ ) -> list[float]:
442
+ del context
443
+ n = len(new_states)
444
+ if n == 0:
445
+ return []
446
+ needed, plans, old_str = self._plan_and_collect(old_state, new_states)
447
+ vectors = self._get_vectors(needed) if needed else {}
448
+ return self._finalize_plans(plans, vectors)
449
+
450
+ # ------------------------------------------------------------------
451
+ # Public batching helpers (used by Decoder to share one ESM2 forward
452
+ # across many rollouts).
453
+ # ------------------------------------------------------------------
454
+
455
+ def prewarm_requests(
456
+ self, pairs: list[tuple[StapleState, list[StapleState]]]
457
+ ) -> None:
458
+ """Force one batched ESM2 forward covering all cache misses across
459
+ the given ``(z, candidates)`` pairs. Subsequent
460
+ ``batch_score_transitions`` calls with the same pairs will hit the
461
+ memory cache and do zero forwards.
462
+ """
463
+ all_needed: list[tuple[str, int]] = []
464
+ for z, cands in pairs:
465
+ if not cands:
466
+ continue
467
+ needed, _plans, _old = self._plan_and_collect(z, cands)
468
+ all_needed.extend(needed)
469
+ if all_needed:
470
+ self._get_vectors(all_needed)
471
+
472
+ # ------------------------------------------------------------------
473
+ # Internals shared with prewarm
474
+ # ------------------------------------------------------------------
475
+
476
+ def _plan_and_collect(
477
+ self, old_state: StapleState, new_states: list[StapleState]
478
+ ) -> tuple[list[tuple[str, int]], list[Any], str]:
479
+ old_tokens = list(old_state.sequence_tokens)
480
+ old_str, pen_old = self._canonicalize(old_tokens)
481
+ plans: list[Any] = []
482
+ needed: list[tuple[str, int]] = []
483
+ for new_state in new_states:
484
+ new_tokens = list(new_state.sequence_tokens)
485
+ if new_tokens == old_tokens:
486
+ plans.append(("zero",))
487
+ continue
488
+ new_str, pen_new = self._canonicalize(new_tokens)
489
+ surrogate_penalty = -(pen_new - pen_old)
490
+ positions = self._substitution_positions(old_str, new_str)
491
+ if positions is None:
492
+ length_delta_pen = -self.unknown_penalty * abs(
493
+ len(new_str) - len(old_str)
494
+ )
495
+ common = min(len(old_str), len(new_str))
496
+ lc_positions = [i for i in range(common) if old_str[i] != new_str[i]]
497
+ for p in lc_positions:
498
+ needed.append((old_str, p))
499
+ needed.append((new_str, p))
500
+ plans.append(
501
+ (
502
+ "length",
503
+ surrogate_penalty,
504
+ length_delta_pen,
505
+ lc_positions,
506
+ old_str,
507
+ new_str,
508
+ )
509
+ )
510
+ continue
511
+ for p in positions:
512
+ needed.append((old_str, p))
513
+ plans.append(("subst", surrogate_penalty, positions, old_str, new_str))
514
+ return needed, plans, old_str
515
+
516
+ def _finalize_plans(
517
+ self,
518
+ plans: list[Any],
519
+ vectors: dict[tuple[str, int], list[float]],
520
+ ) -> list[float]:
521
+ def _lp(seq: str, pos: int, aa: str) -> float:
522
+ vec = vectors.get((seq, pos))
523
+ if vec is None:
524
+ return 0.0
525
+ i = _AA_TO_IDX.get(aa)
526
+ if i is None:
527
+ return 0.0
528
+ return float(vec[i])
529
+
530
+ out: list[float] = []
531
+ for plan in plans:
532
+ if plan[0] == "zero":
533
+ out.append(0.0)
534
+ continue
535
+ if plan[0] == "subst":
536
+ _, surrogate_penalty, positions, o_str, n_str = plan
537
+ if not positions:
538
+ out.append(float(surrogate_penalty))
539
+ continue
540
+ delta = 0.0
541
+ for p in positions:
542
+ lp_new = _lp(o_str, p, n_str[p])
543
+ lp_old = _lp(o_str, p, o_str[p])
544
+ delta += lp_new - lp_old
545
+ out.append(float(0.1 * delta + surrogate_penalty))
546
+ continue
547
+ if plan[0] == "length":
548
+ _, surrogate_penalty, length_delta_pen, lc_positions, o_str, n_str = plan
549
+ delta = 0.0
550
+ for p in lc_positions:
551
+ lp_new = _lp(n_str, p, n_str[p])
552
+ lp_old = _lp(o_str, p, o_str[p])
553
+ delta += lp_new - lp_old
554
+ out.append(float(delta + length_delta_pen + surrogate_penalty))
555
+ continue
556
+ out.append(0.0)
557
+ return out
558
+
559
+ @property
560
+ def is_available(self) -> bool:
561
+ return self._ensure_loaded()
562
+
563
+ @property
564
+ def init_error(self) -> str | None:
565
+ return self._init_error
staplebridge/oracles/heuristic_peptide_prior.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Heuristic peptide prior — a slightly richer version of MockPeptidePrior.
2
+
3
+ Kept as an ablation option (`peptide.backend: heuristic`). The default real
4
+ prior is :class:`ESM2DeltaPeptidePrior`; the mock/heuristic priors are
5
+ reserved for ablation studies and unit tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from staplebridge.chemistry.state import StapleState
13
+ from staplebridge.data.vocab import AMINO_ACIDS, HYDROPHOBIC, RISKY_TOKENS
14
+ from staplebridge.oracles.base import PeptidePriorBase
15
+
16
+
17
+ class HeuristicPeptidePrior(PeptidePriorBase):
18
+ """Length + composition + risky-residue heuristic.
19
+
20
+ Not a learned model, but discriminative enough to differentiate
21
+ substitutions that touch W/F/B (risky) from neutral ones. Only for
22
+ ablations; production runs must use ``esm2_delta``.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ target_length: int = 12,
28
+ length_weight: float = 1.0,
29
+ risky_penalty: float = 0.3,
30
+ hydrophobic_target: float = 0.45,
31
+ hydrophobic_weight: float = 0.4,
32
+ ) -> None:
33
+ self.target_length = int(target_length)
34
+ self.length_weight = float(length_weight)
35
+ self.risky_penalty = float(risky_penalty)
36
+ self.hydrophobic_target = float(hydrophobic_target)
37
+ self.hydrophobic_weight = float(hydrophobic_weight)
38
+
39
+ def score_transition(
40
+ self,
41
+ old_state: StapleState,
42
+ new_state: StapleState,
43
+ context: dict[str, Any] | None = None,
44
+ ) -> float:
45
+ del old_state, context
46
+ seq = new_state.sequence_tokens
47
+ n = max(len(seq), 1)
48
+ valid = sum(1 for t in seq if t in AMINO_ACIDS)
49
+ length_pen = abs(len(seq) - self.target_length) / self.target_length
50
+ hydro = sum(1 for t in seq if t in HYDROPHOBIC) / n
51
+ risky = sum(1 for t in seq if t in RISKY_TOKENS) / n
52
+ composition_score = valid / n
53
+ length_score = -self.length_weight * length_pen
54
+ hydro_score = -self.hydrophobic_weight * abs(hydro - self.hydrophobic_target)
55
+ risky_score = -self.risky_penalty * risky
56
+ return float(composition_score + length_score + hydro_score + risky_score)
staplebridge/oracles/motif_anchor_prior.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Anchor priors — motif-support + geometry surrogate variant.
2
+
3
+ The mock anchor prior in ``anchor_prior.py`` gives essentially uniform score
4
+ for any anchor, which lets the reference process wander into anchors that
5
+ require heavy edits. This prior scores an anchor pair by:
6
+
7
+ 1. Whether ``j - i`` is inside ``allowed_spacings`` (default {3, 4}).
8
+ 2. How many residue substitutions would be needed to make the anchor
9
+ satisfy any block motif in the catalog (0 → strong bonus,
10
+ 1 → medium, 2 → small; > max_motif_edits → penalty).
11
+ 3. Whether the anchor overlaps ``context["protected_positions"]``
12
+ (strong penalty when ``avoid_protected`` is enabled).
13
+ 4. An optional geometry surrogate: if a geometry oracle is available and
14
+ ``context["peptide_ca"]`` is provided, use ``-cgeom`` as an additive
15
+ term (larger is better).
16
+
17
+ For debugging, callers can pass ``context["return_components"] = True`` and
18
+ retrieve the per-component breakdown from ``context["_components"]`` after the
19
+ call. This avoids changing the base-class signature.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ from staplebridge.data.schemas import BuildingBlock
28
+ from staplebridge.oracles.base import AnchorPriorBase, GeometryOracleBase
29
+
30
+
31
+ @dataclass
32
+ class MotifSupportAnchorConfig:
33
+ allowed_spacings: tuple[int, ...] = (3, 4)
34
+ max_motif_edits: int = 2
35
+ avoid_protected: bool = True
36
+ project_motif_for_geometry: bool = False
37
+ weights: dict[str, float] = field(default_factory=lambda: {
38
+ "valid_spacing": 1.0,
39
+ "existing_motif": 2.0,
40
+ "one_edit_motif": 1.2,
41
+ "two_edit_motif": 0.4,
42
+ "protected_penalty": 4.0,
43
+ "geometry_surrogate": 1.0,
44
+ })
45
+
46
+
47
+ class MotifSupportAnchorPrior(AnchorPriorBase):
48
+ """Anchor scorer aware of catalog motifs, protected positions, and geometry."""
49
+
50
+ def __init__(
51
+ self,
52
+ catalog: dict[str, BuildingBlock] | None = None,
53
+ geometry_oracle: GeometryOracleBase | None = None,
54
+ cfg: MotifSupportAnchorConfig | None = None,
55
+ ) -> None:
56
+ self.catalog = catalog or {}
57
+ self.geometry_oracle = geometry_oracle
58
+ self.cfg = cfg or MotifSupportAnchorConfig()
59
+
60
+ # ------------------------------------------------------------------
61
+ # Motif support
62
+ # ------------------------------------------------------------------
63
+ def _motif_edit_distance(
64
+ self, seq: list[str], i: int, j: int, motif: dict[str, Any] | None
65
+ ) -> int:
66
+ """Number of residue substitutions needed to satisfy ``motif`` at (i, j).
67
+
68
+ Spacing mismatch counts as +infinity (motif is unreachable at this pair).
69
+ """
70
+ if motif is None:
71
+ return 0
72
+ spacings = motif.get("spacings")
73
+ if spacings and spacings != "any" and (j - i) not in spacings:
74
+ return 10**9
75
+ edits = 0
76
+ i_aa = motif.get("i_aa")
77
+ j_aa = motif.get("j_aa")
78
+ if i_aa and 0 <= i < len(seq) and seq[i] not in i_aa:
79
+ edits += 1
80
+ if j_aa and 0 <= j < len(seq) and seq[j] not in j_aa:
81
+ edits += 1
82
+ return edits
83
+
84
+ def _best_motif_edit_distance(
85
+ self, seq: list[str], i: int, j: int
86
+ ) -> int:
87
+ if not self.catalog:
88
+ return 0
89
+ best = min(
90
+ (self._motif_edit_distance(seq, i, j, b.motif) for b in self.catalog.values()),
91
+ default=0,
92
+ )
93
+ return best
94
+
95
+ # ------------------------------------------------------------------
96
+ # AnchorPriorBase
97
+ # ------------------------------------------------------------------
98
+ def score_anchor(
99
+ self,
100
+ sequence: list[str],
101
+ anchor_pair: tuple[int, int] | None,
102
+ context: dict[str, Any] | None = None,
103
+ ) -> float:
104
+ context = context or {}
105
+ components: dict[str, float] = {}
106
+
107
+ if anchor_pair is None:
108
+ score = -0.2
109
+ components["no_anchor"] = score
110
+ if context.get("return_components"):
111
+ context["_components"] = components
112
+ return score
113
+
114
+ i, j = anchor_pair
115
+ if i > j:
116
+ i, j = j, i
117
+ w = self.cfg.weights
118
+ score = 0.0
119
+
120
+ # 1. spacing
121
+ spacing_ok = (j - i) in self.cfg.allowed_spacings
122
+ if spacing_ok:
123
+ components["valid_spacing"] = float(w.get("valid_spacing", 1.0))
124
+ else:
125
+ components["valid_spacing"] = -float(w.get("valid_spacing", 1.0))
126
+ score += components["valid_spacing"]
127
+
128
+ # 2. motif support
129
+ edits = self._best_motif_edit_distance(sequence, i, j)
130
+ if edits == 0:
131
+ components["motif_support"] = float(w.get("existing_motif", 2.0))
132
+ elif edits == 1:
133
+ components["motif_support"] = float(w.get("one_edit_motif", 1.2))
134
+ elif edits == 2:
135
+ components["motif_support"] = float(w.get("two_edit_motif", 0.4))
136
+ elif edits <= self.cfg.max_motif_edits:
137
+ components["motif_support"] = 0.0
138
+ else:
139
+ components["motif_support"] = -float(w.get("existing_motif", 2.0))
140
+ score += components["motif_support"]
141
+
142
+ # 3. protected positions
143
+ protected_pen = 0.0
144
+ if self.cfg.avoid_protected:
145
+ protected = set(context.get("protected_positions") or [])
146
+ if i in protected or j in protected:
147
+ protected_pen = -float(w.get("protected_penalty", 4.0))
148
+ components["protected_penalty"] = protected_pen
149
+ score += protected_pen
150
+
151
+ # 4. geometry surrogate
152
+ geom_score = 0.0
153
+ if self.geometry_oracle is not None and w.get("geometry_surrogate", 0.0) > 0.0:
154
+ peptide_ca = context.get("peptide_ca")
155
+ block = None
156
+ if self.catalog:
157
+ # Prefer the block with a matching (i_aa, j_aa) motif; fall
158
+ # back to any block for a generic cgeom surrogate.
159
+ for b in self.catalog.values():
160
+ if self._motif_edit_distance(sequence, i, j, b.motif) < 10**9:
161
+ block = b
162
+ break
163
+ if block is None:
164
+ block = next(iter(self.catalog.values()))
165
+ try:
166
+ geometry_sequence = sequence
167
+ if self.cfg.project_motif_for_geometry and block is not None:
168
+ # Project only the two motif tokens needed by this block so
169
+ # pre-edit hydrocarbon plans can use the PDB C-alpha window.
170
+ motif = block.motif or {}
171
+ i_tokens = motif.get("i_aa") or []
172
+ j_tokens = motif.get("j_aa") or []
173
+ if i_tokens and j_tokens:
174
+ geometry_sequence = list(sequence)
175
+ geometry_sequence[i] = i_tokens[0]
176
+ geometry_sequence[j] = j_tokens[0]
177
+ cgeom = float(
178
+ self.geometry_oracle.cgeom(
179
+ geometry_sequence, (i, j), block, peptide_ca=peptide_ca
180
+ )
181
+ )
182
+ geom_score = -float(w.get("geometry_surrogate", 1.0)) * cgeom
183
+ except Exception:
184
+ geom_score = 0.0
185
+ components["geometry_surrogate"] = geom_score
186
+ score += geom_score
187
+
188
+ if context.get("return_components"):
189
+ context["_components"] = components
190
+ return float(score)