File size: 13,789 Bytes
bb6d2aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
"""Construction of the StapleBridge training stack.

Builds, in dependency order: the hydrocarbon staple catalog, the geometry
oracle, the transition graph, the reference priors (frozen ESM2 peptide prior,
anchor prior, block prior), the reference energy and kernel, the plan-aware
reference sampler, the terminal energy, the policy and value networks, the
controlled kernel, and the optimizer.

The construction is numerically identical to the run that produced the released
checkpoint.
"""
from __future__ import annotations

import random
from functools import wraps
from pathlib import Path
from typing import Any, Callable

import numpy as np
import torch
import yaml

from staplebridge.chemistry.edit_distance import weighted_edit_distance
from staplebridge.chemistry.state import StapleState
from staplebridge.data.dataset import load_leads
from staplebridge.data.schemas import LeadExample
from staplebridge.hydrocarbon.actions import HydrocarbonTransitionGraph
from staplebridge.hydrocarbon.catalog import hydrocarbon_catalog_from_config
from staplebridge.hydrocarbon.endpoint_prior import (
    EmpiricalHydrocarbonEndpointPrior,
    EndpointPriorConfig,
)
from staplebridge.hydrocarbon.geometry import HydrocarbonGeometryOracle
from staplebridge.hydrocarbon.plan_reference import build_plan_aware_sampler
from staplebridge.hydrocarbon.property_energy import (
    HydrocarbonPropertyEnergyConfig,
    HydrocarbonPropertyScorer,
)
from staplebridge.hydrocarbon.terminal_energy import (
    HydrocarbonTerminalEnergy,
    HydrocarbonTerminalEnergyConfig,
)
from staplebridge.integrations.peptiverse import PeptiVerseWrapper
from staplebridge.models.control_kernel import ControlKernelConfig, ControlledKernel
from staplebridge.models.policy_net import PolicyNet
from staplebridge.models.value_net import ValueNet
from staplebridge.oracles.prior_factory import build_reference_priors
from staplebridge.reference.energy import ReferenceEnergy, ReferenceEnergyConfig
from staplebridge.reference.kernel import ReferenceKernel
from staplebridge.utils.paths import PACKAGE_ROOT


def install_embedding_cache(wrapper: PeptiVerseWrapper) -> dict[str, int]:
    predictor = wrapper.predictor
    if predictor is None:
        raise RuntimeError("PeptiVerse predictor unavailable")
    counts = {"hits": 0, "misses": 0}
    for name in ("wt_embedder", "smiles_embedder", "chemberta_embedder"):
        embedder = getattr(predictor, name)
        for method_name in ("pooled", "unpooled"):
            original: Callable[[str], Any] = getattr(embedder, method_name)
            cache: dict[str, Any] = {}

            @wraps(original)
            def cached(value: str, _original=original, _cache=cache):
                if value in _cache:
                    counts["hits"] += 1
                    return _cache[value]
                counts["misses"] += 1
                result = _original(value)
                _cache[value] = result
                return result

            setattr(embedder, method_name, cached)
    return counts


def build_stack(config: dict[str, Any], seed: int) -> dict[str, Any]:
    hydro = dict(config.get("hydrocarbon") or {})
    catalog = hydrocarbon_catalog_from_config(hydro)
    catalog_index = {block.block_id: block for block in catalog}
    geometry = HydrocarbonGeometryOracle(
        catalog,
        sentinel_cgeom=float(
            (hydro.get("geometry") or {}).get("sentinel_cgeom", 10.0)
        ),
    )
    graph = HydrocarbonTransitionGraph(
        catalog, max_neighbors=int(config.get("max_neighbors", 128))
    )
    priors = build_reference_priors(
        config.get("reference_priors"),
        catalog=catalog_index,
        geometry_oracle=geometry,
        device=str((config.get("training") or {}).get("device", "cpu")),
    )
    if (config.get("reference_priors") or {}).get("strict_no_mock"):
        ensure_available = getattr(priors.peptide, "ensure_available", None)
        if callable(ensure_available):
            ensure_available()
    reference_energy = ReferenceEnergy(
        peptide_prior=priors.peptide,
        anchor_prior=priors.anchor,
        block_prior=priors.block,
        geometry_oracle=geometry,
        catalog_index=catalog_index,
        config=ReferenceEnergyConfig(**dict(config.get("reference") or {})),
    )
    base_kernel = ReferenceKernel(
        reference_energy, group_normalize=True, substitution_downweight=0.25
    )
    sampler, plan_cfg = build_plan_aware_sampler(
        graph, base_kernel, config, seed=seed, root=PACKAGE_ROOT
    )

    prior_cfg = EndpointPriorConfig.from_dict(hydro.get("endpoint_prior"))
    if (
        float(prior_cfg.weight_pair) != 0.0
        or prior_cfg.use_length
        or prior_cfg.use_relative_position
        or prior_cfg.use_local_context
    ):
        raise RuntimeError(
            "the main objective forbids empirical endpoint terms in terminal energy; "
            "pair x spacing belongs only to plan-aware reference"
        )
    # The empirical table is consumed by the plan-aware sampler above. Disable
    # the terminal object completely so missing generated analysis artefacts do
    # not trigger a reload and the same evidence cannot be counted twice.
    prior_cfg.enabled = False
    endpoint_prior = EmpiricalHydrocarbonEndpointPrior(prior_cfg, root=PACKAGE_ROOT)
    return {
        "hydro": hydro,
        "catalog": catalog,
        "catalog_index": catalog_index,
        "geometry": geometry,
        "graph": graph,
        "sampler": sampler,
        "plan_cfg": plan_cfg,
        "endpoint_prior": endpoint_prior,
        "reference_priors": priors,
        "reference_priors_manifest": priors.to_manifest(),
    }


