File size: 7,289 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
"""Multi-character monomer tokenizer for the hydrocarbon branch.

The lactam path builds states with ``StapleState.from_sequence``, which is
``list(seq)`` - one character per residue. That is correct for K/D/E lactam and
is left untouched. Hydrocarbon anchors are two characters (``S5``, ``R8``), so
this module provides a *separate* tokenizing constructor. Lactam never calls it.

Model-vocabulary projection
---------------------------
``staplebridge.data.vocab.ALL_TOKENS`` has 24 entries and
:class:`~staplebridge.models.embeddings.TokenMLPEncoder` sizes its embedding as
``nn.Embedding(len(TOKEN_TO_ID), ...)``. Appending ``S5``/``R8`` to that list
would change the embedding matrix shape and break loading of every existing
checkpoint. So this module does **not** touch the vocab. Instead it projects
hydrocarbon monomers onto the ncAA tokens the vocab already carries:

    S3, S5, S8  ->  "X"     (X is already the StaPep alias for S5)
    R3, R5, R8  ->  "B"
    Aib         ->  "X"
    Nle         ->  "B"

The projection is only applied where a tensor of token ids is needed. The
authoritative state keeps the true monomer tokens, so anchor typing, catalog
matching and the endpoint prior all see ``S5``/``R8`` exactly.
"""

from __future__ import annotations

from typing import Final

from staplebridge.chemistry.state import StapleState
from staplebridge.data.vocab import TOKEN_TO_ID

NATURAL_AA: Final[frozenset[str]] = frozenset("ACDEFGHIKLMNPQRSTVWY")

#: Hydrocarbon anchor monomers recognised by the tokenizer. Longest-first so a
#: greedy scan consumes ``S5`` before it can mistake ``S`` for serine.
HYDROCARBON_ANCHOR_TOKENS: Final[tuple[str, ...]] = (
    "S3",
    "S5",
    "S8",
    "R3",
    "R5",
    "R8",
)

#: Non-anchor non-natural monomers the tokenizer accepts as whole segments.
OTHER_MONOMER_TOKENS: Final[tuple[str, ...]] = ("Aib", "AIB", "Nle", "NLE")

#: Terminal modifications: no residue index, no contribution to length.
N_TERMINAL_MODS: Final[frozenset[str]] = frozenset({"AC"})
C_TERMINAL_MODS: Final[frozenset[str]] = frozenset({"NH2"})

#: Projection onto tokens the existing model vocabulary already contains. See
#: the module docstring for why the vocab itself is not extended.
_MODEL_TOKEN_PROJECTION: Final[dict[str, str]] = {
    "S3": "X",
    "S5": "X",
    "S8": "X",
    "R3": "B",
    "R5": "B",
    "R8": "B",
    "AIB": "X",
    "NLE": "B",
}

_MULTI_CHAR: Final[tuple[str, ...]] = tuple(
    sorted(
        {t.upper() for t in HYDROCARBON_ANCHOR_TOKENS + OTHER_MONOMER_TOKENS},
        key=len,
        reverse=True,
    )
)


class HydrocarbonTokenizationError(ValueError):
    """Raised when a sequence cannot be fully consumed into known monomers."""


def is_anchor_token(token: str) -> bool:
    """True when ``token`` is a hydrocarbon staple anchor monomer."""
    return token.upper() in {t.upper() for t in HYDROCARBON_ANCHOR_TOKENS}


def normalize_monomer(token: str) -> str:
    """Canonicalise one monomer token (``s5`` -> ``S5``, ``Aib`` -> ``AIB``)."""
    upper = token.upper()
    if upper in {t.upper() for t in HYDROCARBON_ANCHOR_TOKENS}:
        return upper
    if upper in {"AIB", "NLE"}:
        return upper
    return upper


def tokenize_sequence(sequence: str) -> list[str]:
    """Tokenize a hydrocarbon-style sequence into monomer tokens.

    Supports dash-delimited segments, undelimited runs and mixtures, plus
    ``Ac-``/``-NH2`` terminal modifications (which are dropped from the residue
    list because they carry no residue index).

    Args:
        sequence: e.g. ``"TSFR8EYWALLS5"``, ``"Ac-ISF-R8-ELLDYY-S5-ESGS"``.

    Returns:
        One canonical monomer token per residue.

    Raises:
        HydrocarbonTokenizationError: if any part cannot be consumed. Nothing is
            guessed.
    """
    if sequence is None or not str(sequence).strip():
        raise HydrocarbonTokenizationError("empty sequence")

    text = str(sequence).strip().strip("-")
    segments = [s for s in text.split("-") if s]
    tokens: list[str] = []

    for position, segment in enumerate(segments):
        upper = segment.upper()
        if upper in N_TERMINAL_MODS and position == 0:
            continue
        if upper in C_TERMINAL_MODS and position == len(segments) - 1:
            continue
        if upper in _MULTI_CHAR:
            tokens.append(normalize_monomer(upper))
            continue
        tokens.extend(_tokenize_run(segment))

    if not tokens:
        raise HydrocarbonTokenizationError(
            f"sequence {sequence!r} contained no residues"
        )
    return tokens


def _tokenize_run(run: str) -> list[str]:
    """Longest-match scan of one undelimited run."""
    upper = run.upper()
    tokens: list[str] = []
    index = 0
    while index < len(upper):
        for candidate in _MULTI_CHAR:
            if upper.startswith(candidate, index):
                tokens.append(normalize_monomer(candidate))
                index += len(candidate)
                break
        else:
            char = upper[index]
            if char in NATURAL_AA:
                tokens.append(char)
                index += 1
            else:
                raise HydrocarbonTokenizationError(
                    f"unrecognized character {char!r} at offset {index} of {run!r}"
                )
    return tokens


def state_from_sequence(sequence: str, **kwargs: object) -> StapleState:
    """Build a :class:`StapleState` whose tokens are hydrocarbon monomers.

    This is the hydrocarbon counterpart of ``StapleState.from_sequence``. It is
    a separate function precisely so the lactam constructor keeps its exact
    per-character behaviour.
    """
    return StapleState(sequence_tokens=tokenize_sequence(sequence), **kwargs)


def anchor_positions(tokens: list[str]) -> list[int]:
    """Residue indices of the hydrocarbon anchors, N-to-C."""
    return [i for i, t in enumerate(tokens) if is_anchor_token(t)]


def to_model_tokens(tokens: list[str]) -> list[str]:
    """Project monomer tokens onto tokens present in the existing model vocab.

    Multi-character hydrocarbon monomers are mapped onto the ``X``/``B`` ncAA
    tokens that ``staplebridge.data.vocab`` already defines, so the embedding
    matrix keeps its original size and old checkpoints still load. Natural
    residues pass through unchanged.
    """
    projected: list[str] = []
    for token in tokens:
        upper = token.upper()
        if upper in _MODEL_TOKEN_PROJECTION:
            projected.append(_MODEL_TOKEN_PROJECTION[upper])
        elif token in TOKEN_TO_ID:
            projected.append(token)
        elif upper in TOKEN_TO_ID:
            projected.append(upper)
        else:
            projected.append("<unk>")
    return projected


def to_display_sequence(tokens: list[str]) -> str:
    """Human-readable sequence string, dash-separating multi-character monomers.

    ``"".join`` would render ``[..., "S5", ...]`` ambiguously against a real
    ``S`` followed by a literal ``5``, so multi-character monomers are set off
    with dashes.
    """
    parts: list[str] = []
    for token in tokens:
        if len(token) > 1:
            parts.append(f"-{token}-")
        else:
            parts.append(token)
    return "".join(parts).replace("--", "-").strip("-")