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.
- .gitattributes +2 -0
- README.md +334 -2
- THIRD_PARTY_NOTICES.md +70 -0
- checkpoints/staplebridge_seed42_best.pt +3 -0
- configs/staplebridge_main.yaml +244 -0
- data/README.md +53 -0
- figure/framework.pdf +3 -0
- figure/framework.png +3 -0
- requirements.txt +21 -0
- scripts/train.py +413 -0
- staplebridge/.DS_Store +0 -0
- staplebridge/__init__.py +4 -0
- staplebridge/chemistry/__init__.py +1 -0
- staplebridge/chemistry/actions.py +80 -0
- staplebridge/chemistry/edit_distance.py +18 -0
- staplebridge/chemistry/edit_metrics.py +97 -0
- staplebridge/chemistry/protected.py +5 -0
- staplebridge/chemistry/state.py +45 -0
- staplebridge/data/__init__.py +1 -0
- staplebridge/data/catalog.py +46 -0
- staplebridge/data/dataset.py +23 -0
- staplebridge/data/schemas.py +58 -0
- staplebridge/data/vocab.py +11 -0
- staplebridge/hydrocarbon/__init__.py +39 -0
- staplebridge/hydrocarbon/actions.py +384 -0
- staplebridge/hydrocarbon/catalog.py +198 -0
- staplebridge/hydrocarbon/curriculum.py +249 -0
- staplebridge/hydrocarbon/data/pair_spacing_probabilities.json +28 -0
- staplebridge/hydrocarbon/endpoint_prior.py +442 -0
- staplebridge/hydrocarbon/exact_sb_cache.py +692 -0
- staplebridge/hydrocarbon/factorized_plan_reference.py +220 -0
- staplebridge/hydrocarbon/geometry.py +102 -0
- staplebridge/hydrocarbon/monomers.py +419 -0
- staplebridge/hydrocarbon/plan_control.py +962 -0
- staplebridge/hydrocarbon/plan_reference.py +932 -0
- staplebridge/hydrocarbon/plan_validation.py +214 -0
- staplebridge/hydrocarbon/property_energy.py +703 -0
- staplebridge/hydrocarbon/smiles_builder.py +1071 -0
- staplebridge/hydrocarbon/terminal_energy.py +227 -0
- staplebridge/hydrocarbon/tokenizer.py +210 -0
- staplebridge/integrations/__init__.py +1 -0
- staplebridge/integrations/peptiverse.py +537 -0
- staplebridge/oracles/__init__.py +1 -0
- staplebridge/oracles/anchor_prior.py +27 -0
- staplebridge/oracles/base.py +84 -0
- staplebridge/oracles/block_prior.py +43 -0
- staplebridge/oracles/catalog_block_prior.py +144 -0
- staplebridge/oracles/esm2_peptide_prior.py +565 -0
- staplebridge/oracles/heuristic_peptide_prior.py +56 -0
- 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 |
-
|
| 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 |
+
[](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
|
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)
|