def base_terminal_factory(
    config: dict[str, Any], geometry: HydrocarbonGeometryOracle, catalog_index: dict
):
    section = dict(config.get("terminal_energy") or {})
    lambda_close = float(section.get("lambda_close", 1.0))
    lambda_edit = float(section.get("lambda_edit", 0.2))
    lambda_cost = float(section.get("lambda_cost", 0.2))
    infeasible = float(section.get("infeasible_penalty", 20.0))

    def base(z0: StapleState, zt: StapleState, lead: LeadExample):
        block = catalog_index.get(zt.block_id) if zt.block_id else None
        peptide_ca = (lead.target_context or {}).get("peptide_ca")
        ctype = geometry.ctype(
            zt.sequence_tokens, zt.anchor_pair, block, peptide_ca=peptide_ca
        )
        cgeom = geometry.cgeom(
            zt.sequence_tokens, zt.anchor_pair, block, peptide_ca=peptide_ca
        )
        edit = weighted_edit_distance(zt, z0)
        cost = block.cost_score if block else 1.0
        energy = lambda_close * cgeom + lambda_edit * edit + lambda_cost * cost
        if not ctype or zt.topology != "stapled":
            energy += infeasible
        return float(energy), {
            "cgeom": float(cgeom),
            "edit": float(edit),
            "ctype_ok": bool(ctype),
            "cost": float(cost),
        }

    return base


#: Config keys holding filesystem paths. Relative values are resolved against
#: PACKAGE_ROOT so the release runs correctly from any working directory;
#: absolute values are honoured untouched. This is a packaging concern only --
#: in the official run every one of these was already an absolute path, so
#: resolution is a no-op there and no numerical behaviour depends on it.
_PATH_KEYS: tuple[tuple[str, ...], ...] = (
    ("data", "root"),
    ("reference_priors", "peptide", "model_name_or_path"),
    ("reference_priors", "peptide", "cache_path"),
    ("hydrocarbon", "plan_control", "exact_sb_cache", "path"),
    ("property_predictor", "peptiverse_root"),
    ("property_predictor", "classifier_weight_root"),
    ("property_predictor", "manifest_path"),
    ("property_predictor", "hf_cache_dir"),
    ("property_predictor", "esm_model_name_or_path"),
    ("property_predictor", "peptideclm_model_name_or_path"),
    ("property_predictor", "chemberta_model_name_or_path"),
)


def resolve_config_paths(config: dict[str, Any], root: Path | None = None) -> dict[str, Any]:
    """Make every relative path in ``config`` absolute against ``root``."""
    root = root or PACKAGE_ROOT
    for keys in _PATH_KEYS:
        node = config
        for key in keys[:-1]:
            node = node.get(key) if isinstance(node, dict) else None
            if not isinstance(node, dict):
                break
        else:
            raw = node.get(keys[-1])
            if isinstance(raw, str) and raw and not Path(raw).is_absolute():
                node[keys[-1]] = str((root / raw).resolve())
    return config


def load_config(path: Path) -> dict[str, Any]:
    with path.open("r", encoding="utf-8") as handle:
        config = yaml.safe_load(handle) or {}
    if not isinstance(config, dict):
        raise ValueError("configuration root must be a mapping")
    return resolve_config_paths(config)


def seed_everything(seed: int) -> None:
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)
    torch.use_deterministic_algorithms(False)


def select_leads(path: Path, n: int, max_length: int) -> list[LeadExample]:
    leads = [lead for lead in load_leads(path) if len(lead.linear_sequence) <= max_length][:n]
    if len(leads) != n:
        raise RuntimeError(f"need exactly {n} eligible leads from {path}, found {len(leads)}")
    return leads


