alphex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
alphex/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """Amino-acid alphabet orderings and letter-preserving conversions between them.
2
+
3
+ This package exists because a silent data-corruption bug shipped: a token table built from the
4
+ AlphaFold residue ordering while named for the ProteinMPNN ordering, permuting every residue
5
+ that is not a fixed point of the permutation (only A, S and T are). Being shape-valid, it
6
+ raised nothing.
7
+
8
+ The unit of value here is the **declaration**, not the encoding. See
9
+ `.praxia/docs/specs/260814_alphabet-contract.md` for the contract and
10
+ `260814_alphabet-api-surface.md` for the shape.
11
+
12
+ v0.1 is Phase 0 (decision D4): the `Alphabet` value type and the shipped declarations, for
13
+ **dev-dependency-only conformance testing** in each consumer repo, plus the Phase 1 conversion
14
+ kernel (`relation`, `perm`, `convert`, `reindex`). No asset layer and no plugin registry (D5)
15
+ yet.
16
+ """
17
+
18
+ from alphex import known
19
+ from alphex.alphabet import Alphabet, SpecialKind
20
+ from alphex.convert import MaskedPerm, Policy, PolicySpec, convert, perm, reindex
21
+ from alphex.errors import (
22
+ AliasCollisionError,
23
+ AlphabetDeclarationError,
24
+ AlphabetError,
25
+ DegenerateSymbolError,
26
+ IncompatibleAlphabetError,
27
+ MissingSpecialError,
28
+ MultiCharTokenError,
29
+ NonStandardResidueError,
30
+ ReducedAlphabetError,
31
+ UnmappableSymbolError,
32
+ UnsupportedFeatureError,
33
+ )
34
+ from alphex.relation import Relation, RelationKind, relation
35
+
36
+ __all__ = [
37
+ "AliasCollisionError",
38
+ "Alphabet",
39
+ "AlphabetDeclarationError",
40
+ "AlphabetError",
41
+ "DegenerateSymbolError",
42
+ "IncompatibleAlphabetError",
43
+ "MaskedPerm",
44
+ "MissingSpecialError",
45
+ "MultiCharTokenError",
46
+ "NonStandardResidueError",
47
+ "Policy",
48
+ "PolicySpec",
49
+ "ReducedAlphabetError",
50
+ "Relation",
51
+ "RelationKind",
52
+ "SpecialKind",
53
+ "UnmappableSymbolError",
54
+ "UnsupportedFeatureError",
55
+ "convert",
56
+ "known",
57
+ "perm",
58
+ "reindex",
59
+ "relation",
60
+ ]
alphex/_surface.py ADDED
@@ -0,0 +1,129 @@
1
+ """Shared logic behind the CLI and the MCP server.
2
+
3
+ Both surfaces answer the same questions and must not drift, so the answers live here once and
4
+ each surface only handles its own I/O. Everything below is plain data in, plain data out -- no
5
+ printing, no MCP types, and nothing imported that is not already a hard dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from alphex import known
13
+ from alphex.convert import MaskedPerm, Policy, perm
14
+ from alphex.relation import relation
15
+
16
+ if TYPE_CHECKING:
17
+ import numpy as np
18
+
19
+ from alphex.alphabet import Alphabet
20
+
21
+ POLICIES = tuple(p.value for p in Policy)
22
+
23
+
24
+ def names() -> list[str]:
25
+ """Every shipped declaration name, in declaration order."""
26
+ return [a.name for a in known.ALL]
27
+
28
+
29
+ def lookup(name: str) -> Alphabet:
30
+ """Resolve a declaration by name.
31
+
32
+ Raises `KeyError` listing the valid names rather than a bare miss: an agent or a shell user
33
+ who guessed a name needs the actual set, not a diagnosis.
34
+ """
35
+ for alphabet in known.ALL:
36
+ if alphabet.name == name:
37
+ return alphabet
38
+ msg = f"unknown alphabet {name!r}; shipped declarations are: {', '.join(names())}"
39
+ raise KeyError(msg)
40
+
41
+
42
+ def describe(name: str) -> dict[str, Any]:
43
+ """Everything declared about one alphabet, including its provenance and any lint warnings."""
44
+ a = lookup(name)
45
+ return {
46
+ "name": a.name,
47
+ "symbols": a.symbols,
48
+ "n_symbols": a.n_symbols,
49
+ "offset": a.offset,
50
+ "size": a.size,
51
+ "padded_size": a.padded_size,
52
+ "specials": {kind.value: index for kind, index in sorted(a.specials.items(), key=_by_value)},
53
+ "unclaimed": sorted(a.unclaimed),
54
+ "citation": a.citation,
55
+ "warnings": a.lint(),
56
+ }
57
+
58
+
59
+ def _by_value(item: tuple[Any, int]) -> int:
60
+ return item[1]
61
+
62
+
63
+ def catalog() -> list[dict[str, Any]]:
64
+ """One row per shipped declaration, for a listing."""
65
+ return [
66
+ {
67
+ "name": a.name,
68
+ "symbols": a.symbols,
69
+ "offset": a.offset,
70
+ "size": a.size,
71
+ "specials": {kind.value: index for kind, index in sorted(a.specials.items(), key=_by_value)},
72
+ "warnings": len(a.lint()),
73
+ }
74
+ for a in known.ALL
75
+ ]
76
+
77
+
78
+ def classify(src: str, dst: str) -> dict[str, Any]:
79
+ """How two declarations stand to one another. Cheap, and the right thing to ask first."""
80
+ rel = relation(lookup(src), lookup(dst))
81
+ return {
82
+ "src": src,
83
+ "dst": dst,
84
+ "kind": rel.kind.value,
85
+ "lossy": rel.lossy,
86
+ "moved": sorted(rel.moved),
87
+ "n_moved": len(rel.moved),
88
+ }
89
+
90
+
91
+ def table(src: str, dst: str, policy: str) -> dict[str, Any]:
92
+ """Build a permutation table and report it alongside the letter-level check.
93
+
94
+ `policy` is a single uniform value here, which is a deliberate limit of these surfaces rather
95
+ than of the library: the per-`SpecialKind` mapping form exists so that ESM's eight specials
96
+ cannot be collapsed onto one index by accident, and squeezing a mapping through a CLI flag or
97
+ an MCP string argument would make that easy to do carelessly. Anything needing per-kind policy
98
+ should call `perm` directly.
99
+ """
100
+ source, destination = lookup(src), lookup(dst)
101
+ chosen = Policy(policy)
102
+ built = perm(source, destination, policy=chosen)
103
+
104
+ masked = isinstance(built, MaskedPerm)
105
+ raw: np.ndarray = built.table if isinstance(built, MaskedPerm) else built
106
+ valid = [bool(v) for v in built.valid] if isinstance(built, MaskedPerm) else None
107
+
108
+ return {
109
+ "src": src,
110
+ "dst": dst,
111
+ "policy": chosen.value,
112
+ "masked": masked,
113
+ "table": [int(v) for v in raw],
114
+ "valid": valid,
115
+ "letters_preserved": _letters_preserved(source, destination, raw),
116
+ "relation": classify(src, dst),
117
+ }
118
+
119
+
120
+ def _letters_preserved(src: Alphabet, dst: Alphabet, raw: np.ndarray) -> bool:
121
+ """Check the invariant on the residues: same character, whatever the index."""
122
+ return all(dst.symbols[int(raw[src.index_of(c)]) - dst.offset] == c for c in src.symbols)
123
+
124
+
125
+ def lint() -> list[dict[str, Any]]:
126
+ """Report declarations carrying warnings. Empty means every shipped declaration is clean."""
127
+ return [
128
+ {"name": a.name, "warnings": warnings} for a in known.ALL if (warnings := a.lint())
129
+ ]
alphex/alphabet.py ADDED
@@ -0,0 +1,182 @@
1
+ """The `Alphabet` value type and its declaration-time invariants.
2
+
3
+ See `.praxia/docs/specs/260814_alphabet-api-surface.md` §3.2. The design in one line: an
4
+ alphabet is a bijection between a symbol set and a contiguous integer range, plus named
5
+ exceptions at declared indices.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import enum
12
+ from typing import TYPE_CHECKING
13
+
14
+ import numpy as np
15
+
16
+ from alphex.errors import AlphabetDeclarationError
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping
20
+
21
+
22
+ class SpecialKind(enum.Enum):
23
+ """A non-residue index, by meaning.
24
+
25
+ `UNKNOWN` and `STOP` are deliberately distinct: proteinsmc sets
26
+ `STOP_INT = UNKNOWN_AA_INT = PROTEINMPNN_X_INT = 21`, one index for two meanings, and the
27
+ point of typing them separately is that the conflation becomes reportable rather than
28
+ invisible.
29
+ """
30
+
31
+ UNKNOWN = "unknown"
32
+ GAP = "gap"
33
+ STOP = "stop"
34
+ MASK = "mask"
35
+ BOS = "bos"
36
+ EOS = "eos"
37
+ PAD = "pad"
38
+ CHAIN_BREAK = "chain_break"
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class Alphabet:
43
+ """An ordered symbol set occupying a known integer index space.
44
+
45
+ `symbols` is the contiguous run of *residue* symbols; `offset` is where that run starts in
46
+ the index space. ESM's 20 canonical residues sit contiguously at indices 4-23, so they are
47
+ expressed as `symbols=<20 chars>, offset=4` rather than as a 33-character string with
48
+ placeholders -- which would reintroduce the "is this position a residue?" ambiguity the
49
+ sentinel half of the shipped bug lived in.
50
+
51
+ Equality and hashing ignore `name` and `citation`: two declarations of the same index space
52
+ ARE the same alphabet however they are labelled. That is what makes the alias problem
53
+ detectable instead of invisible.
54
+ """
55
+
56
+ symbols: str
57
+ name: str
58
+ citation: str
59
+ offset: int = 0
60
+ specials: Mapping[SpecialKind, int] = dataclasses.field(default_factory=dict)
61
+ unclaimed: frozenset[int] = frozenset()
62
+
63
+ declared_size: int | None = None
64
+ """Width of the index space, when the source states one. `None` infers the smallest that fits.
65
+
66
+ Read `size` instead: it is always an `int`. The two are separate names because they are
67
+ separate facts -- what the source wrote, and how wide the alphabet actually is. ESM is the one
68
+ shipped declaration that states a width (33) exceeding what its own indices imply.
69
+ """
70
+
71
+ padded_size: int | None = None
72
+ """Width of a model's output axis when it exceeds `size`. `None` when there is no padding."""
73
+
74
+ @property
75
+ def size(self) -> int:
76
+ """Width of the index space. Always an `int`; never `None`, whatever was declared."""
77
+ if self.declared_size is not None:
78
+ return self.declared_size
79
+ return max([*self._residue_indices, *self.specials.values(), *self.unclaimed]) + 1
80
+
81
+ @property
82
+ def _residue_indices(self) -> range:
83
+ return range(self.offset, self.offset + len(self.symbols))
84
+
85
+ def __post_init__(self) -> None:
86
+ """Validate the declaration. Every failure here is a bug caught before it can propagate."""
87
+ if not self.symbols:
88
+ raise AlphabetDeclarationError("symbols must be non-empty")
89
+ if any(len(c) != 1 for c in self.symbols):
90
+ msg = f"symbols must be single characters, got {self.symbols!r}"
91
+ raise AlphabetDeclarationError(msg)
92
+ if len(set(self.symbols)) != len(self.symbols):
93
+ dupes = sorted({c for c in self.symbols if self.symbols.count(c) > 1})
94
+ msg = f"duplicate symbols in {self.symbols!r}: {dupes}"
95
+ raise AlphabetDeclarationError(msg)
96
+ if not self.citation.strip():
97
+ msg = f"alphabet {self.name!r} has no citation; a declaration without provenance is "
98
+ msg += "how an ordering loses track of where it came from"
99
+ raise AlphabetDeclarationError(msg)
100
+
101
+ residue_indices = frozenset(self._residue_indices)
102
+ special_indices = frozenset(self.specials.values())
103
+ resolved = self.size
104
+
105
+ if collide := residue_indices & special_indices:
106
+ msg = f"special index collides with a residue index: {sorted(collide)}"
107
+ raise AlphabetDeclarationError(msg)
108
+ out_of_range = {
109
+ i for i in residue_indices | special_indices | self.unclaimed if not 0 <= i < resolved
110
+ }
111
+ if out_of_range:
112
+ msg = f"index outside [0, {resolved}): {sorted(out_of_range)}"
113
+ raise AlphabetDeclarationError(msg)
114
+
115
+ accounted = residue_indices | special_indices | self.unclaimed
116
+ if unaccounted := set(range(resolved)) - accounted:
117
+ msg = (
118
+ f"alphabet {self.name!r} leaves index(es) {sorted(unaccounted)} unaccounted for. "
119
+ f"Every index in [0, {resolved}) must be a residue, a special, or explicitly "
120
+ f"unclaimed -- an index that means nothing is how a lookup table silently clamps."
121
+ )
122
+ raise AlphabetDeclarationError(msg)
123
+
124
+ # --- identity -------------------------------------------------------------------
125
+
126
+ def _identity(self) -> tuple[object, ...]:
127
+ return (self.symbols, self.offset, frozenset(self.specials.items()), self.size)
128
+
129
+ def __eq__(self, other: object) -> bool:
130
+ """Compare index spaces, not labels."""
131
+ if not isinstance(other, Alphabet):
132
+ return NotImplemented
133
+ return self._identity() == other._identity()
134
+
135
+ def __hash__(self) -> int:
136
+ """Hash the index space, so `name` cannot split one alphabet into two keys."""
137
+ return hash(self._identity())
138
+
139
+ # --- accessors ------------------------------------------------------------------
140
+
141
+ @property
142
+ def n_symbols(self) -> int:
143
+ """Number of residue symbols, excluding specials."""
144
+ return len(self.symbols)
145
+
146
+ @property
147
+ def conflated_specials(self) -> frozenset[frozenset[SpecialKind]]:
148
+ """Groups of SpecialKinds sharing one index.
149
+
150
+ Non-empty means two meanings are indistinguishable at runtime.
151
+ """
152
+ by_index: dict[int, set[SpecialKind]] = {}
153
+ for kind, idx in self.specials.items():
154
+ by_index.setdefault(idx, set()).add(kind)
155
+ return frozenset(frozenset(g) for g in by_index.values() if len(g) > 1)
156
+
157
+ def index_of(self, symbol: str) -> int:
158
+ """Absolute index of a residue symbol."""
159
+ return self.offset + self.symbols.index(symbol)
160
+
161
+ def encode(self, text: str) -> np.ndarray:
162
+ """Residue characters to absolute indices."""
163
+ return np.array([self.index_of(c) for c in text], dtype=np.int32)
164
+
165
+ def decode(self, codes: np.ndarray) -> str:
166
+ """Absolute indices to residue characters."""
167
+ return "".join(self.symbols[int(i) - self.offset] for i in np.asarray(codes).ravel())
168
+
169
+ def lint(self) -> list[str]:
170
+ """Return non-fatal warnings. Never raises: a dirty declaration must stay loadable.
171
+
172
+ Refusing to load a declaration that lints dirty would recreate the pressure to declare
173
+ something untrue, which is worse than declaring something imperfect.
174
+ """
175
+ warnings: list[str] = []
176
+ for group in sorted(self.conflated_specials, key=lambda g: sorted(k.value for k in g)):
177
+ kinds = ", ".join(sorted(k.value for k in group))
178
+ idx = self.specials[next(iter(group))]
179
+ warnings.append(
180
+ f"conflated specials at index {idx}: {kinds} are indistinguishable at runtime",
181
+ )
182
+ return warnings
alphex/cli.py ADDED
@@ -0,0 +1,125 @@
1
+ """`alphex` command line. Install with the `cli` extra: `uv add "alphex[cli]"`.
2
+
3
+ Deliberately small. This is a library first; the CLI exists so a person or an agent can answer
4
+ "which ordering is this?" and "what does the table look like?" without writing a script -- the
5
+ two questions whose absence let a mislabelled table ship.
6
+
7
+ Nothing here is imported by `alphex/__init__.py`, so the base install stays numpy-only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+
15
+ from alphex import _surface
16
+
17
+ try:
18
+ from cyclopts import App
19
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
20
+ msg = (
21
+ "the alphex CLI needs the 'cli' extra: `uv add \"alphex[cli]\"` "
22
+ "(or `pip install 'alphex[cli]'`). The base install is numpy-only on purpose."
23
+ )
24
+ raise SystemExit(msg) from exc
25
+
26
+ app = App(
27
+ name="alphex",
28
+ help="Biological sequence alphabet orderings and conversions between them.",
29
+ )
30
+
31
+
32
+ def _emit(payload: object, *, as_json: bool) -> None:
33
+ if as_json:
34
+ print(json.dumps(payload, indent=2))
35
+ return
36
+ print(_render(payload))
37
+
38
+
39
+ def _render(payload: object) -> str:
40
+ """Plain-text rendering. No `rich` dependency -- this output is meant to be piped."""
41
+ if isinstance(payload, list):
42
+ return "\n".join(_render(row) for row in payload)
43
+ if isinstance(payload, dict):
44
+ width = max((len(k) for k in payload), default=0)
45
+ return "\n".join(f"{k:<{width}} {_scalar(v)}" for k, v in payload.items())
46
+ return str(payload)
47
+
48
+
49
+ def _scalar(value: object) -> str:
50
+ if isinstance(value, dict):
51
+ return ", ".join(f"{k}={v}" for k, v in value.items()) or "-"
52
+ if isinstance(value, list):
53
+ return ", ".join(str(v) for v in value) if value else "-"
54
+ return str(value)
55
+
56
+
57
+ @app.command
58
+ def list_(*, json_out: bool = False) -> None:
59
+ """List every shipped alphabet declaration."""
60
+ rows = _surface.catalog()
61
+ if json_out:
62
+ _emit(rows, as_json=True)
63
+ return
64
+ width = max(len(r["name"]) for r in rows)
65
+ for r in rows:
66
+ specials = ", ".join(f"{k}@{v}" for k, v in r["specials"].items()) or "-"
67
+ flag = " (lints)" if r["warnings"] else ""
68
+ print(f"{r['name']:<{width}} size={r['size']:<3} offset={r['offset']} {specials}{flag}")
69
+
70
+
71
+ @app.command
72
+ def show(name: str, *, json_out: bool = False) -> None:
73
+ """Show one declaration in full, including its citation."""
74
+ _emit(_surface.describe(name), as_json=json_out)
75
+
76
+
77
+ @app.command
78
+ def relation(src: str, dst: str, *, json_out: bool = False) -> None:
79
+ """Classify the mapping between two declarations before converting anything."""
80
+ _emit(_surface.classify(src, dst), as_json=json_out)
81
+
82
+
83
+ @app.command
84
+ def perm(src: str, dst: str, *, policy: str = "raise", json_out: bool = False) -> None:
85
+ """Build the permutation table from `src` to `dst`.
86
+
87
+ `policy` is one of raise, unknown, gap, mask -- applied uniformly. Conversions needing a
88
+ different policy per special kind must call the library directly; see `alphex._surface.table`.
89
+ """
90
+ if policy not in _surface.POLICIES:
91
+ raise SystemExit(f"policy must be one of {', '.join(_surface.POLICIES)}, got {policy!r}")
92
+ _emit(_surface.table(src, dst, policy), as_json=json_out)
93
+
94
+
95
+ @app.command
96
+ def lint(*, strict: bool = False, json_out: bool = False) -> None:
97
+ """Report declarations whose conventions are worth knowing about.
98
+
99
+ Exits 0 by default even when there are findings. A warning here is not a defect to fix: two
100
+ shipped declarations conflate STOP and UNKNOWN because proteinsmc really does put both on
101
+ index 21, and declaring that is what makes it visible. Those two will warn forever, so a
102
+ non-zero default would be permanent noise and would train people to ignore the command.
103
+
104
+ Pass `--strict` to exit 1 on any finding -- useful in a repo that has decided its own
105
+ declarations must stay clean.
106
+ """
107
+ findings = _surface.lint()
108
+ if not findings:
109
+ if json_out:
110
+ _emit([], as_json=True)
111
+ else:
112
+ print("no warnings")
113
+ return
114
+ _emit(findings, as_json=json_out)
115
+ if strict:
116
+ sys.exit(1)
117
+
118
+
119
+ def main() -> None:
120
+ """Console-script entry point."""
121
+ app()
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()