def build_predictor(config: dict[str, Any]) -> tuple[PeptiVerseWrapper, dict[str, int]]:
    cfg = dict(config["property_predictor"])
    hf_cache_dir = Path(
        str(cfg.get("hf_cache_dir") or (PACKAGE_ROOT / "models/hf"))
    )
    wrapper = PeptiVerseWrapper(
        peptiverse_root=Path(cfg["peptiverse_root"]),
        classifier_weight_root=Path(cfg["classifier_weight_root"]),
        manifest_path=Path(cfg["manifest_path"]),
        device=str(cfg["device"]),
        strict=bool(cfg["strict"]),
        uncertainty=bool(cfg.get("uncertainty", False)),
        cache_enabled=bool(cfg.get("cache_enabled", True)),
        hf_cache_dir=hf_cache_dir,
        esm_model_name_or_path=cfg.get("esm_model_name_or_path"),
        peptideclm_model_name_or_path=cfg.get(
            "peptideclm_model_name_or_path"
        ),
        chemberta_model_name_or_path=cfg.get("chemberta_model_name_or_path"),
        offline=bool(cfg.get("offline", True)),
        batch_size=int(cfg.get("batch_size", 32)),
    )
    if not wrapper.available or wrapper.predictor is None:
        raise RuntimeError("strict PeptiVerse backend is unavailable; fallback is forbidden")
    return wrapper, install_embedding_cache(wrapper)


def build_energy(config: dict[str, Any], stack: dict[str, Any], scorer: HydrocarbonPropertyScorer) -> HydrocarbonTerminalEnergy:
    # The base terminal factory reads the generic terminal section. Map the
    # unchanged current hydrocarbon training coefficients into that interface.
    base_config = dict(config)
    train_cfg = dict(config.get("training") or {})
    base_config["terminal_energy"] = {
        "lambda_close": train_cfg.get("lambda_close", 1.0),
        "lambda_edit": train_cfg.get("lambda_edit", 0.2),
        "lambda_cost": train_cfg.get("lambda_cost", 0.2),
        "infeasible_penalty": train_cfg.get("infeasible_penalty", 20.0),
    }
    base = base_terminal_factory(base_config, stack["geometry"], stack["catalog_index"])
    terminal = dict(stack["hydro"].get("terminal_energy") or {})
    prop_cfg = HydrocarbonPropertyEnergyConfig.from_dict(terminal.get("property"))
    return HydrocarbonTerminalEnergy(
        base,
        stack["catalog"],
        stack["endpoint_prior"],
        HydrocarbonTerminalEnergyConfig(
            invalid_topology_penalty=float(terminal.get("invalid_topology_penalty", 10.0)),
            penalize_unstapled=bool(terminal.get("penalize_unstapled", True)),
        ),
        property_scorer=scorer,
        property_config=prop_cfg,
    )


class PlanAwareKernelAdapter:
    """Expose plan-conditioned probabilities through the generic kernel API."""

    def __init__(self, plan_kernel: Any) -> None:
        self.plan_kernel = plan_kernel

    def reference_logits(
        self,
        state: StapleState,
        candidates: list[StapleState],
        context: dict[str, Any] | None = None,
    ) -> torch.Tensor:
        context = dict(context or {})
        probabilities, _ = self.plan_kernel.plan_probs(
            state,
            candidates,
            context.get("hydrocarbon_plan"),
            context=context,
        )
        return torch.log(
            probabilities.clamp_min(torch.finfo(probabilities.dtype).tiny)
        )


def build_models(config: dict[str, Any], stack: dict[str, Any], device: torch.device):
    model_cfg = dict(config.get("model") or {})
    train_cfg = dict(config.get("training") or {})
    emb_dim = int(model_cfg.get("emb_dim", 32))
    encoder_cfg = dict(config.get("sequence_encoder") or {}) or None
    policy = PolicyNet(emb_dim=emb_dim, sequence_encoder_cfg=encoder_cfg).to(device)
    value = ValueNet(emb_dim=emb_dim, sequence_encoder_cfg=encoder_cfg).to(device)
    block_to_idx = {"": 0, **{block.block_id: index + 1 for index, block in enumerate(stack["catalog"])}}
    kernel = ControlledKernel(
        reference_kernel=PlanAwareKernelAdapter(stack["sampler"].kernel),
        policy_net=policy,
        value_net=value,
        block_to_idx=block_to_idx,
        cfg=ControlKernelConfig(mode=str(model_cfg.get("mode", "policy_tilt"))),
        horizon=int(train_cfg["horizon"]),
    )
    optimizer = torch.optim.Adam(
        list(policy.parameters()) + list(value.parameters()),
        lr=float(train_cfg.get("lr", 1e-3)),
    )
    return policy, value, kernel, optimizer