lhallee commited on
Commit
1aef529
·
verified ·
1 Parent(s): 632ff4a

Update FastPLMs runtime files

Browse files

Add-only FastPLMs files-only publication. Checkpoint weights and complete-artifact attestations are unchanged.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. LICENSES/FastPLMs-Apache-2.0.txt +203 -0
  2. LICENSES/biohub-esm/LICENSE.md +9 -0
  3. LICENSES/biohub-esm/THIRD_PARTY_NOTICE.md +13 -0
  4. LICENSES/biohub-transformers/LICENSE +203 -0
  5. LICENSES/protein-ttt/LICENSE +21 -0
  6. LICENSES/protein-ttt/PROVENANCE.md +8 -0
  7. README.md +268 -247
  8. THIRD_PARTY_NOTICES.md +99 -0
  9. config.json +13 -2
  10. fastplms/__init__.py +48 -0
  11. fastplms/attention/__init__.py +63 -0
  12. fastplms/attention/_core.py +779 -0
  13. fastplms/attention/_kernel_lock.py +191 -0
  14. fastplms/attention/interfaces.py +242 -0
  15. fastplms/embeddings/__init__.py +65 -0
  16. fastplms/embeddings/pooling.py +210 -0
  17. fastplms/embeddings/runner.py +1559 -0
  18. fastplms/embeddings/storage.py +1594 -0
  19. fastplms/embeddings/types.py +187 -0
  20. fastplms/models.toml +1223 -0
  21. fastplms/models/__init__.py +10 -0
  22. fastplms/models/esm_plusplus/__init__.py +0 -0
  23. fastplms/models/esm_plusplus/modeling_esm_plusplus.py +1552 -0
  24. fastplms/models/esmfold2/__init__.py +39 -0
  25. fastplms/models/esmfold2/attention.py +48 -0
  26. fastplms/models/esmfold2/configuration_esmfold2.py +306 -0
  27. fastplms/models/esmfold2/embedding.py +105 -0
  28. fastplms/models/esmfold2/esmfold2_affine3d.py +605 -0
  29. fastplms/models/esmfold2/esmfold2_aligner.py +87 -0
  30. fastplms/models/esmfold2/esmfold2_atom_indexer.py +30 -0
  31. fastplms/models/esmfold2/esmfold2_conformers.py +402 -0
  32. fastplms/models/esmfold2/esmfold2_constants.py +156 -0
  33. fastplms/models/esmfold2/esmfold2_constants_esm3.py +98 -0
  34. fastplms/models/esmfold2/esmfold2_input_builder.py +244 -0
  35. fastplms/models/esmfold2/esmfold2_metrics.py +235 -0
  36. fastplms/models/esmfold2/esmfold2_misc.py +400 -0
  37. fastplms/models/esmfold2/esmfold2_mmcif_parsing.py +469 -0
  38. fastplms/models/esmfold2/esmfold2_molecular_complex.py +1016 -0
  39. fastplms/models/esmfold2/esmfold2_msa.py +577 -0
  40. fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py +111 -0
  41. fastplms/models/esmfold2/esmfold2_normalize_coordinates.py +67 -0
  42. fastplms/models/esmfold2/esmfold2_output.py +201 -0
  43. fastplms/models/esmfold2/esmfold2_paired_msa.py +282 -0
  44. fastplms/models/esmfold2/esmfold2_parsing.py +126 -0
  45. fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py +127 -0
  46. fastplms/models/esmfold2/esmfold2_prepare_input.py +1130 -0
  47. fastplms/models/esmfold2/esmfold2_processor.py +332 -0
  48. fastplms/models/esmfold2/esmfold2_protein_chain.py +1450 -0
  49. fastplms/models/esmfold2/esmfold2_protein_complex.py +1240 -0
  50. fastplms/models/esmfold2/esmfold2_protein_structure.py +251 -0
LICENSES/FastPLMs-Apache-2.0.txt ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PLEASE NOTE THE APACHE LICENSE ONLY APPLIES TO THE CODE IN THE FastPLMs GITHUB AND ASSOCIATED HUGGINGFACE REPOSITORIES, NOT NECESSARILY THE MODEL WEIGHTS. THOSE LICENSES CAN BE FOUND HERE https://github.com/Synthyra/FastPLMs/tree/main/LICENSES
2
+
3
+ Apache License
4
+ Version 2.0, January 2004
5
+ http://www.apache.org/licenses/
6
+
7
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
8
+
9
+ 1. Definitions.
10
+
11
+ "License" shall mean the terms and conditions for use, reproduction,
12
+ and distribution as defined by Sections 1 through 9 of this document.
13
+
14
+ "Licensor" shall mean the copyright owner or entity authorized by
15
+ the copyright owner that is granting the License.
16
+
17
+ "Legal Entity" shall mean the union of the acting entity and all
18
+ other entities that control, are controlled by, or are under common
19
+ control with that entity. For the purposes of this definition,
20
+ "control" means (i) the power, direct or indirect, to cause the
21
+ direction or management of such entity, whether by contract or
22
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
23
+ outstanding shares, or (iii) beneficial ownership of such entity.
24
+
25
+ "You" (or "Your") shall mean an individual or Legal Entity
26
+ exercising permissions granted by this License.
27
+
28
+ "Source" form shall mean the preferred form for making modifications,
29
+ including but not limited to software source code, documentation
30
+ source, and configuration files.
31
+
32
+ "Object" form shall mean any form resulting from mechanical
33
+ transformation or translation of a Source form, including but
34
+ not limited to compiled object code, generated documentation,
35
+ and conversions to other media types.
36
+
37
+ "Work" shall mean the work of authorship, whether in Source or
38
+ Object form, made available under the License, as indicated by a
39
+ copyright notice that is included in or attached to the work
40
+ (an example is provided in the Appendix below).
41
+
42
+ "Derivative Works" shall mean any work, whether in Source or Object
43
+ form, that is based on (or derived from) the Work and for which the
44
+ editorial revisions, annotations, elaborations, or other modifications
45
+ represent, as a whole, an original work of authorship. For the purposes
46
+ of this License, Derivative Works shall not include works that remain
47
+ separable from, or merely link (or bind by name) to the interfaces of,
48
+ the Work and Derivative Works thereof.
49
+
50
+ "Contribution" shall mean any work of authorship, including
51
+ the original version of the Work and any modifications or additions
52
+ to that Work or Derivative Works thereof, that is intentionally
53
+ submitted to Licensor for inclusion in the Work by the copyright owner
54
+ or by an individual or Legal Entity authorized to submit on behalf of
55
+ the copyright owner. For the purposes of this definition, "submitted"
56
+ means any form of electronic, verbal, or written communication sent
57
+ to the Licensor or its representatives, including but not limited to
58
+ communication on electronic mailing lists, source code control systems,
59
+ and issue tracking systems that are managed by, or on behalf of, the
60
+ Licensor for the purpose of discussing and improving the Work, but
61
+ excluding communication that is conspicuously marked or otherwise
62
+ designated in writing by the copyright owner as "Not a Contribution."
63
+
64
+ "Contributor" shall mean Licensor and any individual or Legal Entity
65
+ on behalf of whom a Contribution has been received by Licensor and
66
+ subsequently incorporated within the Work.
67
+
68
+ 2. Grant of Copyright License. Subject to the terms and conditions of
69
+ this License, each Contributor hereby grants to You a perpetual,
70
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
71
+ copyright license to reproduce, prepare Derivative Works of,
72
+ publicly display, publicly perform, sublicense, and distribute the
73
+ Work and such Derivative Works in Source or Object form.
74
+
75
+ 3. Grant of Patent License. Subject to the terms and conditions of
76
+ this License, each Contributor hereby grants to You a perpetual,
77
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
78
+ (except as stated in this section) patent license to make, have made,
79
+ use, offer to sell, sell, import, and otherwise transfer the Work,
80
+ where such license applies only to those patent claims licensable
81
+ by such Contributor that are necessarily infringed by their
82
+ Contribution(s) alone or by combination of their Contribution(s)
83
+ with the Work to which such Contribution(s) was submitted. If You
84
+ institute patent litigation against any entity (including a
85
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
86
+ or a Contribution incorporated within the Work constitutes direct
87
+ or contributory patent infringement, then any patent licenses
88
+ granted to You under this License for that Work shall terminate
89
+ as of the date such litigation is filed.
90
+
91
+ 4. Redistribution. You may reproduce and distribute copies of the
92
+ Work or Derivative Works thereof in any medium, with or without
93
+ modifications, and in Source or Object form, provided that You
94
+ meet the following conditions:
95
+
96
+ (a) You must give any other recipients of the Work or
97
+ Derivative Works a copy of this License; and
98
+
99
+ (b) You must cause any modified files to carry prominent notices
100
+ stating that You changed the files; and
101
+
102
+ (c) You must retain, in the Source form of any Derivative Works
103
+ that You distribute, all copyright, patent, trademark, and
104
+ attribution notices from the Source form of the Work,
105
+ excluding those notices that do not pertain to any part of
106
+ the Derivative Works; and
107
+
108
+ (d) If the Work includes a "NOTICE" text file as part of its
109
+ distribution, then any Derivative Works that You distribute must
110
+ include a readable copy of the attribution notices contained
111
+ within such NOTICE file, excluding those notices that do not
112
+ pertain to any part of the Derivative Works, in at least one
113
+ of the following places: within a NOTICE text file distributed
114
+ as part of the Derivative Works; within the Source form or
115
+ documentation, if provided along with the Derivative Works; or,
116
+ within a display generated by the Derivative Works, if and
117
+ wherever such third-party notices normally appear. The contents
118
+ of the NOTICE file are for informational purposes only and
119
+ do not modify the License. You may add Your own attribution
120
+ notices within Derivative Works that You distribute, alongside
121
+ or as an addendum to the NOTICE text from the Work, provided
122
+ that such additional attribution notices cannot be construed
123
+ as modifying the License.
124
+
125
+ You may add Your own copyright statement to Your modifications and
126
+ may provide additional or different license terms and conditions
127
+ for use, reproduction, or distribution of Your modifications, or
128
+ for any such Derivative Works as a whole, provided Your use,
129
+ reproduction, and distribution of the Work otherwise complies with
130
+ the conditions stated in this License.
131
+
132
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
133
+ any Contribution intentionally submitted for inclusion in the Work
134
+ by You to the Licensor shall be under the terms and conditions of
135
+ this License, without any additional terms or conditions.
136
+ Notwithstanding the above, nothing herein shall supersede or modify
137
+ the terms of any separate license agreement you may have executed
138
+ with Licensor regarding such Contributions.
139
+
140
+ 6. Trademarks. This License does not grant permission to use the trade
141
+ names, trademarks, service marks, or product names of the Licensor,
142
+ except as required for reasonable and customary use in describing the
143
+ origin of the Work and reproducing the content of the NOTICE file.
144
+
145
+ 7. Disclaimer of Warranty. Unless required by applicable law or
146
+ agreed to in writing, Licensor provides the Work (and each
147
+ Contributor provides its Contributions) on an "AS IS" BASIS,
148
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
149
+ implied, including, without limitation, any warranties or conditions
150
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
151
+ PARTICULAR PURPOSE. You are solely responsible for determining the
152
+ appropriateness of using or redistributing the Work and assume any
153
+ risks associated with Your exercise of permissions under this License.
154
+
155
+ 8. Limitation of Liability. In no event and under no legal theory,
156
+ whether in tort (including negligence), contract, or otherwise,
157
+ unless required by applicable law (such as deliberate and grossly
158
+ negligent acts) or agreed to in writing, shall any Contributor be
159
+ liable to You for damages, including any direct, indirect, special,
160
+ incidental, or consequential damages of any character arising as a
161
+ result of this License or out of the use or inability to use the
162
+ Work (including but not limited to damages for loss of goodwill,
163
+ work stoppage, computer failure or malfunction, or any and all
164
+ other commercial damages or losses), even if such Contributor
165
+ has been advised of the possibility of such damages.
166
+
167
+ 9. Accepting Warranty or Additional Liability. While redistributing
168
+ the Work or Derivative Works thereof, You may choose to offer,
169
+ and charge a fee for, acceptance of support, warranty, indemnity,
170
+ or other liability obligations and/or rights consistent with this
171
+ License. However, in accepting such obligations, You may act only
172
+ on Your own behalf and on Your sole responsibility, not on behalf
173
+ of any other Contributor, and only if You agree to indemnify,
174
+ defend, and hold each Contributor harmless for any liability
175
+ incurred by, or claims asserted against, such Contributor by reason
176
+ of your accepting any such warranty or additional liability.
177
+
178
+ END OF TERMS AND CONDITIONS
179
+
180
+ APPENDIX: How to apply the Apache License to your work.
181
+
182
+ To apply the Apache License to your work, attach the following
183
+ boilerplate notice, with the fields enclosed by brackets "[]"
184
+ replaced with your own identifying information. (Don't include
185
+ the brackets!) The text should be enclosed in the appropriate
186
+ comment syntax for the file format. We also recommend that a
187
+ file or class name and description of purpose be included on the
188
+ same "printed page" as the copyright notice for easier
189
+ identification within third-party archives.
190
+
191
+ Copyright [yyyy] [name of copyright owner]
192
+
193
+ Licensed under the Apache License, Version 2.0 (the "License");
194
+ you may not use this file except in compliance with the License.
195
+ You may obtain a copy of the License at
196
+
197
+ http://www.apache.org/licenses/LICENSE-2.0
198
+
199
+ Unless required by applicable law or agreed to in writing, software
200
+ distributed under the License is distributed on an "AS IS" BASIS,
201
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
202
+ See the License for the specific language governing permissions and
203
+ limitations under the License.
LICENSES/biohub-esm/LICENSE.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ **License (MIT)**
2
+
3
+ Copyright 2026 Chan Zuckerberg Biohub, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
LICENSES/biohub-esm/THIRD_PARTY_NOTICE.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ The code in this repository depends on the following third-party libraries:
2
+
3
+ | Library | License | Link |
4
+ |----------|----------|----------|
5
+ | flash-attn | BSD | https://github.com/Dao-AILab/flash-attention/blob/main/LICENSE |
6
+ | PyTorch | BSD | https://github.com/pytorch/pytorch/blob/main/LICENSE |
7
+ | xformers | BSD | https://github.com/facebookresearch/xformers/blob/main/LICENSE |
8
+ | jaxtyping | MIT | https://github.com/patrick-kidger/jaxtyping/blob/main/LICENSE |
9
+ | einops | MIT | https://github.com/arogozhnikov/einops/blob/main/LICENSE |
10
+ | omegaconf | BSD | https://github.com/omry/omegaconf/blob/master/LICENSE |
11
+ | attrs | MIT | https://github.com/python-attrs/attrs/blob/main/LICENSE |
12
+ | scipy | BSD-3-Clause | https://github.com/scipy/scipy/blob/main/LICENSE.txt<br>https://github.com/scipy/scipy/blob/main/LICENSES_bundled.txt |
13
+ | lightning / torchmetrics | Apache 2.0 | https://github.com/Lightning-AI/torchmetrics/blob/master/LICENSE |
LICENSES/biohub-transformers/LICENSE ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright 2018- The Hugging Face team. All rights reserved.
2
+
3
+ Apache License
4
+ Version 2.0, January 2004
5
+ http://www.apache.org/licenses/
6
+
7
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
8
+
9
+ 1. Definitions.
10
+
11
+ "License" shall mean the terms and conditions for use, reproduction,
12
+ and distribution as defined by Sections 1 through 9 of this document.
13
+
14
+ "Licensor" shall mean the copyright owner or entity authorized by
15
+ the copyright owner that is granting the License.
16
+
17
+ "Legal Entity" shall mean the union of the acting entity and all
18
+ other entities that control, are controlled by, or are under common
19
+ control with that entity. For the purposes of this definition,
20
+ "control" means (i) the power, direct or indirect, to cause the
21
+ direction or management of such entity, whether by contract or
22
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
23
+ outstanding shares, or (iii) beneficial ownership of such entity.
24
+
25
+ "You" (or "Your") shall mean an individual or Legal Entity
26
+ exercising permissions granted by this License.
27
+
28
+ "Source" form shall mean the preferred form for making modifications,
29
+ including but not limited to software source code, documentation
30
+ source, and configuration files.
31
+
32
+ "Object" form shall mean any form resulting from mechanical
33
+ transformation or translation of a Source form, including but
34
+ not limited to compiled object code, generated documentation,
35
+ and conversions to other media types.
36
+
37
+ "Work" shall mean the work of authorship, whether in Source or
38
+ Object form, made available under the License, as indicated by a
39
+ copyright notice that is included in or attached to the work
40
+ (an example is provided in the Appendix below).
41
+
42
+ "Derivative Works" shall mean any work, whether in Source or Object
43
+ form, that is based on (or derived from) the Work and for which the
44
+ editorial revisions, annotations, elaborations, or other modifications
45
+ represent, as a whole, an original work of authorship. For the purposes
46
+ of this License, Derivative Works shall not include works that remain
47
+ separable from, or merely link (or bind by name) to the interfaces of,
48
+ the Work and Derivative Works thereof.
49
+
50
+ "Contribution" shall mean any work of authorship, including
51
+ the original version of the Work and any modifications or additions
52
+ to that Work or Derivative Works thereof, that is intentionally
53
+ submitted to Licensor for inclusion in the Work by the copyright owner
54
+ or by an individual or Legal Entity authorized to submit on behalf of
55
+ the copyright owner. For the purposes of this definition, "submitted"
56
+ means any form of electronic, verbal, or written communication sent
57
+ to the Licensor or its representatives, including but not limited to
58
+ communication on electronic mailing lists, source code control systems,
59
+ and issue tracking systems that are managed by, or on behalf of, the
60
+ Licensor for the purpose of discussing and improving the Work, but
61
+ excluding communication that is conspicuously marked or otherwise
62
+ designated in writing by the copyright owner as "Not a Contribution."
63
+
64
+ "Contributor" shall mean Licensor and any individual or Legal Entity
65
+ on behalf of whom a Contribution has been received by Licensor and
66
+ subsequently incorporated within the Work.
67
+
68
+ 2. Grant of Copyright License. Subject to the terms and conditions of
69
+ this License, each Contributor hereby grants to You a perpetual,
70
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
71
+ copyright license to reproduce, prepare Derivative Works of,
72
+ publicly display, publicly perform, sublicense, and distribute the
73
+ Work and such Derivative Works in Source or Object form.
74
+
75
+ 3. Grant of Patent License. Subject to the terms and conditions of
76
+ this License, each Contributor hereby grants to You a perpetual,
77
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
78
+ (except as stated in this section) patent license to make, have made,
79
+ use, offer to sell, sell, import, and otherwise transfer the Work,
80
+ where such license applies only to those patent claims licensable
81
+ by such Contributor that are necessarily infringed by their
82
+ Contribution(s) alone or by combination of their Contribution(s)
83
+ with the Work to which such Contribution(s) was submitted. If You
84
+ institute patent litigation against any entity (including a
85
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
86
+ or a Contribution incorporated within the Work constitutes direct
87
+ or contributory patent infringement, then any patent licenses
88
+ granted to You under this License for that Work shall terminate
89
+ as of the date such litigation is filed.
90
+
91
+ 4. Redistribution. You may reproduce and distribute copies of the
92
+ Work or Derivative Works thereof in any medium, with or without
93
+ modifications, and in Source or Object form, provided that You
94
+ meet the following conditions:
95
+
96
+ (a) You must give any other recipients of the Work or
97
+ Derivative Works a copy of this License; and
98
+
99
+ (b) You must cause any modified files to carry prominent notices
100
+ stating that You changed the files; and
101
+
102
+ (c) You must retain, in the Source form of any Derivative Works
103
+ that You distribute, all copyright, patent, trademark, and
104
+ attribution notices from the Source form of the Work,
105
+ excluding those notices that do not pertain to any part of
106
+ the Derivative Works; and
107
+
108
+ (d) If the Work includes a "NOTICE" text file as part of its
109
+ distribution, then any Derivative Works that You distribute must
110
+ include a readable copy of the attribution notices contained
111
+ within such NOTICE file, excluding those notices that do not
112
+ pertain to any part of the Derivative Works, in at least one
113
+ of the following places: within a NOTICE text file distributed
114
+ as part of the Derivative Works; within the Source form or
115
+ documentation, if provided along with the Derivative Works; or,
116
+ within a display generated by the Derivative Works, if and
117
+ wherever such third-party notices normally appear. The contents
118
+ of the NOTICE file are for informational purposes only and
119
+ do not modify the License. You may add Your own attribution
120
+ notices within Derivative Works that You distribute, alongside
121
+ or as an addendum to the NOTICE text from the Work, provided
122
+ that such additional attribution notices cannot be construed
123
+ as modifying the License.
124
+
125
+ You may add Your own copyright statement to Your modifications and
126
+ may provide additional or different license terms and conditions
127
+ for use, reproduction, or distribution of Your modifications, or
128
+ for any such Derivative Works as a whole, provided Your use,
129
+ reproduction, and distribution of the Work otherwise complies with
130
+ the conditions stated in this License.
131
+
132
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
133
+ any Contribution intentionally submitted for inclusion in the Work
134
+ by You to the Licensor shall be under the terms and conditions of
135
+ this License, without any additional terms or conditions.
136
+ Notwithstanding the above, nothing herein shall supersede or modify
137
+ the terms of any separate license agreement you may have executed
138
+ with Licensor regarding such Contributions.
139
+
140
+ 6. Trademarks. This License does not grant permission to use the trade
141
+ names, trademarks, service marks, or product names of the Licensor,
142
+ except as required for reasonable and customary use in describing the
143
+ origin of the Work and reproducing the content of the NOTICE file.
144
+
145
+ 7. Disclaimer of Warranty. Unless required by applicable law or
146
+ agreed to in writing, Licensor provides the Work (and each
147
+ Contributor provides its Contributions) on an "AS IS" BASIS,
148
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
149
+ implied, including, without limitation, any warranties or conditions
150
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
151
+ PARTICULAR PURPOSE. You are solely responsible for determining the
152
+ appropriateness of using or redistributing the Work and assume any
153
+ risks associated with Your exercise of permissions under this License.
154
+
155
+ 8. Limitation of Liability. In no event and under no legal theory,
156
+ whether in tort (including negligence), contract, or otherwise,
157
+ unless required by applicable law (such as deliberate and grossly
158
+ negligent acts) or agreed to in writing, shall any Contributor be
159
+ liable to You for damages, including any direct, indirect, special,
160
+ incidental, or consequential damages of any character arising as a
161
+ result of this License or out of the use or inability to use the
162
+ Work (including but not limited to damages for loss of goodwill,
163
+ work stoppage, computer failure or malfunction, or any and all
164
+ other commercial damages or losses), even if such Contributor
165
+ has been advised of the possibility of such damages.
166
+
167
+ 9. Accepting Warranty or Additional Liability. While redistributing
168
+ the Work or Derivative Works thereof, You may choose to offer,
169
+ and charge a fee for, acceptance of support, warranty, indemnity,
170
+ or other liability obligations and/or rights consistent with this
171
+ License. However, in accepting such obligations, You may act only
172
+ on Your own behalf and on Your sole responsibility, not on behalf
173
+ of any other Contributor, and only if You agree to indemnify,
174
+ defend, and hold each Contributor harmless for any liability
175
+ incurred by, or claims asserted against, such Contributor by reason
176
+ of your accepting any such warranty or additional liability.
177
+
178
+ END OF TERMS AND CONDITIONS
179
+
180
+ APPENDIX: How to apply the Apache License to your work.
181
+
182
+ To apply the Apache License to your work, attach the following
183
+ boilerplate notice, with the fields enclosed by brackets "[]"
184
+ replaced with your own identifying information. (Don't include
185
+ the brackets!) The text should be enclosed in the appropriate
186
+ comment syntax for the file format. We also recommend that a
187
+ file or class name and description of purpose be included on the
188
+ same "printed page" as the copyright notice for easier
189
+ identification within third-party archives.
190
+
191
+ Copyright [yyyy] [name of copyright owner]
192
+
193
+ Licensed under the Apache License, Version 2.0 (the "License");
194
+ you may not use this file except in compliance with the License.
195
+ You may obtain a copy of the License at
196
+
197
+ http://www.apache.org/licenses/LICENSE-2.0
198
+
199
+ Unless required by applicable law or agreed to in writing, software
200
+ distributed under the License is distributed on an "AS IS" BASIS,
201
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
202
+ See the License for the specific language governing permissions and
203
+ limitations under the License.
LICENSES/protein-ttt/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Anton Bushuiev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
LICENSES/protein-ttt/PROVENANCE.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # ProteinTTT provenance
2
+
3
+ FastPLMs uses `anton-bushuiev/ProteinTTT` revision
4
+ `fde2817cd84b936167cc76ccabf31e5c0fe49962` as the official reference for the
5
+ optional protein test-time training workflow. The repository is pinned at
6
+ `vendor/upstream/protein-ttt/` and is not a production dependency or runtime
7
+ image component. The accompanying `LICENSE` is the verbatim MIT text from that
8
+ revision.
README.md CHANGED
@@ -1,247 +1,268 @@
1
- ---
2
- library_name: transformers
3
- tags:
4
- - biology
5
- - protein-structure
6
- - esmfold2
7
- - multimodal-protein-model
8
- ---
9
-
10
- # FastPLMs ESMFold2
11
-
12
- FastPLMs ESMFold2 is a self-contained Hugging Face `AutoModel` wrapper for
13
- Biohub's ESMFold2, ESMFold2-Fast, and experimental ESMFold2 structure
14
- predictors. It vendors the released Biohub ESMFold2 model code, input builder,
15
- MSA helpers, and structure export utilities, while loading the PLM backbone
16
- through FastPLMs ESM++.
17
-
18
- ## Load With AutoModel
19
-
20
- ```python
21
- import torch
22
- from transformers import AutoModel
23
-
24
- model = AutoModel.from_pretrained(
25
- "Synthyra/ESMFold2-Fast",
26
- trust_remote_code=True,
27
- dtype=torch.float32,
28
- ).eval().cuda()
29
- ```
30
-
31
- Use `Synthyra/ESMFold2` for the full model, `Synthyra/ESMFold2-Fast` for the
32
- faster release variant, and the `Synthyra/ESMFold2-Experimental*` checkpoints
33
- for differentiable binder design and experimental critic ensembles.
34
- The folding trunk runs in fp32; the 6B FastPLMs ESM++ backbone is loaded in
35
- bf16 by default via `esmc_precision="bf16"` and uses the flex attention backend
36
- by default inside ESMFold2.
37
-
38
- ## Fold One Protein
39
-
40
- ```python
41
- sequence = "MKTLLILAVVAAALA"
42
-
43
- result = model.fold_protein(
44
- sequence,
45
- num_loops=3,
46
- num_sampling_steps=50,
47
- num_diffusion_samples=1,
48
- seed=0,
49
- )
50
-
51
- print(float(result.plddt.mean()))
52
- print(float(result.ptm))
53
- ```
54
-
55
- ## Experimental Test-Time Training
56
-
57
- TTT is disabled by default. Standard `fold_protein(...)`, `fold(...)`, raw tensor
58
- inference, and `state_dict()` keys are unchanged unless you explicitly pass
59
- `ttt=True` or call `fold_protein_ttt(...)`.
60
-
61
- The ESMFold2 TTT path is experimental and protein-only in v1. It trains local
62
- LoRA adapters only on `_esmc` with a masked language modeling objective. The
63
- folding trunk, confidence head, diffusion head, and structure input pipeline are
64
- frozen. TTT can improve difficult low-confidence folds, but it adds substantial
65
- test-time compute and can degrade already confident predictions.
66
-
67
- ```python
68
- result = model.fold_protein(
69
- "MSTNPKPQRKTKRNT",
70
- num_loops=1,
71
- num_sampling_steps=10,
72
- num_diffusion_samples=1,
73
- seed=0,
74
- ttt=True,
75
- ttt_config={
76
- "steps": 1,
77
- "ags": 1,
78
- "batch_size": 1,
79
- "lora_rank": 8,
80
- "lora_alpha": 32.0,
81
- },
82
- )
83
-
84
- print(result.ttt_metrics["losses"])
85
- print(result.ttt_metrics["step_plddts"])
86
- print(result.ttt_metrics["best_step"])
87
- ```
88
-
89
- `load_esmc=True` is required for TTT because the ESM++ MLM head is loaded lazily
90
- from `config.esmc_id`. If that pretrained MLM head cannot be loaded, TTT raises
91
- an assertion instead of silently using a random head.
92
-
93
- ## Save mmCIF or PDB
94
-
95
- ```python
96
- model.save_as_cif(result, "prediction.cif")
97
- model.save_as_pdb(result, "prediction.pdb")
98
-
99
- cif_text = model.result_to_cif(result)
100
- pdb_text = model.result_to_pdb(result)
101
- ```
102
-
103
- `result_to_cif` preserves the full `MolecularComplex`. `result_to_pdb` converts through Biohub's protein-only `ProteinComplex` representation, so use mmCIF for complexes with ligands or nucleic acids.
104
-
105
- ## Fold Complexes
106
-
107
- ```python
108
- types = model.input_types
109
-
110
- complex_input = types.StructurePredictionInput(
111
- sequences=[
112
- types.ProteinInput(id="A", sequence="MKTLLILAVVAAALA"),
113
- types.DNAInput(id="B", sequence="GATAGC"),
114
- types.LigandInput(id="L", ccd=["SAH"]),
115
- ]
116
- )
117
-
118
- result = model.fold(
119
- complex_input,
120
- num_loops=3,
121
- num_sampling_steps=50,
122
- num_diffusion_samples=1,
123
- seed=0,
124
- )
125
-
126
- model.save_as_cif(result, "complex_prediction.cif")
127
- ```
128
-
129
- ## Binder Design With FastPLMs ESMFold2
130
-
131
- FastPLMs includes a FastPLMs-only port of the Biohub ESMFold2 binder design
132
- tutorial at `cookbook/tutorials/binder_design_fastplms.py`. The workflow uses
133
- ESMFold2 experimental checkpoints for differentiable folding losses, ESM++ for
134
- sequence regularization, and ESMFold2 hero critics for final confidence scoring.
135
-
136
- ![FastPLMs EGFR minibinder design](https://raw.githubusercontent.com/Synthyra/FastPLMs/main/docs/assets/egfr_fastplms_binder_design.png)
137
-
138
- The optimizer follows the official strategy:
139
-
140
- 1. Optimize mutable `#` residues as continuous amino acid logits.
141
- 2. Suppress cysteine design by masking cysteine logits and gradients.
142
- 3. Backpropagate through ESMFold2 `res_type_soft` using intra-contact,
143
- inter-contact, and globularity losses from the distogram.
144
- 4. Add an ESM++ masked-LM pseudoperplexity regularizer on mutable binder
145
- residues.
146
- 5. Keep the late-trajectory sequence with the best iPTM.
147
- 6. Fold the selected sequence with the final critic ensemble and write
148
- `results.parquet`, `selection.parquet`, `trajectory.jsonl`,
149
- `best_sequences.fasta`, and per-critic PDB/CIF/logit files.
150
-
151
- Run the verified EGFR 128 amino acid de novo minibinder example:
152
-
153
- ```bash
154
- cd /home/ubuntu/FastPLMs
155
-
156
- sudo -n docker run --gpus all --rm \
157
- -v /home/ubuntu/FastPLMs:/app \
158
- -v /home/ubuntu/FastPLMs:/workspace \
159
- -v /home/ubuntu/.cache/huggingface:/workspace/.cache/huggingface \
160
- -w /workspace fastplms-esmfold2 \
161
- python /app/cookbook/tutorials/binder_design_fastplms.py \
162
- --backend local \
163
- --target-name egfr \
164
- --binder-sequence '################################################################################################################################' \
165
- --not-antibody \
166
- --steps 150 \
167
- --batch-size 1 \
168
- --seed 103 \
169
- --output-dir /workspace/campaign_egfr_len128_b1_s150_seed103_consensus_cli
170
- ```
171
-
172
- Verified result:
173
-
174
- | Metric | Value |
175
- | :--- | :--- |
176
- | Binder length | `128` |
177
- | Seed | `103` |
178
- | Steps | `150` |
179
- | Hero mean iPTM | `0.913870` |
180
- | Hero min iPTM | `0.904600` |
181
- | All four hero critics above 0.9 | `True` |
182
-
183
- Binder sequence:
184
-
185
- ```text
186
- SAVKHLLEIVKYLEEAIEKALEVDPVFLVPPAAEELLIAAKVIKELAKENPELIEVYELLMKAVKGLKKLVRSNDKEILREVIRLLRKAAKVIREILKNNPDLDPELRKALEELAKVLEEIAEVLEQQ
187
- ```
188
-
189
- See the full guide in [`docs/binder_design.md`](https://github.com/Synthyra/FastPLMs/blob/main/docs/binder_design.md)
190
- for Modal execution, official pI and selection scoring, per-critic metrics, and
191
- the tested cheaper step-count boundary.
192
-
193
- ## Use MSAs
194
-
195
- ```python
196
- types = model.input_types
197
-
198
- msa = types.MSA.from_a3m("query.a3m", max_sequences=128)
199
- input_with_msa = types.StructurePredictionInput(
200
- sequences=[
201
- types.ProteinInput(id="A", sequence=msa.query, msa=msa),
202
- ]
203
- )
204
-
205
- result = model.fold(input_with_msa, num_sampling_steps=50, seed=0)
206
- ```
207
-
208
- ## Raw Tensor Inference
209
-
210
- ```python
211
- features, chain_infos = model.prepare_structure_input(complex_input, seed=0)
212
-
213
- with torch.inference_mode():
214
- output = model(
215
- **features,
216
- num_loops=3,
217
- num_sampling_steps=50,
218
- num_diffusion_samples=1,
219
- )
220
-
221
- decoded = model.input_builder.decode(output, features, chain_infos)
222
- ```
223
-
224
- Set `load_esmc=False` when loading if you want to provide precomputed `lm_hidden_states` manually or run folding-trunk tests without loading the 6B ESM++ backbone:
225
-
226
- ```python
227
- model = AutoModel.from_pretrained(
228
- "Synthyra/ESMFold2-Fast",
229
- trust_remote_code=True,
230
- load_esmc=False,
231
- ).cuda().eval()
232
- ```
233
-
234
- For FP8 LM inference, install `transformer_engine.pytorch` in a CUDA
235
- environment with FP8-capable hardware and load the shared FastPLMs ESM++
236
- backbone with:
237
-
238
- ```python
239
- model = AutoModel.from_pretrained(
240
- "Synthyra/ESMFold2-Fast",
241
- trust_remote_code=True,
242
- esmc_precision="fp8",
243
- ).cuda().eval()
244
- ```
245
-
246
- FP8 is inference-only for the ESMFold2 LM backbone. TTT remains a bf16/fp32
247
- path.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ library_name: transformers
3
+ license: "mit"
4
+ tags:
5
+ - protein-language-model
6
+ - fastplms
7
+ ---
8
+
9
+ <!-- Generated from src/fastplms/models.toml. Do not edit. -->
10
+
11
+ # Synthyra/ESMFold2-Experimental-Cutoff2025
12
+
13
+ This checkpoint packages the FastPLMs `ESMFold2` implementation.
14
+
15
+ Accepted inputs are raw amino-acid sequences or typed molecular-complex
16
+ specifications; low-level forward accepts prepared feature tensors.
17
+ Supported Transformers entry points are `AutoConfig`, `AutoModel`.
18
+
19
+ ## Install and platform requirements
20
+
21
+ Install FastPLMs from the exact source revision paired with this model card:
22
+
23
+ ```bash
24
+ python -m pip install \
25
+ "fastplms[structure] @ git+https://github.com/Synthyra/FastPLMs.git@1b9ce023f1e06571cf3e6324be0610ffa53e0a4a"
26
+ ```
27
+
28
+ Python 3.11-3.14, PyTorch 2.13, and Transformers 5.13 are required. Structure inference requires the `structure` extra and a CUDA device for the published execution contract. The current validated release target is the exact NVIDIA GH200 on Linux aarch64; Linux x86-64, CPU-only, Windows, and macOS structure runs are not current release evidence. The Hub quick start below requires network
29
+ access on first download. For an air-gapped run, first build the manifest-pinned
30
+ local artifact and use the offline form shown in the example.
31
+
32
+ ## Quick start
33
+
34
+ ```python
35
+ from transformers import AutoModel
36
+
37
+ model_id = "Synthyra/ESMFold2-Experimental-Cutoff2025"
38
+ model = AutoModel.from_pretrained(
39
+ model_id,
40
+ trust_remote_code=True,
41
+ ).eval()
42
+ ```
43
+
44
+ This example uses the published Hub repository. For offline validation, build
45
+ the manifest-pinned artifact and replace `model_id` with its local
46
+ `dist/hub/ESMFold2-Experimental-Cutoff2025` path, then pass `local_files_only=True`.
47
+
48
+ Leave attention unspecified for the Transformers default. Supported explicit
49
+ choices are `eager`, `sdpa`, `flex_attention`.
50
+ Pass the selected name through `attn_implementation`.
51
+ When an optimized backend cannot return full attention tensors,
52
+ `output_attentions=True` emits one explicit runtime warning and uses a correctly
53
+ masked eager implementation for that call only. The warning identifies the
54
+ configured backend, effective backend, and reason. Configuration and later
55
+ calls are unchanged.
56
+ For BF16 execution, this family uses FP32 parameters with CUDA BF16 autocast.
57
+
58
+ ## Alignment-conditioning contract
59
+
60
+ This is a full 48-block ESMFold2 checkpoint. It supports both
61
+ single-sequence inference and optional MSA-conditioned inference. Typed
62
+ multichain and multimolecule inputs may attach an MSA to each applicable
63
+ protein chain.
64
+
65
+
66
+ ## Protein folding
67
+
68
+ The single-protein helper returns typed structure and confidence outputs:
69
+
70
+ ```python
71
+ result = model.fold_protein(
72
+ "MSTNPKPQRKTKRNT",
73
+ num_loops=1,
74
+ num_sampling_steps=200,
75
+ num_diffusion_samples=1,
76
+ seed=7,
77
+ )
78
+ pdb_text = model.result_to_pdb(result)
79
+ cif_text = model.result_to_cif(result)
80
+ print(result.ptm, result.plddt.mean().item())
81
+ ```
82
+
83
+ No target structure is required. For complexes, construct the input from the
84
+ types exposed by the loaded artifact:
85
+
86
+ ```python
87
+ types = model.input_types
88
+ complex_input = types.StructurePredictionInput(
89
+ sequences=[
90
+ types.ProteinInput(id="A", sequence="MSTNPKPQRKTKRNT"),
91
+ types.ProteinInput(id="B", sequence="MKTIIALSYIFCLVFA"),
92
+ types.DNAInput(id="C", sequence="ATGC"),
93
+ types.LigandInput(id="L", smiles="O"),
94
+ ]
95
+ )
96
+ complex_result = model.fold(
97
+ complex_input,
98
+ num_loops=1,
99
+ num_sampling_steps=200,
100
+ seed=7,
101
+ )
102
+ print(complex_result.ptm, complex_result.plddt.mean().item())
103
+ ```
104
+
105
+ The typed interface also supports RNA, protein MSAs, modifications, covalent
106
+ bonds, and distogram conditioning. The public schema recognizes
107
+ `PocketConditioning`, but the pinned official runtime discards it and hard-codes
108
+ a zero pocket feature. FastPLMs therefore rejects non-null pocket conditioning
109
+ instead of silently ignoring it. Prepared `ref_pos` values are component
110
+ reference geometries created during featurization, not target coordinates.
111
+ Predicted coordinates and confidence scores are outputs and do not establish
112
+ biochemical activity.
113
+
114
+ ## Learned representation and ESMC precision
115
+
116
+ ESMFold2 combines the ordered 81 ESMC-6B states `H: (b, l, 81, 2560)` with the
117
+ checkpoint's learned projection. Retrieve the resulting residue representation
118
+ through the public embedding API:
119
+
120
+ ```python
121
+ representations = model.embed_dataset(
122
+ ["MSTNPKPQRKTKRNT", "MKTIIALSYIFCLVFA"],
123
+ batch_size=2,
124
+ full_embeddings=True,
125
+ )
126
+ print(representations[0].tensor.shape) # (sequence_length, 256)
127
+ ```
128
+
129
+ `model.embed_dataset(..., full_embeddings=True)` returns one `(l, 256)` residue
130
+ tensor per single-chain input. It rejects complexes, ligands, MSAs,
131
+ chain-separated inputs, `cls`, and `parti` in the embedding path.
132
+
133
+ Set `esmc_precision` to `auto`, `bf16`, `fp32`, or `fp8` when loading.
134
+ `auto` always resolves to BF16. Explicit FP8 is experimental, inference-only,
135
+ and strict:
136
+
137
+ ```python
138
+ model.reload_esmc(precision="fp8", device="cuda:0")
139
+ print(model.esmc_precision_status)
140
+ ```
141
+
142
+ FP8 raises when the validated CUDA and Transformer Engine path is unavailable.
143
+ Canonical BF16 weights are retained, and transient quantization state is never
144
+ serialized.
145
+
146
+ The ESMC backbone uses SDPA as the recommended highest-fidelity path. Flex
147
+ Attention is supported and non-experimental but can be numerically divergent;
148
+ ESMFold2 does not advertise FlashAttention for the folding interface.
149
+
150
+ | Backend | Support | Measurement status |
151
+ | --- | --- | --- |
152
+ | `sdpa` | Recommended fidelity path | Pending complete validated 30-record frozen-head GH200/aarch64 set |
153
+ | `eager` | Supported | Pending complete validated 30-record frozen-head GH200/aarch64 set |
154
+ | `flex_attention` | Supported, numerically divergent | Pending complete validated 30-record frozen-head GH200/aarch64 set |
155
+
156
+ No threshold, report from another checkpoint, or result from another
157
+ accelerator is substituted for a measurement. A release set contains all
158
+ 30 model/backend/panel records from one exact GH200 device and aarch64 runtime:
159
+ 18 eager/SDPA/Flex measurements include relative L2, Q99.9, residue
160
+ cosine, pooled cosine, top-1, and Jensen-Shannon distributions; 12
161
+ FlashAttention 2/3 records explicitly attest locked-platform unavailability.
162
+
163
+ ## Locked oracle package compatibility exception
164
+
165
+ The frozen oracle lock permits exactly one nonzero `pip check` diagnostic:
166
+ `nvidia-cusparselt-cu13 0.8.1 is not supported on this platform`. It applies only to
167
+ `nvidia-cusparselt-cu13==0.8.1` on
168
+ `NVIDIA GH200 480GB` / `linux` /
169
+ `aarch64`. The vendor filename tag is
170
+ `py3-none-manylinux2014_aarch64`, while the wheel metadata declares
171
+ `py3-none-manylinux2014_sbsa`. The exact wheel is
172
+ `nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl` with SHA-256 `4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f`.
173
+ FastPLMs accepts this vendor metadata mismatch only after the lock, installed
174
+ inventory, wheel bytes, metadata tag, and target identity all match. The wheel
175
+ is not rewritten (`validated-vendor-metadata-exception-no-wheel-rewrite`). Any additional diagnostic or
176
+ identity drift fails closed.
177
+
178
+
179
+ Metrics must be tied to the exact ESMFold2 and ESMC revisions, dtype, current
180
+ GH200/aarch64 device and container images, dependency lock, source attestations,
181
+ and sequence panel. Pending cells are not performance or parity claims.
182
+
183
+ ## Hash-pinned CCD runtime asset
184
+
185
+ Structure preparation requires `ccd.pkl` from
186
+ `biohub/ESMFold2@1ebf0e3481a5184eb6171d40615c79e384b48796`. The manifest pins
187
+ its 417,306,584-byte size and SHA-256
188
+ `9ff44b1927c6b9198e38ffe0928706827a09a350c15530beeeabebfa88038fc5`
189
+ under MIT terms. This is a trusted-deserialization boundary: FastPLMs only
190
+ allows the exact manifest repository/revision snapshot link to resolve within
191
+ that repository's contained blob directory; user-supplied asset and `cache_dir`
192
+ symlinks are rejected. The loader creates a private temporary snapshot, verifies
193
+ its size and SHA-256, and unpickles only that loader-owned snapshot, closing
194
+ path-replacement and in-place source-write races. Offline execution requires the
195
+ exact cache object and never downloads a replacement.
196
+
197
+ ## Binder-design research example
198
+
199
+ The FastPLMs binder-design workflow uses the experimental Fast Cutoff2025
200
+ checkpoint for differentiable inversion, both experimental Cutoff2025
201
+ checkpoints as critics, and ESM++ as the sequence prior:
202
+
203
+ ![FastPLMs EGFR minibinder design](https://raw.githubusercontent.com/Synthyra/FastPLMs/main/docs/assets/egfr_fastplms_binder_design.png)
204
+
205
+ ```bash
206
+ python examples/binder_design_fastplms.py \
207
+ --target-name pd-l1 \
208
+ --binder-name minibinder \
209
+ --batch-size 4 \
210
+ --steps 150 \
211
+ --output-dir artifacts/binder-design
212
+ ```
213
+
214
+ The workflow ranks candidates by mean iPTM across the approved critics after
215
+ the minibinder isoelectric-point filter. These are model-based prioritization
216
+ signals, not experimental evidence of affinity or specificity. See the
217
+ [complete workflow](https://github.com/Synthyra/FastPLMs/blob/main/docs/binder_design.md).
218
+
219
+ ## Runtime contract
220
+
221
+ - Public input: Raw amino-acid sequences or typed molecular-complex specifications; low-level forward accepts prepared feature tensors
222
+ - Advertised AutoClasses: `AutoConfig`, `AutoModel`
223
+ - AutoClass weight status: `AutoConfig` = `FastPLMs extension`, `AutoModel` = `pretrained`
224
+ - Attention implementations: `eager`, `sdpa`, `flex_attention`
225
+ - Precision policies: `auto`, `fp32`, `bf16`, `fp8` (experimental)
226
+ - BF16 execution: `fp32_parameters_autocast`
227
+ - Generation contract: `not_applicable`
228
+ - Optional dependency group: `structure`
229
+ - Weight publication allowed: `true`
230
+ - Weight license status: `resolved`
231
+ - Redistributable: `true`
232
+ - Complete weight publication required: `false`
233
+
234
+ ## Provenance
235
+
236
+ - FastPLMs weights: `Synthyra/ESMFold2-Experimental-Cutoff2025@632ff4a9e68f1de78ee956a613267bdcdb5b354d`
237
+ - Runtime revision: `1b9ce023f1e06571cf3e6324be0610ffa53e0a4a`
238
+ - Runtime source-tree SHA-256: `15e781c5f1cd2ba8486e22076df15ffab37d3c00a689bf25280d803f2d60ee74`
239
+ - Runtime bundle SHA-256: `278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232`
240
+ - Generator/schema version and complete/runtime-only attestations: recorded in `provenance.json`
241
+ - Official checkpoint: `biohub/ESMFold2-Experimental-Cutoff2025@56f94f5c1069ecde17512c96928850518340d287`
242
+ - Artifact source: `fast`
243
+ - State transform: `identity`
244
+ - BF16 execution: `fp32_parameters_autocast`
245
+ - Pinned upstreams: `biohub-esm`, `biohub-transformers`, `protein-ttt`
246
+ - Reference container: `reference-esmfold2`
247
+ - Release tiers: `check`, `compliance`, `structure`, `feature`, `artifact`, `benchmark`
248
+ - Unresolved required file identities: `0`
249
+
250
+ The local artifact records exact file identities, conversion provenance, source
251
+ revisions, and legal texts in `provenance.json`. A nonzero unresolved count is a
252
+ release blocker.
253
+
254
+ ## Validation boundary
255
+
256
+ For tiers declared by the manifest, the release contract compares applicable
257
+ semantic configuration, tokenizer behavior, state keys, shapes, dtypes,
258
+ values, aliases, and representative inference with the pinned official
259
+ implementation. This metadata does not by itself claim that a particular build
260
+ passed, that one backend is faster, or that an output has biological or
261
+ therapeutic validity.
262
+
263
+ ## License
264
+
265
+ Checkpoint terms: MIT. The Hub model-card identifier is
266
+ `mit`. Applicable source licenses, notices, attribution,
267
+ and conversion records are distributed with the local artifact. Review them
268
+ before use.
THIRD_PARTY_NOTICES.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-party notices
2
+
3
+ FastPLMs implements interfaces and checkpoint mappings for independently
4
+ released protein models. The pinned repositories under `vendor/upstream/` are
5
+ parity oracles. Production code does not import them, and runtime images do not
6
+ contain them.
7
+
8
+ This notice is informational and is not legal advice. A checkpoint license can
9
+ differ from the license covering its source implementation. The typed inventory
10
+ in `src/fastplms/models.toml` and the verbatim files under `LICENSES/` are the
11
+ distribution record.
12
+
13
+ ## ANKH
14
+
15
+ The pinned ANKH implementation and the mirrored ANKH checkpoints are identified
16
+ as CC BY-NC-SA 4.0. FastPLMs displays those terms but does not enforce them in
17
+ software. Users are responsible for determining whether their use and
18
+ redistribution comply. The complete text is in `LICENSES/ankh/LICENSE.md`.
19
+
20
+ ## Profluent-E1
21
+
22
+ Profluent identifies its E1 model code as Apache-2.0. The E1 weights and full
23
+ release are subject to the Profluent-E1 Clickthrough License Agreement and the
24
+ incorporated attribution requirements. Any E1 distribution must retain all of
25
+ the following files:
26
+
27
+ - `LICENSES/e1/LICENSE`, the Profluent-E1 agreement
28
+ - `LICENSES/e1/ATTRIBUTION`, the attribution guidelines
29
+ - `LICENSES/e1/NOTICE`, the required notice
30
+ - `LICENSES/e1/Apache-2.0.txt`, the code license
31
+ - `LICENSES/e1/BSD-3-Clause.txt`, covering the FlashAttention-derived padding
32
+ utility identified by the official E1 source
33
+ - `LICENSES/e1/MODIFICATIONS.md`, the FastPLMs modified-file notice
34
+
35
+ The exact text `Profluent-E1` must remain prominently displayed in E1
36
+ documentation and at each launch of an executable E1 workflow, as required by
37
+ the upstream attribution guidelines. Certain commercial outputs, including
38
+ specified pharmaceutical and target-related outputs, can require the separate
39
+ `Built with Profluent-E1` statement described in `ATTRIBUTION`.
40
+
41
+ ## DPLM
42
+
43
+ The pinned ByteDance DPLM repository is Apache-2.0. Its
44
+ [README](https://github.com/bytedance/dplm/blob/8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d/README.md#overview)
45
+ explicitly defines the repository release as including pretrained DPLM1 and
46
+ DPLM2 weights, and the same revision carries the complete
47
+ [Apache-2.0 license](https://github.com/bytedance/dplm/blob/8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d/LICENSE).
48
+ FastPLMs records both checkpoint families as Apache-2.0 and distributes the
49
+ verbatim license plus `LICENSES/dplm/PROVENANCE.md`. Converted weights retain
50
+ those terms and remain subject to the ordinary artifact and publication gates.
51
+
52
+ ## Biohub
53
+
54
+ The pinned Biohub ESM implementation is MIT and includes a separate
55
+ `THIRD_PARTY_NOTICE.md`; both files are distributed under
56
+ `LICENSES/biohub-esm/`. The pinned Biohub Transformers fork is Apache-2.0, with
57
+ its complete text under `LICENSES/biohub-transformers/`.
58
+
59
+ ## Boltz
60
+
61
+ The pinned Boltz source is MIT. The verbatim notice is in
62
+ `LICENSES/boltz/LICENSE`.
63
+
64
+ ## Meta ESM and OpenFold
65
+
66
+ The pinned Meta ESM source is MIT. The pinned OpenFold source is Apache-2.0.
67
+ Their verbatim texts and revision-specific provenance notices are under
68
+ `LICENSES/fair-esm/` and `LICENSES/openfold/`.
69
+
70
+ The native H100 ESMFold reference image applies the tracked
71
+ `docker/constraints/openfold-sm90.patch` to the copied OpenFold `setup.py`.
72
+ This build-only change restricts the CUDA extension to `sm90` and selects the
73
+ C++17 standard required by the reference PyTorch version. It leaves the pinned
74
+ submodule, extension source, model classes, checkpoint data, and public API
75
+ unchanged. The complete modified-file record is in
76
+ `LICENSES/openfold/MODIFICATIONS.md`.
77
+
78
+ The isolated reference image also includes Apache-2.0 PyTorch Lightning,
79
+ TorchMetrics, Lightning Utilities, and NVIDIA DLLogger. Their exact versions or
80
+ revision are pinned in `docker/constraints/esmfold.txt`; OpenFold imports them
81
+ eagerly, and FastPLMs production code does not depend on them. DLLogger's exact
82
+ source identity and installed-license handling are recorded in
83
+ `LICENSES/dllogger/PROVENANCE.md`.
84
+
85
+ ## ProteinTTT
86
+
87
+ The optional test-time training workflow is validated against the pinned
88
+ ProteinTTT repository under its MIT license. Its verbatim license and
89
+ revision-specific provenance are under `LICENSES/protein-ttt/`.
90
+
91
+ ## Conversion and packaging record
92
+
93
+ For every supported family, `src/fastplms/models.toml` records an immutable
94
+ official checkpoint revision, an immutable FastPLMs checkpoint revision, file
95
+ digests, a named state transformation, and a mechanism-level conversion record.
96
+ Generated artifacts reproduce that record in `provenance.json`. A release or
97
+ artifact build must fail when a required file identity, legal text, attribution
98
+ notice, modified-file notice, upstream revision, or conversion record is absent
99
+ or differs from its manifest digest.
config.json CHANGED
@@ -3,8 +3,8 @@
3
  "ESMFold2ExperimentalModel"
4
  ],
5
  "auto_map": {
6
- "AutoConfig": "configuration_esmfold2.ESMFold2Config",
7
- "AutoModel": "modeling_esmfold2_experimental.ESMFold2ExperimentalModel"
8
  },
9
  "confidence_head": {
10
  "distogram_bins": 128,
@@ -25,6 +25,16 @@
25
  "disable_msa_features": false,
26
  "dtype": "float32",
27
  "esmc_id": "biohub/ESMC-6B",
 
 
 
 
 
 
 
 
 
 
28
  "folding_trunk": {
29
  "dropout": 0.25,
30
  "n_heads": 8,
@@ -56,6 +66,7 @@
56
  },
57
  "lm_num_layers": 80,
58
  "model_type": "esmfold2",
 
59
  "msa_encoder": {
60
  "d_hidden": 32,
61
  "d_msa": 128,
 
3
  "ESMFold2ExperimentalModel"
4
  ],
5
  "auto_map": {
6
+ "AutoConfig": "modeling_fastplms.ESMFold2Config",
7
+ "AutoModel": "modeling_fastplms.ESMFold2ExperimentalModel"
8
  },
9
  "confidence_head": {
10
  "distogram_bins": 128,
 
25
  "disable_msa_features": false,
26
  "dtype": "float32",
27
  "esmc_id": "biohub/ESMC-6B",
28
+ "fastplms_checkpoint_hash": "3a40758a12594cab337bbe7e664d305df722dc1eb1f0cc1ba0c840bf46d22834",
29
+ "fastplms_checkpoint_repo_id": "Synthyra/ESMFold2-Experimental-Cutoff2025",
30
+ "fastplms_checkpoint_revision": "632ff4a9e68f1de78ee956a613267bdcdb5b354d",
31
+ "fastplms_model_id": "esmfold2_experimental_cutoff2025",
32
+ "fastplms_release_tool_revision": "1b9ce023f1e06571cf3e6324be0610ffa53e0a4a",
33
+ "fastplms_release_tool_sha256": "1459b5d7d13d9b07bd97b3eee764f2ce73623e15e32d07ddf6825c2a9509afb9",
34
+ "fastplms_runtime_bundle_sha256": "278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232",
35
+ "fastplms_runtime_revision": "1b9ce023f1e06571cf3e6324be0610ffa53e0a4a",
36
+ "fastplms_source_tree_sha256": "15e781c5f1cd2ba8486e22076df15ffab37d3c00a689bf25280d803f2d60ee74",
37
+ "fastplms_weights_revision": "632ff4a9e68f1de78ee956a613267bdcdb5b354d",
38
  "folding_trunk": {
39
  "dropout": 0.25,
40
  "n_heads": 8,
 
66
  },
67
  "lm_num_layers": 80,
68
  "model_type": "esmfold2",
69
+ "msa_conditioning": true,
70
  "msa_encoder": {
71
  "d_hidden": 32,
72
  "d_msa": 128,
fastplms/__init__.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastPLMs public package interface.
2
+
3
+ The module uses lazy exports so importing :mod:`fastplms` does not initialize
4
+ Torch, download checkpoints, construct tokenizers, or compile kernels.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib import import_module
10
+ from typing import Any
11
+
12
+ __version__ = "1.0.0"
13
+
14
+ _LAZY_EXPORTS = {
15
+ "CheckpointSource": ("fastplms.registry", "CheckpointSource"),
16
+ "EmbeddingInput": ("fastplms.embeddings", "EmbeddingInput"),
17
+ "EmbeddingRecord": ("fastplms.embeddings", "EmbeddingRecord"),
18
+ "EmbeddingResult": ("fastplms.embeddings", "EmbeddingResult"),
19
+ "FileDigest": ("fastplms.registry", "FileDigest"),
20
+ "ModelFamily": ("fastplms.registry", "ModelFamily"),
21
+ "ModelRegistry": ("fastplms.registry", "ModelRegistry"),
22
+ "ModelSpec": ("fastplms.registry", "ModelSpec"),
23
+ "OracleAsset": ("fastplms.registry", "OracleAsset"),
24
+ "RegistryError": ("fastplms.registry", "RegistryError"),
25
+ "RuntimeProfile": ("fastplms.runtime", "RuntimeProfile"),
26
+ "UpstreamSource": ("fastplms.registry", "UpstreamSource"),
27
+ "embed_dataset": ("fastplms.embeddings", "embed_dataset"),
28
+ "get_model_registry": ("fastplms.registry", "get_model_registry"),
29
+ "get_model_spec": ("fastplms.registry", "get_model_spec"),
30
+ "load_model_registry": ("fastplms.registry", "load_model_registry"),
31
+ "runtime_profile": ("fastplms.runtime", "runtime_profile"),
32
+ }
33
+
34
+ __all__ = ["__version__", *_LAZY_EXPORTS]
35
+
36
+
37
+ def __getattr__(name: str) -> Any:
38
+ try:
39
+ module_name, attribute_name = _LAZY_EXPORTS[name]
40
+ except KeyError as error:
41
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from error
42
+ value = getattr(import_module(module_name), attribute_name)
43
+ globals()[name] = value
44
+ return value
45
+
46
+
47
+ def __dir__() -> list[str]:
48
+ return sorted(set(globals()).union(__all__))
fastplms/attention/__init__.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared attention backends, masks, and optional optimized kernels."""
2
+
3
+ from ._core import (
4
+ VALID_ATTENTION_BACKENDS,
5
+ AttentionBackend,
6
+ BlockMask,
7
+ _ensure_flash_kernels_loaded,
8
+ _get_flex_attention_fn,
9
+ _get_flex_block_mask,
10
+ _kernels_flash_forward,
11
+ _kernels_flash_varlen_forward,
12
+ _unpad_input,
13
+ bool_to_additive_mask,
14
+ clear_flex_attention_caches,
15
+ create_block_mask,
16
+ flex_attention,
17
+ get_attention_mask,
18
+ get_attn_implementation,
19
+ index_first_axis,
20
+ index_put_first_axis,
21
+ kernels_flash_attention_func,
22
+ pad_input,
23
+ resolve_attention_backend,
24
+ resolve_attention_backend_for_call,
25
+ set_config_attn_implementation,
26
+ warn_attention_backend_fallback,
27
+ )
28
+ from .interfaces import (
29
+ FASTPLMS_ATTENTION_FUNCTIONS,
30
+ FASTPLMS_ATTENTION_MASKS,
31
+ FastPLMsAttentionMixin,
32
+ validate_transformers_attention_interfaces,
33
+ )
34
+
35
+ __all__ = [
36
+ "FASTPLMS_ATTENTION_FUNCTIONS",
37
+ "FASTPLMS_ATTENTION_MASKS",
38
+ "VALID_ATTENTION_BACKENDS",
39
+ "AttentionBackend",
40
+ "BlockMask",
41
+ "FastPLMsAttentionMixin",
42
+ "_ensure_flash_kernels_loaded",
43
+ "_get_flex_attention_fn",
44
+ "_get_flex_block_mask",
45
+ "_kernels_flash_forward",
46
+ "_kernels_flash_varlen_forward",
47
+ "_unpad_input",
48
+ "bool_to_additive_mask",
49
+ "clear_flex_attention_caches",
50
+ "create_block_mask",
51
+ "flex_attention",
52
+ "get_attention_mask",
53
+ "get_attn_implementation",
54
+ "index_first_axis",
55
+ "index_put_first_axis",
56
+ "kernels_flash_attention_func",
57
+ "pad_input",
58
+ "resolve_attention_backend",
59
+ "resolve_attention_backend_for_call",
60
+ "set_config_attn_implementation",
61
+ "validate_transformers_attention_interfaces",
62
+ "warn_attention_backend_fallback",
63
+ ]
fastplms/attention/_core.py ADDED
@@ -0,0 +1,779 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Low-level attention kernels and mask construction.
2
+
3
+ The public backend contract lives in :mod:`fastplms.attention`. Optional
4
+ kernels are resolved only after a caller explicitly requests them, so importing
5
+ FastPLMs never downloads or compiles code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from collections import OrderedDict
12
+ from collections.abc import Callable
13
+ from enum import Enum
14
+ from threading import RLock
15
+
16
+ import torch
17
+ from einops import rearrange
18
+ from torch.nn import functional as F
19
+
20
+ from ._kernel_lock import load_locked_kernel
21
+
22
+ try:
23
+ from torch.nn.attention.flex_attention import BlockMask, create_block_mask, flex_attention
24
+ except ImportError:
25
+ create_block_mask = None
26
+ flex_attention = None
27
+ BlockMask = None
28
+
29
+ _MAX_FLEX_CACHE_ENTRIES = 128
30
+ _compiled_flex_attention: OrderedDict[tuple, object] = OrderedDict()
31
+ _flex_block_masks: OrderedDict[tuple, BlockMask] = OrderedDict()
32
+ _flex_cache_lock = RLock()
33
+
34
+
35
+ def _remember(cache: OrderedDict, key: tuple, value):
36
+ """Insert an item into a bounded least-recently-used cache."""
37
+ cache[key] = value
38
+ cache.move_to_end(key)
39
+ while len(cache) > _MAX_FLEX_CACHE_ENTRIES:
40
+ cache.popitem(last=False)
41
+ return value
42
+
43
+
44
+ def clear_flex_attention_caches() -> None:
45
+ """Drop FastPLMs-owned compiled Flex callables and block masks.
46
+
47
+ This deliberately does not call :func:`torch.compiler.reset`, which would
48
+ clear process-global Torch compilation state owned by unrelated models.
49
+ Active forwards retain their local references and can complete safely.
50
+ """
51
+
52
+ with _flex_cache_lock:
53
+ _compiled_flex_attention.clear()
54
+ _flex_block_masks.clear()
55
+
56
+
57
+ def _get_flex_attention_fn(
58
+ *,
59
+ device: torch.device | None = None,
60
+ dtype: torch.dtype | None = None,
61
+ shape: tuple[int, ...] | None = None,
62
+ sequence_lengths: tuple[int, ...] | None = None,
63
+ mask_semantics: str = "padding",
64
+ ):
65
+ """Return a compiled Flex callable for an explicit execution signature.
66
+
67
+ Compilation depends on execution shape, device, dtype, and mask semantics.
68
+ Per-example padding lengths are represented by the ``BlockMask`` argument
69
+ and must not create a new compiled graph for every batch composition.
70
+ """
71
+ if flex_attention is None:
72
+ return None
73
+ # Retain the keyword for compatibility with remote-code artifacts while
74
+ # deliberately excluding data-dependent lengths from the compile key.
75
+ del sequence_lengths
76
+ flex_mod = torch.nn.attention.flex_attention
77
+ if getattr(flex_mod, "_FLEX_ATTENTION_DISABLE_COMPILE_DEBUG", False):
78
+ return flex_attention
79
+ key = (
80
+ None if device is None else str(device),
81
+ None if dtype is None else str(dtype),
82
+ shape,
83
+ mask_semantics,
84
+ )
85
+ with _flex_cache_lock:
86
+ compiled = _compiled_flex_attention.get(key)
87
+ if compiled is None:
88
+ compiled = torch.compile(flex_attention, dynamic=False)
89
+ _remember(_compiled_flex_attention, key, compiled)
90
+ else:
91
+ _compiled_flex_attention.move_to_end(key)
92
+ return compiled
93
+
94
+
95
+ def _get_flex_block_mask(
96
+ *,
97
+ mask_pattern: torch.Tensor,
98
+ batch_size: int,
99
+ query_length: int,
100
+ key_value_length: int,
101
+ device: torch.device,
102
+ dtype: torch.dtype | None,
103
+ mask_semantics: str,
104
+ mask_mod: Callable[
105
+ [torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor],
106
+ torch.Tensor,
107
+ ],
108
+ ) -> BlockMask:
109
+ """Return a bounded, exact-pattern cached Flex ``BlockMask``.
110
+
111
+ The complete pattern is transferred to the host once to avoid a CUDA
112
+ synchronization per batch row. Execution dtype remains part of the key
113
+ because compiled Flex plans can specialize on it even though the pattern
114
+ tensor itself is boolean or integer.
115
+ """
116
+ if create_block_mask is None:
117
+ raise RuntimeError(
118
+ "'flex_attention' was requested, but torch.create_block_mask is unavailable."
119
+ )
120
+ pattern = mask_pattern.detach().to(device=device).contiguous()
121
+ # One device-to-host transfer is required for an exact cache identity. Use
122
+ # the contiguous buffer directly instead of materializing one Python int
123
+ # per byte, which is prohibitively expensive for long batched sequences.
124
+ host_pattern = pattern.to(device="cpu").contiguous()
125
+ pattern_bytes = host_pattern.view(torch.uint8).numpy().tobytes(order="C")
126
+ cache_key = (
127
+ str(device),
128
+ None if dtype is None else str(dtype),
129
+ (batch_size, query_length, key_value_length),
130
+ str(pattern.dtype),
131
+ pattern_bytes,
132
+ mask_semantics,
133
+ )
134
+ with _flex_cache_lock:
135
+ flex_block_mask = _flex_block_masks.get(cache_key)
136
+ if flex_block_mask is None:
137
+ flex_block_mask = create_block_mask(
138
+ mask_mod,
139
+ batch_size,
140
+ 1,
141
+ query_length,
142
+ key_value_length,
143
+ device=device,
144
+ )
145
+ _remember(_flex_block_masks, cache_key, flex_block_mask)
146
+ else:
147
+ _flex_block_masks.move_to_end(cache_key)
148
+ return flex_block_mask
149
+
150
+
151
+ # Hugging Face `kernels` exposes slightly different APIs for FlashAttention 2
152
+ # and 3. Detect the loaded variant once so every caller uses the same dispatch.
153
+ def _infer_kernels_flash_variant(kernel) -> str | None:
154
+ if hasattr(kernel, "fwd") and hasattr(kernel, "varlen_fwd"):
155
+ return "flash_attn2"
156
+ if hasattr(kernel, "flash_attn_func") and hasattr(kernel, "flash_attn_varlen_func"):
157
+ return "flash_attn3"
158
+ return None
159
+
160
+
161
+ def _load_kernels_flash(implementation: str) -> tuple[object, str]:
162
+ """Load exactly the requested FlashAttention kernel.
163
+
164
+ Loading is deferred until backend selection. A FlashAttention-2 request
165
+ never falls through to FlashAttention-3, or vice versa.
166
+ """
167
+ from fastplms.registry import get_model_registry
168
+
169
+ kernel_spec = get_model_registry().attention_kernels[implementation]
170
+ repository = kernel_spec.repository
171
+ try:
172
+ flash_kernel = load_locked_kernel(repository, kernel_spec.revision)
173
+ except Exception as error:
174
+ raise RuntimeError(
175
+ f"Unable to load the manifest-pinned kernel "
176
+ f"{repository}@{kernel_spec.revision} for {implementation!r}."
177
+ ) from error
178
+ flash_kernel_variant = _infer_kernels_flash_variant(flash_kernel)
179
+ if flash_kernel_variant != kernel_spec.expected_variant:
180
+ raise RuntimeError(
181
+ f"{repository}@{kernel_spec.revision} exposed {flash_kernel_variant!r}; "
182
+ f"expected {kernel_spec.expected_variant!r}."
183
+ )
184
+ if not all(
185
+ callable(getattr(flash_kernel, name, None))
186
+ for name in ("flash_attn_func", "flash_attn_varlen_func")
187
+ ):
188
+ raise RuntimeError(
189
+ f"{repository}@{kernel_spec.revision} does not expose the "
190
+ "autograd-enabled flash_attn_func and flash_attn_varlen_func APIs."
191
+ )
192
+ return flash_kernel, flash_kernel_variant
193
+
194
+
195
+ _FLASH_KERNELS: dict[str, tuple[object, str]] = {}
196
+
197
+
198
+ def _validate_kernels_flash_dtype(
199
+ query_states: torch.Tensor,
200
+ key_states: torch.Tensor,
201
+ value_states: torch.Tensor,
202
+ implementation: str,
203
+ ) -> torch.dtype:
204
+ """Reject dtypes outside the immutable kernel manifest before dispatch."""
205
+
206
+ tensor_dtypes = {query_states.dtype, key_states.dtype, value_states.dtype}
207
+ if len(tensor_dtypes) != 1:
208
+ observed = ", ".join(sorted(str(dtype) for dtype in tensor_dtypes))
209
+ raise RuntimeError(
210
+ f"{implementation!r} requires Q, K, and V to share one dtype; received {observed}."
211
+ )
212
+ runtime_dtype = query_states.dtype
213
+ if (
214
+ runtime_dtype == torch.float32
215
+ and query_states.is_cuda
216
+ and torch.is_autocast_enabled("cuda")
217
+ ):
218
+ runtime_dtype = torch.get_autocast_dtype("cuda")
219
+ dtype_names = {
220
+ torch.float32: "float32",
221
+ torch.bfloat16: "bfloat16",
222
+ torch.float16: "float16",
223
+ }
224
+ runtime_dtype_name = dtype_names.get(runtime_dtype, str(runtime_dtype))
225
+ from fastplms.registry import get_model_registry
226
+
227
+ supported = get_model_registry().attention_kernels[implementation].dtypes
228
+ if runtime_dtype_name not in supported:
229
+ expected = ", ".join(supported)
230
+ raise RuntimeError(
231
+ f"{implementation!r} supports only manifest-declared dtype(s) {expected}; "
232
+ f"received {runtime_dtype_name}. Use CUDA BF16 autocast for FP32-resident "
233
+ "models."
234
+ )
235
+ return runtime_dtype
236
+
237
+
238
+ def _validate_kernels_flash_device(
239
+ query_states: torch.Tensor,
240
+ key_states: torch.Tensor,
241
+ value_states: torch.Tensor,
242
+ implementation: str,
243
+ ) -> torch.device:
244
+ """Require Q, K, and V on one CUDA device before loading a kernel."""
245
+
246
+ devices = (query_states.device, key_states.device, value_states.device)
247
+ if len(set(devices)) != 1:
248
+ observed = ", ".join(str(device) for device in devices)
249
+ raise RuntimeError(
250
+ f"{implementation!r} requires Q, K, and V on one device; received {observed}."
251
+ )
252
+ device = devices[0]
253
+ if device.type != "cuda" or not all(
254
+ tensor.is_cuda for tensor in (query_states, key_states, value_states)
255
+ ):
256
+ raise RuntimeError(
257
+ f"{implementation!r} requires CUDA Q, K, and V; received device {device}."
258
+ )
259
+ return device
260
+
261
+
262
+ def _ensure_flash_kernels_loaded(implementation: str) -> tuple[object, str]:
263
+ cached = _FLASH_KERNELS.get(implementation)
264
+ if cached is not None:
265
+ return cached
266
+ loaded = _load_kernels_flash(implementation)
267
+ _FLASH_KERNELS[implementation] = loaded
268
+ return loaded
269
+
270
+
271
+ def _kernels_flash_forward(
272
+ query_states: torch.Tensor,
273
+ key_states: torch.Tensor,
274
+ value_states: torch.Tensor,
275
+ causal: bool = False,
276
+ softmax_scale: float | None = None,
277
+ implementation: str = "flash_attention_3",
278
+ ) -> torch.Tensor:
279
+ """Flash-attention forward, optionally overriding the softmax scale.
280
+
281
+ When `softmax_scale is None`, the flash kernel applies its default
282
+ `1 / sqrt(head_dim)`. Pass `softmax_scale=1.0` if the caller has already
283
+ pre-scaled Q (the convention used by ESM2, DPLM, DPLM2, E1, ESMFold).
284
+ Failing to override when Q is pre-scaled applies the scale twice and breaks
285
+ parity with eager attention and SDPA.
286
+ """
287
+ flash_kernel, flash_kernel_variant = _ensure_flash_kernels_loaded(implementation)
288
+ if flash_kernel_variant == "flash_attn2":
289
+ output = flash_kernel.flash_attn_func(
290
+ q=query_states,
291
+ k=key_states,
292
+ v=value_states,
293
+ dropout_p=0.0,
294
+ softmax_scale=softmax_scale,
295
+ causal=causal,
296
+ )
297
+ return output[0] if isinstance(output, tuple) else output
298
+ if flash_kernel_variant == "flash_attn3":
299
+ output = flash_kernel.flash_attn_func(
300
+ q=query_states,
301
+ k=key_states,
302
+ v=value_states,
303
+ softmax_scale=softmax_scale,
304
+ causal=causal,
305
+ )
306
+ if isinstance(output, tuple):
307
+ return output[0]
308
+ return output
309
+ raise RuntimeError(f"Unsupported FlashAttention kernel variant: {flash_kernel_variant}")
310
+
311
+
312
+ def _kernels_flash_varlen_forward(
313
+ query_states: torch.Tensor,
314
+ key_states: torch.Tensor,
315
+ value_states: torch.Tensor,
316
+ cu_seqlens_q: torch.Tensor,
317
+ cu_seqlens_k: torch.Tensor,
318
+ max_seqlen_in_batch_q: int,
319
+ max_seqlen_in_batch_k: int,
320
+ causal: bool = False,
321
+ softmax_scale: float | None = None,
322
+ implementation: str = "flash_attention_3",
323
+ ) -> torch.Tensor:
324
+ """Varlen flash-attention forward, optionally overriding the softmax scale.
325
+
326
+ See `_kernels_flash_forward` docstring for why `softmax_scale=1.0` must be
327
+ passed when Q has been pre-scaled by the caller.
328
+ """
329
+ flash_kernel, flash_kernel_variant = _ensure_flash_kernels_loaded(implementation)
330
+ if flash_kernel_variant == "flash_attn2":
331
+ output = flash_kernel.flash_attn_varlen_func(
332
+ q=query_states,
333
+ k=key_states,
334
+ v=value_states,
335
+ cu_seqlens_q=cu_seqlens_q,
336
+ cu_seqlens_k=cu_seqlens_k,
337
+ max_seqlen_q=max_seqlen_in_batch_q,
338
+ max_seqlen_k=max_seqlen_in_batch_k,
339
+ dropout_p=0.0,
340
+ softmax_scale=softmax_scale,
341
+ causal=causal,
342
+ )
343
+ return output[0] if isinstance(output, tuple) else output
344
+ if flash_kernel_variant == "flash_attn3":
345
+ output = flash_kernel.flash_attn_varlen_func(
346
+ q=query_states,
347
+ k=key_states,
348
+ v=value_states,
349
+ cu_seqlens_q=cu_seqlens_q,
350
+ cu_seqlens_k=cu_seqlens_k,
351
+ max_seqlen_q=max_seqlen_in_batch_q,
352
+ max_seqlen_k=max_seqlen_in_batch_k,
353
+ softmax_scale=softmax_scale,
354
+ causal=causal,
355
+ )
356
+ if isinstance(output, tuple):
357
+ return output[0]
358
+ return output
359
+ raise RuntimeError(f"Unsupported FlashAttention kernel variant: {flash_kernel_variant}")
360
+
361
+
362
+ # Varlen flash attention runs only on real tokens. These helpers remove padding
363
+ # before the kernel call and restore the original padded batch shape afterward.
364
+ class IndexFirstAxis(torch.autograd.Function):
365
+ @staticmethod
366
+ def forward(ctx, input, indices) -> torch.Tensor:
367
+ ctx.save_for_backward(indices)
368
+ if input.ndim < 2:
369
+ raise ValueError(
370
+ "index_first_axis input must have at least two dimensions; "
371
+ f"received shape {tuple(input.shape)}."
372
+ )
373
+ if indices.ndim != 1:
374
+ raise ValueError(
375
+ "index_first_axis indices must be one-dimensional; "
376
+ f"received shape {tuple(indices.shape)}."
377
+ )
378
+ ctx.first_axis_dim, other_shape = input.shape[0], input.shape[1:]
379
+ second_dim = other_shape.numel()
380
+ return torch.gather(
381
+ rearrange(input, "b ... -> b (...)"), 0, indices.unsqueeze(1).expand(-1, second_dim)
382
+ ).reshape(-1, *other_shape)
383
+
384
+ @staticmethod
385
+ def backward(ctx, grad_output) -> tuple[torch.Tensor, None]:
386
+ (indices,) = ctx.saved_tensors
387
+ if grad_output.ndim < 2:
388
+ raise RuntimeError(
389
+ "index_first_axis received an invalid gradient with fewer than "
390
+ "two dimensions."
391
+ )
392
+ other_shape = grad_output.shape[1:]
393
+ grad_output = rearrange(grad_output, "b ... -> b (...)")
394
+ grad_input = torch.zeros(
395
+ [ctx.first_axis_dim, grad_output.shape[1]],
396
+ device=grad_output.device,
397
+ dtype=grad_output.dtype,
398
+ )
399
+ grad_input.scatter_(0, indices.unsqueeze(1).expand(-1, grad_output.shape[1]), grad_output)
400
+ return grad_input.reshape(ctx.first_axis_dim, *other_shape), None
401
+
402
+
403
+ class IndexPutFirstAxis(torch.autograd.Function):
404
+ @staticmethod
405
+ def forward(ctx, values, indices, first_axis_dim) -> torch.Tensor:
406
+ ctx.save_for_backward(indices)
407
+ if indices.ndim != 1:
408
+ raise ValueError(
409
+ "index_put_first_axis indices must be one-dimensional; "
410
+ f"received shape {tuple(indices.shape)}."
411
+ )
412
+ if values.ndim < 2:
413
+ raise ValueError(
414
+ "index_put_first_axis values must have at least two dimensions; "
415
+ f"received shape {tuple(values.shape)}."
416
+ )
417
+ output = torch.zeros(
418
+ first_axis_dim, *values.shape[1:], device=values.device, dtype=values.dtype
419
+ )
420
+ output[indices] = values
421
+ return output
422
+
423
+ @staticmethod
424
+ def backward(ctx, grad_output) -> tuple[torch.Tensor, None, None]:
425
+ (indices,) = ctx.saved_tensors
426
+ return grad_output[indices], None, None
427
+
428
+
429
+ index_first_axis = IndexFirstAxis.apply
430
+ index_put_first_axis = IndexPutFirstAxis.apply
431
+
432
+
433
+ def pad_input(
434
+ hidden_states: torch.Tensor, indices: torch.Tensor, batch: int, seqlen: int
435
+ ) -> torch.Tensor:
436
+ output = index_put_first_axis(hidden_states, indices, batch * seqlen)
437
+ return rearrange(output, "(b s) ... -> b s ...", b=batch)
438
+
439
+
440
+ def _unpad_input(
441
+ query_layer: torch.Tensor,
442
+ key_layer: torch.Tensor,
443
+ value_layer: torch.Tensor,
444
+ attention_mask_2d: torch.Tensor,
445
+ ) -> tuple[
446
+ torch.Tensor,
447
+ torch.Tensor,
448
+ torch.Tensor,
449
+ torch.Tensor,
450
+ tuple[torch.Tensor, torch.Tensor],
451
+ tuple[int, int],
452
+ ]:
453
+ batch_size, seq_len, num_heads, head_dim = query_layer.shape
454
+ seqlens = attention_mask_2d.sum(dim=1).int()
455
+ cu_seqlens = F.pad(seqlens.cumsum(0, dtype=torch.int32), (1, 0))
456
+ max_seqlen = int(seqlens.max().item())
457
+ indices = attention_mask_2d.flatten().nonzero(as_tuple=False).flatten()
458
+ query_layer = index_first_axis(
459
+ query_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
460
+ )
461
+ key_layer = index_first_axis(
462
+ key_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
463
+ )
464
+ value_layer = index_first_axis(
465
+ value_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
466
+ )
467
+ return (
468
+ query_layer,
469
+ key_layer,
470
+ value_layer,
471
+ indices,
472
+ (cu_seqlens, cu_seqlens),
473
+ (max_seqlen, max_seqlen),
474
+ )
475
+
476
+
477
+ def _validate_flash_padding_mask(
478
+ query_states: torch.Tensor,
479
+ key_states: torch.Tensor,
480
+ value_states: torch.Tensor,
481
+ attention_mask_2d: torch.Tensor,
482
+ ) -> torch.Tensor:
483
+ """Validate the self-attention padding mask used by the varlen kernels."""
484
+
485
+ if attention_mask_2d.ndim != 2:
486
+ raise ValueError("FlashAttention padding masks must have shape (batch, sequence_length).")
487
+ expected_shape = query_states.shape[:2]
488
+ if tuple(attention_mask_2d.shape) != tuple(expected_shape):
489
+ raise ValueError(
490
+ "FlashAttention padding mask shape must match the query batch and "
491
+ f"sequence dimensions; expected {tuple(expected_shape)}, received "
492
+ f"{tuple(attention_mask_2d.shape)}."
493
+ )
494
+ if key_states.shape[:2] != expected_shape or value_states.shape[:2] != expected_shape:
495
+ raise ValueError(
496
+ "Masked FlashAttention requires Q, K, and V to share batch and sequence dimensions."
497
+ )
498
+ if attention_mask_2d.device != query_states.device:
499
+ raise ValueError("FlashAttention padding mask and Q, K, and V must be on the same device.")
500
+ return attention_mask_2d.to(dtype=torch.bool)
501
+
502
+
503
+ def kernels_flash_attention_func(
504
+ query_states: torch.Tensor,
505
+ key_states: torch.Tensor,
506
+ value_states: torch.Tensor,
507
+ attention_mask_2d: torch.Tensor | None = None,
508
+ causal: bool = False,
509
+ softmax_scale: float | None = None,
510
+ implementation: str = "flash_attention_3",
511
+ ) -> torch.Tensor:
512
+ """Public flash-attention entry point with optional padding handling.
513
+
514
+ `softmax_scale`:
515
+ None -> kernel applies its default `1 / sqrt(head_dim)`.
516
+ float -> kernel uses the given scale (pass 1.0 when Q is pre-scaled
517
+ by the caller).
518
+
519
+ Caller contract: if a model family pre-scales Q by `1/sqrt(head_dim)`
520
+ before calling this function (ESM2, DPLM, DPLM2, E1, and ESMFold do), pass
521
+ `softmax_scale=1.0`. Otherwise the flash kernel applies its default scale
522
+ again, yielding an effective `1/head_dim` scale that drifts across layers.
523
+ """
524
+ _validate_kernels_flash_device(
525
+ query_states,
526
+ key_states,
527
+ value_states,
528
+ implementation,
529
+ )
530
+ runtime_dtype = _validate_kernels_flash_dtype(
531
+ query_states,
532
+ key_states,
533
+ value_states,
534
+ implementation,
535
+ )
536
+ if query_states.dtype != runtime_dtype:
537
+ query_states = query_states.to(dtype=runtime_dtype)
538
+ key_states = key_states.to(dtype=runtime_dtype)
539
+ value_states = value_states.to(dtype=runtime_dtype)
540
+ if attention_mask_2d is not None:
541
+ attention_mask_2d = _validate_flash_padding_mask(
542
+ query_states,
543
+ key_states,
544
+ value_states,
545
+ attention_mask_2d,
546
+ )
547
+ _ensure_flash_kernels_loaded(implementation)
548
+ if attention_mask_2d is not None:
549
+ batch_size, q_len = query_states.shape[:2]
550
+ (
551
+ query_states,
552
+ key_states,
553
+ value_states,
554
+ indices_q,
555
+ (cu_seqlens_q, cu_seqlens_k),
556
+ (max_seqlen_q, max_seqlen_k),
557
+ ) = _unpad_input(query_states, key_states, value_states, attention_mask_2d)
558
+ attn_output_unpad = _kernels_flash_varlen_forward(
559
+ query_states=query_states,
560
+ key_states=key_states,
561
+ value_states=value_states,
562
+ cu_seqlens_q=cu_seqlens_q,
563
+ cu_seqlens_k=cu_seqlens_k,
564
+ max_seqlen_in_batch_q=max_seqlen_q,
565
+ max_seqlen_in_batch_k=max_seqlen_k,
566
+ causal=causal,
567
+ softmax_scale=softmax_scale,
568
+ implementation=implementation,
569
+ )
570
+ output = pad_input(attn_output_unpad, indices_q, batch_size, q_len)
571
+ return output.masked_fill(~attention_mask_2d[:, :, None, None], 0)
572
+ else:
573
+ return _kernels_flash_forward(
574
+ query_states=query_states,
575
+ key_states=key_states,
576
+ value_states=value_states,
577
+ causal=causal,
578
+ softmax_scale=softmax_scale,
579
+ implementation=implementation,
580
+ )
581
+
582
+
583
+ # User-facing backend strings follow the Transformers attention interface.
584
+ # Keep ``str`` plus ``Enum`` so stringification stays compatible with existing
585
+ # configuration serialization rather than adopting ``StrEnum.__str__``.
586
+ class AttentionBackend(str, Enum): # noqa: UP042
587
+ EAGER = "eager"
588
+ SDPA = "sdpa"
589
+ FLEX_ATTENTION = "flex_attention"
590
+ FLASH_ATTENTION_2 = "flash_attention_2"
591
+ FLASH_ATTENTION_3 = "flash_attention_3"
592
+
593
+ # Internal spelling retained to keep attention modules concise. It is an
594
+ # enum alias, not an accepted public backend string.
595
+ FLEX = FLEX_ATTENTION
596
+
597
+ @property
598
+ def is_flash(self) -> bool:
599
+ return self in {
600
+ AttentionBackend.FLASH_ATTENTION_2,
601
+ AttentionBackend.FLASH_ATTENTION_3,
602
+ }
603
+
604
+
605
+ VALID_ATTENTION_BACKENDS = tuple(b.value for b in AttentionBackend)
606
+
607
+
608
+ def warn_attention_backend_fallback(
609
+ requested_backend: str | AttentionBackend,
610
+ *,
611
+ effective_backend: str | AttentionBackend,
612
+ reason: str,
613
+ ) -> None:
614
+ """Warn when one forward call cannot honor the configured backend."""
615
+
616
+ requested = resolve_attention_backend(requested_backend).value
617
+ effective = resolve_attention_backend(effective_backend).value
618
+ if requested == effective:
619
+ return
620
+ warnings.warn(
621
+ f"{reason} The requested {requested!r} attention implementation cannot "
622
+ f"satisfy this call, so FastPLMs is using {effective!r} attention for this "
623
+ "call only. This can change performance and memory use; the configured "
624
+ "backend remains unchanged for subsequent calls.",
625
+ RuntimeWarning,
626
+ stacklevel=3,
627
+ )
628
+
629
+
630
+ def resolve_attention_backend_for_call(
631
+ requested_backend: str | AttentionBackend,
632
+ *,
633
+ output_attentions: bool,
634
+ ) -> AttentionBackend:
635
+ """Resolve the effective backend for one call and report substitutions once."""
636
+
637
+ requested = resolve_attention_backend(requested_backend)
638
+ if not output_attentions or requested == AttentionBackend.EAGER:
639
+ return requested
640
+ warn_attention_backend_fallback(
641
+ requested,
642
+ effective_backend=AttentionBackend.EAGER,
643
+ reason=(
644
+ "output_attentions=True requires the full materialized attention probability "
645
+ "matrix, which optimized PyTorch attention APIs do not return."
646
+ ),
647
+ )
648
+ return AttentionBackend.EAGER
649
+
650
+
651
+ def resolve_attention_backend(
652
+ requested_backend: str | AttentionBackend | None,
653
+ ) -> AttentionBackend:
654
+ """Validate a backend without silently substituting another implementation."""
655
+ if requested_backend is None:
656
+ requested_backend = AttentionBackend.SDPA.value
657
+ if isinstance(requested_backend, AttentionBackend):
658
+ resolved = requested_backend
659
+ else:
660
+ try:
661
+ resolved = AttentionBackend(requested_backend)
662
+ except ValueError as error:
663
+ raise ValueError(
664
+ f"Unsupported attention implementation {requested_backend!r}; "
665
+ f"expected one of {VALID_ATTENTION_BACKENDS}."
666
+ ) from error
667
+ if resolved == AttentionBackend.FLEX_ATTENTION and flex_attention is None:
668
+ raise RuntimeError(
669
+ "'flex_attention' was requested, but this PyTorch build does not provide it."
670
+ )
671
+ return resolved
672
+
673
+
674
+ def get_attn_implementation(config) -> str:
675
+ """Read the Transformers attention setting, defaulting to SDPA."""
676
+ requested = getattr(config, "_attn_implementation", None)
677
+ if requested is None:
678
+ requested = getattr(config, "attn_backend", None)
679
+ return resolve_attention_backend(requested).value
680
+
681
+
682
+ def set_config_attn_implementation(config, implementation: str) -> str:
683
+ """Set both the Transformers field and the internal dispatch field."""
684
+ resolved = resolve_attention_backend(implementation).value
685
+ if hasattr(config, "_attn_implementation_internal"):
686
+ config._attn_implementation_internal = resolved
687
+ else:
688
+ config._attn_implementation = resolved
689
+ # Existing checkpoint configs contain this field. Keeping it synchronized
690
+ # preserves their state schema while the public API uses attn_implementation.
691
+ config.attn_backend = resolved
692
+ return resolved
693
+
694
+
695
+ @torch.compiler.disable
696
+ def get_attention_mask(
697
+ effective_backend: AttentionBackend,
698
+ batch_size: int,
699
+ seq_len: int,
700
+ device: torch.device,
701
+ attention_mask: torch.Tensor | None = None,
702
+ dtype: torch.dtype | None = None,
703
+ mask_semantics: str = "padding",
704
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None, BlockMask | None]:
705
+ """Build padding masks once for all encoder layers.
706
+
707
+ Returns (attention_mask_2d, attention_mask_4d, flex_block_mask).
708
+ """
709
+ if attention_mask is None:
710
+ return None, None, None
711
+
712
+ if attention_mask.ndim != 2:
713
+ raise ValueError(
714
+ "attention_mask must have shape (batch, sequence_length); "
715
+ f"received rank {attention_mask.ndim} with shape {tuple(attention_mask.shape)}."
716
+ )
717
+ expected_shape = (batch_size, seq_len)
718
+ if tuple(attention_mask.shape) != expected_shape:
719
+ raise ValueError(
720
+ "attention_mask shape must match the input batch and sequence dimensions; "
721
+ f"expected {expected_shape}, received {tuple(attention_mask.shape)}."
722
+ )
723
+ attention_mask_2d = attention_mask.to(device=device, dtype=torch.bool)
724
+ if not bool(attention_mask_2d.any(dim=1).all()):
725
+ raise ValueError("attention_mask must keep at least one valid key per batch row.")
726
+
727
+ effective_backend = resolve_attention_backend(effective_backend)
728
+
729
+ if effective_backend.is_flash:
730
+ return attention_mask_2d, None, None
731
+
732
+ if effective_backend == AttentionBackend.FLEX_ATTENTION:
733
+ if create_block_mask is None:
734
+ raise RuntimeError(
735
+ "'flex_attention' was requested, but torch.create_block_mask is unavailable."
736
+ )
737
+ def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
738
+ del head_idx, q_idx
739
+ # Match eager and SDPA: padding masks suppress invalid keys only.
740
+ # Invalid queries still attend to real keys and therefore remain
741
+ # finite; downstream residue masks exclude their outputs.
742
+ return attention_mask_2d[batch_idx, kv_idx]
743
+
744
+ flex_block_mask = _get_flex_block_mask(
745
+ mask_pattern=attention_mask_2d,
746
+ batch_size=batch_size,
747
+ query_length=seq_len,
748
+ key_value_length=seq_len,
749
+ device=device,
750
+ dtype=dtype,
751
+ mask_semantics=mask_semantics,
752
+ mask_mod=mask_mod,
753
+ )
754
+ return attention_mask_2d, None, flex_block_mask
755
+
756
+ # SDPA/manual masks only keys. Padding queries still attend to real keys, so
757
+ # their outputs stay finite instead of softmaxing over all -inf scores.
758
+ attention_mask_4d = attention_mask_2d[:, None, None, :]
759
+ return attention_mask_2d, attention_mask_4d, None
760
+
761
+
762
+ def bool_to_additive_mask(
763
+ bool_mask: torch.Tensor,
764
+ dtype: torch.dtype,
765
+ ) -> torch.Tensor:
766
+ """Convert a bool mask (True = valid) to a float additive mask (0.0 valid, -inf invalid).
767
+
768
+ Why this exists: calling `bool_mask.masked_fill(bool_mask.logical_not(), float('-inf'))`
769
+ directly on a bool tensor returns a bool tensor because `-inf` casts to `True`.
770
+ That silently drops the mask. Always allocate a float tensor first, then fill it.
771
+ This helper is the sanctioned way to build an SDPA additive mask from a bool validity mask.
772
+ """
773
+ if bool_mask.dtype != torch.bool:
774
+ raise TypeError(
775
+ f"bool_to_additive_mask requires a bool tensor, got dtype={bool_mask.dtype}"
776
+ )
777
+ additive = torch.zeros_like(bool_mask, dtype=dtype)
778
+ additive.masked_fill_(bool_mask.logical_not(), float("-inf"))
779
+ return additive
fastplms/attention/_kernel_lock.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve and validate Hugging Face kernels before importing their binaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def require_kernels_package() -> None:
13
+ """Fail early when the precompiled-kernel runtime is not installed."""
14
+ try:
15
+ import kernels # noqa: F401
16
+ except ImportError as error:
17
+ raise RuntimeError(
18
+ "Precompiled FlashAttention requires the FastPLMs 'flash' extra."
19
+ ) from error
20
+
21
+
22
+ def _kernel_lock_path() -> Path:
23
+ """Return the lock from an artifact, checkout, or installed distribution."""
24
+ source_path = Path(__file__).resolve()
25
+ candidates = [
26
+ source_path.parents[1] / "kernels.lock",
27
+ source_path.parents[3] / "kernels.lock",
28
+ ]
29
+ try:
30
+ import fastplms
31
+
32
+ candidates.extend(Path(root) / "kernels.lock" for root in fastplms.__path__)
33
+ except (ImportError, AttributeError):
34
+ pass
35
+ for candidate in candidates:
36
+ if candidate.is_file():
37
+ return candidate
38
+
39
+ try:
40
+ distribution = importlib.metadata.distribution("fastplms")
41
+ except importlib.metadata.PackageNotFoundError as error:
42
+ raise RuntimeError("FastPLMs was installed without kernels.lock.") from error
43
+ for relative in distribution.files or ():
44
+ if relative.name != "kernels.lock":
45
+ continue
46
+ candidate = Path(distribution.locate_file(relative))
47
+ if candidate.is_file():
48
+ return candidate
49
+ raise RuntimeError("The installed FastPLMs distribution does not contain kernels.lock.")
50
+
51
+
52
+ def _locked_entry(lock_path: Path, repository: str) -> dict[str, Any]:
53
+ try:
54
+ data = json.loads(lock_path.read_text(encoding="utf-8"))
55
+ except (OSError, json.JSONDecodeError) as error:
56
+ raise RuntimeError(f"Unable to read the packaged kernel lock: {lock_path}") from error
57
+ if not isinstance(data, list):
58
+ raise RuntimeError("kernels.lock must contain a JSON list.")
59
+ if any(not isinstance(entry, dict) for entry in data):
60
+ raise RuntimeError("Every kernels.lock entry must be a JSON object.")
61
+ matches = [entry for entry in data if entry.get("repo_id") == repository]
62
+ if len(matches) != 1:
63
+ raise RuntimeError(
64
+ f"kernels.lock must contain exactly one entry for {repository!r}; found {len(matches)}."
65
+ )
66
+ return matches[0]
67
+
68
+
69
+ def _offline_mode() -> bool:
70
+ """Return whether Hub access was explicitly disabled for this process."""
71
+
72
+ enabled_values = {"1", "on", "true", "yes"}
73
+ return any(
74
+ os.environ.get(name, "").strip().lower() in enabled_values
75
+ for name in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")
76
+ )
77
+
78
+
79
+ def _offline_snapshot_path(repository: str, revision: str) -> Path:
80
+ """Locate one exact, possibly sparse, kernel snapshot without using Hub APIs."""
81
+
82
+ try:
83
+ from huggingface_hub import constants
84
+ from huggingface_hub.file_download import repo_folder_name
85
+ except ImportError as error:
86
+ raise RuntimeError("Offline kernel loading requires huggingface-hub.") from error
87
+
88
+ cache_root = Path(os.environ.get("KERNELS_CACHE") or constants.HF_HUB_CACHE).resolve()
89
+ repository_root = (
90
+ cache_root / repo_folder_name(repo_id=repository, repo_type="kernel")
91
+ ).resolve()
92
+ snapshot = repository_root / "snapshots" / revision
93
+ if not snapshot.is_dir():
94
+ raise RuntimeError(
95
+ f"The exact offline kernel snapshot {repository}@{revision} is not cached under "
96
+ f"{cache_root}. Run `kernels download` before enabling offline mode."
97
+ )
98
+ if repository_root not in snapshot.resolve().parents:
99
+ raise RuntimeError(f"Refusing kernel snapshot outside its cache repository: {snapshot}")
100
+ return snapshot
101
+
102
+
103
+ def _load_offline_locked_kernel(
104
+ repository: str,
105
+ revision: str,
106
+ variant_locks: dict[str, object],
107
+ ) -> object:
108
+ """Validate and import the one compatible variant from a sparse Hub snapshot."""
109
+ snapshot = _offline_snapshot_path(repository, revision)
110
+ build_root = snapshot / "build"
111
+ if not build_root.is_dir():
112
+ raise RuntimeError(f"The cached kernel snapshot has no build directory: {snapshot}")
113
+
114
+ cached_names = sorted(entry.name for entry in build_root.iterdir() if entry.is_dir())
115
+ unexpected = sorted(set(cached_names).difference(variant_locks))
116
+ if unexpected:
117
+ raise RuntimeError(
118
+ f"The cached {repository}@{revision} snapshot contains unlocked variants: "
119
+ f"{', '.join(unexpected)}"
120
+ )
121
+
122
+ try:
123
+ from kernels import get_local_kernel
124
+ from kernels.utils import validate_kernel
125
+ from kernels.variants import get_variants_local, resolve_variants
126
+ except ImportError as error:
127
+ raise RuntimeError(
128
+ "Precompiled FlashAttention requires the FastPLMs 'flash' extra."
129
+ ) from error
130
+
131
+ parsed = get_variants_local(build_root)
132
+ parsed_names = {variant.variant_str for variant in parsed}
133
+ invalid = sorted(set(cached_names).difference(parsed_names))
134
+ if invalid:
135
+ raise RuntimeError(
136
+ f"The cached {repository}@{revision} snapshot contains invalid variants: "
137
+ f"{', '.join(invalid)}"
138
+ )
139
+
140
+ compatible, _ = resolve_variants(parsed)
141
+ if len(compatible) != 1:
142
+ names = ", ".join(variant.variant_str for variant in compatible) or "none"
143
+ raise RuntimeError(
144
+ f"Expected exactly one compatible cached variant for {repository}@{revision}; "
145
+ f"found {names}."
146
+ )
147
+ variant_name = compatible[0].variant_str
148
+ variant_lock = variant_locks.get(variant_name)
149
+ expected_hash = getattr(variant_lock, "hash", None)
150
+ if not isinstance(expected_hash, str) or not expected_hash.startswith("sha256-"):
151
+ raise RuntimeError(f"The kernel lock for {variant_name} has no valid SHA-256 digest.")
152
+
153
+ # Hash validation deliberately happens before import. This operates on the
154
+ # sparse snapshot produced by `kernels download` and avoids Hub 1.23's
155
+ # full-snapshot completeness check in offline mode.
156
+ validate_kernel(repo_path=snapshot, variant=variant_name, hash=expected_hash)
157
+ return get_local_kernel(build_root / variant_name)
158
+
159
+
160
+ def load_locked_kernel(repository: str, revision: str) -> object:
161
+ """Download, hash-validate, then import one immutable precompiled kernel."""
162
+ require_kernels_package()
163
+ try:
164
+ from kernels import get_local_kernel, install_kernel
165
+ from kernels.lockfile import KernelLock
166
+ except ImportError as error:
167
+ raise RuntimeError(
168
+ "Precompiled FlashAttention requires the FastPLMs 'flash' extra."
169
+ ) from error
170
+
171
+ lock_path = _kernel_lock_path()
172
+ kernel_lock = KernelLock.from_json(_locked_entry(lock_path, repository))
173
+ if kernel_lock.sha != revision:
174
+ raise RuntimeError(
175
+ f"The typed manifest pins {repository}@{revision}, but kernels.lock pins "
176
+ f"{kernel_lock.sha}."
177
+ )
178
+
179
+ if _offline_mode():
180
+ return _load_offline_locked_kernel(repository, revision, kernel_lock.variants)
181
+
182
+ # `install_kernel` downloads data without importing it and validates the
183
+ # selected build against the tracked variant hash. Only then is the exact
184
+ # validated path imported directly. Offline mode uses the sparse-cache
185
+ # resolver above because Hub 1.23 rejects partial snapshots as incomplete.
186
+ validated_path = install_kernel(
187
+ repository,
188
+ revision=kernel_lock.sha,
189
+ variant_locks=kernel_lock.variants,
190
+ )
191
+ return get_local_kernel(validated_path)
fastplms/attention/interfaces.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transformers-compatible attention selection for FastPLMs models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from functools import partial
7
+ from typing import Any
8
+
9
+ import torch
10
+ from transformers import AttentionInterface, AttentionMaskInterface
11
+
12
+ from ._core import (
13
+ AttentionBackend,
14
+ get_attn_implementation,
15
+ kernels_flash_attention_func,
16
+ resolve_attention_backend,
17
+ set_config_attn_implementation,
18
+ )
19
+ from ._kernel_lock import require_kernels_package
20
+
21
+
22
+ def _kernels_attention_forward(
23
+ module: torch.nn.Module,
24
+ query: torch.Tensor,
25
+ key: torch.Tensor,
26
+ value: torch.Tensor,
27
+ attention_mask: torch.Tensor | None,
28
+ *,
29
+ implementation: str,
30
+ **kwargs: Any,
31
+ ) -> tuple[torch.Tensor, None]:
32
+ """Run one canonical FlashAttention backend through Hugging Face kernels.
33
+
34
+ Transformers attention functions receive Q, K, and V with shape
35
+ (b, h, l, d) and return an output with shape (b, l, h, d). The shared
36
+ FastPLMs kernel adapter uses the latter layout internally.
37
+ """
38
+
39
+ dropout = float(kwargs.get("dropout", 0.0) or 0.0)
40
+ if module.training and dropout:
41
+ raise RuntimeError(
42
+ "Hugging Face kernels FlashAttention is inference-only when attention dropout "
43
+ "is nonzero. Use SDPA for this training configuration."
44
+ )
45
+ causal = bool(kwargs.get("is_causal", getattr(module, "is_causal", False)))
46
+ softmax_scale = kwargs.get("scaling")
47
+ output = kernels_flash_attention_func(
48
+ query_states=query.transpose(1, 2).contiguous(),
49
+ key_states=key.transpose(1, 2).contiguous(),
50
+ value_states=value.transpose(1, 2).contiguous(),
51
+ attention_mask_2d=attention_mask,
52
+ causal=causal,
53
+ softmax_scale=softmax_scale,
54
+ implementation=implementation,
55
+ )
56
+ return output, None
57
+
58
+
59
+ # Keep FastPLMs' kernels-only adapters local to this registry instance.
60
+ # ``GeneralInterface.register`` updates Transformers' class-wide mapping, so
61
+ # using it here would replace the canonical FlashAttention handlers for every
62
+ # model in the process, including models unrelated to FastPLMs.
63
+ FASTPLMS_ATTENTION_FUNCTIONS = AttentionInterface()
64
+ FASTPLMS_ATTENTION_MASKS = AttentionMaskInterface()
65
+ FASTPLMS_ATTENTION_FUNCTIONS["flash_attention_2"] = partial(
66
+ _kernels_attention_forward,
67
+ implementation="flash_attention_2",
68
+ )
69
+ FASTPLMS_ATTENTION_FUNCTIONS["flash_attention_3"] = partial(
70
+ _kernels_attention_forward,
71
+ implementation="flash_attention_3",
72
+ )
73
+ for _flash_name in ("flash_attention_2", "flash_attention_3"):
74
+ FASTPLMS_ATTENTION_MASKS[_flash_name] = FASTPLMS_ATTENTION_MASKS[_flash_name]
75
+
76
+
77
+ class FastPLMsAttentionMixin:
78
+ """Synchronize Transformers attention selection with custom model layers.
79
+
80
+ Model families retain their checkpoint parameter names. Only runtime
81
+ attributes are updated when ``set_attn_implementation`` is called.
82
+ """
83
+
84
+ _supports_sdpa = True
85
+ _supports_flex_attn = True
86
+ # Transformers 5.13 uses the singular flag during model construction. A
87
+ # family opts in only when its manifest entry advertises at least one of
88
+ # the two FastPLMs kernels-only FlashAttention implementations.
89
+ _supports_flash_attn = False
90
+ _supports_flash_attn_2 = False
91
+ _supports_flash_attn_3 = False
92
+ _fastplms_attention_implementations = (
93
+ "eager",
94
+ "sdpa",
95
+ "flex_attention",
96
+ )
97
+
98
+ def _validate_attention_name(self, implementation: str) -> None:
99
+ if implementation not in self._fastplms_attention_implementations:
100
+ raise ValueError(
101
+ f"{type(self).__name__} does not support {implementation!r}; expected one of "
102
+ f"{self._fastplms_attention_implementations}."
103
+ )
104
+
105
+ def _check_and_adjust_attn_implementation(
106
+ self,
107
+ attn_implementation: str | None,
108
+ is_init_check: bool = False,
109
+ allow_all_kernels: bool = False,
110
+ ) -> str:
111
+ """Resolve attention without invoking Transformers' source-Flash probe.
112
+
113
+ The standard ``flash_attention_2`` and ``flash_attention_3`` names are
114
+ retained for the Transformers API, but FastPLMs resolves them only
115
+ through the exact Hugging Face ``kernels`` artifacts pinned by
116
+ ``models.toml``. Repository-qualified or otherwise external kernels
117
+ are never accepted through this model hook.
118
+ """
119
+
120
+ if allow_all_kernels:
121
+ raise ValueError("FastPLMs does not load external attention kernels.")
122
+ if attn_implementation is None:
123
+ return super()._check_and_adjust_attn_implementation(
124
+ None,
125
+ is_init_check=is_init_check,
126
+ allow_all_kernels=False,
127
+ )
128
+
129
+ self._validate_attention_name(attn_implementation)
130
+ if attn_implementation in {"flash_attention_2", "flash_attention_3"}:
131
+ if not self._supports_flash_attn:
132
+ raise ValueError(
133
+ f"{type(self).__name__} does not advertise kernels-only FlashAttention."
134
+ )
135
+ # Validate the lightweight Python dependency here, but defer binary
136
+ # download and import until Q, K, and V have passed the CUDA gate.
137
+ require_kernels_package()
138
+ return attn_implementation
139
+
140
+ return super()._check_and_adjust_attn_implementation(
141
+ attn_implementation,
142
+ is_init_check=is_init_check,
143
+ allow_all_kernels=False,
144
+ )
145
+
146
+ def __init__(self, config, *args: Any, **kwargs: Any) -> None:
147
+ sentinel = object()
148
+ internal = getattr(config, "_attn_implementation_internal", sentinel)
149
+ canonical = (
150
+ getattr(config, "_attn_implementation", None) if internal is sentinel else internal
151
+ )
152
+ legacy = getattr(config, "attn_backend", None)
153
+ requested = canonical if canonical is not None else legacy
154
+ if requested is not None:
155
+ if not isinstance(requested, str):
156
+ raise TypeError(
157
+ "The configured attention implementation must be a string or None; "
158
+ f"received {type(requested).__name__}."
159
+ )
160
+ self._validate_attention_name(requested)
161
+ # ``PreTrainedModel.__init__`` resolves a missing Transformers
162
+ # implementation to the family default. Legacy FastPLMs configs
163
+ # persist their explicit choice in ``attn_backend``, so forward it
164
+ # into the canonical Transformers field before the base class can
165
+ # replace it with SDPA. A non-None canonical value still wins,
166
+ # including an explicit ``attn_implementation=...`` load override.
167
+ if canonical is None and legacy is not None:
168
+ set_config_attn_implementation(config, legacy)
169
+ super().__init__(config, *args, **kwargs)
170
+ # Transformers resolves an unspecified implementation during the base
171
+ # model initialization. Synchronize that choice before family layers
172
+ # are constructed.
173
+ resolved = get_attn_implementation(config)
174
+ self._validate_attention_name(resolved)
175
+ set_config_attn_implementation(config, resolved)
176
+
177
+ def set_attn_implementation(
178
+ self,
179
+ attn_implementation: str | Mapping[str, str],
180
+ allow_all_kernels: bool = False,
181
+ ) -> None:
182
+ """Select an advertised backend and update every instantiated layer."""
183
+ if isinstance(attn_implementation, Mapping):
184
+ if set(attn_implementation) == {""}:
185
+ attn_implementation = attn_implementation[""]
186
+ else:
187
+ raise ValueError(
188
+ "FastPLMs models have one attention backbone; pass a string or {'': name}."
189
+ )
190
+ resolved_name = self._check_and_adjust_attn_implementation(
191
+ attn_implementation,
192
+ is_init_check=False,
193
+ allow_all_kernels=allow_all_kernels,
194
+ )
195
+ set_config_attn_implementation(self.config, resolved_name)
196
+ resolved = resolve_attention_backend(resolved_name)
197
+ for module in self.modules():
198
+ if module is self:
199
+ continue
200
+ for attribute in ("attn_backend", "attention_backend", "_attn_backend"):
201
+ if attribute not in module.__dict__:
202
+ continue
203
+ current = module.__dict__[attribute]
204
+ module.__dict__[attribute] = (
205
+ resolved if isinstance(current, AttentionBackend) else resolved_name
206
+ )
207
+
208
+
209
+ def validate_transformers_attention_interfaces() -> None:
210
+ """Verify that Transformers exposes functions and masks for every backend.
211
+
212
+ Transformers 5.13 registers these canonical names. The FastPLMs function
213
+ overrides remain instance-local and do not replace process-global handlers.
214
+ """
215
+ function_registry = FASTPLMS_ATTENTION_FUNCTIONS
216
+ mask_registry = FASTPLMS_ATTENTION_MASKS
217
+ missing_functions = [
218
+ name
219
+ for name in (
220
+ "sdpa",
221
+ "flex_attention",
222
+ "flash_attention_2",
223
+ "flash_attention_3",
224
+ )
225
+ if name not in function_registry
226
+ ]
227
+ missing_masks = [
228
+ name
229
+ for name in (
230
+ "eager",
231
+ "sdpa",
232
+ "flex_attention",
233
+ "flash_attention_2",
234
+ "flash_attention_3",
235
+ )
236
+ if name not in mask_registry
237
+ ]
238
+ if missing_functions or missing_masks:
239
+ raise RuntimeError(
240
+ "Transformers attention registry is incomplete: "
241
+ f"functions={missing_functions}, masks={missing_masks}."
242
+ )
fastplms/embeddings/__init__.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ordered, residue-aware protein embedding utilities."""
2
+
3
+ from .pooling import POOLING_NAMES, Pooler, pagerank_weights
4
+ from .runner import (
5
+ EmbeddingMixin,
6
+ embed_dataset,
7
+ iter_fasta,
8
+ parse_fasta,
9
+ select_hidden_state_embeddings,
10
+ )
11
+ from .storage import (
12
+ DEFAULT_SHARD_SIZE,
13
+ append_sqlite_records,
14
+ convert_legacy_sqlite,
15
+ garbage_collect_safetensors_generations,
16
+ initialize_sqlite_run,
17
+ load_legacy_pth,
18
+ load_result,
19
+ load_safetensors_result,
20
+ load_sqlite_result,
21
+ save_result,
22
+ save_safetensors_result,
23
+ save_sqlite_result,
24
+ tensor_sha256,
25
+ update_sqlite_run_metadata,
26
+ )
27
+ from .types import (
28
+ EmbeddingBatch,
29
+ EmbeddingInput,
30
+ EmbeddingRecord,
31
+ EmbeddingResult,
32
+ LazyTensorReference,
33
+ TensorValue,
34
+ )
35
+
36
+ __all__ = [
37
+ "DEFAULT_SHARD_SIZE",
38
+ "POOLING_NAMES",
39
+ "EmbeddingBatch",
40
+ "EmbeddingInput",
41
+ "EmbeddingMixin",
42
+ "EmbeddingRecord",
43
+ "EmbeddingResult",
44
+ "LazyTensorReference",
45
+ "Pooler",
46
+ "TensorValue",
47
+ "append_sqlite_records",
48
+ "convert_legacy_sqlite",
49
+ "embed_dataset",
50
+ "garbage_collect_safetensors_generations",
51
+ "initialize_sqlite_run",
52
+ "iter_fasta",
53
+ "load_legacy_pth",
54
+ "load_result",
55
+ "load_safetensors_result",
56
+ "load_sqlite_result",
57
+ "pagerank_weights",
58
+ "parse_fasta",
59
+ "save_result",
60
+ "save_safetensors_result",
61
+ "save_sqlite_result",
62
+ "select_hidden_state_embeddings",
63
+ "tensor_sha256",
64
+ "update_sqlite_run_metadata",
65
+ ]
fastplms/embeddings/pooling.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Residue-aware pooling implemented entirely with PyTorch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+
8
+ import torch
9
+ from torch import Tensor
10
+
11
+ POOLING_NAMES = frozenset({"mean", "max", "norm", "median", "std", "var", "cls", "parti"})
12
+
13
+
14
+ def _validate_inputs(X: Tensor, M: Tensor) -> Tensor:
15
+ if not isinstance(X, Tensor) or not isinstance(M, Tensor):
16
+ raise TypeError("X and M must be tensors.")
17
+ if X.ndim != 3:
18
+ raise ValueError(f"X must have shape (b, l, d), got {tuple(X.shape)}.")
19
+ if not X.is_floating_point():
20
+ raise TypeError("X must use a floating-point embedding dtype.")
21
+ if M.shape != X.shape[:2]:
22
+ raise ValueError(f"M must have shape (b, l)={tuple(X.shape[:2])}, got {tuple(M.shape)}.")
23
+ if M.is_complex():
24
+ raise TypeError("M must be a boolean or binary numeric residue mask.")
25
+ if not bool(torch.isfinite(M).all()) or not bool(((M == 0) | (M == 1)).all()):
26
+ raise ValueError("M must contain only finite binary mask values.")
27
+ M = M.to(device=X.device, dtype=torch.bool)
28
+ if not bool(M.any(dim=1).all()):
29
+ raise ValueError("Every sample must contain at least one biological residue.")
30
+ if not bool((torch.isfinite(X) | ~M.unsqueeze(-1)).all()):
31
+ raise ValueError("Biological residue embeddings produced non-finite output.")
32
+ return M
33
+
34
+
35
+ def _pooled_attention(attentions: Tensor | Sequence[Tensor], *, batch_size: int) -> Tensor:
36
+ """Max-pool layer/head attention A to shape ``(b, l, l)``.
37
+
38
+ ``parti`` historically keeps the strongest directed edge across the
39
+ available attention maps before PageRank. Replacing NetworkX with Torch
40
+ must not change that reduction.
41
+ """
42
+
43
+ if isinstance(attentions, Sequence):
44
+ if not attentions:
45
+ raise ValueError("parti received an empty attention sequence.")
46
+ # Each A_i has shape (b, h, l, l).
47
+ A = torch.stack(tuple(attentions), dim=1)
48
+ else:
49
+ A = attentions
50
+
51
+ if A.ndim == 5:
52
+ if A.shape[0] != batch_size and A.shape[1] == batch_size:
53
+ A = A.transpose(0, 1)
54
+ if A.shape[0] != batch_size:
55
+ raise ValueError("Five-dimensional attentions must use (b, n, h, l, l).")
56
+ A = A.flatten(1, 2).amax(dim=1)
57
+ elif A.ndim == 4:
58
+ if A.shape[0] != batch_size:
59
+ raise ValueError("Four-dimensional attentions must use (b, h, l, l).")
60
+ A = A.amax(dim=1)
61
+ elif A.ndim == 3:
62
+ if A.shape[0] != batch_size:
63
+ raise ValueError("Three-dimensional attentions must use (b, l, l).")
64
+ else:
65
+ raise ValueError("Attentions must have shape (b, l, l), (b, h, l, l), or (b, n, h, l, l).")
66
+ return A
67
+
68
+
69
+ def pagerank_weights(
70
+ A: Tensor,
71
+ *,
72
+ damping: float = 0.85,
73
+ tolerance: float = 1e-6,
74
+ max_iterations: int = 100,
75
+ ) -> Tensor:
76
+ """Compute PageRank weights for a non-negative attention matrix A.
77
+
78
+ A has shape ``(l, l)``. Rows are normalized into transition
79
+ probabilities; dangling rows transition uniformly.
80
+ """
81
+
82
+ if not isinstance(A, Tensor):
83
+ raise TypeError("A must be a tensor.")
84
+ if A.ndim != 2 or A.shape[0] != A.shape[1]:
85
+ raise ValueError(f"A must be square, got shape {tuple(A.shape)}.")
86
+ if not A.is_floating_point():
87
+ raise TypeError("A must use a floating-point attention dtype.")
88
+ if not isinstance(damping, (int, float)) or isinstance(damping, bool):
89
+ raise TypeError("damping must be a finite float in [0, 1).")
90
+ if not math.isfinite(float(damping)) or not 0 <= damping < 1:
91
+ raise ValueError("damping must be a finite float in [0, 1).")
92
+ if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
93
+ raise TypeError("tolerance must be a positive finite float.")
94
+ if not math.isfinite(float(tolerance)) or tolerance <= 0:
95
+ raise ValueError("tolerance must be a positive finite float.")
96
+ if not isinstance(max_iterations, int) or isinstance(max_iterations, bool):
97
+ raise TypeError("max_iterations must be a positive integer.")
98
+ if max_iterations <= 0:
99
+ raise ValueError("max_iterations must be a positive integer.")
100
+ length = A.shape[0]
101
+ if length == 0:
102
+ raise ValueError("PageRank requires at least one residue.")
103
+ if not bool(torch.isfinite(A).all()):
104
+ raise ValueError("A must contain only finite attention values.")
105
+ work_dtype = torch.float64 if A.dtype == torch.float64 else torch.float32
106
+ P = A.detach().to(dtype=work_dtype).clamp_min(0)
107
+ row_sum = P.sum(dim=-1, keepdim=True)
108
+ uniform = torch.full_like(P, 1.0 / length)
109
+ P = torch.where(row_sum > 0, P / row_sum.clamp_min(torch.finfo(work_dtype).tiny), uniform)
110
+ p = torch.full((length,), 1.0 / length, device=P.device, dtype=work_dtype)
111
+ teleport = (1.0 - damping) / length
112
+ for _ in range(max_iterations):
113
+ p_next = teleport + damping * (P.transpose(0, 1) @ p)
114
+ if torch.linalg.vector_norm(p_next - p, ord=1) <= tolerance:
115
+ p = p_next
116
+ break
117
+ p = p_next
118
+ return p / p.sum()
119
+
120
+
121
+ class Pooler:
122
+ """Apply one or more pooling operations to biological residue rows."""
123
+
124
+ def __init__(self, pooling: str | Sequence[str] = ("mean",)) -> None:
125
+ pooling_value: object = pooling
126
+ if isinstance(pooling_value, (bytes, bytearray)) or not isinstance(
127
+ pooling_value, (str, Sequence)
128
+ ):
129
+ raise TypeError("pooling must be a name or a sequence of names.")
130
+ names = (pooling_value,) if isinstance(pooling_value, str) else tuple(pooling_value)
131
+ if not all(isinstance(name, str) for name in names):
132
+ raise TypeError("pooling names must be strings.")
133
+ if not names:
134
+ raise ValueError("At least one pooling operation is required.")
135
+ unknown = set(names) - POOLING_NAMES
136
+ if unknown:
137
+ raise ValueError(f"Unknown pooling operations: {sorted(unknown)}.")
138
+ duplicates = sorted({name for name in names if names.count(name) > 1})
139
+ if duplicates:
140
+ raise ValueError(f"Duplicate pooling operations are not supported: {duplicates}.")
141
+ self.names = names
142
+
143
+ def output_slices(self, d: int) -> dict[str, tuple[int, int]]:
144
+ """Return the output interval assigned to each pooler."""
145
+
146
+ if not isinstance(d, int) or isinstance(d, bool):
147
+ raise TypeError("d must be a positive integer.")
148
+ if d <= 0:
149
+ raise ValueError("d must be a positive integer.")
150
+ return {name: (i * d, (i + 1) * d) for i, name in enumerate(self.names)}
151
+
152
+ def __call__(
153
+ self,
154
+ X: Tensor,
155
+ residue_mask: Tensor,
156
+ *,
157
+ attentions: Tensor | Sequence[Tensor] | None = None,
158
+ attention_backend: str | None = None,
159
+ ) -> Tensor:
160
+ M = _validate_inputs(X, residue_mask)
161
+ M_expanded = M.unsqueeze(-1)
162
+ count = M_expanded.sum(dim=1).clamp_min(1)
163
+ X_residues = X.masked_fill(~M_expanded, 0)
164
+ outputs: list[Tensor] = []
165
+
166
+ for name in self.names:
167
+ if name == "mean":
168
+ Y = X_residues.sum(dim=1) / count
169
+ elif name == "max":
170
+ Y = X.masked_fill(~M_expanded, -torch.inf).max(dim=1).values
171
+ elif name == "norm":
172
+ Y = torch.linalg.vector_norm(X_residues, ord=2, dim=1)
173
+ elif name == "median":
174
+ Y = X.masked_fill(~M_expanded, torch.nan).nanmedian(dim=1).values
175
+ elif name in {"var", "std"}:
176
+ mean = X_residues.sum(dim=1, keepdim=True) / count.unsqueeze(1)
177
+ centered = (X - mean).masked_fill(~M_expanded, 0)
178
+ variance = (centered**2).sum(dim=1) / count
179
+ Y = variance.sqrt() if name == "std" else variance
180
+ elif name == "cls":
181
+ Y = X[:, 0]
182
+ else:
183
+ if attention_backend != "eager":
184
+ raise ValueError(
185
+ "parti requires attn_implementation='eager' so full "
186
+ "attention matrices are available."
187
+ )
188
+ if attentions is None:
189
+ raise ValueError("parti requires model attention matrices.")
190
+ if int(M.sum(dim=1).max().item()) > 2048:
191
+ raise ValueError("parti supports at most 2,048 biological residues.")
192
+ A = _pooled_attention(attentions, batch_size=X.shape[0]).to(X.device)
193
+ pooled: list[Tensor] = []
194
+ for X_i, M_i, A_i in zip(X, M, A, strict=True):
195
+ indices = M_i.nonzero(as_tuple=True)[0]
196
+ A_residue = A_i.index_select(0, indices).index_select(1, indices)
197
+ w = pagerank_weights(A_residue).to(dtype=X.dtype)
198
+ pooled.append(w @ X_i.index_select(0, indices))
199
+ Y = torch.stack(pooled)
200
+ if not bool(torch.isfinite(Y).all()):
201
+ raise ValueError(
202
+ f"Pooling operation {name!r} produced non-finite output from "
203
+ "biological residue embeddings."
204
+ )
205
+ outputs.append(Y)
206
+
207
+ return torch.cat(outputs, dim=-1)
208
+
209
+
210
+ __all__ = ["POOLING_NAMES", "Pooler", "pagerank_weights"]
fastplms/embeddings/runner.py ADDED
@@ -0,0 +1,1559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model-independent dataset embedding orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import platform
8
+ import sqlite3
9
+ import tempfile
10
+ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ from typing import Any, overload
14
+
15
+ import torch
16
+ from torch import Tensor
17
+
18
+ from .pooling import Pooler
19
+ from .storage import (
20
+ SafetensorsStreamWriter,
21
+ append_sqlite_records,
22
+ initialize_sqlite_run,
23
+ load_result,
24
+ load_sqlite_result,
25
+ safetensors_result_exists,
26
+ save_result,
27
+ tensor_sha256,
28
+ update_sqlite_run_metadata,
29
+ )
30
+ from .types import (
31
+ EmbeddingBatch,
32
+ EmbeddingInput,
33
+ EmbeddingRecord,
34
+ EmbeddingResult,
35
+ LazyTensorReference,
36
+ )
37
+
38
+ _MAX_PARTI_RESIDUES = 2_048
39
+ _RUN_FINGERPRINT_SCHEMA_VERSION = 3
40
+ _MODEL_STATE_HASH_CHUNK_BYTES = 16 * 1024**2
41
+ _DEFAULT_BATCH_WINDOW_MULTIPLIER = 16
42
+ _SUPPORTED_STORAGE_FORMATS = frozenset({"safetensors", "sqlite"})
43
+
44
+
45
+ def _validate_parti_length(M: Tensor) -> None:
46
+ """Reject an oversized attention graph before model inference."""
47
+
48
+ n_residues = int(M.to(dtype=torch.int64).sum(dim=1).max().item())
49
+ if n_residues > _MAX_PARTI_RESIDUES:
50
+ raise ValueError(f"parti supports at most {_MAX_PARTI_RESIDUES:,} biological residues.")
51
+
52
+
53
+ def select_hidden_state_embeddings(
54
+ last_hidden_state: Tensor,
55
+ hidden_states: tuple[Tensor, ...] | None,
56
+ *,
57
+ hidden_state_index: int = -1,
58
+ store_all_hidden_states: bool = False,
59
+ ) -> Tensor:
60
+ """Select one hidden state or stack every state without changing values."""
61
+ if store_all_hidden_states:
62
+ if not hidden_states:
63
+ raise ValueError("store_all_hidden_states requires model hidden states.")
64
+ # H has shape (b, n, l, d), where n follows the model's output order.
65
+ return torch.stack(hidden_states, dim=1)
66
+ if hidden_state_index == -1:
67
+ return last_hidden_state
68
+ if not hidden_states:
69
+ raise ValueError("hidden_state_index requires model hidden states.")
70
+ return hidden_states[hidden_state_index]
71
+
72
+
73
+ def iter_fasta(path: str | Path) -> Iterator[EmbeddingInput]:
74
+ """Yield FASTA records in source order without reading the file into memory."""
75
+
76
+ identifier: str | None = None
77
+ sequence_parts: list[str] = []
78
+ found_record = False
79
+ with Path(path).open("r", encoding="utf-8") as handle:
80
+ for line_number, raw_line in enumerate(handle, start=1):
81
+ line = raw_line.strip()
82
+ if not line:
83
+ continue
84
+ if line.startswith(">"):
85
+ if identifier is not None:
86
+ found_record = True
87
+ yield EmbeddingInput(identifier, "".join(sequence_parts))
88
+ identifier = line[1:].strip().split(maxsplit=1)[0]
89
+ if not identifier:
90
+ raise ValueError(f"Missing FASTA identifier on line {line_number}.")
91
+ sequence_parts = []
92
+ else:
93
+ if identifier is None:
94
+ raise ValueError(
95
+ f"Sequence data precedes the first FASTA header on line {line_number}."
96
+ )
97
+ sequence_parts.append("".join(line.split()))
98
+ if identifier is not None:
99
+ found_record = True
100
+ yield EmbeddingInput(identifier, "".join(sequence_parts))
101
+ if not found_record:
102
+ raise ValueError(f"No FASTA records found in {path}.")
103
+
104
+
105
+ def parse_fasta(path: str | Path) -> list[EmbeddingInput]:
106
+ """Parse FASTA records while preserving identifiers, order, and duplicates."""
107
+
108
+ return list(iter_fasta(path))
109
+
110
+
111
+ def _normalize_input_item(
112
+ position: int,
113
+ item: str | EmbeddingInput | tuple[str, str],
114
+ ) -> EmbeddingInput:
115
+ if isinstance(item, EmbeddingInput):
116
+ return item
117
+ if isinstance(item, str):
118
+ return EmbeddingInput(str(position), item)
119
+ if isinstance(item, tuple) and len(item) == 2:
120
+ return EmbeddingInput(str(item[0]), str(item[1]))
121
+ raise TypeError(
122
+ "inputs must contain sequences, EmbeddingInput values, or (id, sequence) tuples."
123
+ )
124
+
125
+
126
+ class _InputSpool(Sequence[EmbeddingInput]):
127
+ """Immutable disk-backed normalized inputs with an incremental digest."""
128
+
129
+ def __init__(
130
+ self,
131
+ values: Iterable[str | EmbeddingInput | tuple[str, str]],
132
+ ) -> None:
133
+ self._temporary: tempfile.TemporaryDirectory[str] | None = tempfile.TemporaryDirectory(
134
+ prefix="fastplms-inputs-"
135
+ )
136
+ self.path = Path(self._temporary.name) / "inputs.sqlite"
137
+ self._connection: sqlite3.Connection | None = sqlite3.connect(self.path)
138
+ self._connection.execute(
139
+ "CREATE TABLE inputs ("
140
+ "position INTEGER PRIMARY KEY, input_id TEXT NOT NULL, sequence TEXT NOT NULL)"
141
+ )
142
+ digest = hashlib.sha256()
143
+ count = 0
144
+ pending: list[tuple[int, str, str]] = []
145
+ try:
146
+ for position, item in enumerate(values):
147
+ record = _normalize_input_item(position, item)
148
+ for value in (record.id, record.sequence):
149
+ encoded = value.encode("utf-8")
150
+ digest.update(len(encoded).to_bytes(8, "big"))
151
+ digest.update(encoded)
152
+ pending.append((position, record.id, record.sequence))
153
+ count += 1
154
+ if len(pending) == 1_024:
155
+ self._connection.executemany("INSERT INTO inputs VALUES (?, ?, ?)", pending)
156
+ pending.clear()
157
+ if pending:
158
+ self._connection.executemany("INSERT INTO inputs VALUES (?, ?, ?)", pending)
159
+ if count == 0:
160
+ raise ValueError("inputs must contain at least one sequence.")
161
+ self._connection.commit()
162
+ self._connection.close()
163
+ self._connection = sqlite3.connect(
164
+ f"{self.path.resolve().as_uri()}?mode=ro",
165
+ uri=True,
166
+ )
167
+ except BaseException:
168
+ self.close()
169
+ raise
170
+ digest.update(count.to_bytes(8, "big"))
171
+ self.input_fingerprint = digest.hexdigest()
172
+ self._count = count
173
+
174
+ def _require_connection(self) -> sqlite3.Connection:
175
+ if self._connection is None:
176
+ raise RuntimeError("Input spool is closed.")
177
+ return self._connection
178
+
179
+ def __len__(self) -> int:
180
+ return self._count
181
+
182
+ def __iter__(self) -> Iterator[EmbeddingInput]:
183
+ cursor = self._require_connection().execute(
184
+ "SELECT input_id, sequence FROM inputs ORDER BY position"
185
+ )
186
+ while rows := cursor.fetchmany(1_024):
187
+ for input_id, sequence in rows:
188
+ yield EmbeddingInput(input_id, sequence)
189
+
190
+ @overload
191
+ def __getitem__(self, index: int, /) -> EmbeddingInput: ...
192
+
193
+ @overload
194
+ def __getitem__(self, index: slice, /) -> list[EmbeddingInput]: ...
195
+
196
+ def __getitem__(self, index: int | slice) -> EmbeddingInput | list[EmbeddingInput]:
197
+ connection = self._require_connection()
198
+
199
+ if isinstance(index, slice):
200
+ start, stop, step = index.indices(self._count)
201
+ if step != 1:
202
+ return [self[position] for position in range(start, stop, step)]
203
+ rows = connection.execute(
204
+ "SELECT input_id, sequence FROM inputs "
205
+ "WHERE position >= ? AND position < ? ORDER BY position",
206
+ (start, stop),
207
+ ).fetchall()
208
+ return [EmbeddingInput(input_id, sequence) for input_id, sequence in rows]
209
+ position = index + self._count if index < 0 else index
210
+ if position < 0 or position >= self._count:
211
+ raise IndexError(index)
212
+ row = connection.execute(
213
+ "SELECT input_id, sequence FROM inputs WHERE position = ?", (position,)
214
+ ).fetchone()
215
+ if row is None:
216
+ raise IndexError(index)
217
+ return EmbeddingInput(row[0], row[1])
218
+
219
+ def close(self) -> None:
220
+ connection = getattr(self, "_connection", None)
221
+ if connection is not None:
222
+ connection.close()
223
+ self._connection = None
224
+ temporary = getattr(self, "_temporary", None)
225
+ if temporary is not None:
226
+ temporary.cleanup()
227
+ self._temporary = None
228
+
229
+ def __del__(self) -> None:
230
+ self.close()
231
+
232
+
233
+ def _normalize_inputs(
234
+ inputs: (Iterable[str | EmbeddingInput | tuple[str, str]] | Mapping[str, str] | str | Path),
235
+ *,
236
+ disk_backed: bool,
237
+ ) -> Sequence[EmbeddingInput]:
238
+ is_fasta_path = isinstance(inputs, Path)
239
+ if isinstance(inputs, str):
240
+ try:
241
+ is_fasta_path = Path(inputs).is_file()
242
+ except OSError:
243
+ is_fasta_path = False
244
+ should_spool = disk_backed or is_fasta_path or not isinstance(inputs, (str, Sequence, Mapping))
245
+ values: Iterable[str | EmbeddingInput | tuple[str, str]]
246
+ if isinstance(inputs, Path):
247
+ values = iter_fasta(inputs)
248
+ elif isinstance(inputs, str):
249
+ values = iter_fasta(inputs) if is_fasta_path else [inputs]
250
+ elif isinstance(inputs, Mapping):
251
+ values = inputs.items()
252
+ else:
253
+ values = inputs
254
+ if should_spool:
255
+ return _InputSpool(values)
256
+ records: list[EmbeddingInput] = []
257
+ for position, item in enumerate(values):
258
+ records.append(_normalize_input_item(position, item))
259
+ if not records:
260
+ raise ValueError("inputs must contain at least one sequence.")
261
+ return records
262
+
263
+
264
+ def _validate_untruncated_lengths(
265
+ records: Sequence[EmbeddingInput],
266
+ *,
267
+ max_length: int | None,
268
+ truncate: bool,
269
+ ) -> None:
270
+ """Fail before inference when a biological-residue limit would be exceeded."""
271
+
272
+ if max_length is None or truncate:
273
+ return
274
+ for position, record in enumerate(records):
275
+ residue_count = len(record.sequence)
276
+ if residue_count > max_length:
277
+ raise ValueError(
278
+ f"Input at position {position} with id {record.id!r} has "
279
+ f"{residue_count} biological residues, exceeding max_length={max_length} "
280
+ "while truncate=False."
281
+ )
282
+
283
+
284
+ def _model_device(model: Any) -> torch.device:
285
+ try:
286
+ return torch.device(next(model.parameters()).device)
287
+ except (AttributeError, StopIteration):
288
+ return torch.device("cpu")
289
+
290
+
291
+ def _attention_backend(model: Any) -> str | None:
292
+ config = getattr(model, "config", None)
293
+ for name in ("_attn_implementation", "attn_implementation", "attn_backend"):
294
+ value = getattr(config, name, None)
295
+ if value:
296
+ return str(value)
297
+ return None
298
+
299
+
300
+ def _attention_kernel_metadata(backend: str | None) -> dict[str, Any] | None:
301
+ if backend not in {"flash_attention_2", "flash_attention_3"}:
302
+ return None
303
+ from fastplms.registry import get_model_registry
304
+
305
+ spec = get_model_registry().attention_kernels[backend]
306
+ return {
307
+ "repository": spec.repository,
308
+ "revision": spec.revision,
309
+ "version": spec.version,
310
+ "expected_variant": spec.expected_variant,
311
+ "dtypes": list(spec.dtypes),
312
+ }
313
+
314
+
315
+ def _fingerprint_jsonable(value: Any) -> Any:
316
+ if isinstance(value, Mapping):
317
+ return {str(key): _fingerprint_jsonable(item) for key, item in value.items()}
318
+ if isinstance(value, (list, tuple)):
319
+ return [_fingerprint_jsonable(item) for item in value]
320
+ if isinstance(value, (set, frozenset)):
321
+ return sorted((_fingerprint_jsonable(item) for item in value), key=repr)
322
+ if isinstance(value, Path):
323
+ return str(value)
324
+ if isinstance(value, Tensor):
325
+ return {
326
+ "dtype": str(value.dtype).removeprefix("torch."),
327
+ "shape": list(value.shape),
328
+ "sha256": tensor_sha256(value),
329
+ }
330
+ if isinstance(value, torch.dtype):
331
+ return str(value).removeprefix("torch.")
332
+ if isinstance(value, torch.device):
333
+ return str(value)
334
+ if value is None or isinstance(value, (str, int, float, bool)):
335
+ return value
336
+ return {
337
+ "class": f"{value.__class__.__module__}.{value.__class__.__qualname__}",
338
+ "value": str(value),
339
+ }
340
+
341
+
342
+ def _tokenizer_content_sha256(tokenizer: Any) -> str:
343
+ content: dict[str, Any] = {
344
+ "init_kwargs": getattr(tokenizer, "init_kwargs", None),
345
+ "special_tokens_map": getattr(tokenizer, "special_tokens_map", None),
346
+ "model_max_length": getattr(tokenizer, "model_max_length", None),
347
+ "padding_side": getattr(tokenizer, "padding_side", None),
348
+ "truncation_side": getattr(tokenizer, "truncation_side", None),
349
+ }
350
+ get_vocab = getattr(tokenizer, "get_vocab", None)
351
+ if callable(get_vocab):
352
+ content["vocabulary"] = get_vocab()
353
+ get_added_vocab = getattr(tokenizer, "get_added_vocab", None)
354
+ if callable(get_added_vocab):
355
+ content["added_vocabulary"] = get_added_vocab()
356
+ backend = getattr(tokenizer, "backend_tokenizer", None)
357
+ backend_to_str = getattr(backend, "to_str", None)
358
+ if callable(backend_to_str):
359
+ content["backend"] = backend_to_str()
360
+ serialized = json.dumps(
361
+ _fingerprint_jsonable(content),
362
+ sort_keys=True,
363
+ separators=(",", ":"),
364
+ ensure_ascii=False,
365
+ ).encode()
366
+ return hashlib.sha256(serialized).hexdigest()
367
+
368
+
369
+ def _tokenizer_metadata(model: Any, tokenizer: Any | None) -> dict[str, Any]:
370
+ resolved = tokenizer if tokenizer is not None else getattr(model, "tokenizer", None)
371
+ if resolved is None:
372
+ # Raw-sequence families such as E1 retain their loader context on the
373
+ # model/encoder rather than exposing a Transformers tokenizer. Bind the
374
+ # non-secret source policy to resume identity without serializing a Hub
375
+ # token or forcing lazy tokenizer initialization.
376
+ for candidate in (model, getattr(model, "model", None)):
377
+ settings = getattr(candidate, "__dict__", {}).get("_fastplms_tokenizer_kwargs")
378
+ if isinstance(settings, Mapping):
379
+ token_value = settings.get("token")
380
+ return {
381
+ "mode": "native-sequence",
382
+ "source": (
383
+ str(settings.get("tokenizer_source"))
384
+ if settings.get("tokenizer_source") is not None
385
+ else None
386
+ ),
387
+ "revision": settings.get("revision"),
388
+ "cache_dir": (
389
+ str(settings.get("cache_dir"))
390
+ if settings.get("cache_dir") is not None
391
+ else None
392
+ ),
393
+ "local_files_only": bool(settings.get("local_files_only", False)),
394
+ "token_policy": (
395
+ "disabled"
396
+ if token_value is False
397
+ else "provided"
398
+ if token_value is not None
399
+ else "default"
400
+ ),
401
+ }
402
+ return {"mode": "native-sequence"}
403
+ return {
404
+ "mode": "tokenizer",
405
+ "class": f"{resolved.__class__.__module__}.{resolved.__class__.__qualname__}",
406
+ "name_or_path": getattr(resolved, "name_or_path", None),
407
+ "vocab_size": getattr(resolved, "vocab_size", None),
408
+ "special_token_ids": list(getattr(resolved, "all_special_ids", ())),
409
+ "content_sha256": _tokenizer_content_sha256(resolved),
410
+ }
411
+
412
+
413
+ @contextmanager
414
+ def _temporary_eval(model: Any) -> Iterator[None]:
415
+ was_training = getattr(model, "training", None)
416
+ eval_method = getattr(model, "eval", None)
417
+ train_method = getattr(model, "train", None)
418
+ if (
419
+ not isinstance(was_training, bool)
420
+ or not callable(eval_method)
421
+ or not callable(train_method)
422
+ ):
423
+ yield
424
+ return
425
+ eval_method()
426
+ try:
427
+ yield
428
+ finally:
429
+ train_method(was_training)
430
+
431
+
432
+ def _software_versions() -> dict[str, str | None]:
433
+ try:
434
+ import fastplms
435
+
436
+ fastplms_version = fastplms.__version__
437
+ except (AttributeError, ImportError):
438
+ fastplms_version = None
439
+ try:
440
+ import safetensors
441
+
442
+ safetensors_version = safetensors.__version__
443
+ except ImportError:
444
+ safetensors_version = None
445
+ try:
446
+ import transformers
447
+
448
+ transformers_version = transformers.__version__
449
+ except ImportError:
450
+ transformers_version = None
451
+ return {
452
+ "fastplms": fastplms_version,
453
+ "python": platform.python_version(),
454
+ "safetensors": safetensors_version,
455
+ "torch": torch.__version__,
456
+ "torch_cuda": torch.version.cuda,
457
+ "transformers": transformers_version,
458
+ }
459
+
460
+
461
+ def _adapter_identity_metadata(model: Any) -> dict[str, Any] | None:
462
+ """Return deterministic PEFT/adapter identity without tensor payloads."""
463
+
464
+ peft_config = getattr(model, "peft_config", None)
465
+ if not isinstance(peft_config, Mapping) or not peft_config:
466
+ return None
467
+ configurations: dict[str, Any] = {}
468
+ for name, config in sorted(peft_config.items(), key=lambda item: str(item[0])):
469
+ to_dict = getattr(config, "to_dict", None)
470
+ if callable(to_dict):
471
+ value = to_dict()
472
+ else:
473
+ try:
474
+ value = vars(config)
475
+ except TypeError:
476
+ value = config
477
+ configurations[str(name)] = _fingerprint_jsonable(value)
478
+ active_adapters = getattr(model, "active_adapters", None)
479
+ if callable(active_adapters):
480
+ active_adapters = active_adapters()
481
+ return {
482
+ "active": _fingerprint_jsonable(active_adapters),
483
+ "configurations": configurations,
484
+ }
485
+
486
+
487
+ def _execution_identity_metadata(model: Any) -> dict[str, Any]:
488
+ """Capture runtime policy that can change persisted numerical results."""
489
+
490
+ parameter_dtypes = sorted(
491
+ {
492
+ str(parameter.dtype).removeprefix("torch.")
493
+ for parameter in getattr(model, "parameters", lambda: ())()
494
+ }
495
+ )
496
+ return {
497
+ "device": _model_device(model).type,
498
+ "hf_device_map": _fingerprint_jsonable(getattr(model, "hf_device_map", None)),
499
+ "parameter_dtypes": parameter_dtypes,
500
+ "software": _software_versions(),
501
+ }
502
+
503
+
504
+ def _biological_residue_mask(
505
+ input_ids: Tensor,
506
+ attention_mask: Tensor,
507
+ tokenizer: Any,
508
+ ) -> Tensor:
509
+ """Remove padding and tokenizer-declared special tokens from M."""
510
+
511
+ M = attention_mask.to(dtype=torch.bool)
512
+ special_ids = tuple(int(token_id) for token_id in getattr(tokenizer, "all_special_ids", ()))
513
+ if special_ids:
514
+ specials = torch.tensor(special_ids, device=input_ids.device, dtype=input_ids.dtype)
515
+ M = M & ~torch.isin(input_ids, specials)
516
+ return M
517
+
518
+
519
+ def _generic_embedding_batch(
520
+ model: Any,
521
+ sequences: list[str],
522
+ *,
523
+ tokenizer: Any | None,
524
+ max_length: int | None,
525
+ truncate: bool,
526
+ need_attentions: bool,
527
+ model_kwargs: dict[str, Any],
528
+ ) -> EmbeddingBatch:
529
+ config = getattr(model, "config", None)
530
+ model_type = str(getattr(config, "model_type", "")).lower()
531
+ if tokenizer is None:
532
+ tokenizer = getattr(model, "tokenizer", None)
533
+
534
+ if tokenizer is None and model_type == "e1":
535
+ output = model._embed(sequences, return_attention_mask=True, **model_kwargs)
536
+ if not isinstance(output, tuple) or len(output) != 2:
537
+ raise TypeError("E1 _embed must return (X, residue_mask).")
538
+ X, M = output
539
+ preparer = getattr(model, "prep_tokens", None)
540
+ if preparer is not None and hasattr(preparer, "get_batch_kwargs"):
541
+ prepared = preparer.get_batch_kwargs(sequences, device=X.device)
542
+ input_ids = prepared["input_ids"]
543
+ boundary_ids = preparer.boundary_token_ids.to(
544
+ device=input_ids.device, dtype=input_ids.dtype
545
+ )
546
+ # E1 wraps each raw sequence in BOS, context-label, terminal-label,
547
+ # and EOS tokens. Only amino-acid rows are biological residues.
548
+ M = M.to(dtype=torch.bool) & ~torch.isin(input_ids, boundary_ids)
549
+ if need_attentions:
550
+ raise ValueError("parti is not available for tokenizer-free E1 embedding.")
551
+ return EmbeddingBatch(X=X, residue_mask=M.to(dtype=torch.bool))
552
+ if tokenizer is None:
553
+ raise ValueError("A tokenizer is required for this model's embedding path.")
554
+
555
+ tokenize_kwargs: dict[str, Any] = {
556
+ "return_tensors": "pt",
557
+ "padding": True,
558
+ "truncation": truncate,
559
+ }
560
+ if max_length is not None and truncate:
561
+ # ``max_length`` is a biological-residue limit. Tokenizer limits include
562
+ # boundary tokens, so reserve their declared width instead of dropping
563
+ # residues at the exact boundary.
564
+ special_token_count = 0
565
+ num_special_tokens_to_add = getattr(tokenizer, "num_special_tokens_to_add", None)
566
+ if callable(num_special_tokens_to_add):
567
+ special_token_count = int(num_special_tokens_to_add(pair=False))
568
+ tokenize_kwargs["max_length"] = max_length + special_token_count
569
+ sequence_tokenizer = getattr(model, "_tokenize_sequence_batch", None)
570
+ if callable(sequence_tokenizer):
571
+ encoded = sequence_tokenizer(sequences, tokenizer=tokenizer, **tokenize_kwargs)
572
+ else:
573
+ encoded = tokenizer(sequences, **tokenize_kwargs)
574
+ device = _model_device(model)
575
+ input_ids = encoded["input_ids"].to(device)
576
+ attention_mask = encoded.get("attention_mask", input_ids.new_ones(input_ids.shape)).to(device)
577
+ M = _biological_residue_mask(input_ids, attention_mask, tokenizer)
578
+ if need_attentions:
579
+ # Validate l before either the backbone or its quadratic attention graph
580
+ # is materialized. M has shape (b, l).
581
+ _validate_parti_length(M)
582
+ X = model._embed(input_ids, attention_mask, **model_kwargs)
583
+ attentions = None
584
+ if need_attentions:
585
+ output = model(
586
+ input_ids=input_ids,
587
+ attention_mask=attention_mask,
588
+ output_attentions=True,
589
+ return_dict=True,
590
+ )
591
+ attentions = getattr(output, "attentions", None)
592
+ if attentions is None:
593
+ raise ValueError("The model did not return attentions required by parti.")
594
+ return EmbeddingBatch(X=X, residue_mask=M, attentions=attentions)
595
+
596
+
597
+ def _first_metadata_value(*values: Any) -> Any:
598
+ for value in values:
599
+ if isinstance(value, str):
600
+ if value.strip():
601
+ return value
602
+ elif value is not None:
603
+ return value
604
+ return None
605
+
606
+
607
+ def _model_identity_metadata(model: Any) -> dict[str, Any]:
608
+ """Resolve model and checkpoint identity, including local artifact fallbacks."""
609
+
610
+ config = getattr(model, "config", None)
611
+ checkpoint_revision = _first_metadata_value(
612
+ getattr(config, "fastplms_checkpoint_revision", None),
613
+ getattr(config, "_commit_hash", None),
614
+ )
615
+ return {
616
+ "model_id": _first_metadata_value(
617
+ getattr(config, "fastplms_model_id", None),
618
+ getattr(config, "_name_or_path", None),
619
+ ),
620
+ "model_revision": _first_metadata_value(
621
+ getattr(config, "_commit_hash", None),
622
+ checkpoint_revision,
623
+ ),
624
+ "checkpoint_repo_id": getattr(config, "fastplms_checkpoint_repo_id", None),
625
+ "checkpoint_revision": checkpoint_revision,
626
+ "checkpoint_hash": _first_metadata_value(
627
+ getattr(model, "checkpoint_hash", None),
628
+ getattr(config, "checkpoint_hash", None),
629
+ getattr(config, "fastplms_checkpoint_hash", None),
630
+ ),
631
+ "weights_revision": getattr(config, "fastplms_weights_revision", None),
632
+ "runtime_revision": getattr(config, "fastplms_runtime_revision", None),
633
+ "source_tree_sha256": getattr(config, "fastplms_source_tree_sha256", None),
634
+ "runtime_bundle_sha256": getattr(config, "fastplms_runtime_bundle_sha256", None),
635
+ }
636
+
637
+
638
+ def _bounded_tensor_chunks(X: Tensor, max_elements: int) -> Iterable[Tensor]:
639
+ """Yield X in logical row-major order without materializing a full copy."""
640
+
641
+ if X.numel() == 0:
642
+ return
643
+ if X.ndim == 0:
644
+ yield X
645
+ return
646
+ trailing_elements = 1
647
+ for size in X.shape[1:]:
648
+ trailing_elements *= int(size)
649
+ if trailing_elements <= max_elements:
650
+ rows_per_chunk = max(1, max_elements // trailing_elements)
651
+ for start in range(0, X.shape[0], rows_per_chunk):
652
+ yield X[start : start + rows_per_chunk]
653
+ return
654
+ for row in X:
655
+ yield from _bounded_tensor_chunks(row, max_elements)
656
+
657
+
658
+ def _model_state_sha256(model: Any) -> str:
659
+ """Hash named parameters and persistent buffers using bounded CPU copies."""
660
+
661
+ # Never cache this digest from tensor identity or ``Tensor._version``.
662
+ # ``Parameter.data`` and independent tensor aliases can mutate shared storage
663
+ # without changing either signal, while persisted resume identity must bind
664
+ # the authoritative bytes visible at the start of this run.
665
+ state = model.state_dict(keep_vars=True)
666
+ digest = hashlib.sha256()
667
+ for name, value in sorted(state.items()):
668
+ if not isinstance(value, Tensor):
669
+ raise TypeError(f"Model state entry {name!r} is not a tensor.")
670
+ if value.is_meta:
671
+ raise ValueError(
672
+ f"Cannot fingerprint meta-device model state entry {name!r}; pass "
673
+ "model_state_fingerprint with a caller-owned state identity."
674
+ )
675
+ header = json.dumps(
676
+ {
677
+ "name": name,
678
+ "dtype": str(value.dtype).removeprefix("torch."),
679
+ "shape": list(value.shape),
680
+ },
681
+ sort_keys=True,
682
+ separators=(",", ":"),
683
+ ).encode()
684
+ digest.update(len(header).to_bytes(8, "big"))
685
+ digest.update(header)
686
+ max_elements = max(1, _MODEL_STATE_HASH_CHUNK_BYTES // value.element_size())
687
+ for chunk in _bounded_tensor_chunks(value.detach(), max_elements):
688
+ cpu_chunk = chunk.to(device="cpu").contiguous()
689
+ digest.update(cpu_chunk.reshape(-1).view(torch.uint8).numpy().tobytes())
690
+ return digest.hexdigest()
691
+
692
+
693
+ def _input_sha256(records: Iterable[EmbeddingInput]) -> str:
694
+ """Hash an ordered input stream without constructing a duplicate JSON payload."""
695
+
696
+ precomputed = getattr(records, "input_fingerprint", None)
697
+ if isinstance(precomputed, str):
698
+ return precomputed
699
+ digest = hashlib.sha256()
700
+ count = 0
701
+ for record in records:
702
+ count += 1
703
+ for value in (record.id, record.sequence):
704
+ encoded = value.encode("utf-8")
705
+ digest.update(len(encoded).to_bytes(8, "big"))
706
+ digest.update(encoded)
707
+ digest.update(count.to_bytes(8, "big"))
708
+ return digest.hexdigest()
709
+
710
+
711
+ def _run_fingerprint(
712
+ model: Any,
713
+ records: Sequence[EmbeddingInput],
714
+ *,
715
+ pooling: Sequence[str],
716
+ full_embeddings: bool,
717
+ max_length: int | None,
718
+ truncate: bool,
719
+ dtype: torch.dtype | None,
720
+ model_kwargs: dict[str, Any],
721
+ tokenizer_metadata: dict[str, Any],
722
+ model_state_fingerprint: str | None,
723
+ persist_output: bool,
724
+ embedding_context: Mapping[str, Any],
725
+ batch_size: int,
726
+ batch_window_size: int,
727
+ max_tokens_per_batch: int | None,
728
+ ) -> tuple[str, str, str | None, str]:
729
+ input_fingerprint = _input_sha256(records)
730
+ attention_backend = _attention_backend(model)
731
+ model_identity = _model_identity_metadata(model)
732
+ if model_state_fingerprint is None and persist_output:
733
+ resolved_model_state_fingerprint = _model_state_sha256(model)
734
+ model_state_fingerprint_source = "computed"
735
+ elif model_state_fingerprint is not None:
736
+ resolved_model_state_fingerprint = model_state_fingerprint.strip()
737
+ if not resolved_model_state_fingerprint:
738
+ raise ValueError("model_state_fingerprint must not be empty.")
739
+ model_state_fingerprint_source = "caller"
740
+ else:
741
+ resolved_model_state_fingerprint = None
742
+ model_state_fingerprint_source = "not-computed"
743
+ payload = {
744
+ "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
745
+ "input_fingerprint": input_fingerprint,
746
+ "model_state_fingerprint": resolved_model_state_fingerprint,
747
+ "model_state_fingerprint_source": model_state_fingerprint_source,
748
+ "model_class": f"{model.__class__.__module__}.{model.__class__.__qualname__}",
749
+ **model_identity,
750
+ "attention_backend": attention_backend,
751
+ "attention_kernel": _attention_kernel_metadata(attention_backend),
752
+ "layer": repr(
753
+ getattr(model, "embedding_layer", model_kwargs.get("hidden_state_index", -1))
754
+ ),
755
+ "projection": getattr(model, "embedding_projection", None),
756
+ "esmc_source": getattr(model, "_esmc_source", None),
757
+ "esmc_revision": getattr(model, "_esmc_source_revision", None),
758
+ "esmc_files": getattr(model, "_esmc_source_files", None),
759
+ "token_policy": getattr(model, "embedding_token_policy", None),
760
+ "tokenizer": tokenizer_metadata,
761
+ "adapter": _adapter_identity_metadata(model),
762
+ "execution": _execution_identity_metadata(model),
763
+ "embedding_context": _fingerprint_jsonable(embedding_context),
764
+ "pooling": list(pooling),
765
+ "full_embeddings": full_embeddings,
766
+ "max_length": max_length,
767
+ "truncate": truncate,
768
+ "dtype": str(dtype) if dtype is not None else None,
769
+ "batching": {
770
+ "batch_size": batch_size,
771
+ "batch_window_size": batch_window_size,
772
+ "max_tokens_per_batch": max_tokens_per_batch,
773
+ "input_storage": ("disk-spool" if isinstance(records, _InputSpool) else "memory"),
774
+ },
775
+ "model_kwargs": {
776
+ key: _fingerprint_jsonable(value) for key, value in sorted(model_kwargs.items())
777
+ },
778
+ "residue_mask_policy": "attention-mask-minus-special-tokens",
779
+ }
780
+ run_fingerprint = hashlib.sha256(
781
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
782
+ ).hexdigest()
783
+ return (
784
+ input_fingerprint,
785
+ run_fingerprint,
786
+ resolved_model_state_fingerprint,
787
+ model_state_fingerprint_source,
788
+ )
789
+
790
+
791
+ def _output_exists(path: str | Path, format: str) -> bool:
792
+ path = Path(path)
793
+ if format == "sqlite":
794
+ return path.is_file()
795
+ return safetensors_result_exists(path)
796
+
797
+
798
+ def _output_descriptor(position: int, record: EmbeddingRecord) -> dict[str, Any]:
799
+ tensor = record.tensor
800
+ if isinstance(tensor, LazyTensorReference):
801
+ dtype = tensor.dtype
802
+ shape = tensor.shape
803
+ digest = tensor.sha256
804
+ else:
805
+ dtype = str(tensor.dtype).removeprefix("torch.")
806
+ shape = tuple(tensor.shape)
807
+ digest = tensor_sha256(tensor)
808
+ return {
809
+ "position": position,
810
+ "id": record.id,
811
+ "dtype": dtype,
812
+ "shape": shape,
813
+ "sha256": digest,
814
+ }
815
+
816
+
817
+ def _ordered_string_sha256(values: Sequence[str]) -> str:
818
+ digest = hashlib.sha256()
819
+ for value in values:
820
+ encoded = value.encode("utf-8")
821
+ digest.update(len(encoded).to_bytes(8, "big"))
822
+ digest.update(encoded)
823
+ digest.update(len(values).to_bytes(8, "big"))
824
+ return digest.hexdigest()
825
+
826
+
827
+ def _embedding_context(
828
+ model: Any,
829
+ records: Sequence[EmbeddingInput],
830
+ *,
831
+ hidden_state_source: str,
832
+ decoder_inputs: Sequence[str] | None,
833
+ decoder_input_ids: Tensor | None,
834
+ decoder_attention_mask: Tensor | None,
835
+ model_kwargs: Mapping[str, Any],
836
+ ) -> tuple[dict[str, Any], tuple[str, ...] | None]:
837
+ if hidden_state_source not in {"encoder", "decoder"}:
838
+ raise ValueError("hidden_state_source must be 'encoder' or 'decoder'.")
839
+ hidden_state_index = model_kwargs.get("hidden_state_index", -1)
840
+ if not isinstance(hidden_state_index, int) or isinstance(hidden_state_index, bool):
841
+ raise TypeError("hidden_state_index must be an integer.")
842
+ store_all_hidden_states = model_kwargs.get("store_all_hidden_states", False)
843
+ if not isinstance(store_all_hidden_states, bool):
844
+ raise TypeError("store_all_hidden_states must be a boolean.")
845
+ normalized_decoder_inputs: tuple[str, ...] | None = None
846
+ has_decoder_inputs = decoder_inputs is not None
847
+ has_decoder_ids = decoder_input_ids is not None
848
+ if hidden_state_source == "encoder":
849
+ if has_decoder_inputs or has_decoder_ids or decoder_attention_mask is not None:
850
+ raise ValueError("Decoder inputs are only valid when hidden_state_source='decoder'.")
851
+ else:
852
+ if has_decoder_inputs == has_decoder_ids:
853
+ raise ValueError(
854
+ "Decoder embedding requires exactly one of decoder_inputs or decoder_input_ids."
855
+ )
856
+ decoder_input_fingerprint: str | None = None
857
+ if decoder_inputs is not None:
858
+ if isinstance(decoder_inputs, (str, bytes)) or not isinstance(decoder_inputs, Sequence):
859
+ raise TypeError("decoder_inputs must be an aligned sequence of strings.")
860
+ normalized_decoder_inputs = tuple(decoder_inputs)
861
+ if not all(isinstance(value, str) and value for value in normalized_decoder_inputs):
862
+ raise ValueError("decoder_inputs must contain non-empty strings.")
863
+ if len(normalized_decoder_inputs) != len(records):
864
+ raise ValueError("decoder_inputs must align one-to-one with embedding inputs.")
865
+ decoder_input_fingerprint = _ordered_string_sha256(normalized_decoder_inputs)
866
+ if decoder_attention_mask is not None:
867
+ raise ValueError("decoder_attention_mask requires decoder_input_ids.")
868
+ if decoder_input_ids is not None:
869
+ if not isinstance(decoder_input_ids, Tensor) or decoder_input_ids.ndim != 2:
870
+ raise ValueError("decoder_input_ids must have shape (batch, sequence).")
871
+ if decoder_input_ids.shape[0] != len(records):
872
+ raise ValueError("decoder_input_ids must align one-to-one with embedding inputs.")
873
+ if decoder_input_ids.dtype == torch.bool or decoder_input_ids.is_floating_point():
874
+ raise TypeError("decoder_input_ids must use an integer token dtype.")
875
+ decoder_input_fingerprint = tensor_sha256(decoder_input_ids)
876
+ decoder_mask_fingerprint: str | None = None
877
+ if decoder_attention_mask is not None:
878
+ if not isinstance(decoder_attention_mask, Tensor):
879
+ raise TypeError("decoder_attention_mask must be a tensor.")
880
+ if decoder_input_ids is None or decoder_attention_mask.shape != decoder_input_ids.shape:
881
+ raise ValueError("decoder_attention_mask must match decoder_input_ids shape.")
882
+ decoder_mask_fingerprint = tensor_sha256(decoder_attention_mask)
883
+
884
+ context: dict[str, Any] = {
885
+ "hidden_state_source": hidden_state_source,
886
+ "hidden_state_index": hidden_state_index,
887
+ "store_all_hidden_states": store_all_hidden_states,
888
+ "decoder_input_fingerprint": decoder_input_fingerprint,
889
+ "decoder_attention_mask_fingerprint": decoder_mask_fingerprint,
890
+ "decoder_alignment": "input-position" if hidden_state_source == "decoder" else None,
891
+ }
892
+ metadata_hook = getattr(model, "_embedding_metadata", None)
893
+ model_metadata: Mapping[str, Any] | None = None
894
+ if callable(metadata_hook):
895
+ model_metadata = metadata_hook(**context)
896
+ if not isinstance(model_metadata, Mapping):
897
+ raise TypeError("_embedding_metadata must return a mapping.")
898
+ context["model_embedding"] = _fingerprint_jsonable(model_metadata)
899
+ if hidden_state_source == "decoder":
900
+ has_decoder_batch = callable(getattr(model, "_embedding_batch", None))
901
+ declares_decoder_stack = (
902
+ model_metadata is not None and model_metadata.get("hidden_state_stack") == "decoder"
903
+ )
904
+ if not has_decoder_batch or not declares_decoder_stack:
905
+ raise ValueError(
906
+ f"{model.__class__.__name__} does not declare decoder embedding support."
907
+ )
908
+ return context, normalized_decoder_inputs
909
+
910
+
911
+ def _planned_batches(
912
+ records: Sequence[EmbeddingInput],
913
+ positions: range,
914
+ *,
915
+ batch_size: int,
916
+ max_tokens_per_batch: int | None,
917
+ max_length: int | None,
918
+ truncate: bool,
919
+ ) -> Iterator[list[int]]:
920
+ """Length-bucket one bounded window while retaining stable output positions."""
921
+
922
+ def effective_length(position: int) -> int:
923
+ length = len(records[position].sequence)
924
+ return min(length, max_length) if truncate and max_length is not None else length
925
+
926
+ ordered = sorted(positions, key=lambda position: (-effective_length(position), position))
927
+ batch: list[int] = []
928
+ longest = 0
929
+ for position in ordered:
930
+ length = effective_length(position)
931
+ if max_tokens_per_batch is not None and length > max_tokens_per_batch:
932
+ raise ValueError(
933
+ f"Input at position {position} has {length} residues, exceeding "
934
+ f"max_tokens_per_batch={max_tokens_per_batch}."
935
+ )
936
+ candidate_longest = max(longest, length)
937
+ exceeds_tokens = (
938
+ max_tokens_per_batch is not None
939
+ and candidate_longest * (len(batch) + 1) > max_tokens_per_batch
940
+ )
941
+ if batch and (len(batch) >= batch_size or exceeds_tokens):
942
+ yield batch
943
+ batch = []
944
+ longest = 0
945
+ batch.append(position)
946
+ longest = max(longest, length)
947
+ if batch:
948
+ yield batch
949
+
950
+
951
+ def embed_dataset(
952
+ model: Any,
953
+ inputs: (Iterable[str | EmbeddingInput | tuple[str, str]] | Mapping[str, str] | str | Path),
954
+ *,
955
+ batch_size: int = 2,
956
+ pooling: str | Sequence[str] | None = None,
957
+ full_embeddings: bool = False,
958
+ output: str | Path | None = None,
959
+ format: str = "safetensors",
960
+ resume: bool = True,
961
+ tokenizer: Any | None = None,
962
+ max_length: int | None = None,
963
+ truncate: bool = True,
964
+ dtype: torch.dtype | None = torch.float32,
965
+ shard_size: int = 2 * 1024**3,
966
+ model_state_fingerprint: str | None = None,
967
+ batch_window_size: int | None = None,
968
+ max_tokens_per_batch: int | None = None,
969
+ hidden_state_source: str = "encoder",
970
+ decoder_inputs: Sequence[str] | None = None,
971
+ decoder_input_ids: Tensor | None = None,
972
+ decoder_attention_mask: Tensor | None = None,
973
+ _embedding_batch_fn: Callable[..., EmbeddingBatch] | None = None,
974
+ _embedding_batch_identity: Mapping[str, Any] | None = None,
975
+ _allowed_unsupported_pooling: Sequence[str] = (),
976
+ **model_kwargs: Any,
977
+ ) -> EmbeddingResult:
978
+ """Embed protein sequences with stable ordering and residue-only pooling."""
979
+
980
+ for name, value in (
981
+ ("batch_size", batch_size),
982
+ ("shard_size", shard_size),
983
+ ):
984
+ if not isinstance(value, int) or isinstance(value, bool):
985
+ raise TypeError(f"{name} must be a positive integer.")
986
+ if value <= 0:
987
+ raise ValueError(f"{name} must be a positive integer.")
988
+ for optional_name, optional_value in (
989
+ ("max_length", max_length),
990
+ ("max_tokens_per_batch", max_tokens_per_batch),
991
+ ("batch_window_size", batch_window_size),
992
+ ):
993
+ if optional_value is not None and (
994
+ not isinstance(optional_value, int) or isinstance(optional_value, bool)
995
+ ):
996
+ raise TypeError(f"{optional_name} must be a positive integer when provided.")
997
+ if optional_value is not None and optional_value <= 0:
998
+ raise ValueError(f"{optional_name} must be a positive integer when provided.")
999
+ for name, value in (
1000
+ ("full_embeddings", full_embeddings),
1001
+ ("resume", resume),
1002
+ ("truncate", truncate),
1003
+ ):
1004
+ if not isinstance(value, bool):
1005
+ raise TypeError(f"{name} must be a boolean.")
1006
+ if not isinstance(format, str):
1007
+ raise TypeError("format must be a string.")
1008
+ if output is not None and not isinstance(output, (str, Path)):
1009
+ raise TypeError("output must be a path or None.")
1010
+ if model_state_fingerprint is not None and (
1011
+ not isinstance(model_state_fingerprint, str) or not model_state_fingerprint
1012
+ ):
1013
+ raise ValueError("model_state_fingerprint must be a non-empty string when provided.")
1014
+ if hidden_state_source not in {"encoder", "decoder"}:
1015
+ raise ValueError("hidden_state_source must be 'encoder' or 'decoder'.")
1016
+ hidden_state_index = model_kwargs.get("hidden_state_index", -1)
1017
+ if not isinstance(hidden_state_index, int) or isinstance(hidden_state_index, bool):
1018
+ raise TypeError("hidden_state_index must be an integer.")
1019
+ store_all_hidden_states = model_kwargs.get("store_all_hidden_states", False)
1020
+ if not isinstance(store_all_hidden_states, bool):
1021
+ raise TypeError("store_all_hidden_states must be a boolean.")
1022
+ if decoder_input_ids is not None:
1023
+ if not isinstance(decoder_input_ids, Tensor):
1024
+ raise TypeError("decoder_input_ids must be a tensor.")
1025
+ if decoder_input_ids.is_meta:
1026
+ raise ValueError("decoder_input_ids cannot be a meta tensor.")
1027
+ if decoder_input_ids.ndim != 2 or decoder_input_ids.shape[1] == 0:
1028
+ raise ValueError("decoder_input_ids must have non-empty shape (batch, sequence).")
1029
+ if decoder_input_ids.dtype not in {torch.int32, torch.int64}:
1030
+ raise TypeError("decoder_input_ids must use torch.int32 or torch.int64.")
1031
+ if decoder_attention_mask is not None:
1032
+ if not isinstance(decoder_attention_mask, Tensor):
1033
+ raise TypeError("decoder_attention_mask must be a tensor.")
1034
+ if decoder_attention_mask.is_meta:
1035
+ raise ValueError("decoder_attention_mask cannot be a meta tensor.")
1036
+ if decoder_attention_mask.is_complex() or not bool(
1037
+ torch.isfinite(decoder_attention_mask).all()
1038
+ ):
1039
+ raise ValueError("decoder_attention_mask must contain finite binary values.")
1040
+ if not bool(((decoder_attention_mask == 0) | (decoder_attention_mask == 1)).all()):
1041
+ raise ValueError("decoder_attention_mask must contain finite binary values.")
1042
+ pooling_names = (
1043
+ (("mean",) if not full_embeddings else ())
1044
+ if pooling is None
1045
+ else ((pooling,) if isinstance(pooling, str) else tuple(pooling))
1046
+ )
1047
+ if full_embeddings and pooling is not None:
1048
+ raise ValueError("full_embeddings=True cannot be combined with pooling.")
1049
+ if not full_embeddings and not pooling_names:
1050
+ raise ValueError("pooling is required unless full_embeddings=True.")
1051
+ pooler = Pooler(pooling_names) if pooling_names else None
1052
+
1053
+ if batch_size <= 0:
1054
+ raise ValueError("batch_size must be positive.")
1055
+ if format == "pth" or (output is not None and Path(output).suffix.lower() == ".pth"):
1056
+ raise ValueError("Writing pickle-based .pth embeddings is not supported.")
1057
+ if format not in _SUPPORTED_STORAGE_FORMATS:
1058
+ raise ValueError("format must be 'safetensors' or 'sqlite'.")
1059
+ if max_length is not None and max_length <= 0:
1060
+ raise ValueError("max_length must be positive when provided.")
1061
+ if max_tokens_per_batch is not None and max_tokens_per_batch <= 0:
1062
+ raise ValueError("max_tokens_per_batch must be positive when provided.")
1063
+ if not isinstance(dtype, (torch.dtype, type(None))):
1064
+ raise TypeError("dtype must be a torch.dtype or None.")
1065
+ if batch_window_size is not None and batch_window_size <= 0:
1066
+ raise ValueError("batch_window_size must be positive when provided.")
1067
+ if _embedding_batch_fn is not None and not callable(_embedding_batch_fn):
1068
+ raise TypeError("_embedding_batch_fn must be callable when provided.")
1069
+ if _embedding_batch_fn is not None and _embedding_batch_identity is None:
1070
+ raise ValueError(
1071
+ "_embedding_batch_identity is required with _embedding_batch_fn so persisted "
1072
+ "runs bind the family-specific embedding behavior."
1073
+ )
1074
+ if _embedding_batch_identity is not None and not isinstance(_embedding_batch_identity, Mapping):
1075
+ raise TypeError("_embedding_batch_identity must be a mapping when provided.")
1076
+ if isinstance(_allowed_unsupported_pooling, (str, bytes)) or not isinstance(
1077
+ _allowed_unsupported_pooling, Sequence
1078
+ ):
1079
+ raise TypeError("_allowed_unsupported_pooling must be a sequence of pooler names.")
1080
+ if not all(isinstance(name, str) for name in _allowed_unsupported_pooling):
1081
+ raise TypeError("_allowed_unsupported_pooling must contain only strings.")
1082
+ allowed_unsupported_pooling = frozenset(_allowed_unsupported_pooling)
1083
+ if allowed_unsupported_pooling and _embedding_batch_fn is None:
1084
+ raise ValueError(
1085
+ "_allowed_unsupported_pooling is only valid with a family-specific _embedding_batch_fn."
1086
+ )
1087
+ resolved_batch_window_size = (
1088
+ batch_size * _DEFAULT_BATCH_WINDOW_MULTIPLIER
1089
+ if batch_window_size is None
1090
+ else batch_window_size
1091
+ )
1092
+ if resolved_batch_window_size < batch_size:
1093
+ raise ValueError("batch_window_size must be at least batch_size.")
1094
+ records = _normalize_inputs(inputs, disk_backed=output is not None)
1095
+ _validate_untruncated_lengths(
1096
+ records,
1097
+ max_length=max_length,
1098
+ truncate=truncate,
1099
+ )
1100
+ pooling_names = (
1101
+ (("mean",) if not full_embeddings else ())
1102
+ if pooling is None
1103
+ else ((pooling,) if isinstance(pooling, str) else tuple(pooling))
1104
+ )
1105
+ if full_embeddings:
1106
+ if pooling is not None:
1107
+ raise ValueError("full_embeddings=True cannot be combined with pooling.")
1108
+ elif not pooling_names:
1109
+ raise ValueError("pooling is required unless full_embeddings=True.")
1110
+ store_all_hidden_states = bool(model_kwargs.get("store_all_hidden_states", False))
1111
+ if store_all_hidden_states and not full_embeddings:
1112
+ raise ValueError("store_all_hidden_states=True requires full_embeddings=True.")
1113
+
1114
+ unsupported = set(getattr(model, "embedding_unsupported_pooling", ()))
1115
+ unknown_pooling_overrides = allowed_unsupported_pooling.difference(unsupported)
1116
+ if unknown_pooling_overrides:
1117
+ raise ValueError(
1118
+ "_allowed_unsupported_pooling may only override poolers declared unsupported "
1119
+ f"by the model; unknown overrides: {sorted(unknown_pooling_overrides)}."
1120
+ )
1121
+ unsupported.difference_update(allowed_unsupported_pooling)
1122
+ requested_unsupported = unsupported.intersection(pooling_names)
1123
+ if requested_unsupported:
1124
+ raise ValueError(
1125
+ f"{model.__class__.__name__} does not support pooling operations "
1126
+ f"{sorted(requested_unsupported)}."
1127
+ )
1128
+
1129
+ # Constructing the pooler validates names and duplicate operations before
1130
+ # any checkpoint hashing, tokenization, or inference occurs.
1131
+ pooler = Pooler(pooling_names) if pooling_names else None
1132
+ embedding_context, normalized_decoder_inputs = _embedding_context(
1133
+ model,
1134
+ records,
1135
+ hidden_state_source=hidden_state_source,
1136
+ decoder_inputs=decoder_inputs,
1137
+ decoder_input_ids=decoder_input_ids,
1138
+ decoder_attention_mask=decoder_attention_mask,
1139
+ model_kwargs=model_kwargs,
1140
+ )
1141
+ if _embedding_batch_identity is not None:
1142
+ embedding_context["family_adapter"] = _fingerprint_jsonable(_embedding_batch_identity)
1143
+ if allowed_unsupported_pooling:
1144
+ embedding_context["family_adapter_pooling_override"] = sorted(
1145
+ allowed_unsupported_pooling
1146
+ )
1147
+
1148
+ tokenizer_metadata = _tokenizer_metadata(model, tokenizer)
1149
+ (
1150
+ input_fingerprint,
1151
+ run_fingerprint,
1152
+ resolved_model_state_fingerprint,
1153
+ model_state_fingerprint_source,
1154
+ ) = _run_fingerprint(
1155
+ model,
1156
+ records,
1157
+ pooling=pooling_names,
1158
+ full_embeddings=full_embeddings,
1159
+ max_length=max_length,
1160
+ truncate=truncate,
1161
+ dtype=dtype,
1162
+ model_kwargs=model_kwargs,
1163
+ tokenizer_metadata=tokenizer_metadata,
1164
+ model_state_fingerprint=model_state_fingerprint,
1165
+ persist_output=output is not None,
1166
+ embedding_context=embedding_context,
1167
+ batch_size=batch_size,
1168
+ batch_window_size=resolved_batch_window_size,
1169
+ max_tokens_per_batch=max_tokens_per_batch,
1170
+ )
1171
+ output_already_exists = output is not None and _output_exists(output, format)
1172
+ existing: EmbeddingResult | None = None
1173
+ start_position = 0
1174
+ if output is not None and resume and output_already_exists:
1175
+ if format == "sqlite":
1176
+ try:
1177
+ existing = load_sqlite_result(output, run_id=run_fingerprint)
1178
+ except KeyError:
1179
+ existing = load_result(output, format=format)
1180
+ else:
1181
+ existing = load_result(output, format=format)
1182
+ if existing.metadata.get("fingerprint_schema_version") != (_RUN_FINGERPRINT_SCHEMA_VERSION):
1183
+ raise ValueError(
1184
+ "Existing embeddings use an incompatible run fingerprint schema; "
1185
+ "choose another output or set resume=False."
1186
+ )
1187
+ if existing.metadata.get("run_fingerprint") != run_fingerprint:
1188
+ raise ValueError(
1189
+ "Existing embeddings were produced by a different run fingerprint; "
1190
+ "choose another output or set resume=False."
1191
+ )
1192
+ if len(existing) > len(records):
1193
+ raise ValueError(
1194
+ "Existing embeddings are not an ordered prefix of the requested inputs."
1195
+ )
1196
+ prefix_matches = all(
1197
+ (observed.id, observed.sequence) == (expected.id, expected.sequence)
1198
+ for expected, observed in zip(records, existing, strict=False)
1199
+ )
1200
+ if not prefix_matches:
1201
+ raise ValueError(
1202
+ "Existing embeddings are not an ordered prefix of the requested inputs."
1203
+ )
1204
+ if len(existing) == len(records) and existing.metadata.get("complete", True):
1205
+ return existing
1206
+ start_position = len(existing)
1207
+
1208
+ sqlite_run_id: str | None = None
1209
+ sqlite_replace_on_first_commit = False
1210
+ sqlite_initial_metadata: dict[str, Any] | None = None
1211
+ if output is not None and format == "sqlite":
1212
+ sqlite_initial_metadata = {
1213
+ "format_version": 1,
1214
+ "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
1215
+ "run_fingerprint": run_fingerprint,
1216
+ "input_fingerprint": input_fingerprint,
1217
+ "model_state_fingerprint": resolved_model_state_fingerprint,
1218
+ "model_state_fingerprint_source": model_state_fingerprint_source,
1219
+ "complete": False,
1220
+ }
1221
+ sqlite_run_id = run_fingerprint
1222
+ if not resume and output_already_exists:
1223
+ try:
1224
+ load_sqlite_result(output, run_id=run_fingerprint)
1225
+ except KeyError:
1226
+ pass
1227
+ else:
1228
+ # Keep an exact prior run readable until replacement inference
1229
+ # has produced the first complete commit window.
1230
+ sqlite_replace_on_first_commit = True
1231
+ if not sqlite_replace_on_first_commit:
1232
+ initialize_sqlite_run(
1233
+ output,
1234
+ sqlite_initial_metadata,
1235
+ resume=resume,
1236
+ )
1237
+
1238
+ stream_safetensors = output is not None and format == "safetensors"
1239
+ attention_backend = _attention_backend(model)
1240
+ output_records: list[EmbeddingRecord] = (
1241
+ [] if sqlite_run_id is not None or stream_safetensors else list(existing or ())
1242
+ )
1243
+ output_descriptors: list[dict[str, Any]] | None = [] if output is None else None
1244
+ pool_slices: dict[str, tuple[int, int]] = {}
1245
+ if existing and pooler is not None:
1246
+ pooled_width = existing[0].load_tensor().shape[-1]
1247
+ if pooled_width % len(pooling_names) != 0:
1248
+ raise ValueError("Stored pooled width is inconsistent with pooling metadata.")
1249
+ pool_slices = pooler.output_slices(pooled_width // len(pooling_names))
1250
+
1251
+ safetensors_writer: SafetensorsStreamWriter | None = None
1252
+ if stream_safetensors:
1253
+ if output is None:
1254
+ raise RuntimeError("Safetensors streaming was enabled without an output destination.")
1255
+ transactional_overwrite = output_already_exists and not resume
1256
+ safetensors_writer = SafetensorsStreamWriter(
1257
+ output,
1258
+ {
1259
+ "format_version": 1,
1260
+ "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
1261
+ "run_fingerprint": run_fingerprint,
1262
+ "input_fingerprint": input_fingerprint,
1263
+ "model_state_fingerprint": resolved_model_state_fingerprint,
1264
+ "model_state_fingerprint_source": model_state_fingerprint_source,
1265
+ "complete": False,
1266
+ },
1267
+ shard_size=shard_size,
1268
+ existing=existing or (),
1269
+ reuse_existing=bool(resume and existing is not None),
1270
+ publish_initial=not transactional_overwrite,
1271
+ publish_incremental=not transactional_overwrite,
1272
+ )
1273
+ need_attentions = "parti" in pooling_names
1274
+
1275
+ config = getattr(model, "config", None)
1276
+ model_type = str(getattr(config, "model_type", "")).lower()
1277
+ resolved_tokenizer = tokenizer if tokenizer is not None else getattr(model, "tokenizer", None)
1278
+ with _temporary_eval(model), torch.inference_mode():
1279
+ for window_start in range(start_position, len(records), resolved_batch_window_size):
1280
+ window_stop = min(window_start + resolved_batch_window_size, len(records))
1281
+ window_records = records[window_start:window_stop]
1282
+ if not isinstance(window_records, Sequence):
1283
+ raise RuntimeError("The immutable embedding spool returned a non-sequence window.")
1284
+ window_results: dict[int, EmbeddingRecord] = {}
1285
+ for local_positions in _planned_batches(
1286
+ window_records,
1287
+ range(len(window_records)),
1288
+ batch_size=batch_size,
1289
+ max_tokens_per_batch=max_tokens_per_batch,
1290
+ max_length=max_length,
1291
+ truncate=truncate,
1292
+ ):
1293
+ batch_positions = [window_start + position for position in local_positions]
1294
+ batch_records = [window_records[position] for position in local_positions]
1295
+ sequences = [
1296
+ record.sequence[:max_length]
1297
+ if truncate and max_length is not None
1298
+ else record.sequence
1299
+ for record in batch_records
1300
+ ]
1301
+ batch_model_kwargs = dict(model_kwargs)
1302
+ if model_type == "fast_ankh" or hidden_state_source == "decoder":
1303
+ batch_model_kwargs["hidden_state_source"] = hidden_state_source
1304
+ if normalized_decoder_inputs is not None:
1305
+ batch_model_kwargs["decoder_inputs"] = [
1306
+ normalized_decoder_inputs[position] for position in batch_positions
1307
+ ]
1308
+ if decoder_input_ids is not None:
1309
+ indices = torch.tensor(
1310
+ batch_positions,
1311
+ device=decoder_input_ids.device,
1312
+ dtype=torch.long,
1313
+ )
1314
+ batch_model_kwargs["decoder_input_ids"] = decoder_input_ids.index_select(
1315
+ 0, indices
1316
+ )
1317
+ if decoder_attention_mask is not None:
1318
+ indices = torch.tensor(
1319
+ batch_positions,
1320
+ device=decoder_attention_mask.device,
1321
+ dtype=torch.long,
1322
+ )
1323
+ batch_model_kwargs["decoder_attention_mask"] = (
1324
+ decoder_attention_mask.index_select(0, indices)
1325
+ )
1326
+ custom_batch = _embedding_batch_fn or getattr(model, "_embedding_batch", None)
1327
+ if custom_batch is not None:
1328
+ if model_type == "fast_ankh":
1329
+ batch = custom_batch(
1330
+ sequences,
1331
+ tokenizer=resolved_tokenizer,
1332
+ max_length=max_length,
1333
+ truncate=truncate,
1334
+ need_attentions=need_attentions,
1335
+ **batch_model_kwargs,
1336
+ )
1337
+ else:
1338
+ batch = custom_batch(sequences, **batch_model_kwargs)
1339
+ if not isinstance(batch, EmbeddingBatch):
1340
+ raise TypeError("_embedding_batch must return EmbeddingBatch.")
1341
+ else:
1342
+ batch = _generic_embedding_batch(
1343
+ model,
1344
+ sequences,
1345
+ tokenizer=tokenizer,
1346
+ max_length=max_length,
1347
+ truncate=truncate,
1348
+ need_attentions=need_attentions,
1349
+ model_kwargs=batch_model_kwargs,
1350
+ )
1351
+ X = batch.X
1352
+ raw_mask = batch.residue_mask
1353
+ if not isinstance(X, Tensor) or not isinstance(raw_mask, Tensor):
1354
+ raise TypeError("Embedding batches must provide Tensor X and residue_mask.")
1355
+ if X.is_meta or raw_mask.is_meta:
1356
+ raise ValueError("Embedding batches cannot contain meta tensors.")
1357
+ if not X.is_floating_point():
1358
+ raise TypeError("Embedding batches must use a floating-point X dtype.")
1359
+ if raw_mask.is_complex() or not bool(torch.isfinite(raw_mask).all()):
1360
+ raise ValueError("Embedding residue_mask must contain finite binary values.")
1361
+ if not bool(((raw_mask == 0) | (raw_mask == 1)).all()):
1362
+ raise ValueError("Embedding residue_mask must contain finite binary values.")
1363
+ M = raw_mask.to(device=X.device, dtype=torch.bool)
1364
+ valid_X_shape = (
1365
+ X.ndim == 3
1366
+ and X.shape[0] == len(batch_records)
1367
+ and X.shape[-1] > 0
1368
+ and M.shape == X.shape[:2]
1369
+ )
1370
+ valid_all_states_shape = (
1371
+ X.ndim == 4
1372
+ and store_all_hidden_states
1373
+ and full_embeddings
1374
+ and X.shape[0] == len(batch_records)
1375
+ and X.shape[1] > 0
1376
+ and X.shape[-1] > 0
1377
+ and M.shape == (X.shape[0], X.shape[2])
1378
+ )
1379
+ if not (valid_X_shape or valid_all_states_shape):
1380
+ raise ValueError(
1381
+ "Embedding batches must provide X with shape (b, l, d), or "
1382
+ "(b, states, l, d) when storing all hidden states, and "
1383
+ "residue_mask with shape (b, l)."
1384
+ )
1385
+ if not bool(M.any(dim=1).all()):
1386
+ raise ValueError("Every embedding sample must contain a biological residue.")
1387
+ finite_selected = (
1388
+ torch.isfinite(X) | ~M.unsqueeze(-1)
1389
+ if X.ndim == 3
1390
+ else torch.isfinite(X) | ~M[:, None, :, None]
1391
+ )
1392
+ if not bool(finite_selected.all()):
1393
+ raise ValueError("Biological residue embeddings produced non-finite output.")
1394
+ if need_attentions:
1395
+ # Validate the biological graph only after mask integrity is established.
1396
+ _validate_parti_length(M)
1397
+ if dtype is not None:
1398
+ X = X.to(dtype=dtype)
1399
+
1400
+ if full_embeddings:
1401
+ if X.ndim == 4:
1402
+ values = [
1403
+ X_i[:, M_i, :].detach().cpu() for X_i, M_i in zip(X, M, strict=True)
1404
+ ]
1405
+ else:
1406
+ values = [X_i[M_i].detach().cpu() for X_i, M_i in zip(X, M, strict=True)]
1407
+ else:
1408
+ if pooler is None:
1409
+ raise RuntimeError(
1410
+ "Pooled embedding output was requested without an initialized pooler."
1411
+ )
1412
+ Y = pooler(
1413
+ X,
1414
+ M,
1415
+ attentions=batch.attentions,
1416
+ attention_backend=attention_backend,
1417
+ )
1418
+ pool_slices = pooler.output_slices(X.shape[-1])
1419
+ values = list(Y.detach().cpu().unbind(0))
1420
+ for position, record, value in zip(
1421
+ batch_positions, batch_records, values, strict=True
1422
+ ):
1423
+ window_results[position] = EmbeddingRecord(record.id, record.sequence, value)
1424
+
1425
+ new_records = [
1426
+ window_results[position] for position in range(window_start, window_stop)
1427
+ ]
1428
+ if output_descriptors is not None:
1429
+ output_descriptors.extend(
1430
+ _output_descriptor(window_start + offset, record)
1431
+ for offset, record in enumerate(new_records)
1432
+ )
1433
+ if output is not None and sqlite_run_id is not None:
1434
+ append_sqlite_records(
1435
+ output,
1436
+ sqlite_run_id,
1437
+ window_start,
1438
+ new_records,
1439
+ replace_metadata=(
1440
+ sqlite_initial_metadata if sqlite_replace_on_first_commit else None
1441
+ ),
1442
+ )
1443
+ sqlite_replace_on_first_commit = False
1444
+ elif safetensors_writer is not None:
1445
+ safetensors_writer.append(new_records)
1446
+ else:
1447
+ output_records.extend(new_records)
1448
+
1449
+ software_versions = _software_versions()
1450
+ projection = getattr(model, "embedding_projection", None)
1451
+ resolved_layer = getattr(
1452
+ model,
1453
+ "embedding_layer",
1454
+ model_kwargs.get("hidden_state_index", -1),
1455
+ )
1456
+ token_policy = getattr(
1457
+ model,
1458
+ "embedding_token_policy",
1459
+ {
1460
+ "unit": "residue",
1461
+ "include": ["biological residues"],
1462
+ "exclude": [
1463
+ "BOS",
1464
+ "EOS",
1465
+ "padding",
1466
+ "chain delimiters",
1467
+ "non-protein tokens",
1468
+ ],
1469
+ },
1470
+ )
1471
+ model_identity = _model_identity_metadata(model)
1472
+ metadata: dict[str, Any] = {
1473
+ "format_version": 1,
1474
+ "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
1475
+ "run_fingerprint": run_fingerprint,
1476
+ "input_fingerprint": input_fingerprint,
1477
+ "model_state_fingerprint": resolved_model_state_fingerprint,
1478
+ "model_state_fingerprint_source": model_state_fingerprint_source,
1479
+ "model_class": f"{model.__class__.__module__}.{model.__class__.__qualname__}",
1480
+ **model_identity,
1481
+ "dtype": str(dtype).removeprefix("torch.") if dtype is not None else "model",
1482
+ "attention_backend": attention_backend,
1483
+ "attention_kernel": _attention_kernel_metadata(attention_backend),
1484
+ "layer": resolved_layer,
1485
+ "projection": projection,
1486
+ "esmc_source": getattr(model, "_esmc_source", None),
1487
+ "esmc_revision": getattr(model, "_esmc_source_revision", None),
1488
+ "esmc_files": getattr(model, "_esmc_source_files", None),
1489
+ "token_policy": token_policy,
1490
+ "tokenizer": tokenizer_metadata,
1491
+ **embedding_context,
1492
+ "pooling": list(pooling_names),
1493
+ "pool_slices": pool_slices,
1494
+ "full_embeddings": full_embeddings,
1495
+ "max_length": max_length,
1496
+ "truncate": truncate,
1497
+ "truncation": {"enabled": truncate, "max_length": max_length},
1498
+ "batching": {
1499
+ "batch_size": batch_size,
1500
+ "batch_window_size": resolved_batch_window_size,
1501
+ "max_tokens_per_batch": max_tokens_per_batch,
1502
+ "input_storage": ("disk-spool" if isinstance(records, _InputSpool) else "memory"),
1503
+ "ordering": "bounded-length-bucketed-stable-output",
1504
+ "resume_commit_granularity": (
1505
+ "not-applicable"
1506
+ if output is None
1507
+ else "batch-window"
1508
+ if format == "sqlite"
1509
+ else "shard-flush"
1510
+ ),
1511
+ },
1512
+ "residue_mask_policy": "biological-residues-only",
1513
+ "record_count": len(records),
1514
+ "descriptor_index": (
1515
+ "memory-metadata"
1516
+ if output is None
1517
+ else "sqlite-records"
1518
+ if format == "sqlite"
1519
+ else "safetensors-generation-index"
1520
+ ),
1521
+ "storage_format": format if output is not None else "memory",
1522
+ "software": software_versions,
1523
+ "execution": _execution_identity_metadata(model),
1524
+ "adapter": _adapter_identity_metadata(model),
1525
+ "torch_version": software_versions["torch"],
1526
+ "transformers_version": software_versions["transformers"],
1527
+ "complete": True,
1528
+ }
1529
+ if output_descriptors is not None:
1530
+ metadata["outputs"] = output_descriptors
1531
+ metadata["tensor_hashes"] = [item["sha256"] for item in output_descriptors]
1532
+ status = getattr(model, "esmc_precision_status", None)
1533
+ if status is not None:
1534
+ metadata["esmc_precision"] = status.as_dict() if hasattr(status, "as_dict") else status
1535
+ if output is not None and sqlite_run_id is not None:
1536
+ update_sqlite_run_metadata(output, sqlite_run_id, metadata)
1537
+ return load_sqlite_result(output, run_id=sqlite_run_id)
1538
+ if safetensors_writer is not None:
1539
+ return safetensors_writer.publish(complete=True, metadata=metadata)
1540
+ result = EmbeddingResult(output_records, metadata)
1541
+ if output is not None:
1542
+ return save_result(result, output, format=format, shard_size=shard_size)
1543
+ return result
1544
+
1545
+
1546
+ class EmbeddingMixin:
1547
+ """Small delegation mixin shared by FastPLMs model classes."""
1548
+
1549
+ def embed_dataset(self, inputs: Any, **kwargs: Any) -> EmbeddingResult:
1550
+ return embed_dataset(self, inputs, **kwargs)
1551
+
1552
+
1553
+ __all__ = [
1554
+ "EmbeddingMixin",
1555
+ "embed_dataset",
1556
+ "iter_fasta",
1557
+ "parse_fasta",
1558
+ "select_hidden_state_embeddings",
1559
+ ]
fastplms/embeddings/storage.py ADDED
@@ -0,0 +1,1594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lossless, reproducible storage for :mod:`fastplms.embeddings`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import json
8
+ import sqlite3
9
+ import struct
10
+ from bisect import bisect_right
11
+ from collections.abc import Iterable, Iterator, Sequence
12
+ from pathlib import Path
13
+ from typing import Any, cast, overload
14
+ from uuid import uuid4
15
+
16
+ import numpy as np
17
+ import torch
18
+ from torch import Tensor
19
+
20
+ from .types import (
21
+ EmbeddingRecord,
22
+ EmbeddingResult,
23
+ LazyTensorReference,
24
+ )
25
+
26
+ _DTYPE_NAMES: dict[torch.dtype, str] = {
27
+ torch.float16: "float16",
28
+ torch.bfloat16: "bfloat16",
29
+ torch.float32: "float32",
30
+ torch.float64: "float64",
31
+ torch.int64: "int64",
32
+ torch.int32: "int32",
33
+ torch.int16: "int16",
34
+ torch.int8: "int8",
35
+ torch.uint8: "uint8",
36
+ torch.bool: "bool",
37
+ }
38
+ _NAME_DTYPES = {name: dtype for dtype, name in _DTYPE_NAMES.items()}
39
+ DEFAULT_SHARD_SIZE = 2 * 1024**3
40
+ _MAX_RECORDS_PER_DESCRIPTOR_SHARD = 1_024
41
+ _TENSOR_HASH_CHUNK_BYTES = 16 * 1024**2
42
+
43
+
44
+ def _jsonable(value: Any) -> Any:
45
+ if isinstance(value, dict):
46
+ return {str(key): _jsonable(item) for key, item in value.items()}
47
+ if isinstance(value, (list, tuple)):
48
+ return [_jsonable(item) for item in value]
49
+ if isinstance(value, Path):
50
+ return str(value)
51
+ if isinstance(value, torch.dtype):
52
+ return str(value).removeprefix("torch.")
53
+ if isinstance(value, torch.device):
54
+ return str(value)
55
+ if value is None or isinstance(value, (str, int, float, bool)):
56
+ return value
57
+ return repr(value)
58
+
59
+
60
+ def _persistent_metadata(
61
+ metadata: dict[str, Any],
62
+ *,
63
+ descriptor_index: str,
64
+ record_count: int | None = None,
65
+ ) -> dict[str, Any]:
66
+ """Remove per-record copies from metadata and identify the authoritative index."""
67
+
68
+ cleaned_value = _jsonable(metadata)
69
+ if not isinstance(cleaned_value, dict):
70
+ raise TypeError("Embedding metadata must serialize to a JSON object.")
71
+ cleaned: dict[str, Any] = cleaned_value
72
+ cleaned.pop("outputs", None)
73
+ cleaned.pop("tensor_hashes", None)
74
+ cleaned["descriptor_index"] = descriptor_index
75
+ if record_count is not None:
76
+ cleaned["record_count"] = record_count
77
+ return cleaned
78
+
79
+
80
+ def _tensor_bytes(X: Tensor) -> bytes:
81
+ """Return the exact contiguous byte representation of X."""
82
+
83
+ X = X.detach().cpu().contiguous()
84
+ return X.view(torch.uint8).numpy().tobytes()
85
+
86
+
87
+ def _bounded_tensor_chunks(X: Tensor, max_bytes: int) -> Iterator[Tensor]:
88
+ """Yield row-major CPU chunks without materializing one full byte string."""
89
+
90
+ flattened = X.detach().to(device="cpu").reshape(-1)
91
+ if flattened.numel() == 0:
92
+ return
93
+ chunk_elements = max(1, max_bytes // flattened.element_size())
94
+ for start in range(0, flattened.numel(), chunk_elements):
95
+ chunk = flattened[start : start + chunk_elements]
96
+ if chunk.stride(0) != 1:
97
+ chunk = chunk.clone(memory_format=torch.contiguous_format)
98
+ yield chunk
99
+
100
+
101
+ def _tensor_hash_chunks(X: Tensor) -> Iterator[bytes]:
102
+ for chunk in _bounded_tensor_chunks(X, _TENSOR_HASH_CHUNK_BYTES):
103
+ yield chunk.view(torch.uint8).numpy().tobytes()
104
+
105
+
106
+ def tensor_sha256(X: Tensor) -> str:
107
+ """Hash dtype, shape, and exact tensor bytes."""
108
+
109
+ if not isinstance(X, Tensor):
110
+ raise TypeError("X must be a tensor.")
111
+ if X.dtype not in _DTYPE_NAMES:
112
+ raise TypeError(f"Unsupported tensor dtype {X.dtype}.")
113
+ if X.is_meta:
114
+ raise ValueError("Cannot hash a meta tensor without storage.")
115
+ if X.layout != torch.strided:
116
+ raise TypeError("Only strided tensors can be hashed.")
117
+ digest = hashlib.sha256()
118
+ digest.update(_DTYPE_NAMES[X.dtype].encode())
119
+ digest.update(json.dumps(tuple(X.shape)).encode())
120
+ for chunk in _tensor_hash_chunks(X):
121
+ digest.update(chunk)
122
+ return digest.hexdigest()
123
+
124
+
125
+ def _encode_tensor(X: Tensor) -> tuple[str, str, bytes]:
126
+ if X.dtype not in _DTYPE_NAMES:
127
+ raise TypeError(f"Unsupported tensor dtype {X.dtype}.")
128
+ shape = json.dumps(tuple(X.shape), separators=(",", ":"))
129
+ return _DTYPE_NAMES[X.dtype], shape, _tensor_bytes(X)
130
+
131
+
132
+ def _decode_tensor(dtype_name: str, shape_json: str, data: bytes) -> Tensor:
133
+ try:
134
+ dtype = _NAME_DTYPES[dtype_name]
135
+ except KeyError as error:
136
+ raise ValueError(f"Unsupported stored dtype {dtype_name!r}.") from error
137
+ shape = tuple(json.loads(shape_json))
138
+ # uint8 is used only as a byte-level carrier, preserving BF16 bits exactly.
139
+ byte_array = np.frombuffer(data, dtype=np.uint8).copy()
140
+ X = torch.from_numpy(byte_array).view(dtype)
141
+ return X.reshape(shape).clone()
142
+
143
+
144
+ def _index_path(path: str | Path) -> Path:
145
+ path = Path(path)
146
+ if path.suffix == ".json":
147
+ return path
148
+ if path.suffix == ".safetensors":
149
+ return path.with_suffix(".json")
150
+ return path / "index.json"
151
+
152
+
153
+ def _run_manifest_path(path: str | Path) -> Path:
154
+ path = Path(path)
155
+ if path.name == "index.json":
156
+ return path.with_name("run.json")
157
+ if path.suffix == ".json":
158
+ return path.with_name(f"{path.stem}.run.json")
159
+ if path.suffix == ".safetensors":
160
+ return path.with_suffix(".run.json")
161
+ return path / "run.json"
162
+
163
+
164
+ def _resolve_index_child(root: Path, relative: str, *, label: str) -> Path:
165
+ relative_path = Path(relative)
166
+ candidate = (root / relative_path).resolve()
167
+ if relative_path.is_absolute() or candidate.parent != root.resolve():
168
+ raise ValueError(f"Safetensors {label} references a file outside its output directory.")
169
+ return candidate
170
+
171
+
172
+ def _canonical_json_bytes(payload: dict[str, Any]) -> bytes:
173
+ return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")
174
+
175
+
176
+ def _load_authoritative_index(
177
+ path: str | Path,
178
+ ) -> tuple[dict[str, Any], Path, dict[str, Any]]:
179
+ """Load the index selected by the atomic run-manifest commit record."""
180
+
181
+ stable_index_path = _index_path(path)
182
+ run_manifest_path = _run_manifest_path(path)
183
+ if not run_manifest_path.is_file():
184
+ raise ValueError(f"Missing safetensors run manifest: {run_manifest_path}.")
185
+ run_manifest = json.loads(run_manifest_path.read_text(encoding="utf-8"))
186
+ if not isinstance(run_manifest, dict):
187
+ raise ValueError("Safetensors run manifest must contain a JSON object.")
188
+ if run_manifest.get("format") != "fastplms-embedding-run":
189
+ raise ValueError(f"Not a FastPLMs embedding run manifest: {run_manifest_path}.")
190
+ version = run_manifest.get("version")
191
+ index_reference = run_manifest.get("index")
192
+ if not isinstance(index_reference, dict):
193
+ raise ValueError("Safetensors run manifest contains an invalid index reference.")
194
+ if version == 1:
195
+ snapshot = run_manifest.get("index_payload")
196
+ if isinstance(snapshot, dict):
197
+ payload = snapshot
198
+ index_bytes = _canonical_json_bytes(payload)
199
+ elif snapshot is None:
200
+ index_bytes = stable_index_path.read_bytes()
201
+ payload = json.loads(index_bytes.decode("utf-8"))
202
+ if not isinstance(payload, dict):
203
+ raise ValueError("Safetensors index must contain a JSON object.")
204
+ else:
205
+ raise ValueError("Safetensors run manifest contains an invalid index snapshot.")
206
+ expected = {
207
+ "file": stable_index_path.name,
208
+ "sha256": hashlib.sha256(index_bytes).hexdigest(),
209
+ }
210
+ index_path = stable_index_path
211
+ elif version == 2:
212
+ relative = index_reference.get("file")
213
+ if not isinstance(relative, str):
214
+ raise ValueError("Safetensors run manifest index file is invalid.")
215
+ index_path = _resolve_index_child(stable_index_path.parent, relative, label="run manifest")
216
+ index_bytes = index_path.read_bytes()
217
+ payload = json.loads(index_bytes.decode("utf-8"))
218
+ if not isinstance(payload, dict):
219
+ raise ValueError("Safetensors generation index must contain a JSON object.")
220
+ if payload.get("version") != 2:
221
+ raise ValueError("Safetensors v2 run manifest must reference a v2 generation index.")
222
+ expected = {
223
+ "file": relative,
224
+ "sha256": hashlib.sha256(index_bytes).hexdigest(),
225
+ }
226
+ else:
227
+ raise ValueError(f"Unsupported safetensors run manifest version {version!r}.")
228
+ if index_reference != expected:
229
+ raise ValueError("Safetensors run manifest does not match its index.")
230
+ if payload.get("format") != "fastplms-embedding-safetensors":
231
+ raise ValueError(f"Not a FastPLMs embedding index: {index_path}.")
232
+ record_count = payload.get("record_count")
233
+ if record_count is None:
234
+ legacy_records = payload.get("records", ())
235
+ if not isinstance(legacy_records, list):
236
+ raise ValueError("Safetensors index contains invalid records.")
237
+ record_count = len(legacy_records)
238
+ if not isinstance(record_count, int) or isinstance(record_count, bool) or record_count < 0:
239
+ raise ValueError("Safetensors record count must be a non-negative integer.")
240
+ if run_manifest.get("record_count") != record_count:
241
+ raise ValueError("Safetensors run manifest record count does not match its index.")
242
+ metadata = payload.get("metadata", {})
243
+ if not isinstance(metadata, dict):
244
+ raise ValueError("Safetensors index metadata must contain a JSON object.")
245
+ if metadata.get("record_count", record_count) != record_count:
246
+ raise ValueError("Safetensors metadata record count does not match its index.")
247
+ if version == 1 and run_manifest.get("metadata") != payload.get("metadata"):
248
+ raise ValueError("Safetensors run manifest metadata does not match its index.")
249
+ return payload, index_path, run_manifest
250
+
251
+
252
+ def safetensors_result_exists(path: str | Path) -> bool:
253
+ """Return whether an authoritative committed safetensors run exists."""
254
+
255
+ try:
256
+ _load_authoritative_index(path)
257
+ except (OSError, ValueError, json.JSONDecodeError):
258
+ return False
259
+ return True
260
+
261
+
262
+ def _load_safetensor(path: Path, key: str) -> Tensor:
263
+ try:
264
+ from safetensors import safe_open
265
+ except ImportError as error:
266
+ raise ImportError("Loading embeddings requires the 'safetensors' package.") from error
267
+ with safe_open(path, framework="pt", device="cpu") as handle:
268
+ return cast(Tensor, handle.get_tensor(key))
269
+
270
+
271
+ def _safetensors_shard_prefix(path: str | Path) -> str:
272
+ requested_path = Path(path)
273
+ if requested_path.suffix in {".json", ".safetensors"}:
274
+ return f"{requested_path.stem}-embeddings"
275
+ return "embeddings"
276
+
277
+
278
+ def _authoritative_index_payload(path: str | Path) -> dict[str, Any] | None:
279
+ """Return the last atomically committed generation index when available."""
280
+
281
+ try:
282
+ payload, _, _ = _load_authoritative_index(path)
283
+ except (OSError, ValueError, json.JSONDecodeError):
284
+ return None
285
+ return payload
286
+
287
+
288
+ def _referenced_shards(
289
+ index_path: Path,
290
+ payload: dict[str, Any] | None = None,
291
+ ) -> set[Path]:
292
+ if payload is None:
293
+ payload = _authoritative_index_payload(index_path)
294
+ if payload is None:
295
+ return set()
296
+ shards: set[Path] = set()
297
+ for descriptor_shard in payload.get("descriptor_shards", ()):
298
+ tensor_file = descriptor_shard.get("tensor_file")
299
+ if isinstance(tensor_file, str):
300
+ candidate = _resolve_index_child(
301
+ index_path.parent, tensor_file, label="descriptor index"
302
+ )
303
+ shards.add(candidate)
304
+ for item in payload.get("records", ()):
305
+ relative = item.get("tensor", {}).get("file")
306
+ if not isinstance(relative, str):
307
+ continue
308
+ candidate = (index_path.parent / relative).resolve()
309
+ if candidate.parent == index_path.parent.resolve():
310
+ shards.add(candidate)
311
+ return shards
312
+
313
+
314
+ def _validate_tensor_descriptor(
315
+ tensor: dict[str, Any],
316
+ ) -> tuple[str, str, tuple[int, ...], str]:
317
+ key = tensor.get("key")
318
+ if not isinstance(key, str) or not key:
319
+ raise ValueError("Safetensors descriptor tensor key is invalid.")
320
+ dtype = tensor.get("dtype")
321
+ if not isinstance(dtype, str) or dtype not in _NAME_DTYPES:
322
+ raise ValueError("Safetensors descriptor tensor dtype is invalid.")
323
+ raw_shape = tensor.get("shape")
324
+ if not isinstance(raw_shape, (list, tuple)) or not all(
325
+ isinstance(dimension, int) and not isinstance(dimension, bool) and dimension >= 0
326
+ for dimension in raw_shape
327
+ ):
328
+ raise ValueError("Safetensors descriptor tensor shape is invalid.")
329
+ sha256 = tensor.get("sha256")
330
+ if (
331
+ not isinstance(sha256, str)
332
+ or len(sha256) != 64
333
+ or sha256 != sha256.lower()
334
+ or any(character not in "0123456789abcdef" for character in sha256)
335
+ ):
336
+ raise ValueError("Safetensors descriptor tensor SHA-256 is invalid.")
337
+ return key, dtype, tuple(raw_shape), sha256
338
+
339
+
340
+ def _record_from_safetensors_descriptor(root: Path, item: dict[str, Any]) -> EmbeddingRecord:
341
+ if not isinstance(item, dict):
342
+ raise ValueError("Safetensors record descriptor must contain a JSON object.")
343
+ record_id = item.get("id")
344
+ sequence = item.get("sequence")
345
+ if not isinstance(record_id, str) or not record_id:
346
+ raise ValueError("Safetensors descriptor record ID is invalid.")
347
+ if not isinstance(sequence, str) or not sequence:
348
+ raise ValueError("Safetensors descriptor sequence is invalid.")
349
+ tensor = item.get("tensor")
350
+ if not isinstance(tensor, dict):
351
+ raise ValueError("Safetensors descriptor is missing tensor metadata.")
352
+ relative = tensor.get("file")
353
+ if not isinstance(relative, str) or not relative:
354
+ raise ValueError("Safetensors descriptor tensor file is invalid.")
355
+ key, dtype, shape, sha256 = _validate_tensor_descriptor(tensor)
356
+ tensor_path = _resolve_index_child(root, relative, label="descriptor")
357
+ if not tensor_path.is_file():
358
+ raise ValueError(f"Safetensors tensor shard is missing: {relative}.")
359
+
360
+ def load_tensor() -> Tensor:
361
+ return _load_safetensor(tensor_path, key)
362
+
363
+ reference = LazyTensorReference(
364
+ source=str(tensor_path),
365
+ key=key,
366
+ dtype=dtype,
367
+ shape=shape,
368
+ sha256=sha256,
369
+ _loader=load_tensor,
370
+ )
371
+ return EmbeddingRecord(record_id, sequence, reference)
372
+
373
+
374
+ class _SafetensorsRecordSequence(Sequence[EmbeddingRecord]):
375
+ """Lazy immutable view over bounded descriptor JSONL shards."""
376
+
377
+ _fastplms_immutable_sequence = True
378
+
379
+ def __init__(self, root: Path, descriptor_shards: Sequence[dict[str, Any]]) -> None:
380
+ if not isinstance(descriptor_shards, (list, tuple)):
381
+ raise ValueError("Safetensors generation index has invalid descriptor shards.")
382
+ self.root = root
383
+ self.shards = tuple(descriptor_shards)
384
+ cumulative: list[int] = []
385
+ total = 0
386
+ for shard in self.shards:
387
+ if not isinstance(shard, dict):
388
+ raise ValueError("Safetensors descriptor shard entry is invalid.")
389
+ relative = shard.get("file")
390
+ declared_count = shard.get("count")
391
+ if (
392
+ not isinstance(declared_count, int)
393
+ or isinstance(declared_count, bool)
394
+ or declared_count < 0
395
+ ):
396
+ raise ValueError("Safetensors descriptor shard count is invalid.")
397
+ declared_sha256 = shard.get("sha256")
398
+ if not isinstance(declared_sha256, str) or len(declared_sha256) != 64:
399
+ raise ValueError("Safetensors descriptor shard SHA-256 is invalid.")
400
+ if not isinstance(relative, str):
401
+ raise ValueError("Safetensors descriptor index file is invalid.")
402
+ descriptor_path = _resolve_index_child(root, relative, label="index")
403
+ tensor_file = shard.get("tensor_file")
404
+ if not isinstance(tensor_file, str):
405
+ raise ValueError("Safetensors descriptor tensor file is invalid.")
406
+ tensor_path = _resolve_index_child(root, tensor_file, label="index")
407
+ if not tensor_path.is_file():
408
+ raise ValueError(f"Safetensors tensor shard is missing: {tensor_file}.")
409
+ digest = hashlib.sha256()
410
+ count = 0
411
+ with descriptor_path.open("rb") as handle:
412
+ for line in handle:
413
+ digest.update(line)
414
+ if line.strip():
415
+ item = json.loads(line)
416
+ if not isinstance(item, dict):
417
+ raise ValueError("Safetensors record descriptor must be a JSON object.")
418
+ item_tensor = item.get("tensor")
419
+ if not isinstance(item_tensor, dict):
420
+ raise ValueError("Safetensors descriptor is missing tensor metadata.")
421
+ item_tensor_file = item_tensor.get("file")
422
+ if not isinstance(item_tensor_file, str):
423
+ raise ValueError("Safetensors descriptor tensor file is invalid.")
424
+ _resolve_index_child(root, item_tensor_file, label="descriptor")
425
+ if item_tensor_file != tensor_file:
426
+ raise ValueError(
427
+ "Safetensors descriptor tensor file does not match its shard."
428
+ )
429
+ count += 1
430
+ _validate_tensor_descriptor(item_tensor)
431
+ if digest.hexdigest() != declared_sha256 or count != declared_count:
432
+ raise ValueError(
433
+ f"Safetensors descriptor shard failed integrity validation: {relative}."
434
+ )
435
+ total += count
436
+ cumulative.append(total)
437
+ self._cumulative = tuple(cumulative)
438
+ self._count = total
439
+
440
+ def __len__(self) -> int:
441
+ return self._count
442
+
443
+ def _iter_shard(self, shard_index: int) -> Iterator[EmbeddingRecord]:
444
+ descriptor_path = _resolve_index_child(
445
+ self.root, str(self.shards[shard_index]["file"]), label="index"
446
+ )
447
+ with descriptor_path.open("r", encoding="utf-8") as handle:
448
+ for line in handle:
449
+ if line.strip():
450
+ yield _record_from_safetensors_descriptor(self.root, json.loads(line))
451
+
452
+ def __iter__(self) -> Iterator[EmbeddingRecord]:
453
+ for shard_index in range(len(self.shards)):
454
+ yield from self._iter_shard(shard_index)
455
+
456
+ @overload
457
+ def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
458
+
459
+ @overload
460
+ def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
461
+
462
+ def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
463
+ if isinstance(index, slice):
464
+ start, stop, step = index.indices(self._count)
465
+ return [self[position] for position in range(start, stop, step)]
466
+ position = index + self._count if index < 0 else index
467
+ if position < 0 or position >= self._count:
468
+ raise IndexError(index)
469
+ shard_index = bisect_right(self._cumulative, position)
470
+ previous = self._cumulative[shard_index - 1] if shard_index else 0
471
+ local_position = position - previous
472
+ for offset, record in enumerate(self._iter_shard(shard_index)):
473
+ if offset == local_position:
474
+ return record
475
+ raise IndexError(index)
476
+
477
+
478
+ class SafetensorsStreamWriter:
479
+ """Bounded-memory, resumable publisher with immutable retained generations."""
480
+
481
+ def __init__(
482
+ self,
483
+ path: str | Path,
484
+ metadata: dict[str, Any],
485
+ *,
486
+ shard_size: int = DEFAULT_SHARD_SIZE,
487
+ existing: Iterable[EmbeddingRecord] = (),
488
+ reuse_existing: bool = False,
489
+ publish_initial: bool = True,
490
+ publish_incremental: bool = True,
491
+ ) -> None:
492
+ try:
493
+ from safetensors.torch import save_file
494
+ except ImportError as error:
495
+ raise ImportError("Saving embeddings requires the 'safetensors' package.") from error
496
+ if shard_size <= 0:
497
+ raise ValueError("shard_size must be positive.")
498
+
499
+ self.path = Path(path)
500
+ self.index_path = _index_path(path)
501
+ self.run_manifest_path = _run_manifest_path(path)
502
+ self.index_path.parent.mkdir(parents=True, exist_ok=True)
503
+ self.metadata = _persistent_metadata(
504
+ metadata,
505
+ descriptor_index="safetensors-generation-index",
506
+ record_count=0,
507
+ )
508
+ self.shard_size = shard_size
509
+ self.publish_incremental = publish_incremental
510
+ self._save_file = save_file
511
+ authoritative_payload = _authoritative_index_payload(path)
512
+ prefix = _safetensors_shard_prefix(path)
513
+ # A random generation identity prevents a new writer from reusing a
514
+ # previously published or interrupted generation name. Published files
515
+ # are immutable and remain available to lazy readers until explicit GC.
516
+ self._generation = uuid4().hex
517
+ self._prefix = prefix
518
+ self._shard_index = 0
519
+ self._seed_index = 0
520
+ self._commit_index = 0
521
+ self._descriptor_shards: list[dict[str, Any]] = []
522
+ self._record_count = 0
523
+ self._current: dict[str, Tensor] = {}
524
+ self._pending: list[tuple[EmbeddingRecord, str, str, tuple[int, ...], str]] = []
525
+ self._current_size = 0
526
+ if reuse_existing:
527
+ if authoritative_payload is None:
528
+ raise ValueError("Cannot resume without an authoritative safetensors index.")
529
+ authoritative_metadata = authoritative_payload.get("metadata")
530
+ if not isinstance(authoritative_metadata, dict) or authoritative_metadata.get(
531
+ "run_fingerprint"
532
+ ) != self.metadata.get("run_fingerprint"):
533
+ raise ValueError("Cannot resume a safetensors run with a different fingerprint.")
534
+ expected_prefix_length = (
535
+ len(existing) if isinstance(existing, Sequence) else sum(1 for _ in existing)
536
+ )
537
+ if authoritative_payload.get("version") == 2:
538
+ self._descriptor_shards = list(authoritative_payload.get("descriptor_shards", ()))
539
+ self._record_count = int(authoritative_payload.get("record_count", 0))
540
+ else:
541
+ legacy_records = list(authoritative_payload.get("records", ()))
542
+ self._record_count = len(legacy_records)
543
+ if legacy_records:
544
+ self._descriptor_shards.extend(self._write_descriptor_seed(legacy_records))
545
+ if expected_prefix_length != self._record_count:
546
+ raise ValueError(
547
+ "The resumable safetensors prefix does not match the validated "
548
+ "embedding records."
549
+ )
550
+
551
+ if publish_initial:
552
+ self._publish_metadata(complete=False)
553
+
554
+ def _write_descriptor_file(
555
+ self,
556
+ name: str,
557
+ descriptors: Sequence[dict[str, Any]],
558
+ *,
559
+ tensor_file: str,
560
+ ) -> dict[str, Any]:
561
+ temporary = self.index_path.parent / f".{name}.tmp"
562
+ destination = self.index_path.parent / name
563
+ if temporary.exists() or destination.exists():
564
+ raise FileExistsError(
565
+ f"Refusing to reuse immutable safetensors generation path {destination}."
566
+ )
567
+ digest = hashlib.sha256()
568
+ with temporary.open("wb") as handle:
569
+ for item in descriptors:
570
+ encoded = (
571
+ json.dumps(item, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
572
+ )
573
+ handle.write(encoded)
574
+ digest.update(encoded)
575
+ temporary.replace(destination)
576
+ return {
577
+ "file": name,
578
+ "sha256": digest.hexdigest(),
579
+ "count": len(descriptors),
580
+ "tensor_file": tensor_file,
581
+ }
582
+
583
+ def _write_descriptor_seed(self, records: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
584
+ groups: list[tuple[str, list[dict[str, Any]]]] = []
585
+ for record in records:
586
+ tensor_file = str(record["tensor"]["file"])
587
+ if (
588
+ not groups
589
+ or groups[-1][0] != tensor_file
590
+ or len(groups[-1][1]) == _MAX_RECORDS_PER_DESCRIPTOR_SHARD
591
+ ):
592
+ groups.append((tensor_file, []))
593
+ groups[-1][1].append(record)
594
+ descriptor_shards: list[dict[str, Any]] = []
595
+ for tensor_file, descriptors in groups:
596
+ self._seed_index += 1
597
+ name = (
598
+ f"{self._prefix}-records-run-{self._generation}-seed-{self._seed_index:05d}.jsonl"
599
+ )
600
+ descriptor_shards.append(
601
+ self._write_descriptor_file(name, descriptors, tensor_file=tensor_file)
602
+ )
603
+ return descriptor_shards
604
+
605
+ def _write_shard(self) -> None:
606
+ if not self._current:
607
+ return
608
+ self._shard_index += 1
609
+ name = f"{self._prefix}-run-{self._generation}-{self._shard_index:05d}.safetensors"
610
+ temporary = self.index_path.parent / f".{name}.tmp"
611
+ destination = self.index_path.parent / name
612
+ if temporary.exists() or destination.exists():
613
+ raise FileExistsError(
614
+ f"Refusing to reuse immutable safetensors generation path {destination}."
615
+ )
616
+ self._save_file(self._current, temporary)
617
+ temporary.replace(destination)
618
+ descriptors: list[dict[str, Any]] = []
619
+ for record, key, dtype_name, shape, digest in self._pending:
620
+ descriptors.append(
621
+ {
622
+ "id": record.id,
623
+ "sequence": record.sequence,
624
+ "tensor": {
625
+ "file": name,
626
+ "key": key,
627
+ "dtype": dtype_name,
628
+ "shape": list(shape),
629
+ "sha256": digest,
630
+ },
631
+ }
632
+ )
633
+ descriptor_name = (
634
+ f"{self._prefix}-records-run-{self._generation}-{self._shard_index:05d}.jsonl"
635
+ )
636
+ self._descriptor_shards.append(
637
+ self._write_descriptor_file(descriptor_name, descriptors, tensor_file=name)
638
+ )
639
+ self._record_count += len(descriptors)
640
+ self._current = {}
641
+ self._pending = []
642
+ self._current_size = 0
643
+
644
+ def append(
645
+ self,
646
+ records: Iterable[EmbeddingRecord],
647
+ *,
648
+ publish: bool | None = None,
649
+ ) -> None:
650
+ """Persist records while retaining at most one shard of tensors."""
651
+
652
+ for record in records:
653
+ position = self._record_count + len(self._pending)
654
+ tensor = record.load_tensor().detach().cpu().contiguous()
655
+ if tensor.dtype not in _DTYPE_NAMES:
656
+ raise TypeError(f"Unsupported tensor dtype {tensor.dtype}.")
657
+ nbytes = tensor.numel() * tensor.element_size()
658
+ if nbytes > self.shard_size:
659
+ raise ValueError(
660
+ f"Embedding {position} requires {nbytes} bytes and cannot fit in a "
661
+ f"{self.shard_size}-byte safetensors shard."
662
+ )
663
+ if self._current and (
664
+ self._current_size + nbytes > self.shard_size
665
+ or len(self._pending) == _MAX_RECORDS_PER_DESCRIPTOR_SHARD
666
+ ):
667
+ self._write_shard()
668
+ if self.publish_incremental:
669
+ self._publish_metadata(complete=False)
670
+ position = self._record_count
671
+ key = f"embedding_{position:08d}"
672
+ self._current[key] = tensor
673
+ self._current_size += nbytes
674
+ self._pending.append(
675
+ (
676
+ record,
677
+ key,
678
+ _DTYPE_NAMES[tensor.dtype],
679
+ tuple(tensor.shape),
680
+ tensor_sha256(tensor),
681
+ )
682
+ )
683
+ if publish:
684
+ self.publish(complete=False)
685
+
686
+ def _publish_metadata(
687
+ self,
688
+ *,
689
+ complete: bool,
690
+ metadata: dict[str, Any] | None = None,
691
+ ) -> EmbeddingResult:
692
+ """Atomically expose one self-consistent metadata generation."""
693
+
694
+ if metadata is not None:
695
+ self.metadata = _persistent_metadata(
696
+ metadata,
697
+ descriptor_index="safetensors-generation-index",
698
+ )
699
+ self.metadata["complete"] = complete
700
+ self.metadata["record_count"] = self._record_count
701
+ self._commit_index += 1
702
+ payload = {
703
+ "version": 2,
704
+ "format": "fastplms-embedding-safetensors",
705
+ "metadata": self.metadata,
706
+ "record_count": self._record_count,
707
+ "descriptor_shards": self._descriptor_shards,
708
+ }
709
+ generation_index_name = (
710
+ f"{self._prefix}-index-run-{self._generation}-{self._commit_index:05d}.json"
711
+ )
712
+ generation_index_path = self.index_path.parent / generation_index_name
713
+ temporary_generation_index = generation_index_path.with_name(
714
+ f".{generation_index_path.name}.tmp"
715
+ )
716
+ if temporary_generation_index.exists() or generation_index_path.exists():
717
+ raise FileExistsError(
718
+ f"Refusing to reuse immutable safetensors generation index {generation_index_path}."
719
+ )
720
+ encoded_index = _canonical_json_bytes(payload)
721
+ temporary_generation_index.write_bytes(encoded_index)
722
+ temporary_generation_index.replace(generation_index_path)
723
+
724
+ index_sha256 = hashlib.sha256(encoded_index).hexdigest()
725
+ index_reference = {
726
+ "file": generation_index_name,
727
+ "sha256": index_sha256,
728
+ }
729
+ run_manifest = {
730
+ "version": 2,
731
+ "format": "fastplms-embedding-run",
732
+ "index": index_reference,
733
+ "record_count": self._record_count,
734
+ }
735
+ pointer_identity = f"{self._generation}-{self._commit_index:05d}"
736
+ temporary_manifest = self.run_manifest_path.with_name(
737
+ f".{self.run_manifest_path.name}.{pointer_identity}.tmp"
738
+ )
739
+ temporary_manifest.write_bytes(_canonical_json_bytes(run_manifest))
740
+ temporary_manifest.replace(self.run_manifest_path)
741
+
742
+ # ``index.json`` is a non-authoritative convenience pointer. The run
743
+ # manifest is committed first, so interruption here cannot invalidate
744
+ # the newly committed generation.
745
+ stable_pointer = {
746
+ "version": 2,
747
+ "format": "fastplms-embedding-index-pointer",
748
+ "index": index_reference,
749
+ }
750
+ temporary_index = self.index_path.with_name(
751
+ f".{self.index_path.name}.{pointer_identity}.tmp"
752
+ )
753
+ temporary_index.write_bytes(_canonical_json_bytes(stable_pointer))
754
+ temporary_index.replace(self.index_path)
755
+
756
+ return load_safetensors_result(self.index_path)
757
+
758
+ def publish(
759
+ self,
760
+ *,
761
+ complete: bool,
762
+ metadata: dict[str, Any] | None = None,
763
+ ) -> EmbeddingResult:
764
+ """Flush the current shard and atomically expose a consistent generation."""
765
+
766
+ self._write_shard()
767
+ return self._publish_metadata(complete=complete, metadata=metadata)
768
+
769
+
770
+ def save_safetensors_result(
771
+ result: EmbeddingResult,
772
+ path: str | Path,
773
+ *,
774
+ shard_size: int = DEFAULT_SHARD_SIZE,
775
+ ) -> EmbeddingResult:
776
+ """Write sharded safetensors without materializing the full result."""
777
+
778
+ writer = SafetensorsStreamWriter(
779
+ path,
780
+ result.metadata,
781
+ shard_size=shard_size,
782
+ publish_initial=False,
783
+ publish_incremental=False,
784
+ )
785
+ writer.append(result, publish=False)
786
+ return writer.publish(complete=bool(result.metadata.get("complete", True)))
787
+
788
+
789
+ def load_safetensors_result(path: str | Path) -> EmbeddingResult:
790
+ """Load an indexed safetensors result without loading tensor payloads."""
791
+
792
+ payload, index_path, _ = _load_authoritative_index(path)
793
+ if payload.get("version") == 2:
794
+ lazy_records = _SafetensorsRecordSequence(
795
+ index_path.parent, payload.get("descriptor_shards", ())
796
+ )
797
+ if len(lazy_records) != payload.get("record_count"):
798
+ raise ValueError("Safetensors descriptor count does not match its generation index.")
799
+ return EmbeddingResult(lazy_records, payload.get("metadata", {}))
800
+
801
+ records: list[EmbeddingRecord] = []
802
+ for item in payload["records"]:
803
+ records.append(_record_from_safetensors_descriptor(index_path.parent, item))
804
+ return EmbeddingResult(records, payload.get("metadata", {}))
805
+
806
+
807
+ def garbage_collect_safetensors_generations(
808
+ path: str | Path,
809
+ *,
810
+ dry_run: bool = True,
811
+ confirm_no_active_readers_or_writers: bool = False,
812
+ ) -> tuple[Path, ...]:
813
+ """Remove non-authoritative generations after an explicit exclusivity check.
814
+
815
+ Safetensors results retain immutable historical generations because an
816
+ already-open :class:`EmbeddingResult` resolves tensors through those exact
817
+ descriptor and shard paths. Destructive collection is therefore safe only
818
+ when the caller guarantees that no reader or writer for ``path`` remains
819
+ active. ``dry_run=True`` is the default and returns the paths that would be
820
+ removed without changing the output directory.
821
+ """
822
+
823
+ if not isinstance(dry_run, bool):
824
+ raise TypeError("dry_run must be a bool.")
825
+ if not isinstance(confirm_no_active_readers_or_writers, bool):
826
+ raise TypeError("confirm_no_active_readers_or_writers must be a bool.")
827
+ if not dry_run and not confirm_no_active_readers_or_writers:
828
+ raise ValueError(
829
+ "Destructive safetensors generation collection requires "
830
+ "confirm_no_active_readers_or_writers=True."
831
+ )
832
+
833
+ # Validate the full descriptor graph before identifying anything as stale.
834
+ load_safetensors_result(path)
835
+ payload, authoritative_index_path, _ = _load_authoritative_index(path)
836
+ stable_index_path = _index_path(path)
837
+ run_manifest_path = _run_manifest_path(path)
838
+ root = stable_index_path.parent
839
+ prefix = _safetensors_shard_prefix(path)
840
+ protected = {
841
+ stable_index_path.resolve(),
842
+ run_manifest_path.resolve(),
843
+ authoritative_index_path.resolve(),
844
+ *_referenced_shards(stable_index_path, payload),
845
+ }
846
+ for descriptor_shard in payload.get("descriptor_shards", ()):
847
+ relative = descriptor_shard.get("file")
848
+ if isinstance(relative, str):
849
+ protected.add(_resolve_index_child(root, relative, label="index").resolve())
850
+
851
+ candidates: set[Path] = set()
852
+ for pattern in (
853
+ f"{prefix}-run-*-*.safetensors",
854
+ f"{prefix}-records-run-*.jsonl",
855
+ f"{prefix}-index-run-*.json",
856
+ f".{prefix}-*.tmp",
857
+ ):
858
+ candidates.update(root.glob(pattern))
859
+ candidates.update(root.glob(f".{stable_index_path.name}.*.tmp"))
860
+ candidates.update(root.glob(f".{run_manifest_path.name}.*.tmp"))
861
+
862
+ stale = tuple(
863
+ sorted(
864
+ (candidate for candidate in candidates if candidate.resolve() not in protected),
865
+ key=lambda candidate: candidate.name,
866
+ )
867
+ )
868
+ if not dry_run:
869
+ for candidate in stale:
870
+ candidate.unlink(missing_ok=True)
871
+ return stale
872
+
873
+
874
+ def _ensure_sqlite_schema(connection: sqlite3.Connection) -> None:
875
+ connection.executescript(
876
+ """
877
+ PRAGMA foreign_keys = ON;
878
+ CREATE TABLE IF NOT EXISTS runs (
879
+ run_id TEXT PRIMARY KEY,
880
+ metadata_json TEXT NOT NULL,
881
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
882
+ published_order INTEGER
883
+ );
884
+ CREATE TABLE IF NOT EXISTS tensors (
885
+ run_id TEXT NOT NULL,
886
+ position INTEGER NOT NULL,
887
+ dtype TEXT NOT NULL,
888
+ shape_json TEXT NOT NULL,
889
+ data BLOB NOT NULL,
890
+ sha256 TEXT NOT NULL,
891
+ PRIMARY KEY (run_id, position),
892
+ FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE
893
+ );
894
+ CREATE TABLE IF NOT EXISTS records (
895
+ run_id TEXT NOT NULL,
896
+ position INTEGER NOT NULL,
897
+ record_id TEXT NOT NULL,
898
+ sequence TEXT NOT NULL,
899
+ PRIMARY KEY (run_id, position),
900
+ FOREIGN KEY (run_id, position) REFERENCES tensors(run_id, position)
901
+ ON DELETE CASCADE
902
+ );
903
+ """
904
+ )
905
+ run_columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(runs)").fetchall()}
906
+ if "published_order" not in run_columns:
907
+ connection.execute("ALTER TABLE runs ADD COLUMN published_order INTEGER")
908
+ # Databases created before staged publication exposed every stored run.
909
+ # Preserve that view for historical runs containing committed records.
910
+ connection.execute(
911
+ "UPDATE runs SET published_order = rowid "
912
+ "WHERE published_order IS NULL AND EXISTS ("
913
+ "SELECT 1 FROM records WHERE records.run_id = runs.run_id)"
914
+ )
915
+ connection.execute(
916
+ "CREATE INDEX IF NOT EXISTS runs_published_order_idx ON runs(published_order)"
917
+ )
918
+ if "published_order" not in run_columns:
919
+ # Schema upgrades run before callers open their data transaction.
920
+ # End the migration transaction explicitly so BEGIN IMMEDIATE below
921
+ # remains valid on existing databases.
922
+ connection.commit()
923
+
924
+
925
+ def save_sqlite_result(result: EmbeddingResult, path: str | Path) -> EmbeddingResult:
926
+ """Transactionally store an ordered result in normalized SQLite tables."""
927
+
928
+ path = Path(path)
929
+ path.parent.mkdir(parents=True, exist_ok=True)
930
+ run_id = str(result.metadata.get("run_fingerprint", ""))
931
+ if not run_id:
932
+ raise ValueError("SQLite results require metadata['run_fingerprint'].")
933
+ metadata_json = json.dumps(
934
+ _persistent_metadata(
935
+ result.metadata,
936
+ descriptor_index="sqlite-records",
937
+ record_count=len(result),
938
+ ),
939
+ sort_keys=True,
940
+ )
941
+ with sqlite3.connect(path, timeout=30) as connection:
942
+ _ensure_sqlite_schema(connection)
943
+ connection.execute("PRAGMA journal_mode = WAL")
944
+ connection.execute("BEGIN IMMEDIATE")
945
+ connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
946
+ connection.execute(
947
+ "INSERT INTO runs(run_id, metadata_json, published_order) "
948
+ "SELECT ?, ?, COALESCE(MAX(published_order), 0) + 1 FROM runs",
949
+ (run_id, metadata_json),
950
+ )
951
+ for position, record in enumerate(result):
952
+ X = record.load_tensor().detach().cpu().contiguous()
953
+ dtype_name, shape_json, data = _encode_tensor(X)
954
+ digest = tensor_sha256(X)
955
+ connection.execute(
956
+ "INSERT INTO tensors VALUES (?, ?, ?, ?, ?, ?)",
957
+ (run_id, position, dtype_name, shape_json, data, digest),
958
+ )
959
+ connection.execute(
960
+ "INSERT INTO records VALUES (?, ?, ?, ?)",
961
+ (run_id, position, record.id, record.sequence),
962
+ )
963
+ connection.commit()
964
+ return load_sqlite_result(path, run_id=run_id)
965
+
966
+
967
+ def initialize_sqlite_run(
968
+ path: str | Path,
969
+ metadata: dict[str, Any],
970
+ *,
971
+ resume: bool,
972
+ ) -> str:
973
+ """Create a resumable SQLite run without buffering tensor results."""
974
+
975
+ path = Path(path)
976
+ path.parent.mkdir(parents=True, exist_ok=True)
977
+ run_id = str(metadata.get("run_fingerprint", ""))
978
+ if not run_id:
979
+ raise ValueError("SQLite runs require metadata['run_fingerprint'].")
980
+ with sqlite3.connect(path, timeout=30) as connection:
981
+ _ensure_sqlite_schema(connection)
982
+ connection.execute("PRAGMA journal_mode = WAL")
983
+ connection.execute("BEGIN IMMEDIATE")
984
+ exists = connection.execute("SELECT 1 FROM runs WHERE run_id = ?", (run_id,)).fetchone()
985
+ if exists and not resume:
986
+ connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
987
+ exists = None
988
+ if exists is None:
989
+ initial_metadata = _persistent_metadata(
990
+ metadata,
991
+ descriptor_index="sqlite-records",
992
+ record_count=0,
993
+ )
994
+ connection.execute(
995
+ "INSERT INTO runs(run_id, metadata_json) VALUES (?, ?)",
996
+ (run_id, json.dumps(initial_metadata, sort_keys=True)),
997
+ )
998
+ connection.commit()
999
+ return run_id
1000
+
1001
+
1002
+ def append_sqlite_records(
1003
+ path: str | Path,
1004
+ run_id: str,
1005
+ start_position: int,
1006
+ records: list[EmbeddingRecord],
1007
+ *,
1008
+ replace_metadata: dict[str, Any] | None = None,
1009
+ ) -> None:
1010
+ """Commit one ordered embedding batch so an interrupted run can resume."""
1011
+
1012
+ if not isinstance(run_id, str) or not run_id:
1013
+ raise ValueError("run_id must be a non-empty string.")
1014
+ if not isinstance(start_position, int) or isinstance(start_position, bool):
1015
+ raise TypeError("start_position must be a non-negative integer.")
1016
+ if start_position < 0:
1017
+ raise ValueError("start_position must be a non-negative integer.")
1018
+ if not isinstance(records, list) or not all(
1019
+ isinstance(record, EmbeddingRecord) for record in records
1020
+ ):
1021
+ raise TypeError("records must be a list of EmbeddingRecord values.")
1022
+
1023
+ with sqlite3.connect(Path(path), timeout=30) as connection:
1024
+ _ensure_sqlite_schema(connection)
1025
+ connection.execute("PRAGMA journal_mode = WAL")
1026
+ connection.execute("BEGIN IMMEDIATE")
1027
+ if replace_metadata is not None:
1028
+ replacement_run_id = str(replace_metadata.get("run_fingerprint", ""))
1029
+ if replacement_run_id != run_id:
1030
+ raise ValueError("Replacement metadata must match the SQLite run ID.")
1031
+ initial_metadata = _persistent_metadata(
1032
+ replace_metadata,
1033
+ descriptor_index="sqlite-records",
1034
+ record_count=0,
1035
+ )
1036
+ connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
1037
+ connection.execute(
1038
+ "INSERT INTO runs(run_id, metadata_json) VALUES (?, ?)",
1039
+ (run_id, json.dumps(initial_metadata, sort_keys=True)),
1040
+ )
1041
+ if connection.execute("SELECT 1 FROM runs WHERE run_id = ?", (run_id,)).fetchone() is None:
1042
+ raise KeyError(f"Missing SQLite embedding run {run_id}.")
1043
+ current_count, minimum_position, maximum_position = connection.execute(
1044
+ "SELECT COUNT(*), MIN(position), MAX(position) FROM records WHERE run_id = ?",
1045
+ (run_id,),
1046
+ ).fetchone()
1047
+ if current_count and (minimum_position != 0 or maximum_position != current_count - 1):
1048
+ raise ValueError("SQLite embedding run has a non-contiguous record prefix.")
1049
+ if start_position != current_count:
1050
+ raise ValueError(
1051
+ f"start_position={start_position} does not match the contiguous "
1052
+ f"SQLite prefix length {current_count}."
1053
+ )
1054
+ for offset, record in enumerate(records):
1055
+ position = start_position + offset
1056
+ X = record.load_tensor().detach().cpu().contiguous()
1057
+ dtype_name, shape_json, data = _encode_tensor(X)
1058
+ digest = tensor_sha256(X)
1059
+ connection.execute(
1060
+ "INSERT INTO tensors VALUES (?, ?, ?, ?, ?, ?)",
1061
+ (run_id, position, dtype_name, shape_json, data, digest),
1062
+ )
1063
+ connection.execute(
1064
+ "INSERT INTO records VALUES (?, ?, ?, ?)",
1065
+ (run_id, position, record.id, record.sequence),
1066
+ )
1067
+ row = connection.execute(
1068
+ "SELECT metadata_json FROM runs WHERE run_id = ?", (run_id,)
1069
+ ).fetchone()
1070
+ if row is None:
1071
+ raise KeyError(f"Missing SQLite embedding run {run_id}.")
1072
+ metadata = json.loads(row[0])
1073
+ if not isinstance(metadata, dict):
1074
+ raise ValueError("SQLite run metadata must contain a JSON object.")
1075
+ metadata["record_count"] = start_position + len(records)
1076
+ metadata["descriptor_index"] = "sqlite-records"
1077
+ connection.execute(
1078
+ "UPDATE runs SET metadata_json = ? WHERE run_id = ?",
1079
+ (json.dumps(metadata, sort_keys=True), run_id),
1080
+ )
1081
+ if records:
1082
+ connection.execute(
1083
+ "UPDATE runs SET published_order = ("
1084
+ "SELECT COALESCE(MAX(published_order), 0) + 1 FROM runs"
1085
+ ") WHERE run_id = ? AND published_order IS NULL",
1086
+ (run_id,),
1087
+ )
1088
+ connection.commit()
1089
+
1090
+
1091
+ def update_sqlite_run_metadata(path: str | Path, run_id: str, metadata: dict[str, Any]) -> None:
1092
+ """Finalize reproducibility metadata after the last streamed batch."""
1093
+
1094
+ with sqlite3.connect(Path(path), timeout=30) as connection:
1095
+ row = connection.execute(
1096
+ "SELECT COUNT(*) FROM records WHERE run_id = ?", (run_id,)
1097
+ ).fetchone()
1098
+ record_count = int(row[0]) if row is not None else 0
1099
+ cleaned_metadata = _persistent_metadata(
1100
+ metadata,
1101
+ descriptor_index="sqlite-records",
1102
+ record_count=record_count,
1103
+ )
1104
+ updated = connection.execute(
1105
+ "UPDATE runs SET metadata_json = ? WHERE run_id = ?",
1106
+ (json.dumps(cleaned_metadata, sort_keys=True), run_id),
1107
+ ).rowcount
1108
+ if updated != 1:
1109
+ raise KeyError(f"Missing SQLite embedding run {run_id}.")
1110
+ connection.commit()
1111
+
1112
+
1113
+ def _connect_sqlite_read_only(path: Path) -> sqlite3.Connection:
1114
+ if not path.is_file():
1115
+ raise FileNotFoundError(path)
1116
+ return sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=30)
1117
+
1118
+
1119
+ def _validate_sqlite_result_schema(connection: sqlite3.Connection, path: Path) -> None:
1120
+ tables = {
1121
+ str(row[0])
1122
+ for row in connection.execute(
1123
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
1124
+ ).fetchall()
1125
+ }
1126
+ required = {"runs", "records", "tensors"}
1127
+ if not required.issubset(tables):
1128
+ raise ValueError(
1129
+ f"Not a FastPLMs embedding SQLite database: {path}. "
1130
+ "Use convert_legacy_sqlite() for a legacy embeddings table."
1131
+ )
1132
+
1133
+
1134
+ def _load_sqlite_tensor(path: Path, run_id: str, position: int) -> Tensor:
1135
+ with _connect_sqlite_read_only(path) as connection:
1136
+ row = connection.execute(
1137
+ "SELECT dtype, shape_json, data FROM tensors WHERE run_id = ? AND position = ?",
1138
+ (run_id, position),
1139
+ ).fetchone()
1140
+ if row is None:
1141
+ raise KeyError(f"Missing SQLite tensor {run_id}:{position}.")
1142
+ return _decode_tensor(*row)
1143
+
1144
+
1145
+ def _validate_sqlite_descriptor_row(
1146
+ row: Sequence[Any],
1147
+ ) -> tuple[int, str, str, str, str, str]:
1148
+ if len(row) != 6:
1149
+ raise ValueError("SQLite embedding descriptor has an invalid column count.")
1150
+ position, record_id, sequence, dtype_name, shape_json, digest = row
1151
+ if not isinstance(position, int) or isinstance(position, bool) or position < 0:
1152
+ raise ValueError("SQLite embedding position is invalid.")
1153
+ if not isinstance(record_id, str) or not record_id:
1154
+ raise ValueError("SQLite embedding record ID is invalid.")
1155
+ if not isinstance(sequence, str) or not sequence:
1156
+ raise ValueError("SQLite embedding sequence is invalid.")
1157
+ if not isinstance(shape_json, str):
1158
+ raise ValueError("SQLite embedding tensor shape is invalid.")
1159
+ try:
1160
+ shape = json.loads(shape_json)
1161
+ except json.JSONDecodeError as error:
1162
+ raise ValueError("SQLite embedding tensor shape is invalid.") from error
1163
+ _validate_tensor_descriptor(
1164
+ {
1165
+ "key": f"embedding_{position}",
1166
+ "dtype": dtype_name,
1167
+ "shape": shape,
1168
+ "sha256": digest,
1169
+ }
1170
+ )
1171
+ return position, record_id, sequence, dtype_name, shape_json, digest
1172
+
1173
+
1174
+ def _sqlite_record_from_row(path: Path, run_id: str, row: Sequence[Any]) -> EmbeddingRecord:
1175
+ position, record_id, sequence, dtype_name, shape_json, digest = _validate_sqlite_descriptor_row(
1176
+ row
1177
+ )
1178
+
1179
+ def load_tensor() -> Tensor:
1180
+ return _load_sqlite_tensor(path, run_id, position)
1181
+
1182
+ reference = LazyTensorReference(
1183
+ source=str(path),
1184
+ key=f"{run_id}:{position}",
1185
+ dtype=dtype_name,
1186
+ shape=tuple(json.loads(shape_json)),
1187
+ sha256=digest,
1188
+ _loader=load_tensor,
1189
+ )
1190
+ return EmbeddingRecord(record_id, sequence, reference)
1191
+
1192
+
1193
+ class _SQLiteRecordSequence(Sequence[EmbeddingRecord]):
1194
+ """Lazy immutable descriptor view over one SQLite embedding run."""
1195
+
1196
+ _fastplms_immutable_sequence = True
1197
+
1198
+ def __init__(self, path: Path, run_id: str, count: int) -> None:
1199
+ self.path = path
1200
+ self.run_id = run_id
1201
+ self._count = count
1202
+
1203
+ @staticmethod
1204
+ def _row_query() -> str:
1205
+ return (
1206
+ "SELECT r.position, r.record_id, r.sequence, t.dtype, t.shape_json, t.sha256 "
1207
+ "FROM records r JOIN tensors t USING (run_id, position) "
1208
+ "WHERE r.run_id = ?"
1209
+ )
1210
+
1211
+ def __len__(self) -> int:
1212
+ return self._count
1213
+
1214
+ def __iter__(self) -> Iterator[EmbeddingRecord]:
1215
+ with _connect_sqlite_read_only(self.path) as connection:
1216
+ cursor = connection.execute(f"{self._row_query()} ORDER BY r.position", (self.run_id,))
1217
+ while rows := cursor.fetchmany(1_024):
1218
+ for row in rows:
1219
+ yield _sqlite_record_from_row(self.path, self.run_id, row)
1220
+
1221
+ @overload
1222
+ def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
1223
+
1224
+ @overload
1225
+ def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
1226
+
1227
+ def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
1228
+ if isinstance(index, slice):
1229
+ start, stop, step = index.indices(self._count)
1230
+ return [self[position] for position in range(start, stop, step)]
1231
+ position = index + self._count if index < 0 else index
1232
+ if position < 0 or position >= self._count:
1233
+ raise IndexError(index)
1234
+ with _connect_sqlite_read_only(self.path) as connection:
1235
+ row = connection.execute(
1236
+ f"{self._row_query()} AND r.position = ?",
1237
+ (self.run_id, position),
1238
+ ).fetchone()
1239
+ if row is None:
1240
+ raise IndexError(index)
1241
+ return _sqlite_record_from_row(self.path, self.run_id, row)
1242
+
1243
+
1244
+ def load_sqlite_result(
1245
+ path: str | Path,
1246
+ *,
1247
+ run_id: str | None = None,
1248
+ positions: Iterable[int] | None = None,
1249
+ record_ids: Iterable[str] | None = None,
1250
+ sequences: Iterable[str] | None = None,
1251
+ ) -> EmbeddingResult:
1252
+ """Load one SQLite run read-only, optionally in explicit selector order.
1253
+
1254
+ Exactly one selector may be supplied. Repeated selectors are retained. An
1255
+ ID or sequence selector that matches multiple stored rows returns those
1256
+ rows in their original order for every occurrence of that selector.
1257
+ """
1258
+
1259
+ path = Path(path).resolve()
1260
+ supplied_selectors = sum(
1261
+ selector is not None for selector in (positions, record_ids, sequences)
1262
+ )
1263
+ if supplied_selectors > 1:
1264
+ raise ValueError("Choose at most one of positions, record_ids, or sequences.")
1265
+ normalized_positions = tuple(positions) if positions is not None else None
1266
+ normalized_ids = tuple(record_ids) if record_ids is not None else None
1267
+ normalized_sequences = tuple(sequences) if sequences is not None else None
1268
+ if normalized_positions is not None and not all(
1269
+ isinstance(position, int) and not isinstance(position, bool) and position >= 0
1270
+ for position in normalized_positions
1271
+ ):
1272
+ raise ValueError("positions must contain non-negative integers.")
1273
+ for name, values in (
1274
+ ("record_ids", normalized_ids),
1275
+ ("sequences", normalized_sequences),
1276
+ ):
1277
+ if values is not None and not all(isinstance(value, str) for value in values):
1278
+ raise TypeError(f"{name} must contain strings.")
1279
+
1280
+ with _connect_sqlite_read_only(path) as connection:
1281
+ _validate_sqlite_result_schema(connection, path)
1282
+ if run_id is None:
1283
+ run_columns = {
1284
+ str(info[1]) for info in connection.execute("PRAGMA table_info(runs)").fetchall()
1285
+ }
1286
+ if "published_order" in run_columns:
1287
+ row = connection.execute(
1288
+ "SELECT run_id, metadata_json FROM runs "
1289
+ "WHERE published_order IS NOT NULL "
1290
+ "ORDER BY published_order DESC, rowid DESC LIMIT 1"
1291
+ ).fetchone()
1292
+ else:
1293
+ row = connection.execute(
1294
+ "SELECT run_id, metadata_json FROM runs "
1295
+ "ORDER BY created_at DESC, rowid DESC LIMIT 1"
1296
+ ).fetchone()
1297
+ else:
1298
+ row = connection.execute(
1299
+ "SELECT run_id, metadata_json FROM runs WHERE run_id = ?", (run_id,)
1300
+ ).fetchone()
1301
+ if row is None:
1302
+ raise KeyError(f"No embedding run found in {path}.")
1303
+ selected_run, metadata_json = row
1304
+ metadata = json.loads(metadata_json)
1305
+ if not isinstance(metadata, dict):
1306
+ raise ValueError("SQLite run metadata must contain a JSON object.")
1307
+ row_prefix = (
1308
+ "SELECT r.position, r.record_id, r.sequence, t.dtype, t.shape_json, t.sha256 "
1309
+ "FROM records r JOIN tensors t USING (run_id, position) "
1310
+ "WHERE r.run_id = ?"
1311
+ )
1312
+ record_count, minimum_position, maximum_position = connection.execute(
1313
+ "SELECT COUNT(*), MIN(position), MAX(position) FROM records WHERE run_id = ?",
1314
+ (selected_run,),
1315
+ ).fetchone()
1316
+ (tensor_count,) = connection.execute(
1317
+ "SELECT COUNT(*) FROM tensors WHERE run_id = ?", (selected_run,)
1318
+ ).fetchone()
1319
+ (joined_count,) = connection.execute(
1320
+ "SELECT COUNT(*) FROM records r JOIN tensors t USING (run_id, position) "
1321
+ "WHERE r.run_id = ?",
1322
+ (selected_run,),
1323
+ ).fetchone()
1324
+ if (
1325
+ tensor_count != record_count
1326
+ or joined_count != record_count
1327
+ or (record_count and (minimum_position != 0 or maximum_position != record_count - 1))
1328
+ ):
1329
+ raise ValueError("SQLite embedding run has inconsistent or non-contiguous records.")
1330
+ metadata_count = metadata.get("record_count")
1331
+ if (
1332
+ not isinstance(metadata_count, int)
1333
+ or isinstance(metadata_count, bool)
1334
+ or metadata_count != record_count
1335
+ ):
1336
+ raise ValueError("SQLite metadata record count does not match stored records.")
1337
+ descriptor_cursor = connection.execute(f"{row_prefix} ORDER BY r.position", (selected_run,))
1338
+ while descriptor_rows := descriptor_cursor.fetchmany(1_024):
1339
+ for descriptor_row in descriptor_rows:
1340
+ _validate_sqlite_descriptor_row(descriptor_row)
1341
+ if supplied_selectors == 0:
1342
+ rows: list[tuple[Any, ...]] | None = None
1343
+ else:
1344
+ selector_values: tuple[Any, ...]
1345
+ selector_column: str
1346
+ if normalized_positions is not None:
1347
+ selector_values = normalized_positions
1348
+ selector_column = "r.position"
1349
+ elif normalized_ids is not None:
1350
+ selector_values = normalized_ids
1351
+ selector_column = "r.record_id"
1352
+ else:
1353
+ if normalized_sequences is None:
1354
+ raise RuntimeError("Filtered SQLite retrieval resolved no selector values.")
1355
+ selector_values = normalized_sequences
1356
+ selector_column = "r.sequence"
1357
+ fetched: list[tuple[Any, ...]] = []
1358
+ unique_values = tuple(dict.fromkeys(selector_values))
1359
+ for start in range(0, len(unique_values), 900):
1360
+ chunk = unique_values[start : start + 900]
1361
+ placeholders = ",".join("?" for _ in chunk)
1362
+ fetched.extend(
1363
+ connection.execute(
1364
+ f"{row_prefix} AND {selector_column} IN ({placeholders}) "
1365
+ "ORDER BY r.position",
1366
+ (selected_run, *chunk),
1367
+ ).fetchall()
1368
+ )
1369
+ value_index = (
1370
+ 0 if normalized_positions is not None else (1 if normalized_ids is not None else 2)
1371
+ )
1372
+ matched: dict[Any, list[tuple[Any, ...]]] = {}
1373
+ for fetched_row in sorted(fetched, key=lambda item: int(item[0])):
1374
+ matched.setdefault(fetched_row[value_index], []).append(fetched_row)
1375
+ missing = [value for value in selector_values if value not in matched]
1376
+ if missing:
1377
+ raise KeyError(f"SQLite embedding selectors were not found: {missing!r}.")
1378
+ rows = [
1379
+ fetched_row for value in selector_values for fetched_row in matched.get(value, ())
1380
+ ]
1381
+
1382
+ if rows is None:
1383
+ return EmbeddingResult(
1384
+ _SQLiteRecordSequence(path, selected_run, int(record_count)),
1385
+ metadata,
1386
+ )
1387
+ records = [_sqlite_record_from_row(path, selected_run, selected_row) for selected_row in rows]
1388
+ if supplied_selectors:
1389
+ metadata = dict(metadata)
1390
+ metadata["selection"] = {
1391
+ "kind": (
1392
+ "positions"
1393
+ if normalized_positions is not None
1394
+ else "record_ids"
1395
+ if normalized_ids is not None
1396
+ else "sequences"
1397
+ ),
1398
+ "count": len(rows),
1399
+ "duplicate_policy": "preserve-request-order",
1400
+ }
1401
+ return EmbeddingResult(records, metadata)
1402
+
1403
+
1404
+ def load_legacy_pth(path: str | Path, *, allow_unsafe_pickle: bool = False) -> EmbeddingResult:
1405
+ """Import a legacy mapping-only ``.pth`` file after explicit opt-in."""
1406
+
1407
+ if not allow_unsafe_pickle:
1408
+ raise ValueError(
1409
+ "Legacy .pth loading can execute pickle payloads. Pass "
1410
+ "allow_unsafe_pickle=True only for a trusted file."
1411
+ )
1412
+ payload = torch.load(Path(path), map_location="cpu", weights_only=False)
1413
+ if not isinstance(payload, dict):
1414
+ raise ValueError("A legacy .pth embedding file must contain a mapping.")
1415
+ records: list[EmbeddingRecord] = []
1416
+ for position, (sequence, X) in enumerate(payload.items()):
1417
+ if not isinstance(sequence, str) or not isinstance(X, Tensor):
1418
+ raise ValueError("Legacy embedding mappings must use str keys and Tensor values.")
1419
+ records.append(EmbeddingRecord(str(position), sequence, X.detach().cpu()))
1420
+ return EmbeddingResult(records, {"format": "legacy-pth", "unsafe_pickle": True})
1421
+
1422
+
1423
+ _LEGACY_COMPACT_VERSION = 0x01
1424
+ _LEGACY_CODE_DTYPES: dict[int, tuple[np.dtype[Any], torch.dtype]] = {
1425
+ 0: (np.dtype(np.float16), torch.float16),
1426
+ # Legacy BF16 blobs stored FP16 payload bytes and converted back to BF16.
1427
+ 1: (np.dtype(np.float16), torch.bfloat16),
1428
+ 2: (np.dtype(np.float32), torch.float32),
1429
+ }
1430
+
1431
+
1432
+ def _decode_legacy_sqlite_blob(
1433
+ data: bytes,
1434
+ *,
1435
+ fallback_shape: tuple[int, ...] | None,
1436
+ allow_unsafe_pickle: bool,
1437
+ ) -> Tensor:
1438
+ if len(data) >= 6 and data[0] == _LEGACY_COMPACT_VERSION:
1439
+ dtype_code = int(data[1])
1440
+ if dtype_code not in _LEGACY_CODE_DTYPES:
1441
+ raise ValueError(f"Unsupported legacy compact dtype code {dtype_code}.")
1442
+ (ndim,) = struct.unpack_from("<i", data, 2)
1443
+ if ndim < 0 or ndim > 16 or len(data) < 6 + 4 * ndim:
1444
+ raise ValueError("Malformed legacy compact embedding header.")
1445
+ shape = tuple(int(value) for value in struct.unpack_from(f"<{ndim}i", data, 6))
1446
+ if any(size < 0 for size in shape):
1447
+ raise ValueError("Malformed negative legacy embedding dimension.")
1448
+ numpy_dtype, target_dtype = _LEGACY_CODE_DTYPES[dtype_code]
1449
+ offset = 6 + 4 * ndim
1450
+ expected = int(np.prod(shape, dtype=np.int64)) * numpy_dtype.itemsize
1451
+ if len(data) - offset != expected:
1452
+ raise ValueError("Legacy compact embedding payload length does not match shape.")
1453
+ array = np.frombuffer(data, dtype=numpy_dtype, offset=offset).copy().reshape(shape)
1454
+ return torch.from_numpy(array).to(dtype=target_dtype)
1455
+
1456
+ try:
1457
+ loaded = torch.load(io.BytesIO(data), map_location="cpu", weights_only=True)
1458
+ except Exception as safe_error:
1459
+ if allow_unsafe_pickle:
1460
+ loaded = torch.load(io.BytesIO(data), map_location="cpu", weights_only=False)
1461
+ elif fallback_shape is None:
1462
+ raise ValueError(
1463
+ "Legacy embedding blob is neither compact nor safely loadable. "
1464
+ "Provide fallback_shape for raw FP32 bytes, or set "
1465
+ "allow_unsafe_pickle=True only for a trusted database."
1466
+ ) from safe_error
1467
+ else:
1468
+ expected = int(np.prod(fallback_shape, dtype=np.int64)) * 4
1469
+ if len(data) != expected:
1470
+ raise ValueError(
1471
+ "Legacy raw FP32 payload length does not match fallback_shape."
1472
+ ) from safe_error
1473
+ array = np.frombuffer(data, dtype=np.float32).copy().reshape(fallback_shape)
1474
+ return torch.from_numpy(array)
1475
+ if not isinstance(loaded, Tensor):
1476
+ raise ValueError("Legacy serialized embedding payload must contain one tensor.")
1477
+ return loaded.detach().cpu()
1478
+
1479
+
1480
+ def convert_legacy_sqlite(
1481
+ source: str | Path,
1482
+ output: str | Path,
1483
+ *,
1484
+ fallback_shape: tuple[int, ...] | None = None,
1485
+ allow_unsafe_pickle: bool = False,
1486
+ metadata: dict[str, Any] | None = None,
1487
+ ) -> EmbeddingResult:
1488
+ """Convert the v0 ``embeddings(sequence, embedding)`` database safely.
1489
+
1490
+ The source is opened read-only. Compact blobs and ``weights_only`` Torch
1491
+ tensors are accepted by default. Unsafe general pickle deserialization
1492
+ remains an explicit opt-in.
1493
+ """
1494
+
1495
+ source_path = Path(source)
1496
+ output_path = Path(output)
1497
+ if source_path.resolve() == output_path.resolve():
1498
+ raise ValueError("Legacy SQLite conversion requires a different output path.")
1499
+ if fallback_shape is not None and (
1500
+ not fallback_shape or any(not isinstance(size, int) or size < 0 for size in fallback_shape)
1501
+ ):
1502
+ raise ValueError("fallback_shape must contain non-negative integer dimensions.")
1503
+ with _connect_sqlite_read_only(source_path) as connection:
1504
+ columns = {
1505
+ str(row[1]) for row in connection.execute("PRAGMA table_info(embeddings)").fetchall()
1506
+ }
1507
+ if not {"sequence", "embedding"}.issubset(columns):
1508
+ raise ValueError("Legacy SQLite database must contain embeddings(sequence, embedding).")
1509
+ rows = connection.execute(
1510
+ "SELECT sequence, embedding FROM embeddings ORDER BY rowid"
1511
+ ).fetchall()
1512
+ if not rows:
1513
+ raise ValueError("Legacy SQLite database contains no embeddings.")
1514
+
1515
+ records: list[EmbeddingRecord] = []
1516
+ content_digest = hashlib.sha256()
1517
+ for position, (sequence, data) in enumerate(rows):
1518
+ if not isinstance(sequence, str) or not sequence:
1519
+ raise ValueError("Legacy embedding sequences must be non-empty strings.")
1520
+ if not isinstance(data, bytes):
1521
+ data = bytes(data)
1522
+ tensor = _decode_legacy_sqlite_blob(
1523
+ data,
1524
+ fallback_shape=fallback_shape,
1525
+ allow_unsafe_pickle=allow_unsafe_pickle,
1526
+ )
1527
+ tensor_digest = tensor_sha256(tensor)
1528
+ for value in (sequence.encode("utf-8"), tensor_digest.encode("ascii")):
1529
+ content_digest.update(len(value).to_bytes(8, "big"))
1530
+ content_digest.update(value)
1531
+ records.append(EmbeddingRecord(str(position), sequence, tensor))
1532
+
1533
+ content_sha256 = content_digest.hexdigest()
1534
+ run_fingerprint = hashlib.sha256(
1535
+ f"fastplms-legacy-sqlite-v1:{content_sha256}".encode("ascii")
1536
+ ).hexdigest()
1537
+ converted_metadata: dict[str, Any] = {
1538
+ "format_version": 1,
1539
+ "run_fingerprint": run_fingerprint,
1540
+ "source_format": "legacy-fastplms-sqlite-v0",
1541
+ "source_content_sha256": content_sha256,
1542
+ "unsafe_pickle": allow_unsafe_pickle,
1543
+ "complete": True,
1544
+ }
1545
+ if metadata:
1546
+ converted_metadata["conversion_metadata"] = _jsonable(metadata)
1547
+ return save_sqlite_result(
1548
+ EmbeddingResult(records, converted_metadata),
1549
+ output_path,
1550
+ )
1551
+
1552
+
1553
+ def save_result(
1554
+ result: EmbeddingResult,
1555
+ path: str | Path,
1556
+ *,
1557
+ format: str = "safetensors",
1558
+ shard_size: int = DEFAULT_SHARD_SIZE,
1559
+ ) -> EmbeddingResult:
1560
+ if format == "safetensors":
1561
+ return save_safetensors_result(result, path, shard_size=shard_size)
1562
+ if format == "sqlite":
1563
+ return save_sqlite_result(result, path)
1564
+ if format == "pth":
1565
+ raise ValueError("Writing pickle-based .pth embeddings is not supported.")
1566
+ raise ValueError("format must be 'safetensors' or 'sqlite'.")
1567
+
1568
+
1569
+ def load_result(path: str | Path, *, format: str = "safetensors") -> EmbeddingResult:
1570
+ if format == "safetensors":
1571
+ return load_safetensors_result(path)
1572
+ if format == "sqlite":
1573
+ return load_sqlite_result(path)
1574
+ raise ValueError("format must be 'safetensors' or 'sqlite'.")
1575
+
1576
+
1577
+ __all__ = [
1578
+ "DEFAULT_SHARD_SIZE",
1579
+ "SafetensorsStreamWriter",
1580
+ "append_sqlite_records",
1581
+ "convert_legacy_sqlite",
1582
+ "garbage_collect_safetensors_generations",
1583
+ "initialize_sqlite_run",
1584
+ "load_legacy_pth",
1585
+ "load_result",
1586
+ "load_safetensors_result",
1587
+ "load_sqlite_result",
1588
+ "safetensors_result_exists",
1589
+ "save_result",
1590
+ "save_safetensors_result",
1591
+ "save_sqlite_result",
1592
+ "tensor_sha256",
1593
+ "update_sqlite_run_metadata",
1594
+ ]
fastplms/embeddings/types.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public value types for dataset embedding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator, Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal, overload
8
+
9
+ from torch import Tensor
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class EmbeddingInput:
14
+ """One named protein sequence supplied to :func:`embed_dataset`."""
15
+
16
+ id: str
17
+ sequence: str
18
+
19
+ def __post_init__(self) -> None:
20
+ if not isinstance(self.id, str) or not self.id:
21
+ raise ValueError("EmbeddingInput.id must be a non-empty string.")
22
+ if not isinstance(self.sequence, str) or not self.sequence:
23
+ raise ValueError("EmbeddingInput.sequence must be a non-empty string.")
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class LazyTensorReference:
28
+ """A tensor stored outside memory and loaded only when requested."""
29
+
30
+ source: str
31
+ key: str
32
+ dtype: str
33
+ shape: tuple[int, ...]
34
+ sha256: str
35
+ _loader: Callable[[], Tensor] = field(repr=False, compare=False)
36
+
37
+ def load(self, *, verify: bool = True) -> Tensor:
38
+ """Load X and optionally verify its content digest."""
39
+
40
+ if not isinstance(verify, bool):
41
+ raise TypeError("verify must be a boolean.")
42
+ X = self._loader()
43
+ if not isinstance(X, Tensor):
44
+ raise TypeError(f"Stored tensor loader for {self.key!r} must return a Tensor.")
45
+ if tuple(X.shape) != self.shape:
46
+ raise ValueError(
47
+ f"Stored tensor {self.key!r} has shape {tuple(X.shape)}, expected {self.shape}."
48
+ )
49
+ dtype = str(X.dtype).removeprefix("torch.")
50
+ if dtype != self.dtype:
51
+ raise ValueError(
52
+ f"Stored tensor {self.key!r} has dtype {dtype!r}, expected {self.dtype!r}."
53
+ )
54
+ if verify:
55
+ from .storage import tensor_sha256
56
+
57
+ digest = tensor_sha256(X)
58
+ if digest != self.sha256:
59
+ raise ValueError(f"Stored tensor {self.key!r} failed SHA-256 verification.")
60
+ return X
61
+
62
+
63
+ TensorValue = Tensor | LazyTensorReference
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class EmbeddingRecord:
68
+ """One ordered embedding result."""
69
+
70
+ id: str
71
+ sequence: str
72
+ tensor: TensorValue
73
+
74
+ def __post_init__(self) -> None:
75
+ if not isinstance(self.id, str) or not self.id:
76
+ raise ValueError("EmbeddingRecord.id must be a non-empty string.")
77
+ if not isinstance(self.sequence, str) or not self.sequence:
78
+ raise ValueError("EmbeddingRecord.sequence must be a non-empty string.")
79
+ if not isinstance(self.tensor, (Tensor, LazyTensorReference)):
80
+ raise TypeError("EmbeddingRecord.tensor must be a Tensor or LazyTensorReference.")
81
+
82
+ def load_tensor(self, *, verify: bool = True) -> Tensor:
83
+ """Return X regardless of whether this record is memory-backed or lazy."""
84
+
85
+ if not isinstance(verify, bool):
86
+ raise TypeError("verify must be a boolean.")
87
+ if isinstance(self.tensor, LazyTensorReference):
88
+ return self.tensor.load(verify=verify)
89
+ return self.tensor
90
+
91
+
92
+ class EmbeddingResult(Sequence[EmbeddingRecord]):
93
+ """Ordered embedding records and the metadata needed to reproduce them."""
94
+
95
+ def __init__(
96
+ self,
97
+ records: Sequence[EmbeddingRecord],
98
+ metadata: Mapping[str, Any] | None = None,
99
+ ) -> None:
100
+ self.records: Sequence[EmbeddingRecord] = (
101
+ records if getattr(records, "_fastplms_immutable_sequence", False) else tuple(records)
102
+ )
103
+ self.metadata = dict(metadata or {})
104
+
105
+ def __len__(self) -> int:
106
+ return len(self.records)
107
+
108
+ def __iter__(self) -> Iterator[EmbeddingRecord]:
109
+ return iter(self.records)
110
+
111
+ @overload
112
+ def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
113
+
114
+ @overload
115
+ def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
116
+
117
+ def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
118
+ return self.records[index]
119
+
120
+ def as_dict(
121
+ self,
122
+ *,
123
+ key: Literal["id", "sequence"] = "id",
124
+ duplicates: Literal["error", "first", "last"] = "error",
125
+ materialize: bool = True,
126
+ ) -> dict[str, TensorValue]:
127
+ """Convert records to a mapping under an explicit duplicate policy."""
128
+
129
+ if key not in {"id", "sequence"}:
130
+ raise ValueError("key must be 'id' or 'sequence'.")
131
+ if duplicates not in {"error", "first", "last"}:
132
+ raise ValueError("duplicates must be 'error', 'first', or 'last'.")
133
+ if not isinstance(materialize, bool):
134
+ raise TypeError("materialize must be a boolean.")
135
+ output: dict[str, TensorValue] = {}
136
+ for record in self.records:
137
+ record_key = getattr(record, key)
138
+ if record_key in output:
139
+ if duplicates == "error":
140
+ raise ValueError(
141
+ f"Duplicate {key} {record_key!r}; choose duplicates='first' "
142
+ "or duplicates='last' explicitly."
143
+ )
144
+ if duplicates == "first":
145
+ continue
146
+ output[record_key] = record.load_tensor() if materialize else record.tensor
147
+ return output
148
+
149
+ def materialize(self, *, verify: bool = True) -> EmbeddingResult:
150
+ """Return an equivalent result with every X loaded into CPU memory."""
151
+
152
+ if not isinstance(verify, bool):
153
+ raise TypeError("verify must be a boolean.")
154
+ return EmbeddingResult(
155
+ [
156
+ EmbeddingRecord(
157
+ id=record.id,
158
+ sequence=record.sequence,
159
+ tensor=record.load_tensor(verify=verify),
160
+ )
161
+ for record in self.records
162
+ ],
163
+ self.metadata,
164
+ )
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class EmbeddingBatch:
169
+ """Internal model-to-runner contract.
170
+
171
+ ``X`` has shape ``(b, l, d)`` and ``residue_mask`` has shape ``(b, l)``.
172
+ ``attentions`` may contain layer/head attention matrices for ``parti``.
173
+ """
174
+
175
+ X: Tensor
176
+ residue_mask: Tensor
177
+ attentions: Tensor | tuple[Tensor, ...] | None = None
178
+
179
+
180
+ __all__ = [
181
+ "EmbeddingBatch",
182
+ "EmbeddingInput",
183
+ "EmbeddingRecord",
184
+ "EmbeddingResult",
185
+ "LazyTensorReference",
186
+ "TensorValue",
187
+ ]
fastplms/models.toml ADDED
@@ -0,0 +1,1223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ schema_version = 1
2
+ legal_files = [
3
+ "LICENSE=sha256:2d2b50c7b1414bff1189a1db1f0cfb92e3e064b50f4c2b1019827b683e1b629a",
4
+ "THIRD_PARTY_NOTICES.md=sha256:25704b3c76404696cae52e7fca13088d329f70f412687340351259e86cd62baa",
5
+ ]
6
+
7
+ [[attention_kernels]]
8
+ implementation = "flash_attention_2"
9
+ repository = "kernels-community/flash-attn2"
10
+ revision = "db6b51744f0cd7061386442c09df890fc6d9f47e"
11
+ version = 2
12
+ expected_variant = "flash_attn2"
13
+ dtypes = ["bfloat16"]
14
+
15
+ [[attention_kernels]]
16
+ implementation = "flash_attention_3"
17
+ repository = "kernels-community/flash-attn3"
18
+ revision = "43f0bd269777115d94ff826e0d113ce9c1c9087b"
19
+ version = 1
20
+ expected_variant = "flash_attn3"
21
+ dtypes = ["bfloat16"]
22
+
23
+ [[runtime_assets]]
24
+ id = "esmfold2_ccd"
25
+ repository = "biohub/ESMFold2"
26
+ revision = "1ebf0e3481a5184eb6171d40615c79e384b48796"
27
+ path = "ccd.pkl"
28
+ sha256 = "9ff44b1927c6b9198e38ffe0928706827a09a350c15530beeeabebfa88038fc5"
29
+ size = 417306584
30
+ consumer_family = "esmfold2"
31
+ trust_kind = "hash_pinned_pickle"
32
+ license = "MIT"
33
+ offline_behavior = "requires_cached_verified_file"
34
+
35
+ [[upstreams]]
36
+ id = "ankh"
37
+ path = "vendor/upstream/ankh"
38
+ url = "https://github.com/agemagician/Ankh.git"
39
+ revision = "02b4e25ce5389b9e771c9df6e546c62af1216f8e"
40
+ license = "CC-BY-NC-SA-4.0"
41
+ license_files = ["LICENSE.md"]
42
+ license_digests = ["LICENSE.md=sha256:cd041d7f9f52936e8824ac3f754e9c67410763205fc8a7020ba74fc8b6edc088"]
43
+ distribution_files = ["LICENSE.md=sha256:cd041d7f9f52936e8824ac3f754e9c67410763205fc8a7020ba74fc8b6edc088"]
44
+
45
+ [[upstreams]]
46
+ id = "biohub-esm"
47
+ path = "vendor/upstream/biohub-esm"
48
+ url = "https://github.com/Biohub/esm.git"
49
+ revision = "82ee35553d39169d678f784c8d3f8712ffd7d2c4"
50
+ license = "MIT"
51
+ license_files = ["LICENSE.md", "THIRD_PARTY_NOTICE.md"]
52
+ license_digests = [
53
+ "LICENSE.md=sha256:b63df9ca1dd96b3b21eec226b51b236d0bd152ac20eafc43aad46bf832b48d8a",
54
+ "THIRD_PARTY_NOTICE.md=sha256:5bff8515ba4e0f53abdc43714c180b79c5b606160497d98de741a369cb9b6a23",
55
+ ]
56
+ distribution_files = [
57
+ "LICENSE.md=sha256:b63df9ca1dd96b3b21eec226b51b236d0bd152ac20eafc43aad46bf832b48d8a",
58
+ "THIRD_PARTY_NOTICE.md=sha256:5bff8515ba4e0f53abdc43714c180b79c5b606160497d98de741a369cb9b6a23",
59
+ ]
60
+
61
+ [[upstreams]]
62
+ id = "biohub-transformers"
63
+ path = "vendor/upstream/biohub-transformers"
64
+ url = "https://github.com/Biohub/transformers.git"
65
+ revision = "3a8956fb4d4ea16b0ec8e71deef2c2909b6a5cbf"
66
+ license = "Apache-2.0"
67
+ license_files = ["LICENSE"]
68
+ license_digests = ["LICENSE=sha256:77fd4710def9ec3c0f6225800e0235f15a425abd4a8b03559127fcd782612049"]
69
+ distribution_files = ["LICENSE=sha256:77fd4710def9ec3c0f6225800e0235f15a425abd4a8b03559127fcd782612049"]
70
+
71
+ [[upstreams]]
72
+ id = "boltz"
73
+ path = "vendor/upstream/boltz"
74
+ url = "https://github.com/jwohlwend/boltz.git"
75
+ revision = "b1ebfc46ecf57f5414e0d1a6f9027bbb122c53bc"
76
+ license = "MIT"
77
+ license_files = ["LICENSE"]
78
+ license_digests = ["LICENSE=sha256:f0667fd5e66c51e1ba8ddaa0249c6d7225b30037e02c45782d8f2c2943ac2617"]
79
+ distribution_files = ["LICENSE=sha256:f0667fd5e66c51e1ba8ddaa0249c6d7225b30037e02c45782d8f2c2943ac2617"]
80
+
81
+ [[upstreams]]
82
+ id = "dplm"
83
+ path = "vendor/upstream/dplm"
84
+ url = "https://github.com/bytedance/dplm.git"
85
+ revision = "8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d"
86
+ license = "Apache-2.0"
87
+ license_files = ["LICENSE"]
88
+ license_digests = ["LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"]
89
+ distribution_files = [
90
+ "LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
91
+ "PROVENANCE.md=sha256:a659f74be9073cf1ad2d2f7071531ca56959b421f111152cf4c41184ace5970e",
92
+ ]
93
+
94
+ [[upstreams]]
95
+ id = "e1"
96
+ path = "vendor/upstream/e1"
97
+ url = "https://github.com/Profluent-AI/E1.git"
98
+ revision = "bfd2620a602248499f3d2583d85a7ecddf0b6e02"
99
+ license = "Apache-2.0 AND Profluent-E1-Agreement"
100
+ license_files = ["LICENSE", "ATTRIBUTION", "NOTICE"]
101
+ license_digests = [
102
+ "LICENSE=sha256:8ef1dd556091544db3044164a8015424a3dcb3450fb3765a81b88463551bbe81",
103
+ "ATTRIBUTION=sha256:deb22b250f6491b649eda5c63e080dd56486b8d2736cea6a52ef875436214367",
104
+ "NOTICE=sha256:6de9db0320b4ee82f665c0951d8fd4cd53701a659c9dbce9bc3e3ea6afc4c6b3",
105
+ ]
106
+ distribution_files = [
107
+ "LICENSE=sha256:8ef1dd556091544db3044164a8015424a3dcb3450fb3765a81b88463551bbe81",
108
+ "ATTRIBUTION=sha256:deb22b250f6491b649eda5c63e080dd56486b8d2736cea6a52ef875436214367",
109
+ "NOTICE=sha256:6de9db0320b4ee82f665c0951d8fd4cd53701a659c9dbce9bc3e3ea6afc4c6b3",
110
+ "Apache-2.0.txt=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
111
+ "BSD-3-Clause.txt=sha256:36e1987f2f17db7f8ad36cd7a37dbb7aeaaf0ab68b97ab4b9d3556f3a7a76ae8",
112
+ "MODIFICATIONS.md=sha256:2506f47c0f5475af8e8ff2cff13eb8b79e8e25a08a054cdd617bf336536750ca",
113
+ ]
114
+
115
+ [[upstreams]]
116
+ id = "fair-esm"
117
+ path = "vendor/upstream/fair-esm"
118
+ url = "https://github.com/facebookresearch/esm.git"
119
+ revision = "2b369911bb5b4b0dda914521b9475cad1656b2ac"
120
+ license = "MIT"
121
+ license_files = ["LICENSE"]
122
+ license_digests = ["LICENSE=sha256:da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93"]
123
+ distribution_files = [
124
+ "LICENSE=sha256:da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93",
125
+ "PROVENANCE.md=sha256:950adb94daf15e646ddf226dacfe2a8e77801aa0793e439a9a3490a48eb666e7",
126
+ ]
127
+
128
+ [[upstreams]]
129
+ id = "openfold"
130
+ path = "vendor/upstream/openfold"
131
+ url = "https://github.com/aqlaboratory/openfold.git"
132
+ revision = "4b41059694619831a7db195b7e0988fc4ff3a307"
133
+ license = "Apache-2.0"
134
+ license_files = ["LICENSE"]
135
+ license_digests = ["LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"]
136
+ distribution_files = [
137
+ "LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
138
+ "MODIFICATIONS.md=sha256:fd6f0aa1086a0c996cf967b326d18e965660cda0ad5c7f36a3474a8490720da3",
139
+ "PROVENANCE.md=sha256:48c903db43a217a3126afaefbac60b7ddac7efda2dfcc0cbff0bffc7d6c30081",
140
+ ]
141
+
142
+ [[upstreams]]
143
+ id = "protein-ttt"
144
+ path = "vendor/upstream/protein-ttt"
145
+ url = "https://github.com/anton-bushuiev/ProteinTTT.git"
146
+ revision = "fde2817cd84b936167cc76ccabf31e5c0fe49962"
147
+ license = "MIT"
148
+ license_files = ["LICENSE"]
149
+ license_digests = ["LICENSE=sha256:bb01e7d5554f9e2e117172e56551452f68a7818df7bc8e71cd7a776a1d4ba3df"]
150
+ distribution_files = [
151
+ "LICENSE=sha256:bb01e7d5554f9e2e117172e56551452f68a7818df7bc8e71cd7a776a1d4ba3df",
152
+ "PROVENANCE.md=sha256:dc641c37353c2efd50ccbdb316ca4aae495ec02c1563e0e15bac92f75fc482e5",
153
+ ]
154
+
155
+ [families.esm2]
156
+ architecture = "ESM2"
157
+ upstreams = ["fair-esm"]
158
+ tokenizer_mode = "tokenizer"
159
+ public_input = "Amino-acid sequences tokenized to residue IDs"
160
+ extra = "core"
161
+ reference_container = "reference-esm2"
162
+ reference_adapter = "tests.parity.support.reference_adapters.esm2"
163
+ attention = ["eager", "sdpa", "flex_attention", "flash_attention_2", "flash_attention_3"]
164
+ dtypes = ["float32", "bfloat16"]
165
+ bf16_execution = "fp32_parameters_autocast"
166
+ precisions = ["default"]
167
+ vram_tier = "sequence"
168
+ checkpoint_license = "MIT"
169
+ hub_license = "mit"
170
+ weights_publication_allowed = true
171
+ state_transform = "esm2_hf_to_fastplms_v1"
172
+ conversion_provenance = "Input: the pinned official ESM2 state dictionary. Transformation: apply the deterministic esm2_hf_to_fastplms_v1 key map while preserving tensor values and materializing the tied input/output embedding values as independent tensors. Output: the pinned Synthyra FastPLMs checkpoint. Validation: release parity compares exact keys and values after the declared non-aliasing transform, tokenizer behavior, and inference. Limitation: any numerical rewrite requires a new transform identifier and exact conversion test."
173
+ representative = "esm2_8m"
174
+ documentation = "docs/models.md#esm2"
175
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
176
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/esm2", "models/ttt.py"]
177
+ auto_map = { AutoConfig = "fastplms.models.esm2.modeling_fastesm.FastEsmConfig", AutoModel = "fastplms.models.esm2.modeling_fastesm.FastEsmModel", AutoModelForMaskedLM = "fastplms.models.esm2.modeling_fastesm.FastEsmForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.esm2.modeling_fastesm.FastEsmForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm2.modeling_fastesm.FastEsmForTokenClassification" }
178
+
179
+ [families.esm_plusplus]
180
+ architecture = "ESMC"
181
+ upstreams = ["biohub-esm", "biohub-transformers"]
182
+ tokenizer_mode = "tokenizer"
183
+ public_input = "Amino-acid sequences tokenized to residue IDs"
184
+ extra = "core"
185
+ reference_container = "reference-biohub-esm"
186
+ reference_adapter = "tests.parity.support.reference_adapters.esm_plusplus"
187
+ attention = ["eager", "sdpa", "flex_attention", "flash_attention_2", "flash_attention_3"]
188
+ dtypes = ["float32", "bfloat16"]
189
+ bf16_execution = "static_parameters"
190
+ precisions = ["default"]
191
+ vram_tier = "sequence"
192
+ checkpoint_license = "MIT"
193
+ hub_license = "mit"
194
+ weights_publication_allowed = true
195
+ state_transform = "esmc_to_fastplms_v1"
196
+ conversion_provenance = "Input: the pinned Biohub ESMC checkpoint. Transformation: apply the deterministic esmc_to_fastplms_v1 parameter map into the FastPLMs ESMC modules. Output: the pinned Synthyra ESMplusplus checkpoint. Validation: release parity compares keys, shapes, dtypes, values, aliases, and live inference. Limitation: runtime attention and precision selection are not serialized weight transforms."
197
+ representative = "esmc_small"
198
+ documentation = "docs/models.md#esm-and-esmc"
199
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
200
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm_plusplus", "models/ttt.py"]
201
+ auto_map = { AutoConfig = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusConfig", AutoModel = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusModel", AutoModelForMaskedLM = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForMaskedLM" }
202
+
203
+ [families.esm3]
204
+ architecture = "ESM3"
205
+ upstreams = ["biohub-esm", "biohub-transformers"]
206
+ tokenizer_mode = "tokenizer"
207
+ public_input = "Sequence, structure, and function tracks prepared through the multimodal helpers"
208
+ extra = "core"
209
+ reference_container = "reference-biohub-esm"
210
+ reference_adapter = "tests.parity.support.reference_adapters.esm3"
211
+ attention = ["eager", "sdpa", "flex_attention"]
212
+ dtypes = ["float32", "bfloat16"]
213
+ bf16_execution = "fp32_parameters_autocast"
214
+ precisions = ["default"]
215
+ vram_tier = "large-sequence"
216
+ checkpoint_license = "MIT"
217
+ hub_license = "mit"
218
+ weights_publication_allowed = true
219
+ state_transform = "esm3_to_fastplms_v1"
220
+ conversion_provenance = "Input: the pinned Biohub ESM3 checkpoint. Transformation: apply the deterministic esm3_to_fastplms_v1 parameter map for the supported sequence and multimodal modules and expand BF16 checkpoint tensors to FP32 storage. Output: the pinned Synthyra ESM3 checkpoint. Validation: release parity compares exact state identity after the declared map and live feature behavior. Limitation: unsupported upstream modalities may not be inferred from this record."
221
+ representative = "esm3_small"
222
+ documentation = "docs/models.md#esm3"
223
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
224
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm3", "models/ttt.py"]
225
+ auto_map = { AutoConfig = "fastplms.models.esm3.modeling_esm3.FastESM3Config", AutoModel = "fastplms.models.esm3.modeling_esm3.FastESM3Model" }
226
+
227
+ [families.e1]
228
+ architecture = "E1"
229
+ upstreams = ["e1"]
230
+ tokenizer_mode = "sequence"
231
+ public_input = "Raw amino-acid sequences prepared by the native E1 adapter"
232
+ extra = "core"
233
+ reference_container = "reference-e1"
234
+ reference_adapter = "tests.parity.support.reference_adapters.e1"
235
+ attention = ["sdpa", "flex_attention"]
236
+ dtypes = ["float32", "bfloat16"]
237
+ bf16_execution = "static_parameters"
238
+ precisions = ["default"]
239
+ vram_tier = "sequence"
240
+ checkpoint_license = "Profluent-E1-Agreement"
241
+ hub_license = "other"
242
+ hub_license_name = "Profluent-E1 Clickthrough License Agreement"
243
+ hub_license_link = "https://github.com/Profluent-AI/E1/blob/bfd2620a602248499f3d2583d85a7ecddf0b6e02/LICENSE"
244
+ weights_publication_allowed = true
245
+ state_transform = "e1_to_fastplms_v1"
246
+ conversion_provenance = "Input: the pinned Profluent-E1 checkpoint and tokenizer-free sequence contract. Transformation: apply e1_to_fastplms_v1 to the FastPLMs encoder and official task heads, storing floating tensors in BF16. Output: the pinned Synthyra Profluent-E1 checkpoint. Validation: release parity covers state identity after the declared cast, sequence and RAG preparation, aliases, and inference. Limitation: the FastPLMs scoring extension is not represented as an official E1 head."
247
+ representative = "e1_150m"
248
+ documentation = "docs/models.md#e1"
249
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
250
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/e1", "models/ttt.py"]
251
+ auto_map = { AutoConfig = "fastplms.models.e1.modeling_e1.E1Config", AutoModel = "fastplms.models.e1.modeling_e1.E1Model", AutoModelForMaskedLM = "fastplms.models.e1.modeling_e1.E1ForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.e1.modeling_e1.E1ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.e1.modeling_e1.E1ForTokenClassification" }
252
+
253
+ [families.dplm]
254
+ architecture = "DPLM"
255
+ upstreams = ["dplm"]
256
+ tokenizer_mode = "tokenizer"
257
+ public_input = "Amino-acid sequences tokenized to masked or partially masked residue IDs"
258
+ extra = "core"
259
+ reference_container = "reference-dplm"
260
+ reference_adapter = "tests.parity.support.reference_adapters.dplm"
261
+ attention = ["eager", "sdpa", "flex_attention", "flash_attention_3"]
262
+ dtypes = ["float32", "bfloat16"]
263
+ bf16_execution = "fp32_parameters_autocast"
264
+ precisions = ["default"]
265
+ vram_tier = "sequence"
266
+ checkpoint_license = "Apache-2.0"
267
+ hub_license = "apache-2.0"
268
+ weights_publication_allowed = true
269
+ state_transform = "dplm_to_fastplms_v1"
270
+ conversion_provenance = "Input: the pinned official DPLM1 checkpoint. Transformation: apply dplm_to_fastplms_v1, omitting the unused absolute-position table for rotary checkpoints and materializing the tied input/output embedding values as independent tensors. Output: the pinned Synthyra DPLM checkpoint. Validation: release parity compares exact state identity after the declared transform, tokenizer behavior, generation, and inference. License basis: the pinned ByteDance DPLM Apache-2.0 LICENSE and README explicitly scope the repository release to the pretrained DPLM1 and DPLM2 weights; immutable evidence is recorded in LICENSES/dplm/PROVENANCE.md. Limitation: redistribution remains subject to Apache-2.0 and the pinned provenance record; no broader rights are inferred."
271
+ representative = "dplm_150m"
272
+ documentation = "docs/models.md#dplm"
273
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
274
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_diffusion_generation.py", "models/_esm_rotary.py", "models/dplm", "models/ttt.py"]
275
+ auto_map = { AutoConfig = "fastplms.models.dplm.modeling_dplm.DPLMConfig", AutoModel = "fastplms.models.dplm.modeling_dplm.DPLMModel", AutoModelForMaskedLM = "fastplms.models.dplm.modeling_dplm.DPLMForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.dplm.modeling_dplm.DPLMForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.dplm.modeling_dplm.DPLMForTokenClassification" }
276
+
277
+ [families.dplm2]
278
+ architecture = "DPLM2"
279
+ upstreams = ["dplm"]
280
+ tokenizer_mode = "tokenizer"
281
+ public_input = "Tokenized amino-acid and structure tracks with explicit modality boundaries"
282
+ extra = "core"
283
+ reference_container = "reference-dplm"
284
+ reference_adapter = "tests.parity.support.reference_adapters.dplm2"
285
+ attention = ["sdpa"]
286
+ dtypes = ["float32", "bfloat16"]
287
+ bf16_execution = "fp32_parameters_autocast"
288
+ precisions = ["default"]
289
+ vram_tier = "sequence"
290
+ checkpoint_license = "Apache-2.0"
291
+ hub_license = "apache-2.0"
292
+ weights_publication_allowed = true
293
+ state_transform = "dplm2_to_fastplms_v1"
294
+ conversion_provenance = "Input: the pinned official DPLM2 checkpoint. Transformation: apply dplm2_to_fastplms_v1, retaining the independent language-model head and trained encoder contact head while omitting the unused absolute-position table for rotary checkpoints. Output: the pinned Synthyra DPLM2 checkpoint. Validation: release parity compares exact keys and values after the declared omission, non-aliasing, tokenizer behavior, generation, and inference. License basis: the pinned ByteDance DPLM Apache-2.0 LICENSE and README explicitly scope the repository release to the pretrained DPLM1 and DPLM2 weights; immutable evidence is recorded in LICENSES/dplm/PROVENANCE.md. Limitation: no head exception is permitted by this record, and redistribution remains subject to Apache-2.0."
295
+ representative = "dplm2_150m"
296
+ documentation = "docs/models.md#dplm2"
297
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
298
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_diffusion_generation.py", "models/_esm_rotary.py", "models/dplm2", "models/ttt.py"]
299
+ auto_map = { AutoConfig = "fastplms.models.dplm2.modeling_dplm2.DPLM2Config", AutoModel = "fastplms.models.dplm2.modeling_dplm2.DPLM2Model", AutoModelForMaskedLM = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForTokenClassification" }
300
+ tokenizer_class = "fastplms.models.dplm2.tokenization_dplm2.DPLM2Tokenizer"
301
+
302
+ [families.ankh]
303
+ architecture = "ANKH"
304
+ upstreams = ["ankh"]
305
+ tokenizer_mode = "tokenizer"
306
+ public_input = "Amino-acid sequences tokenized for encoder or sequence-to-sequence use"
307
+ extra = "core"
308
+ reference_container = "reference-ankh"
309
+ reference_adapter = "tests.parity.support.reference_adapters.ankh"
310
+ attention = ["eager", "sdpa"]
311
+ dtypes = ["float32", "bfloat16"]
312
+ bf16_execution = "static_parameters"
313
+ precisions = ["default"]
314
+ vram_tier = "large-sequence"
315
+ checkpoint_license = "CC-BY-NC-SA-4.0"
316
+ hub_license = "cc-by-nc-sa-4.0"
317
+ weights_publication_allowed = true
318
+ state_transform = "ankh_t5_to_fastplms_v1"
319
+ conversion_provenance = "Input: the pinned official ANKH T5 checkpoint. Transformation: apply ankh_t5_to_fastplms_v1 to the official encoder and sequence-to-sequence heads. Output: the pinned Synthyra ANKH checkpoint. Validation: release parity compares exact mapped state, tokenizer behavior, official heads, and inference. Limitation: the separately named FastPLMs masked-language-model extension is not an official ANKH head."
320
+ representative = "ankh_base"
321
+ documentation = "docs/models.md#ankh"
322
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
323
+ requires_complete_weight_publication = true
324
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/ankh", "models/ttt.py"]
325
+ auto_map = { AutoConfig = "fastplms.models.ankh.modeling_ankh.FastAnkhConfig", AutoModel = "fastplms.models.ankh.modeling_ankh.FastAnkhModel", AutoModelForMaskedLM = "fastplms.models.ankh.modeling_ankh.FastAnkhForMaskedLMExtension", AutoModelForSeq2SeqLM = "fastplms.models.ankh.modeling_ankh.FastAnkhForConditionalGeneration", AutoModelForSequenceClassification = "fastplms.models.ankh.modeling_ankh.FastAnkhForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.ankh.modeling_ankh.FastAnkhForTokenClassification" }
326
+
327
+ [families.boltz2]
328
+ architecture = "Boltz2"
329
+ upstreams = ["boltz"]
330
+ tokenizer_mode = "structure"
331
+ public_input = "Raw amino-acid sequences through the convenience API, or prepared model features"
332
+ extra = "structure"
333
+ reference_container = "reference-boltz2"
334
+ reference_adapter = "tests.parity.support.reference_adapters.boltz"
335
+ attention = ["eager"]
336
+ dtypes = ["float32", "bfloat16"]
337
+ bf16_execution = "fp32_parameters_autocast"
338
+ precisions = ["default"]
339
+ vram_tier = "structure"
340
+ checkpoint_license = "MIT"
341
+ hub_license = "mit"
342
+ weights_publication_allowed = true
343
+ state_transform = "boltz2_inference_core_v1"
344
+ conversion_provenance = "Input: the pinned official Boltz2 checkpoint. Transformation: select and map the supported Boltz2 inference-core parameters with boltz2_inference_core_v1. Output: the pinned Synthyra Boltz2 checkpoint. Validation: release parity covers state identity for the declared subset, feature preparation, seeded inference, and structure outputs. Limitation: this record does not claim support for undeclared upstream training components."
345
+ representative = "boltz2"
346
+ documentation = "docs/models.md#boltz2"
347
+ test_tiers = ["structure", "artifact", "benchmark"]
348
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "models/boltz"]
349
+ auto_map = { AutoConfig = "fastplms.models.boltz.modeling_boltz2.Boltz2Config", AutoModel = "fastplms.models.boltz.modeling_boltz2.Boltz2Model" }
350
+
351
+ [families.esmfold]
352
+ architecture = "ESMFold"
353
+ upstreams = ["fair-esm", "openfold"]
354
+ tokenizer_mode = "structure"
355
+ public_input = "Raw amino-acid sequences through folding helpers, or prepared residue tensors"
356
+ extra = "structure"
357
+ reference_container = "reference-esmfold"
358
+ reference_adapter = "tests.parity.support.reference_adapters.esmfold"
359
+ attention = ["eager", "sdpa", "flex_attention"]
360
+ dtypes = ["float32", "bfloat16"]
361
+ bf16_execution = "fp32_parameters_autocast"
362
+ precisions = ["default"]
363
+ vram_tier = "structure"
364
+ checkpoint_license = "MIT"
365
+ hub_license = "mit"
366
+ weights_publication_allowed = true
367
+ state_transform = "esmfold_meta_to_fastplms_v1"
368
+ conversion_provenance = "Input: the pinned native Meta ESMFold checkpoint plus its pinned ESM2 backbone. Transformation: apply esmfold_meta_to_fastplms_v1 to map native ESM2 names into the structure-only FastPLMs backbone, retain folding tensors, omit five deterministically reconstructed geometry buffers, omit the folding-unused ESM2 masked-LM and contact-regression heads, and remove the obsolete random FastPLMs TTT head from earlier mirrors. Output: canonical FP32 FastPLMs ESMFold state with an explicit CUDA BF16-autocast execution path. Validation: release parity compares exact mapped keys, shapes, dtypes, values, aliases, semantic configuration, FP32 and BF16-compute seeded inference, and structure metrics with pLDDT normalized to (0, 1). Limitation: ESMFold TTT is rejected because the official checkpoint contains no trained masked-language-model head."
369
+ representative = "esmfold"
370
+ documentation = "docs/models.md#esmfold"
371
+ test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
372
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/esmfold"]
373
+ auto_map = { AutoConfig = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmFoldConfig", AutoModel = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForProteinFolding" }
374
+
375
+ [families.esmfold2]
376
+ architecture = "ESMFold2"
377
+ upstreams = ["biohub-esm", "biohub-transformers", "protein-ttt"]
378
+ backbone_model = "esmc_6b"
379
+ tokenizer_mode = "structure"
380
+ public_input = "Raw amino-acid sequences or typed molecular-complex specifications; low-level forward accepts prepared feature tensors"
381
+ extra = "structure"
382
+ reference_container = "reference-esmfold2"
383
+ reference_adapter = "tests.parity.support.reference_adapters.esmfold2"
384
+ attention = ["eager", "sdpa", "flex_attention"]
385
+ dtypes = ["float32", "bfloat16"]
386
+ bf16_execution = "fp32_parameters_autocast"
387
+ precisions = ["auto", "fp32", "bf16", "fp8"]
388
+ experimental_precisions = ["fp8"]
389
+ vram_tier = "structure-6b"
390
+ checkpoint_license = "MIT"
391
+ hub_license = "mit"
392
+ weights_publication_allowed = true
393
+ state_transform = "identity"
394
+ conversion_provenance = "Input: each pinned Biohub ESMFold2 checkpoint and its separately pinned ESMC checkpoint. Transformation: apply identity to preserve the folding checkpoint exactly, load its parameters in FP32 for CUDA BF16-autocast execution, retain canonical BF16 ESMC weights, and optionally rebuild exactly 80 ESMC attention output projections as transient Transformer Engine linears. Output: the corresponding pinned Synthyra ESMFold2 checkpoint plus its declared ESMC precision policy. Validation: release parity covers exact canonical state, learned projection, prepared features, and seeded BF16 folding; experimental FP8 validation covers strict unavailable-device behavior, all four variants, and three BF16-to-FP8 reload cycles on the standard variant. Limitation: only the four manifest-listed ESMFold2 variants are supported; FP8 is experimental, applies only to inference-time ESMC execution, and requires direct CUDA loading with Transformer Engine availability."
395
+ representative = "esmfold2"
396
+ documentation = "docs/esmfold2.md"
397
+ test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
398
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esmfold2", "models/esm_plusplus", "models/ttt.py"]
399
+ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2.ESMFold2Model" }
400
+
401
+ [[models]]
402
+ id = "esm2_8m"
403
+ family = "esm2"
404
+ size_category = "small"
405
+ generation_contract = "not_applicable"
406
+ official_golden = { metadata = "tests/goldens/esm2_8m.json=sha256:6975e86d1d8f27488bf2a676551feaa48cc19254c9d24b6acb09198122745609", tensors = "tests/goldens/esm2_8m.safetensors=sha256:b40217566c33c71988d28869de353be54a3b3ebfc21fdfd29056e88cf7e99f4c" }
407
+ fast_repo = "Synthyra/ESM2-8M"
408
+ fast_revision = "185ecbd45665d050a8dae326d91886d330c5f9d0"
409
+ fast_files = [
410
+ "config.json=git-sha1:46d0a7b517f59123c6ebc6d1011585731cbab259",
411
+ "model.safetensors=sha256:c824e6ded5fb71c72bc5ac05300699947819023cb26cdaf6897665e6b2645e1b",
412
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
413
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
414
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
415
+ ]
416
+ official_repo = "facebook/esm2_t6_8M_UR50D"
417
+ official_revision = "c731040fcd8d73dceaa04b0a8e6329b345b0f5df"
418
+ official_files = [
419
+ "config.json=git-sha1:c2c6e65a87d9d20d47699ae236d605b80c741dd3",
420
+ "model.safetensors=sha256:24c5fa474c48f3b754b86efe752d5f189d2bcd88190fa2270fc92b2ef3034189",
421
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
422
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
423
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
424
+ ]
425
+
426
+ [[models.oracle_assets]]
427
+ role = "weights"
428
+ path = "models/esm2_t6_8M_UR50D.pt"
429
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t6_8M_UR50D.pt"
430
+ sha256 = "46f002a9870c9bdecd0ea887acb1f9a38a6b561e8f8bf8a6990b679b9d31b928"
431
+ size = 30099493
432
+
433
+ [[models.oracle_assets]]
434
+ role = "contact_regression"
435
+ path = "regression/esm2_t6_8M_UR50D-contact-regression.pt"
436
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t6_8M_UR50D-contact-regression.pt"
437
+ sha256 = "8f7a4557d57713b97ba0e484303007efb7230d25299c0ac47a0a1b12a87bbb9d"
438
+ size = 1511
439
+
440
+ [[models]]
441
+ id = "esm2_35m"
442
+ family = "esm2"
443
+ size_category = "small"
444
+ generation_contract = "not_applicable"
445
+ official_golden = { metadata = "tests/goldens/esm2_35m.json=sha256:e919d3ce6d20b6a942d27d92323814ae7594a0129dc9c4de27c5053e96675bcd", tensors = "tests/goldens/esm2_35m.safetensors=sha256:c9b8bb616cf884fb7744521a2fcc6eed23586342d11241e6c9ef16454ec31e17" }
446
+ fast_repo = "Synthyra/ESM2-35M"
447
+ fast_revision = "37ab9f56b41e365b3bd9e25d6fefe9150fd910f0"
448
+ fast_files = [
449
+ "config.json=git-sha1:4d428c9934572f39e2a00db162249971f37c88e4",
450
+ "model.safetensors=sha256:21d95ab6bb9aa91bfec87eff11da61a657b732f2df279cbddbae6a7f1f0bba9c",
451
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
452
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
453
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
454
+ ]
455
+ official_repo = "facebook/esm2_t12_35M_UR50D"
456
+ official_revision = "6fbf070e65b0b7291e7bbcd451118c216cff79d8"
457
+ official_files = [
458
+ "config.json=git-sha1:3f64131bb610ed1ce482c4b5421fc358c785278f",
459
+ "model.safetensors=sha256:e35647818e0e064351d4531ed480d225a002567b4b2b93ad3a9246d753150fc0",
460
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
461
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
462
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
463
+ ]
464
+
465
+ [[models.oracle_assets]]
466
+ role = "weights"
467
+ path = "models/esm2_t12_35M_UR50D.pt"
468
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t12_35M_UR50D.pt"
469
+ sha256 = "7f21e80e61d16a71735163ef555d3009afb0c98da74c48e29df08606973cc55e"
470
+ size = 134095705
471
+
472
+ [[models.oracle_assets]]
473
+ role = "contact_regression"
474
+ path = "regression/esm2_t12_35M_UR50D-contact-regression.pt"
475
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t12_35M_UR50D-contact-regression.pt"
476
+ sha256 = "16641e05d830d0ce863dd152dbb8c2f3ddfa3c3ec2a66080152c8abad01d8585"
477
+ size = 1959
478
+
479
+ [[models]]
480
+ id = "esm2_150m"
481
+ family = "esm2"
482
+ size_category = "medium"
483
+ generation_contract = "not_applicable"
484
+ official_golden = { metadata = "tests/goldens/esm2_150m.json=sha256:c04c93486024ba0fa1c81fbfbe92ee79d1d4c7f1cfcc2c9886728522f752feab", tensors = "tests/goldens/esm2_150m.safetensors=sha256:c03fe9916dba137b452a6bbe944c7dc414db4019a6f0921e87b92d4bb6a8a42f" }
485
+ fast_repo = "Synthyra/ESM2-150M"
486
+ fast_revision = "979e0880dfc9e0c0080839b83d9d2dc05b92786a"
487
+ fast_files = [
488
+ "config.json=git-sha1:efeae2af182b7d34dc35740a45f157661e7acdf4",
489
+ "model.safetensors=sha256:d1f7c60f98c31af328381519a750972b6a31b13b97aa7cca2e71b5ae1b3f8f53",
490
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
491
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
492
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
493
+ ]
494
+ official_repo = "facebook/esm2_t30_150M_UR50D"
495
+ official_revision = "a695f6045e2e32885fa60af20c13cb35398ce30c"
496
+ official_files = [
497
+ "config.json=git-sha1:52e04179e6fbad6663a94ea5cc44f09d764c5cd4",
498
+ "model.safetensors=sha256:c3f1da8aea53bddd32c246c86168c23b9fd72341fb9db9a94436f855f5053566",
499
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
500
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
501
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
502
+ ]
503
+
504
+ [[models.oracle_assets]]
505
+ role = "weights"
506
+ path = "models/esm2_t30_150M_UR50D.pt"
507
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t30_150M_UR50D.pt"
508
+ sha256 = "881c7176cf198ef8dec26a3c375d40eb58d0c33df95c22562ca6cc6d3f812c62"
509
+ size = 592774773
510
+
511
+ [[models.oracle_assets]]
512
+ role = "contact_regression"
513
+ path = "regression/esm2_t30_150M_UR50D-contact-regression.pt"
514
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t30_150M_UR50D-contact-regression.pt"
515
+ sha256 = "6a604b96722ed052eef8a094ad90b275ba2e987d406315dbed0bdc6b3c4238a7"
516
+ size = 3431
517
+
518
+ [[models]]
519
+ id = "esm2_650m"
520
+ family = "esm2"
521
+ size_category = "large"
522
+ generation_contract = "not_applicable"
523
+ official_golden = { metadata = "tests/goldens/esm2_650m.json=sha256:f18332172fcb3abf5dd2485fd55f5b0d193ad3b93a44cc744e0d02817c927477", tensors = "tests/goldens/esm2_650m.safetensors=sha256:c3a66b75add03628e62e238cb63da6a9e4d321f8160e84bdf2a131c096977f86" }
524
+ fast_repo = "Synthyra/ESM2-650M"
525
+ fast_revision = "ca0718a5d52b80d5c60dd76860e55e061a95fb0a"
526
+ fast_files = [
527
+ "config.json=git-sha1:88f6bd240680b29c3244df8292246048401f5caf",
528
+ "model.safetensors=sha256:a15142e94ecf36f0edde9b37796f591e609ebe1694ca411e93640f0ee384994a",
529
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
530
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
531
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
532
+ ]
533
+ official_repo = "facebook/esm2_t33_650M_UR50D"
534
+ official_revision = "08e4846e537177426273712802403f7ba8261b6c"
535
+ official_files = [
536
+ "config.json=git-sha1:a956a25d277f30bd870d3760b9a116f19ead885e",
537
+ "model.safetensors=sha256:a08adabb949fa67ad3c14b509d04fd60368b35007b0095e3358f81200c4f4db0",
538
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
539
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
540
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
541
+ ]
542
+
543
+ [[models.oracle_assets]]
544
+ role = "weights"
545
+ path = "models/esm2_t33_650M_UR50D.pt"
546
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t33_650M_UR50D.pt"
547
+ sha256 = "ea9d0522b335a8778dea6535a65301f10208dece28cd5865482b0b1fc446168c"
548
+ size = 2604537549
549
+
550
+ [[models.oracle_assets]]
551
+ role = "contact_regression"
552
+ path = "regression/esm2_t33_650M_UR50D-contact-regression.pt"
553
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t33_650M_UR50D-contact-regression.pt"
554
+ sha256 = "8ffe6edbd4173dc8d45c2cd5cb27d43aad77ec26b4c768200c58ae1f96693575"
555
+ size = 3687
556
+
557
+ [[models]]
558
+ id = "esm2_3b"
559
+ family = "esm2"
560
+ size_category = "xlarge"
561
+ generation_contract = "not_applicable"
562
+ official_golden = { metadata = "tests/goldens/esm2_3b.json=sha256:5043b2333c57a34d54fac53916722d1acb4b6fd50395b9abafa805435b184a48", tensors = "tests/goldens/esm2_3b.safetensors=sha256:dfd5a8cb05d3e814a080185c4808c8e7ec2277f070f395562fcfbe4376789e4e" }
563
+ notes = "The pinned default SDPA BF16 path uses a checkpoint-specific numeric calibration: relative L2 target/hard limit 0.06/0.07, relative Q99.9 0.15/0.18, first-percentile residue cosine 0.994/0.992, and pooled cosine 0.998/0.997. Exact state identity and the global logits-distribution contract remain required."
564
+ fast_repo = "Synthyra/ESM2-3B"
565
+ fast_revision = "ff89d0180f414ab9c677219a25da79bf09185456"
566
+ fast_files = [
567
+ "config.json=git-sha1:94944ad6cabaa40a3ce1cbe6699cf464fdc1b2c0",
568
+ "model-00001-of-00003.safetensors=sha256:04b57854545c23779b562ee2ae22f10021ba0f4d586ba0ad482ee6eda187d562",
569
+ "model-00002-of-00003.safetensors=sha256:34954aaa05bc91635776ba6672946da5822626753d80db97b38c0538e9525102",
570
+ "model-00003-of-00003.safetensors=sha256:a6b3a55b9e3b2e1778de34c665c3dd17bdfdf6da9d6d5c97730c57168709ccae",
571
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
572
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
573
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
574
+ ]
575
+ official_repo = "facebook/esm2_t36_3B_UR50D"
576
+ official_revision = "476b639933c8baad5ad09a60ac1a87f987b656fc"
577
+ official_files = [
578
+ "config.json=git-sha1:69e7563923f87d2d7439bfb83e5a19b44b46d71b",
579
+ "pytorch_model-00001-of-00002.bin=sha256:0f971f11c449d21422aa982b791619c10351972992c735f4c3cd43fe09790412",
580
+ "pytorch_model-00002-of-00002.bin=sha256:7560b46fc383c691fb74b915b7d4bcef40d3df181447f16ba4b298845e308d0c",
581
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
582
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
583
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
584
+ ]
585
+
586
+ [[models.oracle_assets]]
587
+ role = "weights"
588
+ path = "models/esm2_t36_3B_UR50D.pt"
589
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t36_3B_UR50D.pt"
590
+ sha256 = "7de8b4082ba15891959ab368b77ce3886697af1efb16d3c9e9e7b0c5d3f07500"
591
+ size = 5678116398
592
+
593
+ [[models.oracle_assets]]
594
+ role = "contact_regression"
595
+ path = "regression/esm2_t36_3B_UR50D-contact-regression.pt"
596
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t36_3B_UR50D-contact-regression.pt"
597
+ sha256 = "4da500eab246481dc9c8c95bc7b1d02f2803d761c380b0e95186d4a07d0fc84e"
598
+ size = 6759
599
+
600
+ [[models]]
601
+ id = "esmc_small"
602
+ family = "esm_plusplus"
603
+ size_category = "medium"
604
+ generation_contract = "not_applicable"
605
+ official_golden = { metadata = "tests/goldens/esmc_small.json=sha256:bb02652cf3cc484756b98ffa4ba55ed4c55870d2cea3342adb1d920ba9dfe10a", tensors = "tests/goldens/esmc_small.safetensors=sha256:03378d0f0fdd8161178ebb2c1f0da1b9776a726c8e8d3a10c009808a24de5654" }
606
+ notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
607
+ fast_repo = "Synthyra/ESMplusplus_small"
608
+ fast_revision = "46c5f7d562e47d4c14165b424c71ab7db008e6fb"
609
+ fast_files = [
610
+ "config.json=git-sha1:df2f44187157b0cc371c48c887b77b1783679201",
611
+ "model.safetensors=sha256:d099223765bc4f1ae8d6c7e18561ce41df1d54073fdc5327ef0a229235a8f52a",
612
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
613
+ "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
614
+ "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
615
+ ]
616
+ official_repo = "biohub/ESMC-300M"
617
+ official_revision = "a59b831785f907e96e6a246b1d142bfb76df31ee"
618
+ official_files = [
619
+ "config.json=git-sha1:9a49eacf4e65c39f74381f0f0d240e3b89ef43d7",
620
+ "model.safetensors=sha256:0772d8fe64bb25e14fe6f23b80e3c9a7d215d0da3c6cba5bd356d7c0e0bb22cc",
621
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
622
+ "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
623
+ "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
624
+ ]
625
+
626
+ [[models]]
627
+ id = "esmc_large"
628
+ family = "esm_plusplus"
629
+ size_category = "large"
630
+ generation_contract = "not_applicable"
631
+ official_golden = { metadata = "tests/goldens/esmc_large.json=sha256:7a4d614f67b6fde417f3fd89f61e7ec442ae284769734b2b73e14945a816a8fd", tensors = "tests/goldens/esmc_large.safetensors=sha256:e13302df4cf7e8381552f1043a8fd0f31f3e0d50b2ab6009fb86b7940ae8ff79" }
632
+ notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
633
+ fast_repo = "Synthyra/ESMplusplus_large"
634
+ fast_revision = "f813401638b3fddab09748aec1ad2bf537aa4208"
635
+ fast_files = [
636
+ "config.json=git-sha1:5736371902fe5d04e2859be30ac7dbd31b271b25",
637
+ "model.safetensors=sha256:4aff3f8c5de68c4d3e3824eb2c478e4a47355d3f849f3c745e5c8a5ee6cff851",
638
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
639
+ "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
640
+ "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
641
+ ]
642
+ official_repo = "biohub/ESMC-600M"
643
+ official_revision = "a7e82012c83126b9eedb055fea9fa84b6c02f094"
644
+ official_files = [
645
+ "config.json=git-sha1:71c8241dc28a5fb636248267a0927c0242b264c1",
646
+ "model.safetensors=sha256:e4232c30fd35fe2f57051ec88a703996ac94520580b4b836894207a3d45d9ff8",
647
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
648
+ "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
649
+ "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
650
+ ]
651
+
652
+ [[models]]
653
+ id = "esmc_6b"
654
+ family = "esm_plusplus"
655
+ size_category = "xlarge"
656
+ generation_contract = "not_applicable"
657
+ official_golden = { metadata = "tests/goldens/esmc_6b.json=sha256:e229d938719782f280fab22dfc4c43e86109fdb0cc523631168c5a491afaace3", tensors = "tests/goldens/esmc_6b.safetensors=sha256:a948945e985c7deaca7be8b7eed09c0a9521a2af3f2b10fc2ec7a7d2a0f99ada" }
658
+ notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
659
+ fast_repo = "Synthyra/ESMplusplus_6B"
660
+ fast_revision = "0d579cce3b0f09efa6b3baddf6cc3fd8c9b616c8"
661
+ fast_files = [
662
+ "config.json=git-sha1:e740cbcf211f2511c70c25a1ff6017a757ba7a69",
663
+ "model-00001-of-00006.safetensors=sha256:d30d18703453019f2d2d050866309888720c28eebc9a10307d1ddf3799e85a65",
664
+ "model-00002-of-00006.safetensors=sha256:b3d85378ab5023f4160a96e9c8cbd4cc6f78a771a83c856e88d48112f555bc13",
665
+ "model-00003-of-00006.safetensors=sha256:52595519b59349c5c6e373e6f5ca4a3d48ea6dde345f7e61e24766df5fab0e5b",
666
+ "model-00004-of-00006.safetensors=sha256:e46c6113c89c6f3e9b072c1bef02d763a625c37bcd8f9da2ed9363891c9a0758",
667
+ "model-00005-of-00006.safetensors=sha256:6d92cb2bf9791de644de2ae86f8523d802ac3b4aaabfff0716ab6c2b97f6fb14",
668
+ "model-00006-of-00006.safetensors=sha256:5fc1a8632490bb34162823c35d0d591337b9e4195b22cc0560741397a6e9d0b3",
669
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
670
+ "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
671
+ "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
672
+ ]
673
+ official_repo = "biohub/ESMC-6B"
674
+ official_revision = "45b0fa5d7fb06faefbd5e3b89bdcef35d564e79a"
675
+ official_files = [
676
+ "config.json=git-sha1:19f5fb09e4f630fb5b748a497183c22a87ec5102",
677
+ "model-00001-of-00006.safetensors=sha256:bd90149ff223e6ac1a0cac6147a5ae0df20d3a21df4f65356a1f19cd14f4aa8a",
678
+ "model-00002-of-00006.safetensors=sha256:f75e2144d8269fe2eb4b3e0823fb089b94f176d8024153e85b8fb573a42294fa",
679
+ "model-00003-of-00006.safetensors=sha256:f699f01ecc9691d9c6470492765fe54b8b5d2e9f277c139e89427433ffdfe0b2",
680
+ "model-00004-of-00006.safetensors=sha256:46add1b7be098bbfdc3073884851ba3057f1b33ea23a158b650a37007dabd13d",
681
+ "model-00005-of-00006.safetensors=sha256:1e1cb62f060a34e18f54a31a76683ef888b8cec59e73315f5b31d25d45a1f88c",
682
+ "model-00006-of-00006.safetensors=sha256:56c73e13ae96e777ce65eee99364056069ef93b646470f352f83c5f1037b1b18",
683
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
684
+ "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
685
+ "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
686
+ ]
687
+
688
+ [[models]]
689
+ id = "esm3_small"
690
+ family = "esm3"
691
+ tokenizer_source = "esmc_small"
692
+ size_category = "large"
693
+ generation_contract = "not_applicable"
694
+ official_golden = { metadata = "tests/goldens/esm3_small.json=sha256:5470e8596cbba0e2882647eccbc53c36d8b48b0f3947d1fe0bcea68da1078c32", tensors = "tests/goldens/esm3_small.safetensors=sha256:d957922f810c9ab4c557d80d5aaaf6a3aab79a5a45e4638012a634a4134803b1" }
695
+ fast_repo = "Synthyra/ESM3_small"
696
+ fast_revision = "7ddb5a740f9e5f93933eb6410c0ee8684bc63ec1"
697
+ fast_files = [
698
+ "config.json=git-sha1:60526e2fdd8af9d4fba17f323775458ef5a1a1f9",
699
+ "model-00001-of-00002.safetensors=sha256:a4c9b736c4c59d51180e966005a164859b47d5cd36e1f8ecdea619fbd34a0e92",
700
+ "model-00002-of-00002.safetensors=sha256:bea60e4e91b03bb00b6cedd29b07606b8543f0869fb74454af7b26e216d80d2b",
701
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
702
+ "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
703
+ "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
704
+ ]
705
+ official_repo = "biohub/esm3-sm-open-v1"
706
+ official_revision = "47f0545b2b6daf26a93439a3cd610f4f7f3d5478"
707
+ official_files = [
708
+ "config.json=git-sha1:0967ef424bce6791893e9a57bb952f80fd536e93",
709
+ "data/weights/esm3_function_decoder_v0.pth=sha256:f76d074efcaccfe21365a4fa96f212dadd66798e1e49d809ab7ffbe025d227c9",
710
+ "data/weights/esm3_sm_open_v1.pth=sha256:5ead5a135c658068db6a4f1b933e72d6110992c4668822e1c0e2dcc53e38acd9",
711
+ "data/weights/esm3_structure_decoder_v0.pth=sha256:3b726258a44274792b40ce7ea307e10c5da09936368a4ffa2970264d909da65b",
712
+ "data/weights/esm3_structure_encoder_v0.pth=sha256:467acbaee703ba3ccde6e75241a912a316952e5ff071355f85c1d33c68704f40",
713
+ ]
714
+
715
+ [[models]]
716
+ id = "e1_150m"
717
+ family = "e1"
718
+ size_category = "small"
719
+ generation_contract = "not_applicable"
720
+ official_golden = { metadata = "tests/goldens/e1_150m.json=sha256:701a64a6ab1a2fec5a427555b6af96232526c15cb3d5b4dc7fb253ac8f20b922", tensors = "tests/goldens/e1_150m.safetensors=sha256:6558bc8f1a7b20629eaaaa6f72601d0c2cdb859a5dc13595549b1773b6e2de41" }
721
+ fast_repo = "Synthyra/Profluent-E1-150M"
722
+ fast_revision = "7c5f3bbf697226a2e0900db7a100f9201774a907"
723
+ fast_files = [
724
+ "config.json=git-sha1:562ef21e722ca708064fc3d54d25b731d4ac8171",
725
+ "model.safetensors=sha256:d779ed3a4e23799aafc932dc09c9963428d10aa7075999b5f8851b39c76b67f6",
726
+ ]
727
+ official_repo = "Profluent-Bio/E1-150m"
728
+ official_revision = "c4dbfe827e4aa6ed7f95eaef50dc1e084f4d77dc"
729
+ official_files = [
730
+ "config.json=git-sha1:485e649199b46fe6ee7456bebf7aae9b3d4baeab",
731
+ "model.safetensors=sha256:ba2656339005e6598642836acfdafde480fecc7e145ce0058eb54adf572c3484",
732
+ ]
733
+
734
+ [[models]]
735
+ id = "e1_300m"
736
+ family = "e1"
737
+ size_category = "medium"
738
+ generation_contract = "not_applicable"
739
+ official_golden = { metadata = "tests/goldens/e1_300m.json=sha256:d3478f3f5957a0e0377864074dde0107de890019f96cb63548ee17ffb8f3ec3a", tensors = "tests/goldens/e1_300m.safetensors=sha256:92778b9ef95a803ddc84b3e3ca764c59e045872a94bcff0eb0cd47647732c188" }
740
+ fast_repo = "Synthyra/Profluent-E1-300M"
741
+ fast_revision = "5ef52c0ad2ae2578f40622696b763523810e8e26"
742
+ fast_files = [
743
+ "config.json=git-sha1:f5c91498b76a3e3282a0d716d87738abb1a1b6c1",
744
+ "model.safetensors=sha256:9271c4176a8a2e0905a0bb769570ba1c2978fb999a87da92db4cf2b041224864",
745
+ ]
746
+ official_repo = "Profluent-Bio/E1-300m"
747
+ official_revision = "5a2871c587eadbcc9237bc686ea45e5b4d28dfb3"
748
+ official_files = [
749
+ "config.json=git-sha1:918cb09e6e96d4719ed85951f38c693360f9cdb8",
750
+ "model.safetensors=sha256:31e09a2542f45b04e6ce4adafb3b657f21e2d56d12bf68fd2266b1576a80bc9b",
751
+ ]
752
+
753
+ [[models]]
754
+ id = "e1_600m"
755
+ family = "e1"
756
+ size_category = "large"
757
+ generation_contract = "not_applicable"
758
+ official_golden = { metadata = "tests/goldens/e1_600m.json=sha256:914be191c28141c1f84535cdb69ead0588a2057bb19d46c5bc7f3891a3d6739e", tensors = "tests/goldens/e1_600m.safetensors=sha256:22ed8417a4651ded255099f6d15c63c2c40552e700d2b0470d1adfde3a39c513" }
759
+ fast_repo = "Synthyra/Profluent-E1-600M"
760
+ fast_revision = "6c8bf0ec83b0e0178677c528b101efffd0677742"
761
+ fast_files = [
762
+ "config.json=git-sha1:1d35c0b35b473259875fd29ee80167487a0d6afe",
763
+ "model.safetensors=sha256:793483b1b3411eab73fe5214b94d1424ca0545992dfac6889cfc0186af472363",
764
+ ]
765
+ official_repo = "Profluent-Bio/E1-600m"
766
+ official_revision = "52d959fb87a609d15cf223a485127b29ed5c382a"
767
+ official_files = [
768
+ "config.json=git-sha1:8a0a439ed4201462bc01189c9f8b43523b257b5c",
769
+ "model.safetensors=sha256:cfc108d4b98baaa62932331b40be265eae39dc382595bc3cde4a5ab55db1bf7a",
770
+ ]
771
+
772
+ [[models]]
773
+ id = "dplm_150m"
774
+ family = "dplm"
775
+ size_category = "small"
776
+ generation_contract = "required"
777
+ official_golden = { metadata = "tests/goldens/dplm_150m.json=sha256:3228551fe3bed951db9ec97347143ec4462ce7c221ac240b7ce7730948c1dc1f", tensors = "tests/goldens/dplm_150m.safetensors=sha256:392992235195beed97ab8359b90a2e11e52f4326606f99a471447bed81d146bd" }
778
+ fast_repo = "Synthyra/DPLM-150M"
779
+ fast_revision = "90ba742754151a774f3b7ed580170d0a76b3e69d"
780
+ fast_files = [
781
+ "config.json=git-sha1:117ac2c1222152ef378abaad1f605e18c4a18ab0",
782
+ "model.safetensors=sha256:8bac5ac767ceb8deb511b272d32883f811768d56cb25e920cea94ba9b979ca14",
783
+ "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
784
+ "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
785
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
786
+ ]
787
+ official_repo = "airkingbd/dplm_150m"
788
+ official_revision = "49b7125a5d28c6418fcc2f3c4fe799352ac1488b"
789
+ official_files = [
790
+ "config.json=git-sha1:4910cb02f1840e9ac577026f601829604af58c74",
791
+ "pytorch_model.bin=sha256:ea4eaa99536b60ed76f945f71a1a5e604f08447ec3def5104a93ca6001a59961",
792
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
793
+ "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
794
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
795
+ ]
796
+
797
+ [[models]]
798
+ id = "dplm_650m"
799
+ family = "dplm"
800
+ size_category = "large"
801
+ generation_contract = "required"
802
+ official_golden = { metadata = "tests/goldens/dplm_650m.json=sha256:bf58d0ce73aaac7e6fb1923ef3d9adad67122df2a3dd414c3229488ef9587a6d", tensors = "tests/goldens/dplm_650m.safetensors=sha256:073f0a6abea7e48f28c2d921ff8329a28e22627f01979277cb324908a01b3378" }
803
+ fast_repo = "Synthyra/DPLM-650M"
804
+ fast_revision = "05dc16d97c5c028aed924c9ed681cee4ab609760"
805
+ fast_files = [
806
+ "config.json=git-sha1:3537150eb87b213a676d5840548625e220b60e8b",
807
+ "model.safetensors=sha256:e27a47b8ec1c078b3fccb36542210e20f0380c88828db2ca9acf3d8a25048bd8",
808
+ "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
809
+ "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
810
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
811
+ ]
812
+ official_repo = "airkingbd/dplm_650m"
813
+ official_revision = "7a7e651baa667d094aba05e9dc1cf52a3332110a"
814
+ official_files = [
815
+ "config.json=git-sha1:625574d625a4178ca6966e9545fee56026c0b634",
816
+ "pytorch_model.bin=sha256:db4e54343a89e7600f41c3aacbc593db1b0caee82ec28cab25ff2ae090eba39c",
817
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
818
+ "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
819
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
820
+ ]
821
+
822
+ [[models]]
823
+ id = "dplm_3b"
824
+ family = "dplm"
825
+ size_category = "xlarge"
826
+ generation_contract = "required"
827
+ official_golden = { metadata = "tests/goldens/dplm_3b.json=sha256:a5b6df8b9c7b371976892ec1d6c45581a32ad3a6325c6c0a0b3267012848c8ed", tensors = "tests/goldens/dplm_3b.safetensors=sha256:75b0a0854fc391133920b0feaaeb8f69ab7568a88b3759627aca1556c4338c1e" }
828
+ fast_repo = "Synthyra/DPLM-3B"
829
+ fast_revision = "7d764dd3d70ecf1ac0e64693de64a0064aacac65"
830
+ fast_files = [
831
+ "config.json=git-sha1:7f5baf9426be06760c86882948b0f4af2e681e22",
832
+ "model-00001-of-00003.safetensors=sha256:37b54855d087ef3e7d883464ae9d5ea3127ec15a16c6323d91ad16a6b98305c9",
833
+ "model-00002-of-00003.safetensors=sha256:042604fefb05ea8c360a48416ce7ba662a4f90b176b4baf646c5c1814c35e6e8",
834
+ "model-00003-of-00003.safetensors=sha256:b9ae04012665163c3fc9781dd04fcd69738ac20c07e615e98fc4483fd2c4de45",
835
+ "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
836
+ "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
837
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
838
+ ]
839
+ official_repo = "airkingbd/dplm_3b"
840
+ official_revision = "53849d4a7fe944ae0b9cf2bbc0d2cc0054795b51"
841
+ official_files = [
842
+ "config.json=git-sha1:f6206456e8c2f22ebe1d37fce3b5d50fd8073e68",
843
+ "pytorch_model-00001-of-00004.bin=sha256:0bcb86a115fe744ed686756db143f78851304e855e2f83cec58681c6080ced5f",
844
+ "pytorch_model-00002-of-00004.bin=sha256:daf3324f3be949e7dd1c3c84b28da7fec5151b1890cb0904e73427266856a06f",
845
+ "pytorch_model-00003-of-00004.bin=sha256:dbbeb7924a21059854f994931e23590b054aa000b10370a71c052c4aa36e9246",
846
+ "pytorch_model-00004-of-00004.bin=sha256:21c01740d091487db43446489d8a893dea1fcc6f2e1c1991ece13945f7ab4e07",
847
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
848
+ "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
849
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
850
+ ]
851
+
852
+ [[models]]
853
+ id = "dplm2_150m"
854
+ family = "dplm2"
855
+ size_category = "small"
856
+ generation_contract = "required"
857
+ official_golden = { metadata = "tests/goldens/dplm2_150m.json=sha256:d269de779ea1503de72c77e7b2e6224afc9797bd945b40c571ff6faec782e4aa", tensors = "tests/goldens/dplm2_150m.safetensors=sha256:17fc26600938ba5364b8ecb96750786d33e9f92bcd4ea4df3e12a389340748eb" }
858
+ artifact_source = "official"
859
+ canonical_state_sha256 = "82e1751f59052b8de72b082517557db47947e8d9b4ac2f11278369e6c0cbf001"
860
+ fast_repo = "Synthyra/DPLM2-150M"
861
+ fast_revision = "182745b8dc5661f898481a4fa60a7af9d53385c4"
862
+ fast_files = [
863
+ "config.json=git-sha1:07905a2e4327d27d073cd0390f140aec2976125a",
864
+ "model.safetensors=sha256:0a7751b3113027b1d9c966a5bda2d6ab831855de7aaa047b911731665a7c3cc6",
865
+ "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
866
+ "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
867
+ "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
868
+ ]
869
+ official_repo = "airkingbd/dplm2_150m"
870
+ official_revision = "3451d984d06497f835ed49634bd68c9dfb54d730"
871
+ official_files = [
872
+ "config.json=git-sha1:20f1e55c64fdc4d1d30f7b1df64b6167fa23dc7c",
873
+ "pytorch_model.bin=sha256:be7f5cf9e421f59fcc437e63ce1c7391099a314a4e9a4f10b8688785fa581238",
874
+ "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
875
+ "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
876
+ "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
877
+ ]
878
+
879
+ [[models]]
880
+ id = "dplm2_650m"
881
+ family = "dplm2"
882
+ size_category = "large"
883
+ generation_contract = "required"
884
+ official_golden = { metadata = "tests/goldens/dplm2_650m.json=sha256:d9a7548f9af657a72d441ca70f27379863724fcce8ddd3da4f672104b7bfb772", tensors = "tests/goldens/dplm2_650m.safetensors=sha256:c4e0e467c252c3ac813363d2d4b17a5e3bd99e75fad315e76d97689b4655ddac" }
885
+ artifact_source = "official"
886
+ canonical_state_sha256 = "cba76b6602d2258de9fffff953b608d93cb8ef4a9e89b0bbd27e160c81e78bb4"
887
+ fast_repo = "Synthyra/DPLM2-650M"
888
+ fast_revision = "b9d8527a9473a54954fa2764f590b9ea1b435bb2"
889
+ fast_files = [
890
+ "config.json=git-sha1:3e079579b214d48a09db57f2c60be6a1acea5baf",
891
+ "model.safetensors=sha256:92db08c7dbfd6c5e03fbfeaea3f36b09640ee794dcf5ea8d550527869a9f1d63",
892
+ "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
893
+ "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
894
+ "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
895
+ ]
896
+ official_repo = "airkingbd/dplm2_650m"
897
+ official_revision = "0bc69b644976c6680ab7e26669854d1979e8876e"
898
+ official_files = [
899
+ "config.json=git-sha1:4cce8d9dc212cdace0e20e89169790bcf199c158",
900
+ "pytorch_model.bin=sha256:8d6e08cc05e4858064a714013c74cc88c9caa2cc8b12c34605a3c24bcd877cfb",
901
+ "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
902
+ "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
903
+ "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
904
+ ]
905
+
906
+ [[models]]
907
+ id = "dplm2_3b"
908
+ family = "dplm2"
909
+ size_category = "xlarge"
910
+ # The pinned public sampler fails before generation because cls_token_id is None.
911
+ # State, tokenizer, and inference parity remain required for this checkpoint.
912
+ generation_contract = "official_unavailable"
913
+ official_golden = { metadata = "tests/goldens/dplm2_3b.json=sha256:d6e0e02af53b13cb129192f06e264758aa21c9ebf4ee82411cf67037082d2329", tensors = "tests/goldens/dplm2_3b.safetensors=sha256:838b11824d08f83bcb0c0b3268e579f3a87dbfb965370cfe5c3f8793b96b1964" }
914
+ notes = "The pinned official DPLM2-3B sampler fails before generation, so live generation equivalence cannot be established for this checkpoint. State, tokenizer, and inference parity remain required."
915
+ artifact_source = "official"
916
+ canonical_state_sha256 = "8c46ec09115dbe6cbfb91d94ab5e906369d57e27fe620a7741c6f8cb1b6ca890"
917
+ fast_repo = "Synthyra/DPLM2-3B"
918
+ fast_revision = "2a63babe8848abf5233d31bd55891dff8285fc50"
919
+ fast_files = [
920
+ "config.json=git-sha1:5932b1d501fed28b84614e0d2c1ecc4e89f10d6e",
921
+ "model-00001-of-00003.safetensors=sha256:2ff393f6e8df1568ce075d50de69ff4e5e9d9886e5ec47e43d6c24df23459be3",
922
+ "model-00002-of-00003.safetensors=sha256:feb3cea852c2aa849cc30783a984a97f0d076990ade6606cda5e38bf2a5a9621",
923
+ "model-00003-of-00003.safetensors=sha256:9be363ddb98436af20901981ffbed2f1097377424987f6c1baad27d512b62e71",
924
+ "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
925
+ "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
926
+ "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
927
+ ]
928
+ official_repo = "airkingbd/dplm2_3b"
929
+ official_revision = "9e77567926f98d1b997ea9131a8eeb035b9bf827"
930
+ official_files = [
931
+ "config.json=git-sha1:22d51ce44cd6da8d819e0d00566987bb51d74753",
932
+ "pytorch_model-00001-of-00004.bin=sha256:d8c641eae6bf891581ec64d543169891b093e296f5679ac75c695bcf596b4211",
933
+ "pytorch_model-00002-of-00004.bin=sha256:6478ad86ec5fef3d1d26580493af2d8666009d3ff884f3f88548080c8bbf94b5",
934
+ "pytorch_model-00003-of-00004.bin=sha256:dde8f88dac4a6355488c2fb433ee12cd69f1169950566624fba43684d4d99dc6",
935
+ "pytorch_model-00004-of-00004.bin=sha256:17ec0145152bc10e4dd3b4c2edff337979f6b99ee7c7bfd6cf4e6dbd7262d079",
936
+ "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
937
+ "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
938
+ "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
939
+ ]
940
+
941
+ [[models]]
942
+ id = "ankh_base"
943
+ family = "ankh"
944
+ size_category = "medium"
945
+ generation_contract = "required"
946
+ official_golden = { metadata = "tests/goldens/ankh_base.json=sha256:ebce8d7de821827ee995789c9b38d79252d3b2f76888130b0a8a7eedafaefe2b", tensors = "tests/goldens/ankh_base.safetensors=sha256:f0e78aa15d11749e0c64ff57f9e88c51cec6538a0adf8951f839df70cc708b65" }
947
+ notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
948
+ artifact_source = "official"
949
+ canonical_state_sha256 = "cdd8d30d88e5bf41f44e1eef4470d8e46607aba5f7c7c805b06c035b89c8c16f"
950
+ fast_repo = "Synthyra/ANKH_base"
951
+ fast_revision = "7ec329aae8e3e174bf22a1eb9e0e9fcc12b53092"
952
+ fast_files = [
953
+ "config.json=git-sha1:7e1cbce6d08f9bb64eee4410899b1c6b4054f418",
954
+ "model.safetensors=sha256:b0d3473cac1bda90e39cde54f2abe86da1fc84f872c833ca3415672776dccb95",
955
+ "special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1",
956
+ "tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be",
957
+ "tokenizer_config.json=git-sha1:db0b80de72d3b16242b9eda74ed4663e39c65bcf",
958
+ ]
959
+ official_repo = "ElnaggarLab/ankh-base"
960
+ official_revision = "d99cb6b966530dfc2ae96bc69d9255c2a07308b0"
961
+ official_files = [
962
+ "config.json=git-sha1:abd44a36b5469e9a7cb019e4059b5ac1392d8422",
963
+ "pytorch_model.bin=sha256:9b2a886374f0ff4a893f4e7a989deed76bb2458c8998bd5202ea8e97d92ddcc3",
964
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
965
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
966
+ "tokenizer_config.json=git-sha1:a8a872ae3441e7cc85ce19210dff1e4c5d2d7bd0",
967
+ ]
968
+
969
+ [[models]]
970
+ id = "ankh_large"
971
+ family = "ankh"
972
+ size_category = "large"
973
+ generation_contract = "required"
974
+ official_golden = { metadata = "tests/goldens/ankh_large.json=sha256:59492518b021de5cfaea87d672c9448c8558e99a3443ba2cc7ab544963196ecb", tensors = "tests/goldens/ankh_large.safetensors=sha256:3fb8d3ac27716d15a9ea92aeef6acf2b977bcc887d9b535000539e523673459b" }
975
+ notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
976
+ artifact_source = "official"
977
+ canonical_state_sha256 = "e498a2e9aea76ef784cbe3e596c6b3f5e9a40e209ad837f7e3207099e4d74483"
978
+ fast_repo = "Synthyra/ANKH_large"
979
+ fast_revision = "3be3df34140f49dc4e65bd1f247e3ce819e7fc59"
980
+ fast_files = [
981
+ "config.json=git-sha1:272509deedb527e5c2c95b0c269194a44148fdcc",
982
+ "model.safetensors=sha256:e70b8f9755ac6bfe95d18359060ae9fe38fac63b12a89a886c83349d1adbaa53",
983
+ "special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1",
984
+ "tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be",
985
+ "tokenizer_config.json=git-sha1:2bcaff2567826f5f51188b00600d2c6e7bcea56e",
986
+ ]
987
+ official_repo = "ElnaggarLab/ankh-large"
988
+ official_revision = "74b371dbfa3ee0a05d32ae74df0c2e0b82d6b9a6"
989
+ official_files = [
990
+ "config.json=git-sha1:1abf33e52ee3d6be67d780ec57d32ac2b27b5306",
991
+ "pytorch_model.bin=sha256:517b6e8b279dedcb477af240b35c46bd6eb3307723eb281e60d4b2c8a87b889b",
992
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
993
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
994
+ "tokenizer_config.json=git-sha1:d7fe02ba6f2b18d9ccfa19ac129c9fdc9ec24d09",
995
+ ]
996
+
997
+ [[models]]
998
+ id = "ankh2_large"
999
+ family = "ankh"
1000
+ size_category = "large"
1001
+ generation_contract = "required"
1002
+ official_golden = { metadata = "tests/goldens/ankh2_large.json=sha256:e8df38994ca1a1e0c598ace34a0b257b264937e4fdbb01bc41544985116b02a4", tensors = "tests/goldens/ankh2_large.safetensors=sha256:25fe1569f55c635fab8fa49c1d62a889a35a2a738bad921f5764a85b58fd4b5d" }
1003
+ notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
1004
+ artifact_source = "official"
1005
+ canonical_state_sha256 = "597c4fe2fa8711f11a25317905f1d62fa92905e55fdd5c0a79614cd9c9d2bca3"
1006
+ fast_repo = "Synthyra/ANKH2_large"
1007
+ fast_revision = "392de5ed52bbfd73b45f545e378aaebcff096d0e"
1008
+ fast_files = [
1009
+ "config.json=git-sha1:66b6adc7215743a98a3229958bbd1c9c42b6108b",
1010
+ "model.safetensors=sha256:be8e6242388d93b51cd9719a0e32cfc17a2e804786570c795ba332197eccb915",
1011
+ "special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1",
1012
+ "tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be",
1013
+ "tokenizer_config.json=git-sha1:db0b80de72d3b16242b9eda74ed4663e39c65bcf",
1014
+ ]
1015
+ official_repo = "ElnaggarLab/ankh2-ext2"
1016
+ official_revision = "aa9b9fa72288c47d9f618ce80c011e24b54e17a8"
1017
+ official_files = [
1018
+ "config.json=git-sha1:9286bed4ecbc4f7113024919d16ec9719b0c0748",
1019
+ "generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a",
1020
+ "pytorch_model.bin=sha256:2df583f28f111276ee22a7b76007f4297e9a69766d60bccd9c8d7169c06ac606",
1021
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
1022
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
1023
+ "tokenizer_config.json=git-sha1:854e5db75dae8b1e9dd39c5bae80dae5508b3e25",
1024
+ ]
1025
+
1026
+ [[models]]
1027
+ id = "ankh3_large"
1028
+ family = "ankh"
1029
+ size_category = "large"
1030
+ generation_contract = "required"
1031
+ official_golden = { metadata = "tests/goldens/ankh3_large.json=sha256:2e5bb05b3baa5baa78f61fef7d2a2c669b0da5dbfaf6b50b12abd3e17253a961", tensors = "tests/goldens/ankh3_large.safetensors=sha256:e5c494ac418e0a2fe7bdad1376676d48960d58ec9e044d19bfffccb8c3288513" }
1032
+ notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
1033
+ artifact_source = "official"
1034
+ canonical_state_sha256 = "60acb7ef86e85dc0c51fc1edf4c8e69a0480049723b6b2c95e6e9faa720c112a"
1035
+ fast_repo = "Synthyra/ANKH3_large"
1036
+ fast_revision = "53600f175f328f986f43e55ca8ceb14935d337a4"
1037
+ fast_files = [
1038
+ "config.json=git-sha1:432b09625d44a2eeab679fddb7495d42b560b7f9",
1039
+ "model.safetensors=sha256:9f50f58cf5b3a537a0a41aa918695c3a26d7985dd0b2266642d6f86324c9e7a1",
1040
+ "special_tokens_map.json=git-sha1:1fc3a4d6d4282e5201cd7c30d5c0a6a8bfa04f82",
1041
+ "tokenizer.json=git-sha1:3d14291df2d6db3a183c5c4fe133afb330cc44cf",
1042
+ "tokenizer_config.json=git-sha1:2005fec00a7ae9a49e248a1ecefbbd81c56674d6",
1043
+ ]
1044
+ official_repo = "ElnaggarLab/ankh3-large"
1045
+ official_revision = "2be091622e8a393f0ef21735070084123c874b6e"
1046
+ official_files = [
1047
+ "config.json=git-sha1:f5278f77d158cdd8a173df888e3ed365e84a80a3",
1048
+ "generation_config.json=git-sha1:5767cc0cacebfd06884eb27ae1c796d3ca829fd2",
1049
+ "pytorch_model.bin=sha256:26321a345e07a25b21c6c41b651c4db91b420892e52c0dcbc55bd7a8f510f95b",
1050
+ "special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4",
1051
+ "spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0",
1052
+ "tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca",
1053
+ "tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21",
1054
+ ]
1055
+
1056
+ [[models]]
1057
+ id = "ankh3_xl"
1058
+ family = "ankh"
1059
+ size_category = "xlarge"
1060
+ generation_contract = "required"
1061
+ official_golden = { metadata = "tests/goldens/ankh3_xl.json=sha256:66bb12e033e4163be225d636108a479393228a4f5061015c8af114e766c3c486", tensors = "tests/goldens/ankh3_xl.safetensors=sha256:72d34567d0228cb6f1ee701c578ed4039fead4346e3f161a52e0e74df28dc8ae" }
1062
+ notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head. The official PyTorch shard index is deliberately excluded: the builder verifies every declared source shard directly and writes a new canonical safetensors index."
1063
+ artifact_source = "official"
1064
+ canonical_state_sha256 = "dd2188e0d2ca65232135714eef6de394239734d843ddae4928c7398685d858e7"
1065
+ fast_repo = "Synthyra/ANKH3_xl"
1066
+ fast_revision = "3cbf2c22c4f7d67bf0bfcbdcd500f41723e91d29"
1067
+ fast_files = [
1068
+ "config.json=git-sha1:23f6d78ddcb3a031b88f876eaaf04c2fafaea46f",
1069
+ "model-00001-of-00003.safetensors=sha256:39bd8f75cf98a67cf04055399f9fc401198f6fc2896b112aba9fd9ec9df52ab9",
1070
+ "model-00002-of-00003.safetensors=sha256:9ff73233b39d2c200abb78e66b320c014ec61431bd6e1af36fb188a3cfa24c34",
1071
+ "model-00003-of-00003.safetensors=sha256:c13125c02dbcd7f07bd412e9e085f2bca6624d2f1f45fedc95fb777f53161cbe",
1072
+ "special_tokens_map.json=git-sha1:1fc3a4d6d4282e5201cd7c30d5c0a6a8bfa04f82",
1073
+ "tokenizer.json=git-sha1:3d14291df2d6db3a183c5c4fe133afb330cc44cf",
1074
+ "tokenizer_config.json=git-sha1:2005fec00a7ae9a49e248a1ecefbbd81c56674d6",
1075
+ ]
1076
+ official_repo = "ElnaggarLab/ankh3-xl"
1077
+ official_revision = "e00113df5c95ef71df7ea3f5a73d56bd00e473a4"
1078
+ official_files = [
1079
+ "config.json=git-sha1:f8997040e8913df75fd2eebe71a2a8eb750ed0d0",
1080
+ "generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a",
1081
+ "pytorch_model-00001-of-00003.bin=sha256:2c9793cbee16697cd4149debe07d3a27143e280f6e970fa46042aae820fea981",
1082
+ "pytorch_model-00002-of-00003.bin=sha256:31c5a860e414513c829ae52affb0970d7cef2c0545df2d6e1338b6806ab7174b",
1083
+ "pytorch_model-00003-of-00003.bin=sha256:055a853bdd3623db95a637935aa299427e837cd8ea69fc04708b0262508bec75",
1084
+ "special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4",
1085
+ "spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0",
1086
+ "tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca",
1087
+ "tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21",
1088
+ ]
1089
+
1090
+ [[models]]
1091
+ id = "boltz2"
1092
+ family = "boltz2"
1093
+ size_category = "structure"
1094
+ generation_contract = "not_applicable"
1095
+ notes = "Boltz2 is provisional in FastPLMs 1.0. Exact configuration, the declared inference-core state, feature preparation, and seeded execution remain tested, but native-environment BF16 end-to-end inference currently exceeds the fixed numerical-equivalence limits. FastPLMs therefore does not claim official inference equivalence for this checkpoint yet. Work on that numerical gap continues independently of the ESM++ and ESMFold2 release gates."
1096
+ fast_repo = "Synthyra/Boltz2"
1097
+ fast_revision = "3b148fc5efea109c065ec82ba8683d024de7134e"
1098
+ fast_files = [
1099
+ "config.json=git-sha1:8682ccb12e177e73bc7a351ff7e3af484bfb6fac",
1100
+ "model.safetensors=sha256:5c863fd200a1613a0e311071e2ad73ab350635e3fd336e6822cf45c52cb960e5",
1101
+ ]
1102
+ official_repo = "boltz-community/boltz-2"
1103
+ official_revision = "6fdef46d763fee7fbb83ca5501ccceff43b85607"
1104
+ official_files = [
1105
+ "boltz2_conf.ckpt=sha256:090e82ac8c92f5e943fa1b39e7410a44027bea7243c0bbb3caa67a77fc1428e1",
1106
+ "mols.tar=sha256:39e076d96dbec6b4e86982bbda16f3a53a2a60c9bdc17828d88f6f9a0c7d1fd7",
1107
+ ]
1108
+
1109
+ [[models]]
1110
+ id = "esmfold"
1111
+ family = "esmfold"
1112
+ size_category = "structure"
1113
+ generation_contract = "not_applicable"
1114
+ official_golden = { metadata = "tests/goldens/esmfold.json=sha256:380b9a96168410717d1f698feaabb826b1606444cbdeec86c2ea06d9ffe8f186", tensors = "tests/goldens/esmfold.safetensors=sha256:873b1b325a43d8e0f35f355c8914a2a9fe611cc48763875e9e6a22e09ec9ebcb" }
1115
+ fast_repo = "Synthyra/FastESMFold"
1116
+ fast_revision = "b88c8cb50d19b2cf7ab4fee4b0a61f5e02da7823"
1117
+ fast_files = [
1118
+ "config.json=git-sha1:18e0091dcbf6140bf68924d53c4c8917b9cd90b1",
1119
+ "model-00001-of-00003.safetensors=sha256:36fab9e5c96d409b2a34a8b4f1273acac8c07f119c32c4fcfa7d47bbcd55b83c",
1120
+ "model-00002-of-00003.safetensors=sha256:34954aaa05bc91635776ba6672946da5822626753d80db97b38c0538e9525102",
1121
+ "model-00003-of-00003.safetensors=sha256:2f1178cda0e6cff3b1e158e1acc59c83e3f4fc46e246388a5127bc56b8d9c4f2",
1122
+ "special_tokens_map.json=git-sha1:53cd95604a28eb7e23da763c8da23f5006ab2179",
1123
+ "tokenizer_config.json=git-sha1:10213f69b51b4b38876a29271b8f908e853a5800",
1124
+ "vocab.txt=git-sha1:eee0a1fc93c82568f78f086550fbd7c591cf423a",
1125
+ ]
1126
+ official_repo = "facebook/esmfold_v1"
1127
+ official_revision = "75a3841ee059df2bf4d56688166c8fb459ddd97a"
1128
+ official_files = [
1129
+ "config.json=git-sha1:1232d0aee4be551021d8e70e66ed2b062df917bf",
1130
+ "pytorch_model.bin=sha256:2ee07356b125d1e3e57503c204111fd7323347fc4735d41d3caac57c2a78e116",
1131
+ "special_tokens_map.json=git-sha1:121c8d54f8ea66cdf678f48b3cb37c05b4de5c0d",
1132
+ "tokenizer_config.json=git-sha1:aad24fba9f1bad2d74ed79d414ddcd60e6b0f812",
1133
+ "vocab.txt=git-sha1:9abfdf5472c0ed970648b683b86ab131256b3e42",
1134
+ ]
1135
+
1136
+ [[models.oracle_assets]]
1137
+ role = "weights"
1138
+ path = "models/esmfold_3B_v1.pt"
1139
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esmfold_3B_v1.pt"
1140
+ sha256 = "e9a52579027e77d2d2e0a18218e755821f395730e86624cab9413dc117f5ca62"
1141
+ size = 2771653574
1142
+
1143
+ [[models]]
1144
+ id = "esmfold2"
1145
+ family = "esmfold2"
1146
+ size_category = "structure"
1147
+ generation_contract = "not_applicable"
1148
+ msa_conditioning = true
1149
+ official_golden = { metadata = "tests/goldens/esmfold2.json=sha256:f6e0ed1ec400b9a0fcc817db51774be968dc454b7a32645a07c479e42423ab20", tensors = "tests/goldens/esmfold2.safetensors=sha256:e4d6be4344c528e26b13f79a9303549e3de7e582da195c0078db3ce957fad420" }
1150
+ fast_repo = "Synthyra/ESMFold2"
1151
+ fast_revision = "cd5a0927cec585a778d983b99a8db23d2e9b281e"
1152
+ fast_files = [
1153
+ "config.json=git-sha1:67e81ff571f393f0b630cd5a22398bd84979c030",
1154
+ "model.safetensors=sha256:138fd4350d6892b81ce6be7ff9bf5a93ae9d4d3751f46a27438a3f9f0dcefa0e",
1155
+ ]
1156
+ official_repo = "biohub/ESMFold2"
1157
+ official_revision = "1ebf0e3481a5184eb6171d40615c79e384b48796"
1158
+ official_files = [
1159
+ "config.json=git-sha1:0300c084b990b2bd600efd9f538aa5de27109fea",
1160
+ "model.safetensors=sha256:138fd4350d6892b81ce6be7ff9bf5a93ae9d4d3751f46a27438a3f9f0dcefa0e",
1161
+ ]
1162
+
1163
+ [[models]]
1164
+ id = "esmfold2_fast"
1165
+ family = "esmfold2"
1166
+ size_category = "structure"
1167
+ generation_contract = "not_applicable"
1168
+ msa_conditioning = false
1169
+ official_golden = { metadata = "tests/goldens/esmfold2_fast.json=sha256:091b004c0b330217b59c12acd6da3d6edaf91e48d95f6d5f40fc20399cef9478", tensors = "tests/goldens/esmfold2_fast.safetensors=sha256:6e2e1cd07401538b4d9df994f82abe7a5b38a01e8d1ee26681e1216d44a81990" }
1170
+ fast_repo = "Synthyra/ESMFold2-Fast"
1171
+ fast_revision = "407875bfcaa42552bfcb25acd67ee1888b790170"
1172
+ fast_files = [
1173
+ "config.json=git-sha1:62ccca15a416a5dcbd02cd6ce161f432c7b4de58",
1174
+ "model.safetensors=sha256:60ca19f2898188beba92944365f7b909efd9c99212f5018af75cc47cd9a6184a",
1175
+ ]
1176
+ official_repo = "biohub/ESMFold2-Fast"
1177
+ official_revision = "b28d8ace5e05e61e5bec1e6820cfd3e221819d12"
1178
+ official_files = [
1179
+ "config.json=git-sha1:c0ca526090fa7f8342ee4666d56e7fe3a4b8cbb2",
1180
+ "model.safetensors=sha256:60ca19f2898188beba92944365f7b909efd9c99212f5018af75cc47cd9a6184a",
1181
+ ]
1182
+
1183
+ [[models]]
1184
+ id = "esmfold2_experimental_cutoff2025"
1185
+ family = "esmfold2"
1186
+ size_category = "structure"
1187
+ generation_contract = "not_applicable"
1188
+ msa_conditioning = true
1189
+ official_golden = { metadata = "tests/goldens/esmfold2_experimental_cutoff2025.json=sha256:cfd0e35b2bc468a0dc4f614d3acfa2fce004f96e9ae2433256ed095b829d55cc", tensors = "tests/goldens/esmfold2_experimental_cutoff2025.safetensors=sha256:9347466bbe803b6f5dc82e3356ca6cbbf2c2edd8765f9fd273385bda255019f6" }
1190
+ fast_repo = "Synthyra/ESMFold2-Experimental-Cutoff2025"
1191
+ fast_revision = "632ff4a9e68f1de78ee956a613267bdcdb5b354d"
1192
+ fast_files = [
1193
+ "config.json=git-sha1:41119745d38bc5503a0212ad923e75211dec565f",
1194
+ "model.safetensors=sha256:01358c317428d38535e3db513cab177336fc0f7fab0d84002e64b7741d5181b3",
1195
+ ]
1196
+ official_repo = "biohub/ESMFold2-Experimental-Cutoff2025"
1197
+ official_revision = "56f94f5c1069ecde17512c96928850518340d287"
1198
+ official_files = [
1199
+ "config.json=git-sha1:79ed0dc0f867b8f09bfa004d6f77397c2ab9b38d",
1200
+ "model.safetensors=sha256:01358c317428d38535e3db513cab177336fc0f7fab0d84002e64b7741d5181b3",
1201
+ ]
1202
+ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel" }
1203
+
1204
+ [[models]]
1205
+ id = "esmfold2_experimental_fast_cutoff2025"
1206
+ family = "esmfold2"
1207
+ size_category = "structure"
1208
+ generation_contract = "not_applicable"
1209
+ msa_conditioning = false
1210
+ official_golden = { metadata = "tests/goldens/esmfold2_experimental_fast_cutoff2025.json=sha256:1d0b2da4f1579243f37ae04bd4b834b747005cd8e8e7665e00d088123c43afd9", tensors = "tests/goldens/esmfold2_experimental_fast_cutoff2025.safetensors=sha256:516e216d05d7e6bee59e77126d3e595e2bb7821929433f00c259c5d5241964bb" }
1211
+ fast_repo = "Synthyra/ESMFold2-Experimental-Fast-Cutoff2025"
1212
+ fast_revision = "8f022c2514a6c32692aaca078a8391d6bc6c4bac"
1213
+ fast_files = [
1214
+ "config.json=git-sha1:b9d39e941050179ca51faaed58cbbd77778c1143",
1215
+ "model.safetensors=sha256:4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f",
1216
+ ]
1217
+ official_repo = "biohub/ESMFold2-Experimental-Fast-Cutoff2025"
1218
+ official_revision = "74b88548bf19688b8727432db0d698cb2e1d8783"
1219
+ official_files = [
1220
+ "config.json=git-sha1:0333d68ddb12ed2f066741dcb801142f466c0a2c",
1221
+ "model.safetensors=sha256:4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f",
1222
+ ]
1223
+ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel" }
fastplms/models/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lazy model-family namespace for FastPLMs.
2
+
3
+ Model classes are resolved through Transformers AutoClasses and the typed
4
+ registry. Importing this package therefore does not load checkpoints, create
5
+ tokenizers, compile kernels, or initialize an accelerator runtime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__: tuple[str, ...] = ()
fastplms/models/esm_plusplus/__init__.py ADDED
File without changes
fastplms/models/esm_plusplus/modeling_esm_plusplus.py ADDED
@@ -0,0 +1,1552 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face-compatible ESMC models implemented by FastPLMs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from functools import partial
8
+ from typing import ClassVar
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+ import torch.nn.functional as F
13
+ from einops import rearrange
14
+ from tokenizers import Tokenizer
15
+ from tokenizers.models import BPE
16
+ from tokenizers.processors import TemplateProcessing
17
+ from transformers import PretrainedConfig, PreTrainedModel, PreTrainedTokenizerFast
18
+ from transformers.modeling_outputs import (
19
+ MaskedLMOutput,
20
+ ModelOutput,
21
+ SequenceClassifierOutput,
22
+ TokenClassifierOutput,
23
+ )
24
+
25
+ try:
26
+ from fastplms.attention import (
27
+ AttentionBackend,
28
+ BlockMask,
29
+ FastPLMsAttentionMixin,
30
+ _get_flex_attention_fn,
31
+ _get_flex_block_mask,
32
+ flex_attention,
33
+ get_attention_mask,
34
+ kernels_flash_attention_func,
35
+ resolve_attention_backend,
36
+ resolve_attention_backend_for_call,
37
+ )
38
+ from fastplms.embeddings import EmbeddingMixin, Pooler, select_hidden_state_embeddings
39
+ from fastplms.models.ttt import FastPLMTestTimeTrainingMixin
40
+ except ModuleNotFoundError as error:
41
+ _COMPOSITE_REQUIRED_NAMES = (
42
+ "AttentionBackend",
43
+ "BlockMask",
44
+ "EmbeddingMixin",
45
+ "FastPLMsAttentionMixin",
46
+ "FastPLMTestTimeTrainingMixin",
47
+ "Pooler",
48
+ "_get_flex_attention_fn",
49
+ "_get_flex_block_mask",
50
+ "flex_attention",
51
+ "get_attention_mask",
52
+ "kernels_flash_attention_func",
53
+ "resolve_attention_backend",
54
+ "resolve_attention_backend_for_call",
55
+ "select_hidden_state_embeddings",
56
+ )
57
+ if error.name != "fastplms" or any(
58
+ name not in globals() for name in _COMPOSITE_REQUIRED_NAMES
59
+ ):
60
+ raise
61
+ # Legacy flat Hub composites define every shared symbol above this block.
62
+
63
+
64
+ class ESMplusplusConfig(PretrainedConfig):
65
+ """Configuration class for ESM++ model.
66
+
67
+ Args:
68
+ vocab_size: Size of the vocabulary
69
+ hidden_size: Dimension of hidden layers
70
+ num_attention_heads: Number of attention heads
71
+ num_hidden_layers: Number of transformer layers
72
+ num_labels: Number of output labels for classification
73
+ problem_type: Type of problem - regression, single/multi label classification
74
+ """
75
+
76
+ model_type = "ESMplusplus"
77
+
78
+ def __init__(
79
+ self,
80
+ vocab_size: int = 64,
81
+ hidden_size: int = 960,
82
+ num_attention_heads: int = 15,
83
+ num_hidden_layers: int = 30,
84
+ num_labels: int | None = None,
85
+ problem_type: str | None = None,
86
+ dropout: float = 0.0,
87
+ initializer_range: float = 0.02,
88
+ classifier_dropout: float = 0.1,
89
+ classifier_pooling_types: list[str] | None = None,
90
+ attn_backend: str | None = None,
91
+ pad_token_id: int = 1,
92
+ mask_token_id: int = 32,
93
+ **kwargs,
94
+ ):
95
+ if num_labels is None:
96
+ configured_labels = kwargs.get("id2label")
97
+ num_labels = len(configured_labels) if configured_labels else 2
98
+ super().__init__(
99
+ pad_token_id=pad_token_id,
100
+ mask_token_id=mask_token_id,
101
+ num_labels=num_labels,
102
+ **kwargs,
103
+ )
104
+ self.vocab_size = vocab_size
105
+ self.hidden_size = hidden_size
106
+ self.num_attention_heads = num_attention_heads
107
+ self.num_hidden_layers = num_hidden_layers
108
+ self.problem_type = problem_type
109
+ self.dropout = dropout
110
+ self.initializer_range = initializer_range
111
+ self.classifier_dropout = classifier_dropout
112
+ self.classifier_pooling_types = (
113
+ list(classifier_pooling_types) if classifier_pooling_types is not None else None
114
+ )
115
+ self.tie_word_embeddings = False
116
+ self.attn_backend = attn_backend
117
+
118
+
119
+ ### Rotary Embeddings
120
+ def rotate_half(x: torch.Tensor, interleaved: bool = False) -> torch.Tensor:
121
+ """Rotate the final axis of X by 90 degrees in each two-dimensional plane."""
122
+ if interleaved:
123
+ paired = x.unflatten(-1, (-1, 2))
124
+ return torch.stack((-paired[..., 1], paired[..., 0]), dim=-1).flatten(-2)
125
+
126
+ # torch.chunk assigns an odd remainder to the first half. Express the same
127
+ # public behavior explicitly while keeping the ESMC path branch-free.
128
+ midpoint = (x.shape[-1] + 1) // 2
129
+ return torch.cat((-x[..., midpoint:], x[..., :midpoint]), dim=-1)
130
+
131
+
132
+ def apply_rotary_emb_torch(
133
+ x: torch.Tensor,
134
+ cos: torch.Tensor,
135
+ sin: torch.Tensor,
136
+ interleaved: bool = False,
137
+ _inplace: bool = False,
138
+ ) -> torch.Tensor:
139
+ """Apply cached rotary angles to X while preserving any unrotated features."""
140
+ del _inplace # Kept in the signature for checkpoint remote-code compatibility.
141
+ rotary_width = 2 * cos.shape[-1]
142
+ if rotary_width > x.shape[-1]:
143
+ raise AssertionError("rotary width exceeds the attention head dimension")
144
+
145
+ token_count = x.shape[1]
146
+ cos_full = torch.cat((cos[:token_count], cos[:token_count]), dim=-1).unsqueeze(1)
147
+ sin_full = torch.cat((sin[:token_count], sin[:token_count]), dim=-1).unsqueeze(1)
148
+ x_rotary = x[..., :rotary_width]
149
+ y_rotary = x_rotary * cos_full + rotate_half(x_rotary, interleaved) * sin_full
150
+ if rotary_width == x.shape[-1]:
151
+ return y_rotary
152
+ return torch.cat((y_rotary, x[..., rotary_width:]), dim=-1)
153
+
154
+
155
+ class RotaryEmbedding(torch.nn.Module):
156
+ """Rotary position embeddings.
157
+
158
+ Based on the paper "RoFormer: Enhanced Transformer with Rotary Position Embedding"
159
+
160
+ Args:
161
+ dim: Dimension of the embedding
162
+ base: Base for computing angular frequencies
163
+ interleaved: Whether to use interleaved rotations
164
+ scale_base: Base for scaling
165
+ scaling_factor: Factor for scaling positions
166
+ pos_idx_in_fp32: Whether to compute position indices in fp32
167
+ device: Computation device
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ dim: int,
173
+ base: float = 10000.0,
174
+ interleaved: bool = False,
175
+ scale_base: float | None = None,
176
+ scaling_factor: float = 1.0,
177
+ pos_idx_in_fp32: bool = True,
178
+ device: torch.device | None = None,
179
+ ):
180
+ super().__init__()
181
+ self.dim, self.base = dim, float(base)
182
+ self.interleaved, self.scale_base = interleaved, scale_base
183
+ self.scaling_factor, self.pos_idx_in_fp32 = scaling_factor, pos_idx_in_fp32
184
+ self.device = device
185
+ self._clear_cache()
186
+ self.reset_parameters()
187
+
188
+ def _clear_cache(self) -> None:
189
+ self._seq_len_cached = 0
190
+ self._cos_cached: torch.Tensor | None = None
191
+ self._sin_cached: torch.Tensor | None = None
192
+ self._cos_k_cached: torch.Tensor | None = None
193
+ self._sin_k_cached: torch.Tensor | None = None
194
+
195
+ def reset_parameters(self, device: torch.device | str | None = None):
196
+ """Rebuild the non-persistent frequency buffers on ``device``."""
197
+ if device is not None:
198
+ buffer_device = torch.device(device)
199
+ elif "inv_freq" in self._buffers and isinstance(self._buffers["inv_freq"], torch.Tensor):
200
+ buffer_device = self._buffers["inv_freq"].device
201
+ else:
202
+ buffer_device = self.device
203
+ inv_freq = self._compute_inv_freq(buffer_device)
204
+ self._clear_cache()
205
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
206
+ arange = torch.arange(0, self.dim, 2, device=buffer_device, dtype=torch.float32)
207
+ scale = (
208
+ (arange + 0.4 * self.dim) / (1.4 * self.dim) if self.scale_base is not None else None
209
+ )
210
+ self.register_buffer("scale", scale)
211
+
212
+ def _compute_inv_freq(self, device: torch.device | None = None) -> torch.Tensor:
213
+ """Compute inverse frequency bands on their execution device."""
214
+ return 1 / (
215
+ self.base
216
+ ** (torch.arange(0, self.dim, 2, device=device, dtype=torch.float32) / self.dim)
217
+ )
218
+
219
+ def _apply(self, fn, recurse: bool = True):
220
+ """Move the module, then regenerate device-specific RoPE frequencies."""
221
+ if self.inv_freq.is_meta:
222
+ self.reset_parameters(device="cpu")
223
+ result = super()._apply(fn, recurse=recurse)
224
+ self.register_buffer(
225
+ "inv_freq",
226
+ self._compute_inv_freq(self.inv_freq.device),
227
+ persistent=False,
228
+ )
229
+ self._clear_cache()
230
+ return result
231
+
232
+ def _cache_is_current(
233
+ self,
234
+ token_count: int,
235
+ device: torch.device | None,
236
+ dtype: torch.dtype | None,
237
+ ) -> bool:
238
+ cached = self._cos_cached
239
+ return (
240
+ cached is not None
241
+ and self._seq_len_cached >= token_count
242
+ and cached.device == device
243
+ and cached.dtype == dtype
244
+ and not (self.training and cached.is_inference())
245
+ )
246
+
247
+ def _rotary_angles(
248
+ self,
249
+ token_count: int,
250
+ device: torch.device | None,
251
+ ) -> torch.Tensor:
252
+ position_dtype = torch.float32 if self.pos_idx_in_fp32 else self.inv_freq.dtype
253
+ positions = torch.arange(token_count, device=device, dtype=position_dtype)
254
+ positions.div_(self.scaling_factor)
255
+ frequencies = (
256
+ self.inv_freq.to(torch.float32)
257
+ if self.pos_idx_in_fp32 and self.inv_freq.dtype != torch.float32
258
+ else self.inv_freq
259
+ )
260
+ return torch.outer(positions, frequencies)
261
+
262
+ def _update_cos_sin_cache(
263
+ self, seqlen: int, device: torch.device | None = None, dtype: torch.dtype | None = None
264
+ ) -> None:
265
+ """Build angle tables when the requested cache identity has changed."""
266
+ if self._cache_is_current(seqlen, device, dtype):
267
+ return
268
+
269
+ self._seq_len_cached = seqlen
270
+ angles = self._rotary_angles(seqlen, device)
271
+ cos_angles = torch.cos(angles)
272
+ sin_angles = torch.sin(angles)
273
+ if self.scale is None:
274
+ self._cos_cached = cos_angles.to(dtype)
275
+ self._sin_cached = sin_angles.to(dtype)
276
+ return
277
+
278
+ centered_positions = (
279
+ torch.arange(seqlen, dtype=self.scale.dtype, device=self.scale.device) - seqlen // 2
280
+ ) / self.scale_base
281
+ scale = self.scale ** centered_positions.unsqueeze(-1)
282
+ self._cos_cached = (cos_angles * scale).to(dtype)
283
+ self._sin_cached = (sin_angles * scale).to(dtype)
284
+ self._cos_k_cached = (cos_angles / scale).to(dtype)
285
+ self._sin_k_cached = (sin_angles / scale).to(dtype)
286
+
287
+ def forward(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
288
+ """Apply rotary embeddings to queries and keys.
289
+
290
+ Args:
291
+ q: Query tensor Q with shape (b, l, h, d).
292
+ k: Key tensor K with shape (b, l, h, d).
293
+
294
+ Returns:
295
+ Tuple of rotated query and key tensors
296
+ """
297
+ # The pinned Biohub Transformers oracle recomputes inverse frequencies
298
+ # on the execution device. CPU and CUDA differ by about one FP32 ULP in
299
+ # some bands, which is immaterial in BF16 but accumulates measurably in
300
+ # deep FP32 execution.
301
+ self._update_cos_sin_cache(q.shape[1], device=q.device, dtype=q.dtype)
302
+ if self._cos_cached is None or self._sin_cached is None:
303
+ raise RuntimeError(
304
+ "Rotary cache initialization did not produce cosine and sine values."
305
+ )
306
+ if self.scale is not None:
307
+ raise AssertionError("Scaled rotary embeddings are unsupported for ESMC.")
308
+
309
+ cos_angles = self._cos_cached
310
+ sin_angles = self._sin_cached
311
+ return (
312
+ apply_rotary_emb_torch(q, cos_angles, sin_angles, self.interleaved, True),
313
+ apply_rotary_emb_torch(k, cos_angles, sin_angles, self.interleaved, True),
314
+ )
315
+
316
+
317
+ ### Feedforward Network Components
318
+ def swiglu_correction_fn(expansion_ratio: float, d_model: int) -> int:
319
+ """Compute corrected dimension for SwiGLU."""
320
+ return int(((expansion_ratio * d_model) + 255) // 256 * 256)
321
+
322
+
323
+ class SwiGLU(nn.Module):
324
+ """SwiGLU activation function."""
325
+
326
+ def __init__(self):
327
+ super().__init__()
328
+
329
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
330
+ x1, x2 = x.chunk(2, dim=-1)
331
+ return F.silu(x1) * x2
332
+
333
+
334
+ def swiglu_ln_ffn(d_model: int, expansion_ratio: float) -> nn.Sequential:
335
+ """Create SwiGLU feedforward network with layer normalization."""
336
+ return nn.Sequential(
337
+ nn.LayerNorm(d_model),
338
+ nn.Linear(d_model, swiglu_correction_fn(expansion_ratio, d_model) * 2, bias=False),
339
+ SwiGLU(),
340
+ nn.Linear(swiglu_correction_fn(expansion_ratio, d_model), d_model, bias=False),
341
+ )
342
+
343
+
344
+ ### Attention
345
+ class MultiHeadAttention(nn.Module):
346
+ """Multi-head attention with rotary embeddings and configurable backend.
347
+
348
+ Args:
349
+ d_model: Model dimension
350
+ n_heads: Number of attention heads
351
+ attn_backend: One of "eager", "sdpa", or "flex_attention".
352
+ """
353
+
354
+ def __init__(
355
+ self,
356
+ d_model: int,
357
+ n_heads: int,
358
+ attn_backend: str = "sdpa",
359
+ ):
360
+ super().__init__()
361
+ self.d_model = d_model
362
+ self.n_heads = n_heads
363
+ self.d_head = self.d_model // self.n_heads
364
+ self.scale = 1.0 / math.sqrt(self.d_head)
365
+ self.attn_backend = resolve_attention_backend(attn_backend)
366
+ self.layernorm_qkv = nn.Sequential(
367
+ nn.LayerNorm(d_model), nn.Linear(d_model, d_model * 3, bias=False)
368
+ )
369
+ self.out_proj = nn.Linear(d_model, d_model, bias=False)
370
+ self.q_ln = nn.LayerNorm(d_model, bias=False)
371
+ self.k_ln = nn.LayerNorm(d_model, bias=False)
372
+ self.reshaper = partial(rearrange, pattern="b s (h d) -> b h s d", h=n_heads)
373
+ self.rotary = RotaryEmbedding(d_model // n_heads)
374
+
375
+ def _apply_rotary(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
376
+ q = q.unflatten(-1, (self.n_heads, self.d_head))
377
+ k = k.unflatten(-1, (self.n_heads, self.d_head))
378
+ q, k = self.rotary(q, k)
379
+ q = q.flatten(-2, -1)
380
+ k = k.flatten(-2, -1)
381
+ return q, k
382
+
383
+ def forward(
384
+ self,
385
+ x: torch.Tensor,
386
+ attention_mask_2d: torch.Tensor | None = None,
387
+ attention_mask_4d: torch.Tensor | None = None,
388
+ flex_block_mask: BlockMask | None = None,
389
+ output_attentions: bool = False,
390
+ output_s_max: bool = False,
391
+ ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
392
+ qkv = self.layernorm_qkv(x)
393
+ query_sequence, key_sequence, value_sequence = torch.chunk(qkv, 3, dim=-1)
394
+ query_sequence, key_sequence = (
395
+ self.q_ln(query_sequence).to(query_sequence.dtype),
396
+ self.k_ln(key_sequence).to(query_sequence.dtype),
397
+ )
398
+ query_sequence, key_sequence = self._apply_rotary(query_sequence, key_sequence)
399
+ query_heads, key_heads, value_heads = map(
400
+ self.reshaper, (query_sequence, key_sequence, value_sequence)
401
+ )
402
+
403
+ attn_output, attn_weights, s_max = self._attn(
404
+ query_heads,
405
+ key_heads,
406
+ value_heads,
407
+ attention_mask_2d=attention_mask_2d,
408
+ attention_mask_4d=attention_mask_4d,
409
+ flex_block_mask=flex_block_mask,
410
+ output_attentions=output_attentions,
411
+ output_s_max=output_s_max,
412
+ )
413
+
414
+ output = self.out_proj(attn_output)
415
+ return output, attn_weights, s_max
416
+
417
+ def _attn(
418
+ self,
419
+ query_heads: torch.Tensor,
420
+ key_heads: torch.Tensor,
421
+ value_heads: torch.Tensor,
422
+ attention_mask_2d: torch.Tensor | None = None,
423
+ attention_mask_4d: torch.Tensor | None = None,
424
+ flex_block_mask: BlockMask | None = None,
425
+ output_attentions: bool = False,
426
+ output_s_max: bool = False,
427
+ ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
428
+ if output_attentions:
429
+ return self._manual_attn(
430
+ query_heads, key_heads, value_heads, attention_mask_4d, output_s_max
431
+ )
432
+
433
+ if self.attn_backend == AttentionBackend.EAGER:
434
+ attn_output, _, s_max = self._manual_attn(
435
+ query_heads, key_heads, value_heads, attention_mask_4d, output_s_max
436
+ )
437
+ return attn_output, None, s_max
438
+ if self.attn_backend.is_flash:
439
+ attn_output, attn_weights = self._kernels_flash_attn(
440
+ query_heads, key_heads, value_heads, attention_mask_2d
441
+ )
442
+ elif self.attn_backend == AttentionBackend.FLEX:
443
+ attn_output, attn_weights = self._flex_attn(
444
+ query_heads,
445
+ key_heads,
446
+ value_heads,
447
+ flex_block_mask,
448
+ attention_mask_2d,
449
+ )
450
+ elif self.attn_backend == AttentionBackend.SDPA:
451
+ attn_output, attn_weights = self._sdpa_attn(
452
+ query_heads, key_heads, value_heads, attention_mask_4d
453
+ )
454
+ else:
455
+ raise AssertionError(f"Unsupported resolved backend: {self.attn_backend}")
456
+
457
+ s_max = self._compute_s_max(query_heads, key_heads) if output_s_max else None
458
+ return attn_output, attn_weights, s_max
459
+
460
+ @torch.no_grad()
461
+ def _compute_s_max(
462
+ self, query_heads: torch.Tensor, key_heads: torch.Tensor
463
+ ) -> list[torch.Tensor]:
464
+ q_norm = torch.linalg.vector_norm(query_heads, dim=-1)
465
+ k_norm = torch.linalg.vector_norm(key_heads, dim=-1)
466
+ s_max_bound = (q_norm.max(dim=-1).values * k_norm.max(dim=-1).values).max(
467
+ dim=0
468
+ ).values * self.scale
469
+ return [s_max_bound[h] for h in range(self.n_heads)]
470
+
471
+ def _manual_attn(
472
+ self,
473
+ query_heads: torch.Tensor,
474
+ key_heads: torch.Tensor,
475
+ value_heads: torch.Tensor,
476
+ attention_mask_4d: torch.Tensor | None = None,
477
+ output_s_max: bool = False,
478
+ ) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor] | None]:
479
+ attn_weights = torch.matmul(query_heads, key_heads.transpose(-2, -1)) * self.scale
480
+ if attention_mask_4d is not None:
481
+ attn_weights = attn_weights.masked_fill(attention_mask_4d.logical_not(), float("-inf"))
482
+ attn_weights = F.softmax(attn_weights, dim=-1)
483
+ context_heads = torch.matmul(attn_weights, value_heads)
484
+ attn_output = rearrange(context_heads, "b h s d -> b s (h d)")
485
+ s_max = self._compute_s_max(query_heads, key_heads) if output_s_max else None
486
+ return attn_output, attn_weights, s_max
487
+
488
+ def _kernels_flash_attn(
489
+ self,
490
+ query_heads: torch.Tensor,
491
+ key_heads: torch.Tensor,
492
+ value_heads: torch.Tensor,
493
+ attention_mask_2d: torch.Tensor | None = None,
494
+ ) -> tuple[torch.Tensor, None]:
495
+ query_tokens = query_heads.transpose(1, 2).contiguous()
496
+ key_tokens = key_heads.transpose(1, 2).contiguous()
497
+ value_tokens = value_heads.transpose(1, 2).contiguous()
498
+ attn_output = kernels_flash_attention_func(
499
+ query_states=query_tokens,
500
+ key_states=key_tokens,
501
+ value_states=value_tokens,
502
+ attention_mask_2d=attention_mask_2d,
503
+ causal=False,
504
+ implementation=self.attn_backend.value,
505
+ )
506
+ return rearrange(attn_output, "b s h d -> b s (h d)"), None
507
+
508
+ def _flex_attn(
509
+ self,
510
+ query_heads: torch.Tensor,
511
+ key_heads: torch.Tensor,
512
+ value_heads: torch.Tensor,
513
+ flex_block_mask: BlockMask | None = None,
514
+ attention_mask_2d: torch.Tensor | None = None,
515
+ ) -> tuple[torch.Tensor, None]:
516
+ if flex_attention is None:
517
+ raise RuntimeError("Flex attention is not available in this environment.")
518
+ fn = _get_flex_attention_fn(
519
+ device=query_heads.device,
520
+ dtype=query_heads.dtype,
521
+ shape=tuple(query_heads.shape),
522
+ mask_semantics="padding",
523
+ )
524
+ context_heads = fn(
525
+ query_heads,
526
+ key_heads,
527
+ value_heads,
528
+ block_mask=flex_block_mask,
529
+ scale=self.scale,
530
+ kernel_options={"PRESCALE_QK": True, "BLOCK_N": 32},
531
+ )
532
+ return rearrange(context_heads, "b h s d -> b s (h d)"), None
533
+
534
+ def _sdpa_attn(
535
+ self,
536
+ query_heads: torch.Tensor,
537
+ key_heads: torch.Tensor,
538
+ value_heads: torch.Tensor,
539
+ attention_mask_4d: torch.Tensor | None = None,
540
+ ) -> tuple[torch.Tensor, None]:
541
+ context_heads = F.scaled_dot_product_attention(
542
+ query_heads,
543
+ key_heads,
544
+ value_heads,
545
+ attn_mask=attention_mask_4d,
546
+ scale=self.scale,
547
+ )
548
+ return rearrange(context_heads, "b h s d -> b s (h d)"), None
549
+
550
+
551
+ ### Regression Head
552
+ def RegressionHead(d_model: int, output_dim: int, hidden_dim: int | None = None) -> nn.Module:
553
+ """Create a regression head with optional hidden dimension.
554
+
555
+ Args:
556
+ d_model: Input dimension
557
+ output_dim: Output dimension
558
+ hidden_dim: Optional hidden dimension (defaults to d_model)
559
+ """
560
+ hidden_dim = hidden_dim if hidden_dim is not None else d_model
561
+ return nn.Sequential(
562
+ nn.Linear(d_model, hidden_dim),
563
+ nn.GELU(),
564
+ nn.LayerNorm(hidden_dim),
565
+ nn.Linear(hidden_dim, output_dim),
566
+ )
567
+
568
+
569
+ ### Transformer Block
570
+ class UnifiedTransformerBlock(nn.Module):
571
+ """Transformer block with attention and feedforward layers."""
572
+
573
+ def __init__(
574
+ self,
575
+ d_model: int,
576
+ n_heads: int,
577
+ residue_scaling_factor: float = 1,
578
+ expansion_ratio: float = 8 / 3,
579
+ dropout: float = 0.0,
580
+ attn_backend: str = "sdpa",
581
+ ):
582
+ super().__init__()
583
+ self.attn = MultiHeadAttention(d_model=d_model, n_heads=n_heads, attn_backend=attn_backend)
584
+ self.ffn = swiglu_ln_ffn(d_model, expansion_ratio)
585
+ self.scaling_factor = residue_scaling_factor
586
+ self.dropout = nn.Dropout(dropout)
587
+
588
+ def forward(
589
+ self,
590
+ x: torch.Tensor,
591
+ attention_mask_2d: torch.Tensor | None = None,
592
+ attention_mask_4d: torch.Tensor | None = None,
593
+ flex_block_mask: BlockMask | None = None,
594
+ output_attentions: bool = False,
595
+ output_s_max: bool = False,
596
+ ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
597
+ attn_output, attn_weights, s_max = self.attn(
598
+ x,
599
+ attention_mask_2d=attention_mask_2d,
600
+ attention_mask_4d=attention_mask_4d,
601
+ flex_block_mask=flex_block_mask,
602
+ output_attentions=output_attentions,
603
+ output_s_max=output_s_max,
604
+ )
605
+ x = x + self.dropout(attn_output) / self.scaling_factor
606
+ x = x + self.dropout(self.ffn(x)) / self.scaling_factor
607
+ return x, attn_weights, s_max
608
+
609
+
610
+ ### Model Outputs
611
+ @dataclass
612
+ class TransformerOutput(ModelOutput):
613
+ """Output type for transformer encoder."""
614
+
615
+ last_hidden_state: torch.Tensor | None = None
616
+ hidden_states: tuple[torch.Tensor] | None = None
617
+ attentions: tuple[torch.Tensor] | None = None
618
+ s_max: tuple[list[torch.Tensor], ...] | None = None
619
+
620
+
621
+ @dataclass
622
+ class ESMplusplusOutput(MaskedLMOutput):
623
+ """Masked-LM output with FastPLMs fields after the HF contract."""
624
+
625
+ s_max: tuple[list[torch.Tensor], ...] | None = None
626
+ last_hidden_state: torch.Tensor | None = None
627
+
628
+
629
+ @dataclass
630
+ class ESMplusplusSequenceClassifierOutput(SequenceClassifierOutput):
631
+ """Sequence-classification output with optional attention diagnostics."""
632
+
633
+ s_max: tuple[list[torch.Tensor], ...] | None = None
634
+
635
+
636
+ @dataclass
637
+ class ESMplusplusTokenClassifierOutput(TokenClassifierOutput):
638
+ """Token-classification output with optional attention diagnostics."""
639
+
640
+ s_max: tuple[list[torch.Tensor], ...] | None = None
641
+
642
+
643
+ ### Transformer Stack
644
+ class TransformerStack(nn.Module):
645
+ """Stack of transformer blocks."""
646
+
647
+ def __init__(
648
+ self,
649
+ d_model: int,
650
+ n_heads: int,
651
+ n_layers: int,
652
+ dropout: float = 0.0,
653
+ attn_backend: str = "sdpa",
654
+ ):
655
+ super().__init__()
656
+ self.attention_backend = resolve_attention_backend(attn_backend)
657
+ self.blocks = nn.ModuleList(
658
+ [
659
+ UnifiedTransformerBlock(
660
+ d_model,
661
+ n_heads,
662
+ residue_scaling_factor=math.sqrt(n_layers / 36),
663
+ dropout=dropout,
664
+ attn_backend=attn_backend,
665
+ )
666
+ for i in range(n_layers)
667
+ ]
668
+ )
669
+ self.norm = nn.LayerNorm(d_model, bias=False)
670
+ self.gradient_checkpointing = False
671
+
672
+ @property
673
+ def attn_backend(self) -> AttentionBackend:
674
+ return self.attention_backend
675
+
676
+ @attn_backend.setter
677
+ def attn_backend(self, backend: str) -> None:
678
+ resolved = resolve_attention_backend(backend)
679
+ self.attention_backend = resolved
680
+ for block in self.blocks:
681
+ block.attn.attn_backend = resolved
682
+
683
+ def forward(
684
+ self,
685
+ x: torch.Tensor,
686
+ attention_mask: torch.Tensor | None = None,
687
+ sequence_id: torch.Tensor | None = None,
688
+ output_hidden_states: bool | None = False,
689
+ output_attentions: bool | None = False,
690
+ output_s_max: bool | None = False,
691
+ esmfold2_hidden_states: bool = False,
692
+ ) -> TransformerOutput:
693
+ hidden_states = () if output_hidden_states else None
694
+ attentions = () if output_attentions else None
695
+ full_s_max = () if output_s_max else None
696
+ # Match the pinned Biohub Transformers contract: a supplied sequence_id
697
+ # is authoritative and must encode padding as -1. attention_mask is
698
+ # ignored in that mode rather than intersected with the chain mask.
699
+ if sequence_id is None and attention_mask is not None:
700
+ expected_shape = (x.shape[0], x.shape[1])
701
+ if attention_mask.ndim != 2 or tuple(attention_mask.shape) != expected_shape:
702
+ raise ValueError(
703
+ f"attention_mask must have shape {expected_shape}; "
704
+ f"received {tuple(attention_mask.shape)}."
705
+ )
706
+ attention_mask = attention_mask.to(device=x.device, dtype=torch.bool)
707
+ if not bool(attention_mask.any(dim=1).all()):
708
+ raise ValueError("attention_mask must keep at least one valid key per batch row.")
709
+ effective_backend = resolve_attention_backend_for_call(
710
+ self.attention_backend,
711
+ output_attentions=bool(output_attentions),
712
+ )
713
+
714
+ if sequence_id is None and attention_mask is not None:
715
+ attention_mask_2d, attention_mask_4d, flex_block_mask = (
716
+ self._sequence_id_attention_masks(
717
+ sequence_id=attention_mask.to(device=x.device, dtype=torch.bool),
718
+ batch_size=x.shape[0],
719
+ seq_len=x.shape[1],
720
+ device=x.device,
721
+ dtype=x.dtype,
722
+ effective_backend=effective_backend,
723
+ )
724
+ )
725
+ elif sequence_id is None:
726
+ attention_mask_2d, attention_mask_4d, flex_block_mask = get_attention_mask(
727
+ effective_backend=effective_backend,
728
+ batch_size=x.shape[0],
729
+ seq_len=x.shape[1],
730
+ device=x.device,
731
+ attention_mask=attention_mask,
732
+ dtype=x.dtype,
733
+ mask_semantics="padding",
734
+ )
735
+ else:
736
+ attention_mask_2d, attention_mask_4d, flex_block_mask = (
737
+ self._sequence_id_attention_masks(
738
+ sequence_id=sequence_id,
739
+ batch_size=x.shape[0],
740
+ seq_len=x.shape[1],
741
+ device=x.device,
742
+ dtype=x.dtype,
743
+ effective_backend=effective_backend,
744
+ )
745
+ )
746
+
747
+ for block in self.blocks:
748
+ if output_hidden_states:
749
+ if hidden_states is None:
750
+ raise RuntimeError(
751
+ "Hidden-state collection was not initialized for an enabled request."
752
+ )
753
+ # Biohub Transformers records the input to each block followed
754
+ # by the final normalized state. This gives n_layers + 1 states
755
+ # and, for ESMC-6B, the 81-state order consumed by ESMFold2.
756
+ hidden_states += (x,)
757
+ if self.gradient_checkpointing and self.training:
758
+ x, attn_weights, s_max = self._gradient_checkpointing_func(
759
+ block.__call__,
760
+ x=x,
761
+ attention_mask_2d=attention_mask_2d,
762
+ attention_mask_4d=attention_mask_4d,
763
+ flex_block_mask=flex_block_mask,
764
+ output_attentions=output_attentions,
765
+ output_s_max=output_s_max,
766
+ )
767
+ else:
768
+ x, attn_weights, s_max = block(
769
+ x=x,
770
+ attention_mask_2d=attention_mask_2d,
771
+ attention_mask_4d=attention_mask_4d,
772
+ flex_block_mask=flex_block_mask,
773
+ output_attentions=output_attentions,
774
+ output_s_max=output_s_max,
775
+ )
776
+
777
+ if attentions is not None:
778
+ attentions += (attn_weights,)
779
+ if full_s_max is not None:
780
+ full_s_max += (s_max,)
781
+
782
+ last_hidden_state = self.norm(x)
783
+ if output_hidden_states:
784
+ hidden_states += (last_hidden_state,)
785
+
786
+ return TransformerOutput(
787
+ last_hidden_state=last_hidden_state,
788
+ hidden_states=hidden_states,
789
+ attentions=attentions,
790
+ s_max=full_s_max,
791
+ )
792
+
793
+ def _sequence_id_attention_masks(
794
+ self,
795
+ sequence_id: torch.Tensor,
796
+ batch_size: int,
797
+ seq_len: int,
798
+ device: torch.device,
799
+ dtype: torch.dtype | None = None,
800
+ effective_backend: AttentionBackend | None = None,
801
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None, BlockMask | None]:
802
+ expected_shape = (batch_size, seq_len)
803
+ if sequence_id.ndim != 2 or tuple(sequence_id.shape) != expected_shape:
804
+ raise ValueError(
805
+ f"sequence_id must have shape {expected_shape}; "
806
+ f"received {tuple(sequence_id.shape)}."
807
+ )
808
+ if sequence_id.device != device:
809
+ sequence_id = sequence_id.to(device=device)
810
+ backend = (
811
+ self.attention_backend
812
+ if effective_backend is None
813
+ else resolve_attention_backend(effective_backend)
814
+ )
815
+ if sequence_id.dtype == torch.bool:
816
+ attention_mask_2d = sequence_id
817
+ # Biohub's boolean single-chain form groups biological positions
818
+ # together and padding positions together. Padding queries remain
819
+ # finite without allowing their states to enter residue attention.
820
+ attention_mask_4d = sequence_id[:, None, :, None] == sequence_id[:, None, None, :]
821
+ else:
822
+ attention_mask_2d = sequence_id != -1
823
+ attention_mask_4d = (sequence_id.unsqueeze(-1) == sequence_id.unsqueeze(-2)).unsqueeze(
824
+ 1
825
+ )
826
+ if not bool(attention_mask_2d.any(dim=1).all()):
827
+ raise ValueError("attention_mask must keep at least one valid key per batch row.")
828
+
829
+ if backend.is_flash:
830
+ if sequence_id.dtype != torch.bool:
831
+ raise ValueError(
832
+ "ESM++ FlashAttention only supports boolean sequence_id padding masks. "
833
+ "Use eager, sdpa, or flex_attention for chain-aware integer sequence_id "
834
+ "masks."
835
+ )
836
+ return attention_mask_2d, attention_mask_4d, None
837
+
838
+ if backend == AttentionBackend.FLEX:
839
+ if sequence_id.dtype == torch.bool:
840
+
841
+ def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
842
+ del head_idx
843
+ return sequence_id[batch_idx, q_idx] == sequence_id[batch_idx, kv_idx]
844
+
845
+ else:
846
+
847
+ def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
848
+ del head_idx
849
+ q_id = sequence_id[batch_idx, q_idx]
850
+ kv_id = sequence_id[batch_idx, kv_idx]
851
+ return q_id == kv_id
852
+
853
+ flex_block_mask = _get_flex_block_mask(
854
+ mask_pattern=sequence_id,
855
+ batch_size=batch_size,
856
+ query_length=seq_len,
857
+ key_value_length=seq_len,
858
+ device=device,
859
+ dtype=dtype,
860
+ mask_semantics=(
861
+ "boolean_sequence_id"
862
+ if sequence_id.dtype == torch.bool
863
+ else "integer_sequence_id"
864
+ ),
865
+ mask_mod=mask_mod,
866
+ )
867
+ return attention_mask_2d, attention_mask_4d, flex_block_mask
868
+
869
+ return attention_mask_2d, attention_mask_4d, None
870
+
871
+
872
+ class PreTrainedESMplusplusModel(FastPLMsAttentionMixin, PreTrainedModel):
873
+ """
874
+ init weights for ESM++ models
875
+ """
876
+
877
+ config_class = ESMplusplusConfig
878
+ base_model_prefix = "esm++"
879
+ supports_gradient_checkpointing = True
880
+ all_tied_weights_keys: ClassVar[dict[str, str]] = {}
881
+ _supports_flash_attn = True
882
+ _supports_flash_attn_2 = True
883
+ _supports_flash_attn_3 = True
884
+ _fastplms_attention_implementations = (
885
+ "eager",
886
+ "sdpa",
887
+ "flex_attention",
888
+ "flash_attention_2",
889
+ "flash_attention_3",
890
+ )
891
+
892
+ @property
893
+ def tokenizer(self) -> EsmSequenceTokenizer:
894
+ """Construct the sequence tokenizer only when a raw-sequence API needs it."""
895
+
896
+ tokenizer = self.__dict__.get("_fastplms_tokenizer")
897
+ if tokenizer is None:
898
+ tokenizer = EsmSequenceTokenizer()
899
+ self.__dict__["_fastplms_tokenizer"] = tokenizer
900
+ return tokenizer
901
+
902
+ @tokenizer.setter
903
+ def tokenizer(self, value: EsmSequenceTokenizer | None) -> None:
904
+ self.__dict__["_fastplms_tokenizer"] = value
905
+
906
+ def _init_weights(self, module):
907
+ """Initialize the weights"""
908
+ # HF from_pretrained marks loaded parameters with `_is_hf_initialized`.
909
+ # Skip this module if any local parameter is already marked as loaded.
910
+ for parameter in module.parameters(recurse=False):
911
+ if parameter.__dict__.get("_is_hf_initialized"):
912
+ return
913
+
914
+ if isinstance(module, nn.Linear):
915
+ nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range)
916
+ if module.bias is not None:
917
+ nn.init.zeros_(module.bias)
918
+ elif isinstance(module, nn.Embedding):
919
+ nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range)
920
+ if module.padding_idx is not None:
921
+ with torch.no_grad():
922
+ module.weight[module.padding_idx].zero_()
923
+ elif isinstance(module, nn.LayerNorm):
924
+ if module.bias is not None:
925
+ nn.init.zeros_(module.bias)
926
+ nn.init.ones_(module.weight)
927
+
928
+ @property
929
+ def attn_backend(self) -> str:
930
+ return self.config.attn_backend
931
+
932
+ @attn_backend.setter
933
+ def attn_backend(self, backend: str) -> None:
934
+ if backend not in self._fastplms_attention_implementations:
935
+ raise ValueError(
936
+ f"{type(self).__name__} does not support {backend!r}; expected one of "
937
+ f"{self._fastplms_attention_implementations}."
938
+ )
939
+ self.set_attn_implementation(backend)
940
+
941
+ def _reset_rotary_embeddings(self):
942
+ """Refresh non-persistent rotary buffers after checkpoint loading."""
943
+ for module in self.modules():
944
+ if isinstance(module, RotaryEmbedding):
945
+ module.reset_parameters()
946
+
947
+ @classmethod
948
+ def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
949
+ output_loading_info = (
950
+ bool(kwargs["output_loading_info"]) if "output_loading_info" in kwargs else False
951
+ )
952
+ loaded = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)
953
+ if output_loading_info:
954
+ model, loading_info = loaded
955
+ model._reset_rotary_embeddings()
956
+ return model, loading_info
957
+ loaded._reset_rotary_embeddings()
958
+ return loaded
959
+
960
+
961
+ ### ESM++ Models
962
+ class ESMplusplusModel(PreTrainedESMplusplusModel, EmbeddingMixin):
963
+ """
964
+ ESM++ transformer backbone.
965
+
966
+ Official ESM++ checkpoints contain the sequence head even when loaded through
967
+ ``AutoModel``. Keep that module in the base class so the checkpoint has one
968
+ exact state-dict contract across ``AutoModel`` and ``AutoModelForMaskedLM``;
969
+ the base forward path intentionally does not compute or return logits.
970
+ """
971
+
972
+ config_class = ESMplusplusConfig
973
+
974
+ def __init__(self, config: ESMplusplusConfig, **kwargs):
975
+ PreTrainedESMplusplusModel.__init__(self, config, **kwargs)
976
+ self.config = config
977
+ self.vocab_size = config.vocab_size
978
+ self.embed = nn.Embedding(self.vocab_size, config.hidden_size)
979
+ self.transformer = TransformerStack(
980
+ d_model=config.hidden_size,
981
+ n_heads=config.num_attention_heads,
982
+ n_layers=config.num_hidden_layers,
983
+ dropout=config.dropout,
984
+ attn_backend=config.attn_backend,
985
+ )
986
+ self.sequence_head = RegressionHead(config.hidden_size, self.vocab_size)
987
+ self.init_weights()
988
+
989
+ def get_input_embeddings(self):
990
+ return self.embed
991
+
992
+ def set_input_embeddings(self, value):
993
+ self.embed = value
994
+
995
+ def get_output_embeddings(self):
996
+ return self.sequence_head[-1]
997
+
998
+ def set_output_embeddings(self, new_embeddings):
999
+ self.sequence_head[-1] = new_embeddings
1000
+
1001
+ def _embed(
1002
+ self,
1003
+ input_ids: torch.Tensor,
1004
+ attention_mask: torch.Tensor | None = None,
1005
+ hidden_state_index: int = -1,
1006
+ store_all_hidden_states: bool = False,
1007
+ ) -> torch.Tensor:
1008
+ if attention_mask is None:
1009
+ attention_mask = input_ids.ne(self.config.pad_token_id)
1010
+ x = self.embed(input_ids)
1011
+ output_hidden_states = store_all_hidden_states or hidden_state_index != -1
1012
+ output = self.transformer(
1013
+ x=x,
1014
+ attention_mask=attention_mask,
1015
+ output_hidden_states=output_hidden_states,
1016
+ output_attentions=False,
1017
+ )
1018
+ return select_hidden_state_embeddings(
1019
+ output.last_hidden_state,
1020
+ output.hidden_states,
1021
+ hidden_state_index=hidden_state_index,
1022
+ store_all_hidden_states=store_all_hidden_states,
1023
+ )
1024
+
1025
+ def forward(
1026
+ self,
1027
+ input_ids: torch.Tensor | None = None,
1028
+ attention_mask: torch.Tensor | None = None,
1029
+ sequence_id: torch.Tensor | None = None,
1030
+ inputs_embeds: torch.Tensor | None = None,
1031
+ output_attentions: bool | None = None,
1032
+ output_hidden_states: bool | None = None,
1033
+ output_s_max: bool | None = False,
1034
+ esmfold2_hidden_states: bool = False,
1035
+ return_dict: bool | None = None,
1036
+ ) -> TransformerOutput | tuple[torch.Tensor, ...]:
1037
+ """Run ESMC inference with the pinned Biohub mask precedence.
1038
+
1039
+ ``sequence_id`` is authoritative when supplied: non-negative integers
1040
+ identify chains and ``-1`` identifies padding. In that mode
1041
+ ``attention_mask`` is ignored, matching the official implementation.
1042
+ Without ``sequence_id``, ``attention_mask`` is the ordinary padding
1043
+ mask and defaults to ``input_ids != pad_token_id``.
1044
+ """
1045
+ if input_ids is None and inputs_embeds is None:
1046
+ raise ValueError("You have to specify either input_ids or inputs_embeds")
1047
+ if input_ids is not None and inputs_embeds is not None:
1048
+ raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
1049
+ output_attentions = (
1050
+ output_attentions if output_attentions is not None else self.config.output_attentions
1051
+ )
1052
+ output_hidden_states = (
1053
+ output_hidden_states
1054
+ if output_hidden_states is not None
1055
+ else self.config.output_hidden_states
1056
+ )
1057
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
1058
+
1059
+ if attention_mask is None and sequence_id is None and input_ids is not None:
1060
+ attention_mask = input_ids.ne(self.config.pad_token_id)
1061
+
1062
+ x = self.embed(input_ids) if inputs_embeds is None else inputs_embeds
1063
+
1064
+ transformer_output = self.transformer(
1065
+ x=x,
1066
+ attention_mask=attention_mask,
1067
+ sequence_id=sequence_id,
1068
+ output_hidden_states=output_hidden_states,
1069
+ output_attentions=output_attentions,
1070
+ output_s_max=output_s_max,
1071
+ esmfold2_hidden_states=esmfold2_hidden_states,
1072
+ )
1073
+ result = TransformerOutput(
1074
+ last_hidden_state=transformer_output.last_hidden_state,
1075
+ hidden_states=transformer_output.hidden_states,
1076
+ attentions=transformer_output.attentions,
1077
+ s_max=transformer_output.s_max,
1078
+ )
1079
+ return result if return_dict else result.to_tuple()
1080
+
1081
+
1082
+ class ESMplusplusForMaskedLM(
1083
+ FastPLMTestTimeTrainingMixin, PreTrainedESMplusplusModel, EmbeddingMixin
1084
+ ):
1085
+ """
1086
+ ESM++ model for masked language modeling.
1087
+ Implements the base ESM++ architecture with a masked language modeling head.
1088
+ """
1089
+
1090
+ config_class = ESMplusplusConfig
1091
+
1092
+ def __init__(self, config: ESMplusplusConfig, **kwargs):
1093
+ PreTrainedESMplusplusModel.__init__(self, config, **kwargs)
1094
+ self.config = config
1095
+ self.vocab_size = config.vocab_size
1096
+ self.embed = nn.Embedding(self.vocab_size, config.hidden_size)
1097
+ self.transformer = TransformerStack(
1098
+ d_model=config.hidden_size,
1099
+ n_heads=config.num_attention_heads,
1100
+ n_layers=config.num_hidden_layers,
1101
+ dropout=config.dropout,
1102
+ attn_backend=config.attn_backend,
1103
+ )
1104
+ self.sequence_head = RegressionHead(config.hidden_size, self.vocab_size)
1105
+ self.ce_loss = nn.CrossEntropyLoss()
1106
+ self.init_weights()
1107
+ self.init_ttt({"lora_target_replace_module": "MultiHeadAttention"})
1108
+
1109
+ def get_input_embeddings(self):
1110
+ return self.embed
1111
+
1112
+ def set_input_embeddings(self, value):
1113
+ self.embed = value
1114
+
1115
+ def get_output_embeddings(self):
1116
+ return self.sequence_head[-1]
1117
+
1118
+ def set_output_embeddings(self, new_embeddings):
1119
+ self.sequence_head[-1] = new_embeddings
1120
+
1121
+ def _embed(
1122
+ self,
1123
+ input_ids: torch.Tensor,
1124
+ attention_mask: torch.Tensor | None = None,
1125
+ hidden_state_index: int = -1,
1126
+ store_all_hidden_states: bool = False,
1127
+ ) -> torch.Tensor:
1128
+ if attention_mask is None:
1129
+ attention_mask = input_ids.ne(self.config.pad_token_id)
1130
+ x = self.embed(input_ids)
1131
+ output_hidden_states = store_all_hidden_states or hidden_state_index != -1
1132
+ output = self.transformer(
1133
+ x=x,
1134
+ attention_mask=attention_mask,
1135
+ output_hidden_states=output_hidden_states,
1136
+ output_attentions=False,
1137
+ )
1138
+ return select_hidden_state_embeddings(
1139
+ output.last_hidden_state,
1140
+ output.hidden_states,
1141
+ hidden_state_index=hidden_state_index,
1142
+ store_all_hidden_states=store_all_hidden_states,
1143
+ )
1144
+
1145
+ def _ttt_get_trainable_modules(self) -> list[nn.Module]:
1146
+ return [self.transformer]
1147
+
1148
+ def forward(
1149
+ self,
1150
+ input_ids: torch.Tensor | None = None,
1151
+ attention_mask: torch.Tensor | None = None,
1152
+ sequence_id: torch.Tensor | None = None,
1153
+ inputs_embeds: torch.Tensor | None = None,
1154
+ labels: torch.Tensor | None = None,
1155
+ output_attentions: bool | None = None,
1156
+ output_hidden_states: bool | None = None,
1157
+ output_s_max: bool | None = False,
1158
+ esmfold2_hidden_states: bool = False,
1159
+ return_dict: bool | None = None,
1160
+ compute_logits: bool = True,
1161
+ ) -> ESMplusplusOutput | tuple[torch.Tensor, ...]:
1162
+ if input_ids is None and inputs_embeds is None:
1163
+ raise ValueError("You have to specify either input_ids or inputs_embeds")
1164
+ if input_ids is not None and inputs_embeds is not None:
1165
+ raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
1166
+ if labels is not None and not compute_logits:
1167
+ raise ValueError("labels require compute_logits=True.")
1168
+ output_attentions = (
1169
+ output_attentions if output_attentions is not None else self.config.output_attentions
1170
+ )
1171
+ output_hidden_states = (
1172
+ output_hidden_states
1173
+ if output_hidden_states is not None
1174
+ else self.config.output_hidden_states
1175
+ )
1176
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
1177
+ if attention_mask is None and sequence_id is None and input_ids is not None:
1178
+ attention_mask = input_ids.ne(self.config.pad_token_id)
1179
+
1180
+ x = self.embed(input_ids) if inputs_embeds is None else inputs_embeds
1181
+
1182
+ output = self.transformer(
1183
+ x=x,
1184
+ attention_mask=attention_mask,
1185
+ sequence_id=sequence_id,
1186
+ output_hidden_states=output_hidden_states,
1187
+ output_attentions=output_attentions,
1188
+ output_s_max=output_s_max,
1189
+ esmfold2_hidden_states=esmfold2_hidden_states,
1190
+ )
1191
+
1192
+ last_hidden_state = output.last_hidden_state
1193
+ logits = self.sequence_head(last_hidden_state) if compute_logits else None
1194
+ loss = None
1195
+ if labels is not None:
1196
+ if logits is None:
1197
+ raise ValueError("labels require compute_logits=True.")
1198
+ labels = labels.to(logits.device)
1199
+ loss = self.ce_loss(logits.view(-1, self.vocab_size), labels.view(-1))
1200
+
1201
+ result = ESMplusplusOutput(
1202
+ loss=loss,
1203
+ logits=logits,
1204
+ hidden_states=output.hidden_states,
1205
+ attentions=output.attentions,
1206
+ s_max=output.s_max,
1207
+ last_hidden_state=last_hidden_state,
1208
+ )
1209
+ return result if return_dict else result.to_tuple()
1210
+
1211
+
1212
+ class ESMplusplusForSequenceClassification(ESMplusplusForMaskedLM, EmbeddingMixin):
1213
+ """
1214
+ ESM++ model for sequence classification.
1215
+ Extends the base ESM++ model with a classification head.
1216
+ """
1217
+
1218
+ def __init__(self, config: ESMplusplusConfig, **kwargs):
1219
+ pooling_types = kwargs.pop("pooling_types", None)
1220
+ if pooling_types is None:
1221
+ pooling_types = config.classifier_pooling_types or ["mean", "var"]
1222
+ elif not isinstance(pooling_types, list):
1223
+ raise TypeError("pooling_types must be a non-empty list of strings.")
1224
+ elif not pooling_types:
1225
+ raise ValueError("pooling_types must contain at least one pooling operation.")
1226
+ elif not all(isinstance(pooling_type, str) for pooling_type in pooling_types):
1227
+ raise TypeError("pooling_types must be a non-empty list of strings.")
1228
+ if "parti" in pooling_types:
1229
+ raise ValueError(
1230
+ "pooling_types cannot contain 'parti' for sequence classification "
1231
+ "because the classifier does not expose layer attentions to its pooler."
1232
+ )
1233
+ config.classifier_pooling_types = list(pooling_types)
1234
+
1235
+ ESMplusplusForMaskedLM.__init__(self, config, **kwargs)
1236
+ self.config = config
1237
+ self.num_labels = config.num_labels
1238
+ self.classifier = RegressionHead(
1239
+ config.hidden_size * len(pooling_types),
1240
+ config.num_labels,
1241
+ config.hidden_size * 4,
1242
+ )
1243
+ # Large intermediate projections help with sequence classification tasks (*4)
1244
+ self.mse = nn.MSELoss()
1245
+ self.ce = nn.CrossEntropyLoss()
1246
+ self.bce = nn.BCEWithLogitsLoss()
1247
+ self.pooler = Pooler(pooling_types)
1248
+ self.init_weights()
1249
+
1250
+ def _embed(
1251
+ self,
1252
+ input_ids: torch.Tensor,
1253
+ attention_mask: torch.Tensor | None = None,
1254
+ hidden_state_index: int = -1,
1255
+ store_all_hidden_states: bool = False,
1256
+ ) -> torch.Tensor:
1257
+ x = self.embed(input_ids)
1258
+ output_hidden_states = store_all_hidden_states or hidden_state_index != -1
1259
+ output = self.transformer(
1260
+ x=x,
1261
+ attention_mask=attention_mask,
1262
+ output_hidden_states=output_hidden_states,
1263
+ output_attentions=False,
1264
+ )
1265
+ return select_hidden_state_embeddings(
1266
+ output.last_hidden_state,
1267
+ output.hidden_states,
1268
+ hidden_state_index=hidden_state_index,
1269
+ store_all_hidden_states=store_all_hidden_states,
1270
+ )
1271
+
1272
+ def forward(
1273
+ self,
1274
+ input_ids: torch.Tensor | None = None,
1275
+ attention_mask: torch.Tensor | None = None,
1276
+ sequence_id: torch.Tensor | None = None,
1277
+ inputs_embeds: torch.Tensor | None = None,
1278
+ labels: torch.Tensor | None = None,
1279
+ output_attentions: bool | None = None,
1280
+ output_hidden_states: bool | None = None,
1281
+ output_s_max: bool | None = False,
1282
+ return_dict: bool | None = None,
1283
+ ) -> ESMplusplusSequenceClassifierOutput | tuple[torch.Tensor, ...]:
1284
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
1285
+ pooling_mask = attention_mask
1286
+ if pooling_mask is None:
1287
+ if sequence_id is not None:
1288
+ pooling_mask = (
1289
+ sequence_id if sequence_id.dtype == torch.bool else sequence_id.ne(-1)
1290
+ )
1291
+ elif input_ids is not None:
1292
+ pooling_mask = input_ids.ne(self.config.pad_token_id)
1293
+ else:
1294
+ if inputs_embeds is None:
1295
+ raise ValueError("You have to specify either input_ids or inputs_embeds")
1296
+ pooling_mask = torch.ones(
1297
+ inputs_embeds.shape[:2],
1298
+ dtype=torch.bool,
1299
+ device=inputs_embeds.device,
1300
+ )
1301
+
1302
+ output = super().forward(
1303
+ input_ids=input_ids,
1304
+ attention_mask=attention_mask,
1305
+ sequence_id=sequence_id,
1306
+ inputs_embeds=inputs_embeds,
1307
+ labels=None,
1308
+ output_attentions=output_attentions,
1309
+ output_hidden_states=output_hidden_states,
1310
+ output_s_max=output_s_max,
1311
+ return_dict=True,
1312
+ compute_logits=False,
1313
+ )
1314
+
1315
+ last_hidden_state = output.last_hidden_state
1316
+ features = self.pooler(last_hidden_state, pooling_mask)
1317
+ logits = self.classifier(features)
1318
+
1319
+ loss = None
1320
+ if labels is not None:
1321
+ labels = labels.to(logits.device)
1322
+ if self.config.problem_type is None:
1323
+ if self.num_labels == 1:
1324
+ self.config.problem_type = "regression"
1325
+ elif self.num_labels > 1 and (
1326
+ labels.dtype == torch.long or labels.dtype == torch.int
1327
+ ):
1328
+ self.config.problem_type = "single_label_classification"
1329
+ else:
1330
+ self.config.problem_type = "multi_label_classification"
1331
+
1332
+ if self.config.problem_type == "regression":
1333
+ if self.num_labels == 1:
1334
+ loss = self.mse(logits.flatten(), labels.flatten())
1335
+ else:
1336
+ loss = self.mse(logits, labels)
1337
+ elif self.config.problem_type == "single_label_classification":
1338
+ loss = self.ce(logits.view(-1, self.num_labels), labels.view(-1))
1339
+ elif self.config.problem_type == "multi_label_classification":
1340
+ loss = self.bce(logits, labels)
1341
+
1342
+ result = ESMplusplusSequenceClassifierOutput(
1343
+ loss=loss,
1344
+ logits=logits,
1345
+ hidden_states=output.hidden_states,
1346
+ attentions=output.attentions,
1347
+ s_max=output.s_max,
1348
+ )
1349
+ return result if return_dict else result.to_tuple()
1350
+
1351
+
1352
+ class ESMplusplusForTokenClassification(ESMplusplusForMaskedLM, EmbeddingMixin):
1353
+ """
1354
+ ESM++ model for token classification.
1355
+ Extends the base ESM++ model with a token classification head.
1356
+ """
1357
+
1358
+ def __init__(self, config: ESMplusplusConfig, **kwargs):
1359
+ ESMplusplusForMaskedLM.__init__(self, config, **kwargs)
1360
+ self.config = config
1361
+ self.num_labels = config.num_labels
1362
+ self.classifier = RegressionHead(
1363
+ config.hidden_size, config.num_labels, config.hidden_size * 4
1364
+ )
1365
+ # Large intermediate projections help with sequence classification tasks (*4)
1366
+ self.loss_fct = nn.CrossEntropyLoss()
1367
+ self.init_weights()
1368
+
1369
+ def _embed(
1370
+ self,
1371
+ input_ids: torch.Tensor,
1372
+ attention_mask: torch.Tensor | None = None,
1373
+ hidden_state_index: int = -1,
1374
+ store_all_hidden_states: bool = False,
1375
+ ) -> torch.Tensor:
1376
+ x = self.embed(input_ids)
1377
+ output_hidden_states = store_all_hidden_states or hidden_state_index != -1
1378
+ output = self.transformer(
1379
+ x,
1380
+ attention_mask,
1381
+ output_hidden_states=output_hidden_states,
1382
+ output_attentions=False,
1383
+ )
1384
+ return select_hidden_state_embeddings(
1385
+ output.last_hidden_state,
1386
+ output.hidden_states,
1387
+ hidden_state_index=hidden_state_index,
1388
+ store_all_hidden_states=store_all_hidden_states,
1389
+ )
1390
+
1391
+ def forward(
1392
+ self,
1393
+ input_ids: torch.Tensor | None = None,
1394
+ attention_mask: torch.Tensor | None = None,
1395
+ sequence_id: torch.Tensor | None = None,
1396
+ inputs_embeds: torch.Tensor | None = None,
1397
+ labels: torch.Tensor | None = None,
1398
+ output_attentions: bool | None = None,
1399
+ output_hidden_states: bool | None = None,
1400
+ output_s_max: bool | None = False,
1401
+ return_dict: bool | None = None,
1402
+ ) -> ESMplusplusTokenClassifierOutput | tuple[torch.Tensor, ...]:
1403
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
1404
+ output = super().forward(
1405
+ input_ids=input_ids,
1406
+ attention_mask=attention_mask,
1407
+ sequence_id=sequence_id,
1408
+ inputs_embeds=inputs_embeds,
1409
+ labels=None,
1410
+ output_attentions=output_attentions,
1411
+ output_hidden_states=output_hidden_states,
1412
+ output_s_max=output_s_max,
1413
+ return_dict=True,
1414
+ compute_logits=False,
1415
+ )
1416
+
1417
+ last_hidden_state = output.last_hidden_state
1418
+ logits = self.classifier(last_hidden_state)
1419
+ loss = None
1420
+ if labels is not None:
1421
+ labels = labels.to(logits.device)
1422
+ loss = self.loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
1423
+
1424
+ result = ESMplusplusTokenClassifierOutput(
1425
+ loss=loss,
1426
+ logits=logits,
1427
+ hidden_states=output.hidden_states,
1428
+ attentions=output.attentions,
1429
+ s_max=output.s_max,
1430
+ )
1431
+ return result if return_dict else result.to_tuple()
1432
+
1433
+
1434
+ ### Tokenization
1435
+ SEQUENCE_VOCAB = [
1436
+ "<cls>",
1437
+ "<pad>",
1438
+ "<eos>",
1439
+ "<unk>",
1440
+ "L",
1441
+ "A",
1442
+ "G",
1443
+ "V",
1444
+ "S",
1445
+ "E",
1446
+ "R",
1447
+ "T",
1448
+ "I",
1449
+ "D",
1450
+ "P",
1451
+ "K",
1452
+ "Q",
1453
+ "N",
1454
+ "F",
1455
+ "Y",
1456
+ "M",
1457
+ "H",
1458
+ "W",
1459
+ "C",
1460
+ "X",
1461
+ "B",
1462
+ "U",
1463
+ "Z",
1464
+ "O",
1465
+ ".",
1466
+ "-",
1467
+ "|",
1468
+ "<mask>",
1469
+ ]
1470
+
1471
+
1472
+ def _build_sequence_tokenizer_backend(
1473
+ *,
1474
+ unk_token: str,
1475
+ cls_token: str,
1476
+ pad_token: str,
1477
+ mask_token: str,
1478
+ eos_token: str,
1479
+ chain_break_token: str,
1480
+ ) -> Tokenizer:
1481
+ """Build the fixed ESMC character vocabulary and boundary-token policy."""
1482
+ vocabulary = dict(zip(SEQUENCE_VOCAB, range(len(SEQUENCE_VOCAB)), strict=True))
1483
+ backend = Tokenizer(BPE(vocabulary, merges=[], unk_token=unk_token))
1484
+ backend.add_special_tokens([cls_token, pad_token, mask_token, eos_token, chain_break_token])
1485
+ backend.post_processor = TemplateProcessing(
1486
+ single="<cls> $A <eos>",
1487
+ pair="<cls>:0 $A:0 <eos>:0 $B:1 <eos>:1",
1488
+ special_tokens=[
1489
+ ("<cls>", backend.token_to_id("<cls>")),
1490
+ ("<eos>", backend.token_to_id("<eos>")),
1491
+ ],
1492
+ )
1493
+ return backend
1494
+
1495
+
1496
+ class EsmSequenceTokenizer(PreTrainedTokenizerFast):
1497
+ model_input_names: ClassVar[list[str]] = ["input_ids", "attention_mask"]
1498
+
1499
+ def __init__(
1500
+ self,
1501
+ unk_token="<unk>",
1502
+ cls_token="<cls>",
1503
+ pad_token="<pad>",
1504
+ mask_token="<mask>",
1505
+ eos_token="<eos>",
1506
+ chain_break_token="|",
1507
+ **kwargs,
1508
+ ):
1509
+ backend = _build_sequence_tokenizer_backend(
1510
+ unk_token=unk_token,
1511
+ cls_token=cls_token,
1512
+ pad_token=pad_token,
1513
+ mask_token=mask_token,
1514
+ eos_token=eos_token,
1515
+ chain_break_token=chain_break_token,
1516
+ )
1517
+ self.cb_token = chain_break_token
1518
+ super().__init__(
1519
+ tokenizer_object=backend,
1520
+ unk_token=unk_token,
1521
+ cls_token=cls_token,
1522
+ pad_token=pad_token,
1523
+ mask_token=mask_token,
1524
+ eos_token=eos_token,
1525
+ additional_special_tokens=[chain_break_token],
1526
+ **kwargs,
1527
+ )
1528
+
1529
+ # These are a footgun, we never use the `bos` token anywhere so we're just overriding it here.
1530
+ @property
1531
+ def bos_token(self):
1532
+ return self.cls_token
1533
+
1534
+ @property
1535
+ def bos_token_id(self):
1536
+ return self.cls_token_id
1537
+
1538
+ @property
1539
+ def chain_break_token(self):
1540
+ return self.cb_token
1541
+
1542
+ @property
1543
+ def chain_break_token_id(self):
1544
+ return self.convert_tokens_to_ids(self.chain_break_token)
1545
+
1546
+ @property
1547
+ def all_token_ids(self):
1548
+ return list(range(self.vocab_size))
1549
+
1550
+ @property
1551
+ def special_token_ids(self):
1552
+ return self.all_special_ids
fastplms/models/esmfold2/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ESMFold2 public classes, imported lazily to keep optional extras isolated."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from .configuration_esmfold2 import ESMFold2Config as ESMFold2Config
10
+ from .modeling_esmfold2 import ESMFold2Model as ESMFold2Model
11
+ from .modeling_esmfold2 import ESMFold2Output as ESMFold2Output
12
+ from .modeling_esmfold2_experimental import (
13
+ ESMFold2ExperimentalModel as ESMFold2ExperimentalModel,
14
+ )
15
+ from .reproducibility import seed_context as seed_context
16
+
17
+ _EXPORT_MODULES = {
18
+ "ESMFold2Config": ".configuration_esmfold2",
19
+ "ESMFold2ExperimentalModel": ".modeling_esmfold2_experimental",
20
+ "ESMFold2Model": ".modeling_esmfold2",
21
+ "ESMFold2Output": ".modeling_esmfold2",
22
+ "seed_context": ".reproducibility",
23
+ }
24
+
25
+
26
+ def __getattr__(name: str) -> Any:
27
+ module_name = _EXPORT_MODULES.get(name)
28
+ if module_name is None:
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
30
+ value = getattr(import_module(module_name, __name__), name)
31
+ globals()[name] = value
32
+ return value
33
+
34
+
35
+ def __dir__() -> list[str]:
36
+ return sorted(set(globals()) | set(_EXPORT_MODULES))
37
+
38
+
39
+ __all__ = list(_EXPORT_MODULES)
fastplms/models/esmfold2/attention.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transformers-compatible attention selection for ESMFold2's ESMC backbone."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from ...attention import FastPLMsAttentionMixin, get_attn_implementation
8
+
9
+
10
+ class ESMFold2AttentionMixin(FastPLMsAttentionMixin):
11
+ """Route the outer Transformers attention API into the loaded ESMC model."""
12
+
13
+ _supports_attention_backend = True
14
+ _supports_sdpa = True
15
+ _supports_flex_attn = True
16
+ _supports_flash_attn_2 = False
17
+ _supports_flash_attn_3 = False
18
+ _fastplms_attention_implementations = (
19
+ "eager",
20
+ "sdpa",
21
+ "flex_attention",
22
+ )
23
+
24
+ def __init__(self, config, *args, **kwargs) -> None:
25
+ super().__init__(config, *args, **kwargs)
26
+ config.esmc_attn_backend = get_attn_implementation(config)
27
+
28
+ def set_attn_implementation(
29
+ self,
30
+ attn_implementation: str | Mapping[str, str],
31
+ allow_all_kernels: bool = False,
32
+ ) -> None:
33
+ """Set one canonical backend on ESMFold2 and its loaded ESMC model."""
34
+
35
+ if allow_all_kernels:
36
+ raise ValueError(
37
+ "ESMFold2 accepts only its declared built-in attention backends; "
38
+ "external attention kernels are not supported."
39
+ )
40
+ super().set_attn_implementation(attn_implementation)
41
+ resolved = get_attn_implementation(self.config)
42
+ self.config.esmc_attn_backend = resolved
43
+ esmc = getattr(self, "_esmc", None)
44
+ if esmc is not None:
45
+ esmc.set_attn_implementation(resolved)
46
+
47
+
48
+ __all__ = ["ESMFold2AttentionMixin"]
fastplms/models/esmfold2/configuration_esmfold2.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Biohub. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Configuration schema for release and experimental ESMFold2 checkpoints."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import asdict, dataclass, field
20
+ from typing import Any, TypeVar, cast
21
+
22
+ from transformers.configuration_utils import PretrainedConfig
23
+
24
+ _ESMC_ATTENTION_IMPLEMENTATIONS = frozenset({"eager", "flex_attention", "sdpa"})
25
+ _ESMC_PRECISIONS = frozenset({"auto", "bf16", "fp32", "fp8"})
26
+
27
+
28
+ def _esmc_backbone_checkpoint_ids() -> tuple[str, str]:
29
+ """Return the manifest-pinned official and FastPLMs ESMC repositories."""
30
+
31
+ from fastplms.registry import RegistryError, get_model_registry
32
+
33
+ registry = get_model_registry()
34
+ family = registry.families["esmfold2"]
35
+ if family.backbone_model is None:
36
+ raise RegistryError("families.esmfold2 must declare backbone_model.")
37
+ backbone = registry[family.backbone_model]
38
+ return backbone.official.repo_id, backbone.fast.repo_id
39
+
40
+
41
+ def normalize_esmc_id(esmc_id: str) -> str:
42
+ """Resolve an official ESMC identifier to its FastPLMs checkpoint mirror."""
43
+
44
+ official_repo, fast_repo = _esmc_backbone_checkpoint_ids()
45
+ return fast_repo if esmc_id == official_repo else esmc_id
46
+
47
+
48
+ def normalize_esmc_attention_implementation(
49
+ implementation: str | dict[str, str] | None,
50
+ ) -> str | None:
51
+ """Validate the ESMC backend and translate the historical ``flex`` name."""
52
+
53
+ if isinstance(implementation, dict):
54
+ if tuple(implementation) != ("",):
55
+ raise ValueError(
56
+ "ESMFold2 has one ESMC attention backbone; use a string or {'': implementation}."
57
+ )
58
+ implementation = implementation[""]
59
+ canonical = "flex_attention" if implementation == "flex" else implementation
60
+ if canonical is not None and canonical not in _ESMC_ATTENTION_IMPLEMENTATIONS:
61
+ expected = sorted(_ESMC_ATTENTION_IMPLEMENTATIONS)
62
+ raise ValueError(
63
+ f"Unsupported ESMFold2 attention implementation {canonical!r}; "
64
+ f"expected one of {expected}."
65
+ )
66
+ return canonical
67
+
68
+
69
+ NestedConfig = TypeVar("NestedConfig")
70
+
71
+
72
+ def _nested_config(value: Any, config_type: type[NestedConfig]) -> NestedConfig:
73
+ if isinstance(value, config_type):
74
+ return value
75
+ return config_type(**value) if isinstance(value, dict) else config_type()
76
+
77
+
78
+ def _coerce_nested_field(
79
+ value: NestedConfig | dict[str, Any], config_type: type[NestedConfig]
80
+ ) -> NestedConfig:
81
+ """Convert serialized nested dictionaries while retaining supplied objects."""
82
+
83
+ return config_type(**value) if isinstance(value, dict) else value
84
+
85
+
86
+ @dataclass
87
+ class AtomAttentionConfig:
88
+ """Sliding-window atom attention and three-dimensional RoPE settings."""
89
+
90
+ d_atom: int = field(default=128)
91
+ d_token: int = field(default=768)
92
+ n_blocks: int = field(default=3)
93
+ n_heads: int = field(default=4)
94
+ swa_window_size: int = field(default=128)
95
+ expansion_ratio: int = field(default=2)
96
+ spatial_rope_base_frequency: float = field(default=20.0)
97
+ n_spatial_rope_pairs_per_axis: int = field(default=2)
98
+ n_uid_rope_pairs: int = field(default=10)
99
+ uid_rope_base_frequency: float = field(default=10000.0)
100
+
101
+
102
+ @dataclass
103
+ class DiffusionModuleConfig:
104
+ """Dimensions and depth of the coordinate diffusion network."""
105
+
106
+ sigma_data: float = field(default=16.0)
107
+ c_atom: int = field(default=128)
108
+ c_token: int = field(default=768)
109
+ c_z: int = field(default=256)
110
+ c_s_inputs: int = field(default=451)
111
+ fourier_dim: int = field(default=256)
112
+ relpos_r_max: int = field(default=32)
113
+ relpos_s_max: int = field(default=2)
114
+ atom_num_blocks: int = field(default=3)
115
+ atom_num_heads: int = field(default=4)
116
+ token_num_blocks: int = field(default=12)
117
+ token_num_heads: int = field(default=16)
118
+ transition_multiplier: int = field(default=2)
119
+
120
+
121
+ @dataclass
122
+ class FoldingTrunkConfig:
123
+ """Iterative pair/single trunk dimensions."""
124
+
125
+ n_layers: int = field(default=24)
126
+ n_heads: int = field(default=8)
127
+ dropout: float = field(default=0.0)
128
+
129
+
130
+ @dataclass
131
+ class InputsEmbedderConfig:
132
+ """Input feature width and atom encoder settings."""
133
+
134
+ d_inputs: int = field(default=451)
135
+ atom_encoder: AtomAttentionConfig = field(default_factory=AtomAttentionConfig)
136
+
137
+ def __post_init__(self) -> None:
138
+ self.atom_encoder = _coerce_nested_field(self.atom_encoder, AtomAttentionConfig)
139
+
140
+
141
+ @dataclass
142
+ class DiffusionStructureHeadConfig:
143
+ """Training and inference schedules for coordinate denoising."""
144
+
145
+ diffusion_module: DiffusionModuleConfig = field(default_factory=DiffusionModuleConfig)
146
+ distogram_bins: int = field(default=128)
147
+ train_noise_log_mean: float = field(default=-1.2)
148
+ train_noise_log_std: float = field(default=1.5)
149
+ gamma_0: float = field(default=0.605)
150
+ gamma_min: float = field(default=1.107)
151
+ noise_scale: float = field(default=0.0)
152
+ step_scale: float = field(default=1.0)
153
+ inference_s_max: float = field(default=160.0)
154
+ inference_s_min: float = field(default=4e-4)
155
+ inference_p: float = field(default=8.0)
156
+ inference_num_steps: int = field(default=68)
157
+
158
+ def __post_init__(self) -> None:
159
+ self.diffusion_module = _coerce_nested_field(self.diffusion_module, DiffusionModuleConfig)
160
+
161
+
162
+ @dataclass
163
+ class ConfidenceHeadConfig:
164
+ """Confidence-bin definitions and the compact confidence trunk."""
165
+
166
+ enabled: bool = field(default=True)
167
+ num_plddt_bins: int = field(default=50)
168
+ num_pde_bins: int = field(default=64)
169
+ num_pae_bins: int = field(default=64)
170
+ min_dist: float = field(default=2.0)
171
+ max_dist: float = field(default=52.0)
172
+ distogram_bins: int = field(default=128)
173
+ folding_trunk: FoldingTrunkConfig = field(
174
+ default_factory=lambda: FoldingTrunkConfig(n_layers=4)
175
+ )
176
+
177
+ def __post_init__(self) -> None:
178
+ self.folding_trunk = _coerce_nested_field(self.folding_trunk, FoldingTrunkConfig)
179
+
180
+
181
+ @dataclass
182
+ class MSAEncoderConfig:
183
+ """Optional multiple-sequence-alignment encoder settings."""
184
+
185
+ enabled: bool = field(default=False)
186
+ d_msa: int = field(default=128)
187
+ d_hidden: int = field(default=32)
188
+ n_layers: int = field(default=4)
189
+ n_heads_msa: int = field(default=8)
190
+ msa_head_width: int = field(default=32)
191
+
192
+
193
+ @dataclass
194
+ class LMEncoderConfig:
195
+ """Release-model pair encoder derived from language-model states."""
196
+
197
+ enabled: bool = field(default=True)
198
+ n_layers: int = field(default=4)
199
+ lm_dropout: float = field(default=0.25)
200
+ per_loop_lm_dropout: bool = field(default=True)
201
+
202
+
203
+ @dataclass
204
+ class ParcaeConfig:
205
+ """Release-model diffusion-loop scheduler settings."""
206
+
207
+ enabled: bool = field(default=True)
208
+ poisson_mean: float = field(default=3.0)
209
+ min_steps: int = field(default=1)
210
+ max_steps: int | None = field(default=6)
211
+ coda_n_layers: int = field(default=2)
212
+
213
+
214
+ _SCALAR_DEFAULTS: tuple[tuple[str, Any], ...] = (
215
+ ("d_single", 384),
216
+ ("d_pair", 256),
217
+ ("n_relative_residx_bins", 32),
218
+ ("n_relative_chain_bins", 2),
219
+ ("num_loops", 10),
220
+ ("num_diffusion_samples", 8),
221
+ ("disable_msa_features", False),
222
+ ("lm_dropout", 0.0),
223
+ ("force_lm_dropout_during_inference", False),
224
+ ("lm_mask_pct", 0.0),
225
+ ("lm_d_model", 2560),
226
+ ("lm_num_layers", 80),
227
+ )
228
+ _NESTED_CONFIGS = (
229
+ ("inputs", InputsEmbedderConfig),
230
+ ("folding_trunk", FoldingTrunkConfig),
231
+ ("structure_head", DiffusionStructureHeadConfig),
232
+ ("confidence_head", ConfidenceHeadConfig),
233
+ ("msa_encoder", MSAEncoderConfig),
234
+ ("parcae", ParcaeConfig),
235
+ ("lm_encoder", LMEncoderConfig),
236
+ )
237
+
238
+
239
+ class ESMFold2Config(PretrainedConfig):
240
+ """Serializable ESMFold2 architecture, runtime, and precision settings."""
241
+
242
+ model_type = "esmfold2"
243
+ has_no_defaults_at_init = True
244
+
245
+ def __init__(self, **kwargs: Any) -> None:
246
+ legacy_backend = normalize_esmc_attention_implementation(kwargs.get("esmc_attn_backend"))
247
+ requested_backend = normalize_esmc_attention_implementation(
248
+ kwargs.get("attn_implementation")
249
+ )
250
+ resolved_backend = requested_backend or legacy_backend
251
+ kwargs["attn_implementation"] = resolved_backend
252
+ super().__init__(**kwargs)
253
+
254
+ self.type = kwargs.get("type", "release")
255
+ if self.type not in {"experimental", "release"}:
256
+ raise ValueError(
257
+ f"ESMFold2Config.type must be 'release' or 'experimental', got {self.type!r}"
258
+ )
259
+
260
+ for name, default in _SCALAR_DEFAULTS:
261
+ setattr(self, name, kwargs.get(name, default))
262
+
263
+ _official_esmc_repo, default_esmc_repo = _esmc_backbone_checkpoint_ids()
264
+ self.esmc_id = normalize_esmc_id(kwargs.get("esmc_id", default_esmc_repo))
265
+ self.esmc_attn_backend = resolved_backend
266
+ self.esmc_precision = str(kwargs.get("esmc_precision", "auto"))
267
+ if self.esmc_precision not in _ESMC_PRECISIONS:
268
+ raise ValueError(
269
+ "esmc_precision must be 'auto', 'bf16', 'fp32', or 'fp8', "
270
+ f"got {self.esmc_precision!r}."
271
+ )
272
+
273
+ for name, config_type in _NESTED_CONFIGS:
274
+ setattr(self, name, _nested_config(kwargs.get(name), config_type))
275
+ if not isinstance(self.msa_encoder.enabled, bool):
276
+ raise TypeError("msa_encoder.enabled must be a boolean.")
277
+ declared_msa_conditioning = kwargs.get("msa_conditioning")
278
+ if "msa_conditioning" in kwargs and not isinstance(declared_msa_conditioning, bool):
279
+ raise TypeError("msa_conditioning must be a boolean when provided.")
280
+ self.msa_conditioning = (
281
+ self.msa_encoder.enabled
282
+ if "msa_conditioning" not in kwargs
283
+ else declared_msa_conditioning
284
+ )
285
+ if self.msa_conditioning != self.msa_encoder.enabled:
286
+ raise ValueError(
287
+ "msa_conditioning must match msa_encoder.enabled; received "
288
+ f"{self.msa_conditioning!r} and {self.msa_encoder.enabled!r}."
289
+ )
290
+ self.msa_encoder_overwrite = bool(kwargs.get("msa_encoder_overwrite", True))
291
+
292
+ def to_dict(self) -> dict[str, Any]:
293
+ output = cast(dict[str, Any], super().to_dict())
294
+ for name, _config_type in _NESTED_CONFIGS:
295
+ output[name] = asdict(getattr(self, name))
296
+ return output
297
+
298
+
299
+ __all__ = [
300
+ "ESMFold2Config",
301
+ "LMEncoderConfig",
302
+ "MSAEncoderConfig",
303
+ "ParcaeConfig",
304
+ "normalize_esmc_attention_implementation",
305
+ "normalize_esmc_id",
306
+ ]
fastplms/models/esmfold2/embedding.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ESMFold2 integration for the shared FastPLMs embedding API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, ClassVar
6
+
7
+ import torch
8
+ from torch import Tensor
9
+
10
+ from ...embeddings import EmbeddingBatch, EmbeddingResult, embed_dataset
11
+ from .esmfold2_constants_esm3 import SEQUENCE_PAD_TOKEN, SEQUENCE_VOCAB
12
+
13
+ _TOKEN_TO_ID = {token: index for index, token in enumerate(SEQUENCE_VOCAB)}
14
+ _VALID_RESIDUES = frozenset(SEQUENCE_VOCAB[4:31]) - {".", "-", "|"}
15
+
16
+
17
+ def _encode_single_chain(sequence: str) -> list[int]:
18
+ normalized = sequence.upper()
19
+ if not normalized:
20
+ raise ValueError("ESMFold2 dataset embedding requires at least one protein residue.")
21
+ invalid = sorted(set(normalized) - _VALID_RESIDUES)
22
+ if invalid:
23
+ raise ValueError(
24
+ "ESMFold2 dataset embedding accepts one ungapped protein chain; "
25
+ f"invalid symbols: {invalid}."
26
+ )
27
+ return [_TOKEN_TO_ID[residue] for residue in normalized]
28
+
29
+
30
+ class ESMFold2EmbeddingMixin:
31
+ """Learned ESMC sequence summaries for ESMFold2 models."""
32
+
33
+ embedding_unsupported_pooling = frozenset({"cls", "parti"})
34
+ embedding_layer = "all_81_esmc_states"
35
+ embedding_projection = "esmfold2_learned_sequence_summary"
36
+ embedding_token_policy: ClassVar[dict[str, object]] = {
37
+ "unit": "residue",
38
+ "normalization": "uppercase",
39
+ "include": ["single-chain protein residues"],
40
+ "exclude": [
41
+ "BOS",
42
+ "EOS",
43
+ "padding",
44
+ "chain delimiters",
45
+ "non-protein tokens",
46
+ ],
47
+ }
48
+
49
+ def project_esmc_hidden_states(
50
+ self,
51
+ hidden_states: Tensor,
52
+ residue_mask: Tensor | None = None,
53
+ ) -> Tensor:
54
+ """Project H from ``(b, l, 81, 2560)`` to Z with shape ``(b, l, 256)``."""
55
+
56
+ if hidden_states.ndim != 4 or hidden_states.shape[-2] != 81:
57
+ raise ValueError(
58
+ "ESMFold2 projection requires the official ordered 81-state "
59
+ "ESMC tensor H with shape (b, l, 81, d_model)."
60
+ )
61
+ return self.language_model.project_sequence(hidden_states, residue_mask)
62
+
63
+ def _embedding_batch(self, sequences: list[str], **kwargs: Any) -> EmbeddingBatch:
64
+ if kwargs:
65
+ raise TypeError(f"Unexpected ESMFold2 embedding options: {', '.join(sorted(kwargs))}.")
66
+ if self._esmc is None:
67
+ raise RuntimeError("ESMFold2 embeddings require load_esmc=True.")
68
+ encoded = [_encode_single_chain(sequence) for sequence in sequences]
69
+ sequence_length = max(map(len, encoded))
70
+ b = len(encoded)
71
+ device = self.device
72
+ input_ids = torch.full(
73
+ (b, sequence_length),
74
+ SEQUENCE_PAD_TOKEN,
75
+ dtype=torch.long,
76
+ device=device,
77
+ )
78
+ residue_mask = torch.zeros((b, sequence_length), dtype=torch.bool, device=device)
79
+ for batch_index, token_ids in enumerate(encoded):
80
+ length = len(token_ids)
81
+ input_ids[batch_index, :length] = torch.tensor(
82
+ token_ids, dtype=torch.long, device=device
83
+ )
84
+ residue_mask[batch_index, :length] = True
85
+
86
+ residue_index = torch.arange(sequence_length, device=device).expand(b, -1)
87
+ asym_id = torch.zeros_like(input_ids)
88
+ mol_type = torch.zeros_like(input_ids)
89
+ hidden_states = self._compute_lm_hidden_states(
90
+ input_ids,
91
+ asym_id,
92
+ residue_index,
93
+ mol_type,
94
+ residue_mask,
95
+ )
96
+ projected = self.project_esmc_hidden_states(hidden_states, residue_mask)
97
+ return EmbeddingBatch(X=projected, residue_mask=residue_mask)
98
+
99
+ def embed_dataset(self, inputs: Any, **kwargs: Any) -> EmbeddingResult:
100
+ """Embed single-chain proteins using the learned 256-wide ESMFold2 summary."""
101
+
102
+ return embed_dataset(self, inputs, **kwargs)
103
+
104
+
105
+ __all__ = ["ESMFold2EmbeddingMixin"]
fastplms/models/esmfold2/esmfold2_affine3d.py ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Differentiable rigid rotations and affine transforms for ESMFold2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Self
7
+
8
+ import torch
9
+ from torch.nn import functional as F
10
+
11
+ from .esmfold2_misc import fp32_autocast_context
12
+
13
+
14
+ def _index_tuple(index: Any) -> tuple[Any, ...]:
15
+ if isinstance(index, int) or index is None:
16
+ return (index,)
17
+ return tuple(index)
18
+
19
+
20
+ def _sqrt_subgradient(values: torch.Tensor) -> torch.Tensor:
21
+ """Square root with a zero subgradient for non-positive inputs."""
22
+
23
+ result = torch.zeros_like(values)
24
+ positive = values > 0
25
+ result[positive] = torch.sqrt(values[positive])
26
+ return result
27
+
28
+
29
+ def _quat_invert(quaternion: torch.Tensor) -> torch.Tensor:
30
+ conjugate_sign = torch.tensor([1, -1, -1, -1], device=quaternion.device)
31
+ return quaternion * conjugate_sign
32
+
33
+
34
+ def _quat_mult(left: torch.Tensor, right: torch.Tensor) -> torch.Tensor:
35
+ """Hamilton product for real-first quaternion tensors."""
36
+
37
+ aw, ax, ay, az = torch.unbind(left, -1)
38
+ bw, bx, by, bz = torch.unbind(right, -1)
39
+ return torch.stack(
40
+ (
41
+ aw * bw - ax * bx - ay * by - az * bz,
42
+ aw * bx + ax * bw + ay * bz - az * by,
43
+ aw * by - ax * bz + ay * bw + az * bx,
44
+ aw * bz + ax * by - ay * bx + az * bw,
45
+ ),
46
+ -1,
47
+ )
48
+
49
+
50
+ def _quat_rotation(
51
+ quaternion: torch.Tensor,
52
+ points: torch.Tensor,
53
+ ) -> torch.Tensor:
54
+ """Rotate points using normalized real-first quaternions."""
55
+
56
+ aw, ax, ay, az = torch.unbind(quaternion, -1)
57
+ bx, by, bz = torch.unbind(points, -1)
58
+ product = torch.stack(
59
+ (
60
+ -ax * bx - ay * by - az * bz,
61
+ aw * bx + ay * bz - az * by,
62
+ aw * by - ax * bz + az * bx,
63
+ aw * bz + ax * by - ay * bx,
64
+ ),
65
+ -1,
66
+ )
67
+ return _quat_mult(product, _quat_invert(quaternion))[..., 1:]
68
+
69
+
70
+ def _graham_schmidt(
71
+ x_axis: torch.Tensor,
72
+ xy_plane: torch.Tensor,
73
+ eps: float = 1e-12,
74
+ ) -> torch.Tensor:
75
+ """Construct a right-handed orthonormal frame from two directions."""
76
+
77
+ with fp32_autocast_context(x_axis.device.type):
78
+ e1 = xy_plane
79
+ denominator = torch.sqrt((x_axis**2).sum(dim=-1, keepdim=True) + eps)
80
+ x_axis = x_axis / denominator
81
+ projection = (x_axis * e1).sum(dim=-1, keepdim=True)
82
+ e1 = e1 - x_axis * projection
83
+ denominator = torch.sqrt((e1**2).sum(dim=-1, keepdim=True) + eps)
84
+ e1 = e1 / denominator
85
+ e2 = torch.cross(x_axis, e1, dim=-1)
86
+ return torch.stack([x_axis, e1, e2], dim=-1)
87
+
88
+
89
+ class Rotation:
90
+ """Common interface for matrix-backed and quaternion-backed rotations."""
91
+
92
+ @classmethod
93
+ def identity(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ...
94
+
95
+ @classmethod
96
+ def random(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ...
97
+
98
+ def __getitem__(self, idx: Any) -> Self: ...
99
+
100
+ @property
101
+ def tensor(self) -> torch.Tensor: ...
102
+
103
+ @property
104
+ def shape(self) -> torch.Size: ...
105
+
106
+ def as_matrix(self) -> RotationMatrix: ...
107
+
108
+ def as_quat(self, normalize: bool = False) -> RotationQuat: ...
109
+
110
+ def compose(self, other: Self) -> Self: ...
111
+
112
+ def convert_compose(self, other: Self) -> Self: ...
113
+
114
+ def apply(self, points: torch.Tensor) -> torch.Tensor: ...
115
+
116
+ def invert(self) -> Self: ...
117
+
118
+ @property
119
+ def dtype(self) -> torch.dtype:
120
+ return self.tensor.dtype
121
+
122
+ @property
123
+ def device(self) -> torch.device:
124
+ return self.tensor.device
125
+
126
+ @property
127
+ def requires_grad(self) -> bool:
128
+ return self.tensor.requires_grad
129
+
130
+ @classmethod
131
+ def _from_tensor(cls, tensor: torch.Tensor) -> Self:
132
+ return cls(tensor) # type: ignore[call-arg]
133
+
134
+ def to(self, **kwargs) -> Self:
135
+ return self._from_tensor(self.tensor.to(**kwargs))
136
+
137
+ def detach(self, *args, **kwargs) -> Self:
138
+ return self._from_tensor(self.tensor.detach(**kwargs))
139
+
140
+ def tensor_apply(self, func) -> Self:
141
+ transformed = [func(component) for component in self.tensor.unbind(dim=-1)]
142
+ return self._from_tensor(torch.stack(transformed, dim=-1))
143
+
144
+
145
+ class RotationQuat(Rotation):
146
+ """A rotation represented by a real-first quaternion."""
147
+
148
+ def __init__(self, quats: torch.Tensor, normalized: bool = False):
149
+ if not isinstance(quats, torch.Tensor):
150
+ raise TypeError("quats must be a Torch tensor.")
151
+ if quats.ndim == 0 or quats.shape[-1] != 4:
152
+ raise ValueError(
153
+ f"quats must have trailing dimension 4, got shape {tuple(quats.shape)}."
154
+ )
155
+ if not isinstance(normalized, bool):
156
+ raise TypeError("normalized must be a boolean.")
157
+ self._normalized = normalized
158
+ if normalized:
159
+ quats = F.normalize(quats.to(torch.float32), dim=-1)
160
+ self._quats = quats.where(quats[..., :1] >= 0, -quats)
161
+ else:
162
+ self._quats = quats.to(torch.float32)
163
+
164
+ @property
165
+ def tensor(self) -> torch.Tensor:
166
+ return self._quats
167
+
168
+ @property
169
+ def shape(self) -> torch.Size:
170
+ return self._quats.shape[:-1]
171
+
172
+ @classmethod
173
+ def identity(cls, shape, **tensor_kwargs) -> RotationQuat:
174
+ quaternions = torch.ones((*shape, 4), **tensor_kwargs)
175
+ selector = torch.tensor([1, 0, 0, 0], device=quaternions.device)
176
+ return cls(quaternions * selector)
177
+
178
+ @classmethod
179
+ def random(cls, shape, **tensor_kwargs) -> RotationQuat:
180
+ return cls(torch.randn((*shape, 4), **tensor_kwargs), normalized=True)
181
+
182
+ def __getitem__(self, idx: Any) -> RotationQuat:
183
+ indices = _index_tuple(idx)
184
+ return RotationQuat(self._quats[(*indices, slice(None))])
185
+
186
+ def normalized(self) -> RotationQuat:
187
+ if self._normalized:
188
+ return self
189
+ return RotationQuat(self._quats, normalized=True)
190
+
191
+ def as_quat(self, normalize: bool = False) -> RotationQuat:
192
+ return self
193
+
194
+ def as_matrix(self) -> RotationMatrix:
195
+ quaternion = self.normalized().tensor
196
+ r, i, j, k = torch.unbind(quaternion, -1)
197
+ scale = 2.0 / torch.linalg.norm(quaternion, dim=-1)
198
+ elements = torch.stack(
199
+ (
200
+ 1 - scale * (j * j + k * k),
201
+ scale * (i * j - k * r),
202
+ scale * (i * k + j * r),
203
+ scale * (i * j + k * r),
204
+ 1 - scale * (i * i + k * k),
205
+ scale * (j * k - i * r),
206
+ scale * (i * k - j * r),
207
+ scale * (j * k + i * r),
208
+ 1 - scale * (i * i + j * j),
209
+ ),
210
+ -1,
211
+ )
212
+ return RotationMatrix(elements.reshape((*quaternion.shape[:-1], 3, 3)))
213
+
214
+ def compose(self, other: RotationQuat) -> RotationQuat:
215
+ with fp32_autocast_context(self.device.type):
216
+ return RotationQuat(_quat_mult(self._quats, other._quats))
217
+
218
+ def convert_compose(self, other: Rotation) -> RotationQuat:
219
+ return self.compose(other.as_quat())
220
+
221
+ def apply(self, points: torch.Tensor) -> torch.Tensor:
222
+ return _quat_rotation(self.normalized()._quats, points)
223
+
224
+ def invert(self) -> RotationQuat:
225
+ return RotationQuat(_quat_invert(self._quats))
226
+
227
+
228
+ class RotationMatrix(Rotation):
229
+ """A rotation represented by a dense FP32 matrix."""
230
+
231
+ def __init__(self, rots: torch.Tensor):
232
+ if not isinstance(rots, torch.Tensor):
233
+ raise TypeError("rots must be a Torch tensor.")
234
+ if rots.ndim > 0 and rots.shape[-1] == 9:
235
+ rots = rots.unflatten(-1, (3, 3))
236
+ if rots.ndim < 2 or rots.shape[-2:] != (3, 3):
237
+ raise ValueError(
238
+ "rots must have trailing shape (3, 3) or flattened width 9, got "
239
+ f"shape {tuple(rots.shape)}."
240
+ )
241
+ self._rots = rots.to(torch.float32)
242
+
243
+ @property
244
+ def tensor(self) -> torch.Tensor:
245
+ return self._rots.flatten(-2)
246
+
247
+ @property
248
+ def shape(self) -> torch.Size:
249
+ return self._rots.shape[:-2]
250
+
251
+ @classmethod
252
+ def identity(cls, shape, **tensor_kwargs) -> RotationMatrix:
253
+ matrix = torch.eye(3, **tensor_kwargs)
254
+ matrix = matrix.view(*(1 for _ in shape), 3, 3)
255
+ return cls(matrix.expand(*shape, -1, -1))
256
+
257
+ @classmethod
258
+ def random(cls, shape, **tensor_kwargs) -> RotationMatrix:
259
+ return RotationQuat.random(shape, **tensor_kwargs).as_matrix()
260
+
261
+ @staticmethod
262
+ def from_graham_schmidt(
263
+ x_axis: torch.Tensor,
264
+ xy_plane: torch.Tensor,
265
+ eps: float = 1e-12,
266
+ ) -> RotationMatrix:
267
+ return RotationMatrix(_graham_schmidt(x_axis, xy_plane, eps))
268
+
269
+ def __getitem__(self, idx: Any) -> RotationMatrix:
270
+ indices = _index_tuple(idx)
271
+ return RotationMatrix(self._rots[(*indices, slice(None), slice(None))])
272
+
273
+ def as_matrix(self) -> RotationMatrix:
274
+ return self
275
+
276
+ def to_3x3(self) -> torch.Tensor:
277
+ return self._rots
278
+
279
+ def as_quat(self, normalize: bool = False) -> RotationQuat:
280
+ m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind(
281
+ self._rots.flatten(-2),
282
+ dim=-1,
283
+ )
284
+ q_abs = _sqrt_subgradient(
285
+ torch.stack(
286
+ (
287
+ 1.0 + m00 + m11 + m22,
288
+ 1.0 + m00 - m11 - m22,
289
+ 1.0 - m00 + m11 - m22,
290
+ 1.0 - m00 - m11 + m22,
291
+ ),
292
+ dim=-1,
293
+ )
294
+ )
295
+ products = torch.stack(
296
+ (
297
+ q_abs[..., 0] ** 2,
298
+ m21 - m12,
299
+ m02 - m20,
300
+ m10 - m01,
301
+ m21 - m12,
302
+ q_abs[..., 1] ** 2,
303
+ m10 + m01,
304
+ m02 + m20,
305
+ m02 - m20,
306
+ m10 + m01,
307
+ q_abs[..., 2] ** 2,
308
+ m12 + m21,
309
+ m10 - m01,
310
+ m20 + m02,
311
+ m21 + m12,
312
+ q_abs[..., 3] ** 2,
313
+ ),
314
+ dim=-1,
315
+ ).unflatten(-1, (4, 4))
316
+ floor = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device)
317
+ candidates = products / (2.0 * q_abs[..., None].max(floor))
318
+ best = torch.zeros_like(q_abs, dtype=torch.bool)
319
+ best.scatter_(-1, q_abs.argmax(dim=-1, keepdim=True), True)
320
+ quaternion = candidates[best, :].reshape(q_abs.shape)
321
+ return RotationQuat(quaternion)
322
+
323
+ def compose(self, other: RotationMatrix) -> RotationMatrix:
324
+ with fp32_autocast_context(self.device.type):
325
+ return RotationMatrix(self._rots @ other._rots)
326
+
327
+ def convert_compose(self, other: Rotation) -> RotationMatrix:
328
+ return self.compose(other.as_matrix())
329
+
330
+ def apply(self, points: torch.Tensor) -> torch.Tensor:
331
+ with fp32_autocast_context(self.device.type):
332
+ if self._rots.shape[-3] == 1:
333
+ return points @ self._rots.transpose(-1, -2).squeeze(-3)
334
+ return torch.einsum("...ij,...j", self._rots, points)
335
+
336
+ def invert(self) -> RotationMatrix:
337
+ return RotationMatrix(self._rots.transpose(-1, -2))
338
+
339
+
340
+ @dataclass(frozen=True)
341
+ class Affine3D:
342
+ """A rigid transform with translation and rotation components."""
343
+
344
+ trans: torch.Tensor
345
+ rot: Rotation
346
+
347
+ def __post_init__(self) -> None:
348
+ if not isinstance(self.trans, torch.Tensor):
349
+ raise TypeError("trans must be a Torch tensor.")
350
+ if not isinstance(self.rot, Rotation):
351
+ raise TypeError("rot must implement the ESMFold2 Rotation interface.")
352
+ if self.trans.ndim == 0 or self.trans.shape[-1] != 3:
353
+ raise ValueError(
354
+ "trans must have trailing dimension 3, got "
355
+ f"shape {tuple(self.trans.shape)}."
356
+ )
357
+ if self.trans.shape[:-1] != self.rot.shape:
358
+ raise ValueError(
359
+ "translation and rotation batch shapes must match, got "
360
+ f"{tuple(self.trans.shape[:-1])} and {tuple(self.rot.shape)}."
361
+ )
362
+
363
+ @property
364
+ def shape(self) -> torch.Size:
365
+ return self.trans.shape[:-1]
366
+
367
+ @property
368
+ def dtype(self) -> torch.dtype:
369
+ return self.trans.dtype
370
+
371
+ @property
372
+ def device(self) -> torch.device:
373
+ return self.trans.device
374
+
375
+ @property
376
+ def requires_grad(self) -> bool:
377
+ return self.trans.requires_grad
378
+
379
+ @property
380
+ def tensor(self) -> torch.Tensor:
381
+ return torch.cat((self.rot.tensor, self.trans), dim=-1)
382
+
383
+ @staticmethod
384
+ def identity(
385
+ shape_or_affine: tuple[int, ...] | Affine3D,
386
+ rotation_type: type[Rotation] = RotationMatrix,
387
+ **tensor_kwargs,
388
+ ) -> Affine3D:
389
+ if isinstance(shape_or_affine, Affine3D):
390
+ kwargs = {
391
+ "dtype": shape_or_affine.dtype,
392
+ "device": shape_or_affine.device,
393
+ }
394
+ kwargs.update(tensor_kwargs)
395
+ shape = shape_or_affine.shape
396
+ rotation_type = type(shape_or_affine.rot)
397
+ else:
398
+ kwargs = tensor_kwargs
399
+ shape = shape_or_affine
400
+ return Affine3D(
401
+ torch.zeros((*shape, 3), **kwargs),
402
+ rotation_type.identity(shape, **kwargs),
403
+ )
404
+
405
+ @staticmethod
406
+ def random(
407
+ shape: tuple[int, ...],
408
+ std: float = 1,
409
+ rotation_type: type[Rotation] = RotationMatrix,
410
+ **tensor_kwargs,
411
+ ) -> Affine3D:
412
+ translation = torch.randn((*shape, 3), **tensor_kwargs).mul(std)
413
+ rotation = rotation_type.random(shape, **tensor_kwargs)
414
+ return Affine3D(trans=translation, rot=rotation)
415
+
416
+ @staticmethod
417
+ def from_tensor(tensor: torch.Tensor) -> Affine3D:
418
+ if not isinstance(tensor, torch.Tensor):
419
+ raise TypeError("tensor must be a Torch tensor.")
420
+ if tensor.ndim == 0:
421
+ raise ValueError("tensor must have at least one dimension.")
422
+ width = tensor.shape[-1]
423
+ if width == 4:
424
+ if tensor.ndim < 2 or tensor.shape[-2] not in (3, 4):
425
+ raise ValueError(
426
+ "matrix-form affine tensors must have trailing shape (3, 4) or "
427
+ f"(4, 4), got {tuple(tensor.shape)}."
428
+ )
429
+ translation = tensor[..., :3, 3]
430
+ rotation: Rotation = RotationMatrix(tensor[..., :3, :3])
431
+ elif width == 6:
432
+ translation = tensor[..., -3:]
433
+ rotation = RotationQuat(F.pad(tensor[..., :3], (1, 0), value=1))
434
+ elif width == 7:
435
+ translation = tensor[..., -3:]
436
+ rotation = RotationQuat(tensor[..., :4])
437
+ elif width == 12:
438
+ translation = tensor[..., -3:]
439
+ rotation = RotationMatrix(tensor[..., :-3].unflatten(-1, (3, 3)))
440
+ else:
441
+ raise RuntimeError(
442
+ f"Cannot detect rotation format from {tensor.shape[-1] - 3}-d flat vector"
443
+ )
444
+ return Affine3D(translation, rotation)
445
+
446
+ @staticmethod
447
+ def from_tensor_pair(
448
+ translation: torch.Tensor,
449
+ rotation: torch.Tensor,
450
+ ) -> Affine3D:
451
+ return Affine3D(translation, RotationMatrix(rotation))
452
+
453
+ @staticmethod
454
+ def from_graham_schmidt(
455
+ neg_x_axis: torch.Tensor,
456
+ origin: torch.Tensor,
457
+ xy_plane: torch.Tensor,
458
+ eps: float = 1e-10,
459
+ ) -> Affine3D:
460
+ x_axis = origin - neg_x_axis
461
+ plane_direction = xy_plane - origin
462
+ rotation = RotationMatrix.from_graham_schmidt(
463
+ x_axis,
464
+ plane_direction,
465
+ eps,
466
+ )
467
+ return Affine3D(trans=origin, rot=rotation)
468
+
469
+ @staticmethod
470
+ def cat(affines: list[Affine3D], dim: int = 0) -> Affine3D:
471
+ if not affines:
472
+ raise ValueError("affines must contain at least one transform.")
473
+ if any(not isinstance(affine, Affine3D) for affine in affines):
474
+ raise TypeError("affines must contain only Affine3D instances.")
475
+ if dim < 0:
476
+ dim = len(affines[0].shape) + dim
477
+ return Affine3D.from_tensor(torch.cat([affine.tensor for affine in affines], dim=dim))
478
+
479
+ def __getitem__(self, idx: Any) -> Affine3D:
480
+ indices = _index_tuple(idx)
481
+ translation = self.trans[(*indices, slice(None))]
482
+ return Affine3D(trans=translation, rot=self.rot[idx])
483
+
484
+ def to(self, **kwargs) -> Affine3D:
485
+ return Affine3D(self.trans.to(**kwargs), self.rot.to(**kwargs))
486
+
487
+ def detach(self, *args, **kwargs) -> Affine3D:
488
+ return Affine3D(
489
+ self.trans.detach(**kwargs),
490
+ self.rot.detach(**kwargs),
491
+ )
492
+
493
+ def tensor_apply(self, func) -> Affine3D:
494
+ components = [func(value) for value in self.tensor.unbind(dim=-1)]
495
+ return Affine3D.from_tensor(torch.stack(components, dim=-1))
496
+
497
+ def as_matrix(self) -> Affine3D:
498
+ return Affine3D(trans=self.trans, rot=self.rot.as_matrix())
499
+
500
+ def as_quat(self, normalize: bool = False) -> Affine3D:
501
+ return Affine3D(
502
+ trans=self.trans,
503
+ rot=self.rot.as_quat(normalize),
504
+ )
505
+
506
+ def compose(
507
+ self,
508
+ other: Affine3D,
509
+ autoconvert: bool = False,
510
+ ) -> Affine3D:
511
+ compose_rotation = self.rot.convert_compose if autoconvert else self.rot.compose
512
+ rotation = compose_rotation(other.rot)
513
+ translation = self.rot.apply(other.trans) + self.trans
514
+ return Affine3D(trans=translation, rot=rotation)
515
+
516
+ def compose_rotation(
517
+ self,
518
+ other: Rotation,
519
+ autoconvert: bool = False,
520
+ ) -> Affine3D:
521
+ compose = self.rot.convert_compose if autoconvert else self.rot.compose
522
+ return Affine3D(trans=self.trans, rot=compose(other))
523
+
524
+ def scale(self, value: torch.Tensor | float) -> Affine3D:
525
+ return Affine3D(self.trans * value, self.rot)
526
+
527
+ def mask(self, mask: torch.Tensor, with_zero: bool = False) -> Affine3D:
528
+ if with_zero:
529
+ masked = torch.zeros_like(self.tensor).where(
530
+ mask[..., None],
531
+ self.tensor,
532
+ )
533
+ return Affine3D.from_tensor(masked)
534
+ identity = self.identity(
535
+ self.shape,
536
+ rotation_type=type(self.rot),
537
+ device=self.device,
538
+ dtype=self.dtype,
539
+ ).tensor
540
+ return Affine3D.from_tensor(identity.where(mask[..., None], self.tensor))
541
+
542
+ def apply(self, points: torch.Tensor) -> torch.Tensor:
543
+ return self.rot.apply(points) + self.trans
544
+
545
+ def invert(self) -> Affine3D:
546
+ rotation = self.rot.invert()
547
+ return Affine3D(trans=-rotation.apply(self.trans), rot=rotation)
548
+
549
+
550
+ def build_affine3d_from_coordinates(
551
+ coords: torch.Tensor,
552
+ ) -> tuple[Affine3D, torch.Tensor]:
553
+ """Build residue frames from X with shape (b, l, 3, 3)."""
554
+
555
+ if not isinstance(coords, torch.Tensor):
556
+ raise TypeError("coords must be a Torch tensor.")
557
+ if coords.ndim != 4 or coords.shape[-2:] != (3, 3):
558
+ raise ValueError(
559
+ "coords must have shape (batch, length, 3, 3), got "
560
+ f"{tuple(coords.shape)}."
561
+ )
562
+
563
+ maximum_distance = 1e6
564
+ coord_mask = torch.all(
565
+ torch.all(
566
+ torch.isfinite(coords) & (coords < maximum_distance),
567
+ dim=-1,
568
+ ),
569
+ dim=-1,
570
+ )
571
+
572
+ def backbone_affine(positions: torch.Tensor) -> Affine3D:
573
+ n, ca, c = positions.unbind(dim=-2)
574
+ return Affine3D.from_graham_schmidt(c, ca, n)
575
+
576
+ coords = coords.clone().float()
577
+ coords[~coord_mask] = 0
578
+ average = coords.masked_fill(~coord_mask[..., None, None], 0).sum(1) / (
579
+ coord_mask.sum(-1)[..., None, None] + 1e-8
580
+ )
581
+ average_affine = backbone_affine(average.float()).as_matrix()
582
+
583
+ b, length, _, _ = coords.shape
584
+ rotation = average_affine.rot.tensor[..., None, :].expand(b, length, 9)
585
+ translation = average_affine.trans[..., None, :].expand(b, length, 3)
586
+ identity = RotationMatrix.identity(
587
+ (b, length),
588
+ dtype=torch.float32,
589
+ device=coords.device,
590
+ requires_grad=False,
591
+ )
592
+ rotation = rotation.where(
593
+ coord_mask.any(-1)[..., None, None],
594
+ identity.tensor,
595
+ )
596
+ missing_frame = Affine3D(translation, RotationMatrix(rotation))
597
+
598
+ residue_frame = backbone_affine(coords.float())
599
+ residue_frame = Affine3D.from_tensor(
600
+ residue_frame.tensor.where(
601
+ coord_mask[..., None],
602
+ missing_frame.tensor,
603
+ )
604
+ )
605
+ return residue_frame, coord_mask
fastplms/models/esmfold2/esmfold2_aligner.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rigid alignment for structure dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import Field, replace
6
+ from typing import Any, ClassVar, Protocol, TypeVar
7
+
8
+ import numpy as np
9
+ import torch
10
+ from torch import Tensor
11
+
12
+ from .esmfold2_protein_structure import compute_affine_and_rmsd
13
+
14
+
15
+ class Alignable(Protocol):
16
+ """Minimum structure interface accepted by :class:`Aligner`."""
17
+
18
+ __dataclass_fields__: ClassVar[dict[str, Field[Any]]]
19
+
20
+ @property
21
+ def atom37_positions(self) -> np.ndarray: ...
22
+
23
+ @property
24
+ def atom37_mask(self) -> np.ndarray: ...
25
+
26
+ def __len__(self) -> int: ...
27
+
28
+
29
+ AlignableT = TypeVar("AlignableT", bound=Alignable)
30
+
31
+
32
+ def _coordinate_batch(structure: Alignable) -> Tensor:
33
+ return torch.as_tensor(structure.atom37_positions, dtype=torch.double).unsqueeze(0)
34
+
35
+
36
+ def _shared_atom_mask(mobile: Alignable, target: Alignable, backbone_only: bool) -> Tensor:
37
+ shared = np.asarray(mobile.atom37_mask, dtype=bool) & np.asarray(
38
+ target.atom37_mask,
39
+ dtype=bool,
40
+ )
41
+ if backbone_only:
42
+ shared = shared.copy()
43
+ shared[:, 3:] = False
44
+ return torch.from_numpy(shared).unsqueeze(0)
45
+
46
+
47
+ class Aligner:
48
+ """Fit a mobile structure onto a target with masked Kabsch alignment."""
49
+
50
+ def __init__(
51
+ self,
52
+ mobile: Alignable,
53
+ target: Alignable,
54
+ only_use_backbone: bool = False,
55
+ use_reflection: bool = False,
56
+ ) -> None:
57
+ if len(mobile) != len(target):
58
+ raise AssertionError("mobile and target must contain the same residue count")
59
+
60
+ mobile_coordinates = _coordinate_batch(mobile)
61
+ target_coordinates = _coordinate_batch(target)
62
+ if use_reflection:
63
+ target_coordinates = -target_coordinates
64
+ atom_mask = _shared_atom_mask(mobile, target, only_use_backbone)
65
+ self._affine3D, rmsd = compute_affine_and_rmsd(
66
+ mobile_coordinates,
67
+ target_coordinates,
68
+ atom_exists_mask=atom_mask,
69
+ )
70
+ self._rmsd = rmsd.item()
71
+
72
+ @property
73
+ def rmsd(self) -> float:
74
+ return self._rmsd
75
+
76
+ def apply(self, mobile: AlignableT) -> AlignableT:
77
+ """Return a dataclass copy with all present atom coordinates aligned."""
78
+
79
+ present = np.asarray(mobile.atom37_mask, dtype=bool)
80
+ packed = torch.as_tensor(
81
+ mobile.atom37_positions[present],
82
+ dtype=torch.float32,
83
+ ).unsqueeze(0)
84
+ aligned = self._affine3D.apply(packed).squeeze(0).cpu().numpy()
85
+ atom37_positions = np.full_like(mobile.atom37_positions, np.nan)
86
+ atom37_positions[present] = aligned
87
+ return replace(mobile, atom37_positions=atom37_positions)
fastplms/models/esmfold2/esmfold2_atom_indexer.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Name-based views into an atom-axis property."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from operator import attrgetter
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from .esmfold2_protein_structure import index_by_atom_name
11
+
12
+
13
+ class AtomIndexer:
14
+ """Select named atoms from one property of a structure-like object.
15
+
16
+ The wrapper intentionally remains small because ``ProteinChain.atom37`` and
17
+ related public properties expose it directly.
18
+ """
19
+
20
+ __slots__ = ("_get_property", "dim", "property", "structure")
21
+
22
+ def __init__(self, structure: Any, property: str, dim: int):
23
+ self.structure = structure
24
+ self.property = property
25
+ self.dim = dim
26
+ self._get_property = attrgetter(property)
27
+
28
+ def __getitem__(self, atom_names: str | list[str]) -> np.ndarray:
29
+ values = self._get_property(self.structure)
30
+ return index_by_atom_name(values, atom_names, dim=self.dim)
fastplms/models/esmfold2/esmfold2_conformers.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lazy access to Chemical Component Dictionary conformers.
2
+
3
+ The feature pipeline depends on atom names, formal charges, bonds, leaving-atom
4
+ flags, and one preferred reference conformer. Asset resolution is explicit at
5
+ ``load_ccd`` time; importing this module performs no download or file access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import pickle
12
+ import stat
13
+ import tempfile
14
+ from collections.abc import Iterator
15
+ from contextlib import contextmanager
16
+ from dataclasses import dataclass
17
+ from hashlib import file_digest
18
+ from pathlib import Path
19
+ from typing import Any, BinaryIO
20
+
21
+ import numpy as np
22
+ from huggingface_hub import hf_hub_download
23
+ from huggingface_hub.constants import HF_HUB_CACHE
24
+
25
+ from fastplms.registry import RuntimeAsset, get_model_registry
26
+
27
+ from .esmfold2_constants import RES_TYPE_TO_CCD
28
+
29
+ _CCD_ENVIRONMENT_VARIABLE = "ESMCFOLD_CCD_PATH"
30
+ _CCD_ASSET_ID = "esmfold2_ccd"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class _ResolvedAsset:
35
+ path: Path
36
+ trusted_hub_cache_root: Path | None = None
37
+
38
+
39
+ def _asset_contract() -> RuntimeAsset:
40
+ """Return the manifest-owned identity of the trusted CCD pickle."""
41
+
42
+ try:
43
+ asset = get_model_registry().runtime_assets[_CCD_ASSET_ID]
44
+ except KeyError as error:
45
+ raise RuntimeError(
46
+ f"The package manifest does not declare runtime asset {_CCD_ASSET_ID!r}."
47
+ ) from error
48
+ if asset.trust_kind != "hash_pinned_pickle":
49
+ raise RuntimeError(
50
+ f"Runtime asset {_CCD_ASSET_ID!r} must use the hash_pinned_pickle trust policy."
51
+ )
52
+ return asset
53
+
54
+
55
+ @contextmanager
56
+ def _open_verified_asset(
57
+ asset_path: Path,
58
+ contract: RuntimeAsset,
59
+ *,
60
+ trusted_hub_cache_root: Path | None = None,
61
+ ) -> Iterator[BinaryIO]:
62
+ """Yield a private snapshot containing exactly the verified pickle bytes."""
63
+
64
+ try:
65
+ path_state = asset_path.lstat()
66
+ except FileNotFoundError as error:
67
+ raise FileNotFoundError(f"CCD asset does not exist: {asset_path}") from error
68
+ opened_path = asset_path
69
+ if stat.S_ISLNK(path_state.st_mode):
70
+ if trusted_hub_cache_root is None:
71
+ raise ValueError(f"CCD asset must not be a symlink: {asset_path}")
72
+ opened_path = _resolve_trusted_hub_snapshot_link(
73
+ asset_path,
74
+ contract,
75
+ trusted_hub_cache_root,
76
+ )
77
+ path_state = opened_path.lstat()
78
+ if not stat.S_ISREG(path_state.st_mode):
79
+ raise ValueError(f"CCD asset must be a regular file: {asset_path}")
80
+
81
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
82
+ flags |= getattr(os, "O_NOFOLLOW", 0)
83
+ descriptor: int | None = None
84
+ try:
85
+ descriptor = os.open(opened_path, flags)
86
+ opened_state = os.fstat(descriptor)
87
+ if not stat.S_ISREG(opened_state.st_mode):
88
+ raise ValueError(f"CCD asset must be a regular file: {asset_path}")
89
+ if (path_state.st_dev, path_state.st_ino) != (
90
+ opened_state.st_dev,
91
+ opened_state.st_ino,
92
+ ):
93
+ raise ValueError(f"CCD asset changed while it was being opened: {asset_path}")
94
+
95
+ source = os.fdopen(descriptor, "rb")
96
+ descriptor = None
97
+ with source, tempfile.TemporaryFile(mode="w+b") as snapshot:
98
+ actual_size = opened_state.st_size
99
+ if actual_size != contract.size:
100
+ raise ValueError(
101
+ "CCD asset size mismatch: "
102
+ f"expected {contract.size} bytes, received {actual_size}."
103
+ )
104
+ # Copy into a loader-owned OS temporary file. Hashing and
105
+ # deserialization then consume the same immutable snapshot, so a
106
+ # path replacement or in-place source write cannot substitute
107
+ # unverified pickle bytes after validation.
108
+ remaining = contract.size
109
+ while remaining:
110
+ chunk = source.read(min(1024 * 1024, remaining))
111
+ if not chunk:
112
+ break
113
+ snapshot.write(chunk)
114
+ remaining -= len(chunk)
115
+ copied_size = snapshot.tell()
116
+ extra_byte = source.read(1)
117
+ if remaining or extra_byte:
118
+ observed_size = copied_size if remaining else copied_size + len(extra_byte)
119
+ raise ValueError(
120
+ "CCD asset size changed while it was being copied: "
121
+ f"expected {contract.size} bytes, received at least {observed_size}."
122
+ )
123
+ snapshot.flush()
124
+ snapshot.seek(0)
125
+ actual_hash = file_digest(snapshot, "sha256").hexdigest()
126
+ if actual_hash != contract.sha256:
127
+ raise ValueError(
128
+ "CCD asset SHA256 mismatch; refusing to cross the "
129
+ "trusted-pickle boundary."
130
+ )
131
+ snapshot.seek(0)
132
+ yield snapshot
133
+ finally:
134
+ if descriptor is not None:
135
+ os.close(descriptor)
136
+
137
+
138
+ def _resolve_trusted_hub_snapshot_link(
139
+ asset_path: Path,
140
+ contract: RuntimeAsset,
141
+ cache_root: Path,
142
+ ) -> Path:
143
+ """Resolve only the immutable Hub snapshot link declared by the manifest."""
144
+
145
+ root = cache_root.expanduser().resolve(strict=True)
146
+ if len(contract.revision) != 40 or any(
147
+ character not in "0123456789abcdef" for character in contract.revision.lower()
148
+ ):
149
+ raise ValueError("CCD Hub asset revision must be an immutable 40-character commit.")
150
+ relative_asset = Path(contract.path)
151
+ if relative_asset.is_absolute() or ".." in relative_asset.parts:
152
+ raise ValueError(f"CCD Hub asset path is unsafe: {contract.path!r}")
153
+ repository_cache = root / f"models--{contract.repository.replace('/', '--')}"
154
+ try:
155
+ repository_cache.resolve(strict=True).relative_to(root)
156
+ except (FileNotFoundError, ValueError) as error:
157
+ raise ValueError(
158
+ f"CCD Hub repository cache escapes the effective Hub cache root: {repository_cache}"
159
+ ) from error
160
+ snapshot_root = repository_cache / "snapshots" / contract.revision
161
+ expected_path = snapshot_root / relative_asset
162
+ lexical_path = Path(os.path.abspath(asset_path))
163
+ if lexical_path != Path(os.path.abspath(expected_path)):
164
+ raise ValueError(
165
+ "CCD Hub symlink is not the manifest-owned immutable snapshot path: "
166
+ f"{asset_path}"
167
+ )
168
+
169
+ try:
170
+ asset_path.parent.resolve(strict=True).relative_to(root)
171
+ except (FileNotFoundError, ValueError) as error:
172
+ raise ValueError(
173
+ f"CCD Hub snapshot path escapes the effective Hub cache root: {asset_path}"
174
+ ) from error
175
+
176
+ resolved = asset_path.resolve(strict=True)
177
+ blob_root = (repository_cache / "blobs").resolve(strict=True)
178
+ try:
179
+ blob_root.relative_to(root)
180
+ resolved.relative_to(blob_root)
181
+ except ValueError as error:
182
+ raise ValueError(
183
+ f"CCD Hub snapshot link escapes its repository blob cache: {asset_path}"
184
+ ) from error
185
+ if not resolved.is_file() or resolved.is_symlink():
186
+ raise ValueError(f"CCD Hub snapshot target must be a regular file: {resolved}")
187
+ return resolved
188
+
189
+
190
+ class _ChemicalComponentStore:
191
+ def __init__(self) -> None:
192
+ self.molecules: dict[str, Any] | None = None
193
+ self.conformers: dict[str, dict[str, np.ndarray]] = {}
194
+ self.atoms: dict[str, list[tuple[str, str, int]]] = {}
195
+ self.bonds: dict[str, list[tuple[str, str]]] = {}
196
+ self.leaving_atoms: dict[str, set[str]] = {}
197
+ self.standard_positions: dict[tuple[int, str], np.ndarray | None] = {}
198
+ self.ligand_positions: dict[tuple[str, str], np.ndarray | None] = {}
199
+
200
+ def load(self, cache_dir: Path | str | None = None) -> dict[str, Any]:
201
+ if self.molecules is not None:
202
+ return self.molecules
203
+ contract = _asset_contract()
204
+ resolved = self._resolve_asset_location(cache_dir, contract)
205
+ asset = resolved.path
206
+ try:
207
+ # SECURITY: the private snapshot is both hash-validated and
208
+ # deserialized, closing path-replacement and in-place-write races.
209
+ with _open_verified_asset(
210
+ asset,
211
+ contract,
212
+ trusted_hub_cache_root=resolved.trusted_hub_cache_root,
213
+ ) as handle:
214
+ loaded = pickle.load(handle)
215
+ except FileNotFoundError:
216
+ raise
217
+ except Exception as error:
218
+ raise ValueError(f"Could not read the CCD asset at {asset}: {error}") from error
219
+ if loaded is not None and not isinstance(loaded, dict):
220
+ raise TypeError("The CCD asset must contain a component dictionary.")
221
+ self.molecules = loaded or {}
222
+ return self.molecules
223
+
224
+ @staticmethod
225
+ def _resolve_asset(cache_dir: Path | str | None) -> Path:
226
+ contract = _asset_contract()
227
+ return _ChemicalComponentStore._resolve_asset_location(cache_dir, contract).path
228
+
229
+ @staticmethod
230
+ def _resolve_asset_location(
231
+ cache_dir: Path | str | None,
232
+ contract: RuntimeAsset,
233
+ ) -> _ResolvedAsset:
234
+ configured = os.environ.get(_CCD_ENVIRONMENT_VARIABLE)
235
+ if configured:
236
+ asset = Path(configured).expanduser()
237
+ elif cache_dir is not None:
238
+ asset = Path(cache_dir).expanduser() / contract.path
239
+ else:
240
+ try:
241
+ asset = Path(
242
+ hf_hub_download(
243
+ repo_id=contract.repository,
244
+ filename=contract.path,
245
+ revision=contract.revision,
246
+ )
247
+ )
248
+ except Exception as error:
249
+ raise FileNotFoundError(
250
+ "Could not resolve the ESMFold2 CCD asset. Set "
251
+ f"{_CCD_ENVIRONMENT_VARIABLE} or populate the Hugging Face cache."
252
+ ) from error
253
+ return _ResolvedAsset(
254
+ path=asset,
255
+ trusted_hub_cache_root=Path(HF_HUB_CACHE),
256
+ )
257
+ return _ResolvedAsset(path=asset)
258
+
259
+ def _component_with_conformer(self, component_id: str):
260
+ molecule = self.load().get(component_id)
261
+ if molecule is None or molecule.GetNumConformers() == 0:
262
+ return None, None
263
+
264
+ conformers = list(molecule.GetConformers())
265
+ priority = {"Computed": 0, "Ideal": 1}
266
+ selected_index = min(
267
+ range(len(conformers)),
268
+ key=lambda index: priority.get(conformers[index].GetPropsAsDict().get("name"), 2),
269
+ )
270
+
271
+ from rdkit import Chem
272
+
273
+ heavy_molecule = Chem.RemoveHs(molecule, sanitize=False)
274
+ if heavy_molecule.GetNumConformers() == 0:
275
+ return None, None
276
+ conformer_index = min(selected_index, heavy_molecule.GetNumConformers() - 1)
277
+ return heavy_molecule, heavy_molecule.GetConformer(conformer_index)
278
+
279
+ def conformer(self, component_id: str) -> dict[str, np.ndarray] | None:
280
+ if component_id not in self.conformers:
281
+ molecule, conformer = self._component_with_conformer(component_id)
282
+ positions: dict[str, np.ndarray] = {}
283
+ if molecule is not None and conformer is not None:
284
+ for atom in molecule.GetAtoms():
285
+ atom_name = atom.GetPropsAsDict().get("name")
286
+ if not isinstance(atom_name, str) or not atom_name:
287
+ continue
288
+ point = conformer.GetAtomPosition(atom.GetIdx())
289
+ positions[atom_name] = np.asarray((point.x, point.y, point.z), dtype=np.float32)
290
+ self.conformers[component_id] = positions
291
+ result = self.conformers[component_id]
292
+ return result or None
293
+
294
+ def atom_records(self, component_id: str) -> list[tuple[str, str, int]] | None:
295
+ if component_id not in self.atoms:
296
+ molecule, _conformer = self._component_with_conformer(component_id)
297
+ records: list[tuple[str, str, int]] = []
298
+ if molecule is not None:
299
+ for atom in molecule.GetAtoms():
300
+ atom_name = atom.GetPropsAsDict().get("name")
301
+ if isinstance(atom_name, str) and atom_name:
302
+ records.append((atom_name, atom.GetSymbol(), atom.GetFormalCharge()))
303
+ self.atoms[component_id] = records
304
+ result = self.atoms[component_id]
305
+ return result or None
306
+
307
+ def bond_records(self, component_id: str) -> list[tuple[str, str]] | None:
308
+ if component_id not in self.bonds:
309
+ molecule, _conformer = self._component_with_conformer(component_id)
310
+ records: list[tuple[str, str]] = []
311
+ if molecule is not None:
312
+ names = {
313
+ atom.GetIdx(): atom.GetPropsAsDict().get("name") for atom in molecule.GetAtoms()
314
+ }
315
+ for bond in molecule.GetBonds():
316
+ first = names.get(bond.GetBeginAtomIdx())
317
+ second = names.get(bond.GetEndAtomIdx())
318
+ if isinstance(first, str) and first and isinstance(second, str) and second:
319
+ records.append((first, second))
320
+ self.bonds[component_id] = records
321
+ result = self.bonds[component_id]
322
+ return result or None
323
+
324
+ def component_leaving_atoms(self, component_id: str) -> set[str]:
325
+ if component_id not in self.leaving_atoms:
326
+ molecule = self.load().get(component_id)
327
+ names: set[str] = set()
328
+ if molecule is not None:
329
+ for atom in molecule.GetAtoms():
330
+ if atom.HasProp("leaving_atom") and atom.GetProp("leaving_atom") == "1":
331
+ name = atom.GetProp("name") if atom.HasProp("name") else ""
332
+ if name:
333
+ names.add(name)
334
+ self.leaving_atoms[component_id] = names
335
+ return self.leaving_atoms[component_id]
336
+
337
+
338
+ _STORE = _ChemicalComponentStore()
339
+
340
+
341
+ def load_ccd(cache_dir: Path | str | None = None) -> dict[str, Any]:
342
+ """Load and cache the CCD asset, resolving it only when called."""
343
+
344
+ return _STORE.load(cache_dir)
345
+
346
+
347
+ def get_ccd_conformer(component_id: str) -> dict[str, np.ndarray] | None:
348
+ """Return the preferred heavy-atom conformer by atom name."""
349
+
350
+ return _STORE.conformer(component_id)
351
+
352
+
353
+ def get_idealized_atom_pos(res_type: int, atom_name: str) -> np.ndarray | None:
354
+ """Return one standard-residue atom position from the preferred conformer."""
355
+
356
+ key = (res_type, atom_name)
357
+ if key not in _STORE.standard_positions:
358
+ component_id = RES_TYPE_TO_CCD.get(res_type)
359
+ conformer = _STORE.conformer(component_id) if component_id is not None else None
360
+ _STORE.standard_positions[key] = None if conformer is None else conformer.get(atom_name)
361
+ return _STORE.standard_positions[key]
362
+
363
+
364
+ def get_ligand_idealized_atom_pos(residue_name: str, atom_name: str) -> np.ndarray | None:
365
+ """Return one ligand atom position from the preferred conformer."""
366
+
367
+ key = (residue_name, atom_name)
368
+ if key not in _STORE.ligand_positions:
369
+ conformer = _STORE.conformer(residue_name)
370
+ _STORE.ligand_positions[key] = None if conformer is None else conformer.get(atom_name)
371
+ return _STORE.ligand_positions[key]
372
+
373
+
374
+ def get_ligand_ccd_atoms_with_charges(
375
+ component_id: str,
376
+ ) -> list[tuple[str, str, int]] | None:
377
+ """Return heavy-atom name, element, and formal-charge records."""
378
+
379
+ return _STORE.atom_records(component_id)
380
+
381
+
382
+ def get_ligand_ccd_bonds(component_id: str) -> list[tuple[str, str]] | None:
383
+ """Return bonds as component atom-name pairs."""
384
+
385
+ return _STORE.bond_records(component_id)
386
+
387
+
388
+ def get_ccd_leaving_atoms(component_id: str) -> set[str]:
389
+ """Return atoms removed when a CCD component is polymerized."""
390
+
391
+ return _STORE.component_leaving_atoms(component_id)
392
+
393
+
394
+ __all__ = [
395
+ "get_ccd_conformer",
396
+ "get_ccd_leaving_atoms",
397
+ "get_idealized_atom_pos",
398
+ "get_ligand_ccd_atoms_with_charges",
399
+ "get_ligand_ccd_bonds",
400
+ "get_ligand_idealized_atom_pos",
401
+ "load_ccd",
402
+ ]
fastplms/models/esmfold2/esmfold2_constants.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Declarative molecular schema for ESMFold2 feature preparation.
2
+
3
+ The package manifest owns the upstream revision and license provenance. This
4
+ module expresses the corresponding checkpoint-facing integer schema as compact
5
+ ordered records, then derives lookup tables from those records. The generated
6
+ tables are validated at import without reading files, downloading assets, or
7
+ mutating process state.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ SCHEMA_PROVENANCE = {
13
+ "manifest_family": "esmfold2",
14
+ "contract": "biohub_esmfold2_input_v1",
15
+ }
16
+
17
+
18
+ def _words(value: str) -> list[str]:
19
+ return value.split()
20
+
21
+
22
+ MOL_TYPE_PROTEIN = 0
23
+ MOL_TYPE_DNA = 1
24
+ MOL_TYPE_RNA = 2
25
+ MOL_TYPE_NONPOLYMER = 3
26
+
27
+ # The record order is part of the checkpoint input contract. Residue indices
28
+ # start at two because zero and one are reserved by the model feature schema.
29
+ _PROTEIN_SCHEMA = tuple(
30
+ tuple(record.split(":"))
31
+ for record in (
32
+ "ALA:A:N CA C O CB",
33
+ "ARG:R:N CA C O CB CG CD NE CZ NH1 NH2",
34
+ "ASN:N:N CA C O CB CG OD1 ND2",
35
+ "ASP:D:N CA C O CB CG OD1 OD2",
36
+ "CYS:C:N CA C O CB SG",
37
+ "GLN:Q:N CA C O CB CG CD OE1 NE2",
38
+ "GLU:E:N CA C O CB CG CD OE1 OE2",
39
+ "GLY:G:N CA C O",
40
+ "HIS:H:N CA C O CB CG ND1 CD2 CE1 NE2",
41
+ "ILE:I:N CA C O CB CG1 CG2 CD1",
42
+ "LEU:L:N CA C O CB CG CD1 CD2",
43
+ "LYS:K:N CA C O CB CG CD CE NZ",
44
+ "MET:M:N CA C O CB CG SD CE",
45
+ "PHE:F:N CA C O CB CG CD1 CD2 CE1 CE2 CZ",
46
+ "PRO:P:N CA C O CB CG CD",
47
+ "SER:S:N CA C O CB OG",
48
+ "THR:T:N CA C O CB OG1 CG2",
49
+ "TRP:W:N CA C O CB CG CD1 CD2 NE1 CE2 CE3 CZ2 CZ3 CH2",
50
+ "TYR:Y:N CA C O CB CG CD1 CD2 CE1 CE2 CZ OH",
51
+ "VAL:V:N CA C O CB CG1 CG2",
52
+ )
53
+ )
54
+
55
+ PROTEIN_RESIDUE_TO_RES_TYPE = {
56
+ residue: index for index, (residue, _letter, _atoms) in enumerate(_PROTEIN_SCHEMA, 2)
57
+ }
58
+ PROTEIN_RESIDUE_TO_RES_TYPE["MSE"] = PROTEIN_RESIDUE_TO_RES_TYPE["MET"]
59
+ PROTEIN_UNK_RES_TYPE = 22
60
+
61
+ RNA_RESIDUE_TO_RES_TYPE = dict(zip("AGCU", range(23, 27), strict=True))
62
+ RNA_UNK_RES_TYPE = 27
63
+ DNA_RESIDUE_TO_RES_TYPE = dict(zip(("DA", "DG", "DC", "DT"), range(28, 32), strict=True))
64
+ DNA_UNK_RES_TYPE = 32
65
+ GAP_RES_TYPE = DNA_UNK_RES_TYPE
66
+
67
+ PROTEIN_3TO1 = {residue: letter for residue, letter, _atoms in _PROTEIN_SCHEMA}
68
+ PROTEIN_3TO1["MSE"] = "M"
69
+ PROTEIN_1TO3 = {letter: residue for residue, letter, _atoms in _PROTEIN_SCHEMA}
70
+ PROTEIN_1TO3["X"] = "UNK"
71
+ DNA_1TO3 = dict(zip("ATCG", ("DA", "DT", "DC", "DG"), strict=True))
72
+ RNA_1TO3 = {letter: letter for letter in "AUCG"}
73
+
74
+ _ESM_RESIDUE_ORDER = "LAGVSERTIDPKQNFYM HWC".replace(" ", "")
75
+ ESM_PROTEIN_VOCAB = {residue: token_id for token_id, residue in enumerate(_ESM_RESIDUE_ORDER, 4)}
76
+ ESM_PROTEIN_VOCAB["X"] = 3
77
+ DNA_RNA_LIGAND_INPUT_ID = 24
78
+ MSA_PAD_TOKEN_ID = 0
79
+ MSA_GAP_TOKEN_ID = 1
80
+
81
+ RES_TYPE_TO_CCD = {
82
+ **{
83
+ index: residue for residue, index in PROTEIN_RESIDUE_TO_RES_TYPE.items() if residue != "MSE"
84
+ },
85
+ 22: "UNK",
86
+ **dict(zip(range(23, 28), ("A", "G", "C", "U", "N"), strict=True)),
87
+ **dict(zip(range(28, 33), ("DA", "DG", "DC", "DT", "DN"), strict=True)),
88
+ }
89
+
90
+ _CHARGE_SCHEMA = _words(
91
+ "LYS:NZ:1 ARG:NH2:1 HIS:ND1:1 PO4:O2:-1 PO4:O3:-1 PO4:O4:-1 "
92
+ "SO4:O3:-1 SO4:O4:-1 MG:MG:2 ZN:ZN:2 CA:CA:2 FE2:FE:2 MN:MN:2 "
93
+ "CO:CO:2 NCO:CO:3 CU:CU:2 NI:NI:2 K:K:1 NA:NA:1 CD:CD:2 CL:CL:-1 "
94
+ "ACT:OXT:-1 NAD:O2N:-1 NAD:N1N:1 NAP:O2N:-1 NAP:N1N:1 IMD:N3:1 "
95
+ "SAM:SD:1 FE:FE:3 A1BH3:N3:1"
96
+ )
97
+ CHARGED_ATOMS = {
98
+ (component, atom): int(charge)
99
+ for component, atom, charge in (record.split(":") for record in _CHARGE_SCHEMA)
100
+ }
101
+
102
+ _PERIODIC_SYMBOLS = _words(
103
+ "H HE LI BE B C N O F NE NA MG AL SI P S CL AR K CA SC TI V CR MN FE CO NI CU ZN "
104
+ "GA GE AS SE BR KR RB SR Y ZR NB MO TC RU RH PD AG CD IN SN SB TE I XE CS BA LA CE "
105
+ "PR ND PM SM EU GD TB DY HO ER TM YB LU HF TA W RE OS IR PT AU HG TL PB BI PO AT RN "
106
+ "FR RA AC TH PA U"
107
+ )
108
+ ELEMENT_TO_ATOMIC_NUM = {
109
+ symbol: atomic_number
110
+ for atomic_number, symbol in enumerate(_PERIODIC_SYMBOLS, 1)
111
+ if symbol != "HE"
112
+ }
113
+ ELEMENT_NUMBER_TO_SYMBOL = {
114
+ atomic_number: symbol for symbol, atomic_number in ELEMENT_TO_ATOMIC_NUM.items()
115
+ }
116
+
117
+ PROTEIN_HEAVY_ATOMS = {
118
+ residue: atom_string.split() for residue, _letter, atom_string in _PROTEIN_SCHEMA
119
+ }
120
+ PROTEIN_HEAVY_ATOMS["MSE"] = PROTEIN_HEAVY_ATOMS["MET"].copy()
121
+ PROTEIN_HEAVY_ATOMS["UNK"] = _words("N CA C O")
122
+
123
+ DNA_BACKBONE_ATOMS = _words("P OP1 OP2 O5' C5' C4' O4' C3' O3' C2' C1'")
124
+ RNA_BACKBONE_ATOMS = _words("P OP1 OP2 O5' C5' C4' O4' C3' O3' C2' O2' C1'")
125
+ _NUCLEOBASE_ATOMS = {
126
+ "A": _words("N9 C8 N7 C5 C6 N6 N1 C2 N3 C4"),
127
+ "G": _words("N9 C8 N7 C5 C6 O6 N1 C2 N2 N3 C4"),
128
+ "C": _words("N1 C2 O2 N3 C4 N4 C5 C6"),
129
+ "U": _words("N1 C2 O2 N3 C4 O4 C5 C6"),
130
+ "T": _words("N1 C2 O2 N3 C4 O4 C5 C7 C6"),
131
+ }
132
+ DNA_HEAVY_ATOMS = {
133
+ "DA": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["A"],
134
+ "DG": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["G"],
135
+ "DC": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["C"],
136
+ "DT": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["T"],
137
+ }
138
+ RNA_HEAVY_ATOMS = {residue: RNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS[residue] for residue in "AGCU"}
139
+
140
+
141
+ def _validate_schema() -> None:
142
+ if sorted(set(PROTEIN_RESIDUE_TO_RES_TYPE.values())) != list(range(2, 22)):
143
+ raise RuntimeError("Protein residue indices must cover the checkpoint interval 2..21.")
144
+ if len(_ESM_RESIDUE_ORDER) != 20 or len(set(_ESM_RESIDUE_ORDER)) != 20:
145
+ raise RuntimeError("The ESM residue vocabulary must contain 20 canonical residues.")
146
+ if RES_TYPE_TO_CCD[14] != "MET" or PROTEIN_RESIDUE_TO_RES_TYPE["MSE"] != 14:
147
+ raise RuntimeError("Selenomethionine must share the methionine residue index.")
148
+ if ELEMENT_TO_ATOMIC_NUM.get("U") != 92 or 2 in ELEMENT_NUMBER_TO_SYMBOL:
149
+ raise RuntimeError("The element schema must preserve the training-time atomic-number map.")
150
+ if set(DNA_HEAVY_ATOMS) != {"DA", "DG", "DC", "DT"}:
151
+ raise RuntimeError("The DNA atom schema is incomplete.")
152
+
153
+
154
+ _validate_schema()
155
+
156
+ __all__ = [name for name in globals() if name.isupper()]
fastplms/models/esmfold2/esmfold2_constants_esm3.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Token schemas needed by the ESMC encoder inside ESMFold2.
2
+
3
+ The values implement the published Biohub ESM sequence-token contract pinned by
4
+ ``models.toml``. They are generated from ordered schemas so token position and
5
+ special-token relationships are explicit and independently testable. This
6
+ module performs no downloads and resolves no model assets at import time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from types import MappingProxyType
12
+
13
+
14
+ def _words(value: str) -> list[str]:
15
+ return value.split()
16
+
17
+
18
+ SEQUENCE_VOCAB = _words(
19
+ "<cls> <pad> <eos> <unk> L A G V S E R T I D P K Q N F Y M H W C X B U Z O . - | <mask>"
20
+ )
21
+
22
+ _sequence_token_ids = MappingProxyType({token: index for index, token in enumerate(SEQUENCE_VOCAB)})
23
+ SEQUENCE_BOS_TOKEN = _sequence_token_ids["<cls>"]
24
+ SEQUENCE_PAD_TOKEN = _sequence_token_ids["<pad>"]
25
+ SEQUENCE_EOS_TOKEN = _sequence_token_ids["<eos>"]
26
+ SEQUENCE_CHAINBREAK_TOKEN = _sequence_token_ids["|"]
27
+ SEQUENCE_MASK_TOKEN = _sequence_token_ids["<mask>"]
28
+ SEQUENCE_STANDARD_AA_MIN_TOKEN = _sequence_token_ids["L"]
29
+ SEQUENCE_STANDARD_AA_MAX_TOKEN = _sequence_token_ids["X"]
30
+
31
+ VQVAE_CODEBOOK_SIZE = 4096
32
+ VQVAE_SPECIAL_TOKENS = {
33
+ name: VQVAE_CODEBOOK_SIZE + offset
34
+ for offset, name in enumerate(("MASK", "EOS", "BOS", "PAD", "CHAINBREAK"))
35
+ }
36
+ VQVAE_DIRECTION_LOSS_BINS = 16
37
+ VQVAE_PAE_BINS = 64
38
+ VQVAE_MAX_PAE_BIN = 31.0
39
+ VQVAE_PLDDT_BINS = 50
40
+
41
+ STRUCTURE_MASK_TOKEN = VQVAE_SPECIAL_TOKENS["MASK"]
42
+ STRUCTURE_EOS_TOKEN = VQVAE_SPECIAL_TOKENS["EOS"]
43
+ STRUCTURE_BOS_TOKEN = VQVAE_SPECIAL_TOKENS["BOS"]
44
+ STRUCTURE_PAD_TOKEN = VQVAE_SPECIAL_TOKENS["PAD"]
45
+ STRUCTURE_CHAINBREAK_TOKEN = VQVAE_SPECIAL_TOKENS["CHAINBREAK"]
46
+ STRUCTURE_UNDEFINED_TOKEN = 955
47
+
48
+ SASA_PAD_TOKEN = 0
49
+ SS8_PAD_TOKEN = 0
50
+ INTERPRO_PAD_TOKEN = 0
51
+ RESIDUE_PAD_TOKEN = 0
52
+
53
+ CHAIN_BREAK_STR = "|"
54
+ SEQUENCE_BOS_STR = "<cls>"
55
+ SEQUENCE_EOS_STR = "<eos>"
56
+ MASK_STR_SHORT = "_"
57
+ SEQUENCE_MASK_STR = "<mask>"
58
+ SASA_MASK_STR = "<unk>"
59
+ SS8_MASK_STR = "<unk>"
60
+
61
+ SSE_8CLASS_VOCAB = "GHITEBSC"
62
+ SSE_3CLASS_VOCAB = "HEC"
63
+ SSE_8CLASS_TO_3CLASS_MAP = dict(zip(SSE_8CLASS_VOCAB, "HHHCEECC", strict=True))
64
+
65
+ SASA_DISCRETIZATION_BOUNDARIES = [
66
+ 0.8,
67
+ 4.0,
68
+ 9.6,
69
+ 16.4,
70
+ 24.5,
71
+ 32.9,
72
+ 42.0,
73
+ 51.5,
74
+ 61.2,
75
+ 70.9,
76
+ 81.6,
77
+ 93.3,
78
+ 107.2,
79
+ 125.4,
80
+ 151.4,
81
+ ]
82
+ MAX_RESIDUE_ANNOTATIONS = 16
83
+ TFIDF_VECTOR_SIZE = 58_641
84
+ FUNCTION_TOKENS_DEPTH = 8
85
+
86
+
87
+ def _validate_schema() -> None:
88
+ if len(SEQUENCE_VOCAB) != len(set(SEQUENCE_VOCAB)):
89
+ raise RuntimeError("The ESM sequence vocabulary contains duplicate tokens.")
90
+ if SEQUENCE_STANDARD_AA_MAX_TOKEN - SEQUENCE_STANDARD_AA_MIN_TOKEN != 20:
91
+ raise RuntimeError("The canonical residue interval must contain 20 tokens.")
92
+ if tuple(VQVAE_SPECIAL_TOKENS.values()) != tuple(range(4096, 4101)):
93
+ raise RuntimeError("The structure special-token interval is not contiguous.")
94
+
95
+
96
+ _validate_schema()
97
+
98
+ __all__ = [name for name in globals() if name.isupper()]
fastplms/models/esmfold2/esmfold2_input_builder.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed, JSON-safe inputs for ESMFold2 feature preparation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, TypeAlias
8
+
9
+ import numpy as np
10
+
11
+ from .esmfold2_msa import MSA
12
+
13
+ MSAInput: TypeAlias = MSA | None
14
+
15
+
16
+ @dataclass
17
+ class Modification:
18
+ """A zero-indexed residue substitution using a CCD component."""
19
+
20
+ position: int
21
+ ccd: str
22
+ smiles: str | None = None
23
+
24
+
25
+ @dataclass
26
+ class ProteinInput:
27
+ id: str | list[str]
28
+ sequence: str
29
+ modifications: list[Modification] | None = None
30
+ msa: MSAInput = None
31
+
32
+
33
+ @dataclass
34
+ class RNAInput:
35
+ id: str | list[str]
36
+ sequence: str
37
+ modifications: list[Modification] | None = None
38
+
39
+
40
+ @dataclass
41
+ class DNAInput:
42
+ id: str | list[str]
43
+ sequence: str
44
+ modifications: list[Modification] | None = None
45
+
46
+
47
+ @dataclass
48
+ class LigandInput:
49
+ id: str | list[str]
50
+ smiles: str | None = None
51
+ ccd: list[str] | None = None
52
+
53
+
54
+ @dataclass
55
+ class DistogramConditioning:
56
+ chain_id: str
57
+ distogram: np.ndarray
58
+
59
+
60
+ @dataclass
61
+ class PocketConditioning:
62
+ binder_chain_id: str
63
+ contacts: list[tuple[str, int]]
64
+
65
+
66
+ @dataclass
67
+ class CovalentBond:
68
+ chain_id1: str
69
+ res_idx1: int
70
+ atom_idx1: int
71
+ chain_id2: str
72
+ res_idx2: int
73
+ atom_idx2: int
74
+
75
+
76
+ SequenceInput: TypeAlias = ProteinInput | RNAInput | DNAInput | LigandInput
77
+
78
+
79
+ @dataclass
80
+ class StructurePredictionInput:
81
+ sequences: Sequence[SequenceInput]
82
+ pocket: PocketConditioning | None = None
83
+ distogram_conditioning: list[DistogramConditioning] | None = None
84
+ covalent_bonds: list[CovalentBond] | None = None
85
+
86
+
87
+ _CHAIN_TYPE = {
88
+ ProteinInput: "protein",
89
+ RNAInput: "rna",
90
+ DNAInput: "dna",
91
+ }
92
+
93
+
94
+ def _serialize_modifications(
95
+ modifications: list[Modification] | None,
96
+ ) -> list[dict[str, Any]] | None:
97
+ if not modifications:
98
+ return None
99
+ return [{"position": item.position, "ccd": item.ccd} for item in modifications]
100
+
101
+
102
+ def _serialize_chain(chain: SequenceInput) -> dict[str, Any]:
103
+ if isinstance(chain, LigandInput):
104
+ return {
105
+ "smiles": chain.smiles,
106
+ "id": chain.id,
107
+ "ccd": chain.ccd,
108
+ "type": "ligand",
109
+ }
110
+
111
+ chain_type = _CHAIN_TYPE.get(type(chain))
112
+ if chain_type is None:
113
+ raise ValueError(f"Unsupported sequence input type: {type(chain)}")
114
+ serialized: dict[str, Any] = {
115
+ "sequence": chain.sequence,
116
+ "id": chain.id,
117
+ "type": chain_type,
118
+ }
119
+ if modifications := _serialize_modifications(chain.modifications):
120
+ serialized["modifications"] = modifications
121
+ if isinstance(chain, ProteinInput):
122
+ if chain.msa is not None and not isinstance(chain.msa, MSA):
123
+ raise AttributeError(f"MSA must be None or MSA. Got {chain.msa} instead.")
124
+ serialized["msa"] = None if chain.msa is None else {"sequences": chain.msa.sequences}
125
+ return serialized
126
+
127
+
128
+ def serialize_structure_prediction_input(
129
+ structure_input: StructurePredictionInput,
130
+ ) -> dict[str, Any]:
131
+ """Convert an input object to a JSON-safe mapping."""
132
+
133
+ serialized: dict[str, Any] = {
134
+ "sequences": [_serialize_chain(chain) for chain in structure_input.sequences]
135
+ }
136
+ if structure_input.covalent_bonds is not None:
137
+ serialized["covalent_bonds"] = [
138
+ vars(bond).copy() for bond in structure_input.covalent_bonds
139
+ ]
140
+ if structure_input.pocket is not None:
141
+ serialized["pocket"] = {
142
+ "binder_chain_id": structure_input.pocket.binder_chain_id,
143
+ "contacts": structure_input.pocket.contacts,
144
+ }
145
+ if structure_input.distogram_conditioning is not None:
146
+ serialized["distogram_conditioning"] = [
147
+ {"chain_id": item.chain_id, "distogram": item.distogram.tolist()}
148
+ for item in structure_input.distogram_conditioning
149
+ ]
150
+ return serialized
151
+
152
+
153
+ def _deserialize_modifications(chain: dict[str, Any]) -> list[Modification] | None:
154
+ raw = chain.get("modifications")
155
+ if not raw:
156
+ return None
157
+ return [Modification(position=item["position"], ccd=item["ccd"]) for item in raw]
158
+
159
+
160
+ def _deserialize_msa(chain: dict[str, Any]) -> MSAInput:
161
+ raw = chain.get("msa")
162
+ if raw is None:
163
+ return None
164
+ if not isinstance(raw, dict) or not isinstance(raw.get("sequences"), list):
165
+ raise ValueError(f"Unexpected MSA value: {raw!r}")
166
+ return MSA.from_sequences(raw["sequences"])
167
+
168
+
169
+ def _deserialize_chain(chain: dict[str, Any]) -> SequenceInput:
170
+ chain_type = chain.get("type")
171
+ common = {"id": chain["id"]}
172
+ if chain_type == "protein":
173
+ return ProteinInput(
174
+ **common,
175
+ sequence=chain["sequence"],
176
+ modifications=_deserialize_modifications(chain),
177
+ msa=_deserialize_msa(chain),
178
+ )
179
+ if chain_type == "rna":
180
+ return RNAInput(
181
+ **common,
182
+ sequence=chain["sequence"],
183
+ modifications=_deserialize_modifications(chain),
184
+ )
185
+ if chain_type == "dna":
186
+ return DNAInput(
187
+ **common,
188
+ sequence=chain["sequence"],
189
+ modifications=_deserialize_modifications(chain),
190
+ )
191
+ if chain_type == "ligand":
192
+ return LigandInput(**common, smiles=chain.get("smiles"), ccd=chain.get("ccd"))
193
+ raise ValueError(f"Unsupported sequence type: {chain_type!r}")
194
+
195
+
196
+ def deserialize_structure_prediction_input(data: dict[str, Any]) -> StructurePredictionInput:
197
+ """Reconstruct the typed input represented by a serialized mapping."""
198
+
199
+ pocket_data = data.get("pocket")
200
+ pocket = None
201
+ if pocket_data is not None:
202
+ pocket = PocketConditioning(
203
+ binder_chain_id=pocket_data["binder_chain_id"],
204
+ contacts=[tuple(contact) for contact in pocket_data["contacts"]],
205
+ )
206
+
207
+ distogram_data = data.get("distogram_conditioning")
208
+ distograms = None
209
+ if distogram_data is not None:
210
+ distograms = [
211
+ DistogramConditioning(
212
+ chain_id=item["chain_id"], distogram=np.asarray(item["distogram"])
213
+ )
214
+ for item in distogram_data
215
+ ]
216
+
217
+ bond_data = data.get("covalent_bonds")
218
+ bonds = None
219
+ if bond_data is not None:
220
+ bonds = [CovalentBond(**item) for item in bond_data]
221
+
222
+ return StructurePredictionInput(
223
+ sequences=[_deserialize_chain(chain) for chain in data["sequences"]],
224
+ pocket=pocket,
225
+ distogram_conditioning=distograms,
226
+ covalent_bonds=bonds,
227
+ )
228
+
229
+
230
+ __all__ = [
231
+ "CovalentBond",
232
+ "DNAInput",
233
+ "DistogramConditioning",
234
+ "LigandInput",
235
+ "MSAInput",
236
+ "Modification",
237
+ "PocketConditioning",
238
+ "ProteinInput",
239
+ "RNAInput",
240
+ "SequenceInput",
241
+ "StructurePredictionInput",
242
+ "deserialize_structure_prediction_input",
243
+ "serialize_structure_prediction_input",
244
+ ]
fastplms/models/esmfold2/esmfold2_metrics.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contact, lDDT, RMSD, and GDT-TS metrics for structure validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import torch
7
+ import torch.nn.functional as F
8
+ from torch import Tensor
9
+ from torch.amp import autocast # type: ignore
10
+
11
+ from . import esmfold2_residue_constants as residue_constants
12
+ from .esmfold2_misc import binpack, unbinpack
13
+ from .esmfold2_protein_structure import (
14
+ compute_alignment_tensors,
15
+ compute_gdt_ts_no_alignment,
16
+ compute_rmsd_no_alignment,
17
+ )
18
+
19
+
20
+ def _distance_matrix(positions: Tensor, eps: float) -> Tensor:
21
+ displacement = positions[..., None, :] - positions[..., None, :, :]
22
+ return torch.sqrt(eps + torch.sum(displacement**2, dim=-1))
23
+
24
+
25
+ def compute_lddt_from_dmat(
26
+ dmat_pred: Tensor,
27
+ dmat_true: Tensor,
28
+ pairwise_mask: Tensor,
29
+ cutoff: float | Tensor = 15.0,
30
+ eps: float = 1e-10,
31
+ per_residue: bool = True,
32
+ ) -> Tensor:
33
+ """Score distance matrices ``D_pred`` and ``D_true`` with shape (..., l, l)."""
34
+
35
+ sequence_length = dmat_true.size(-1)
36
+ identity = torch.eye(sequence_length, device=dmat_true.device)
37
+ scored_pairs = (dmat_true < cutoff) * pairwise_mask * (1.0 - identity)
38
+ absolute_error = torch.abs(dmat_true - dmat_pred)
39
+ score = (
40
+ (absolute_error < 0.5).type(absolute_error.dtype)
41
+ + (absolute_error < 1.0).type(absolute_error.dtype)
42
+ + (absolute_error < 2.0).type(absolute_error.dtype)
43
+ + (absolute_error < 4.0).type(absolute_error.dtype)
44
+ ) * 0.25
45
+ dimensions = (-1,) if per_residue else (-2, -1)
46
+ normalization = 1.0 / (eps + scored_pairs.sum(dim=dimensions))
47
+ return normalization * (eps + (scored_pairs * score).sum(dim=dimensions))
48
+
49
+
50
+ def compute_lddt(
51
+ all_atom_pred_pos: Tensor,
52
+ all_atom_positions: Tensor,
53
+ all_atom_mask: Tensor,
54
+ pairwise_all_atom_mask: Tensor | None = None,
55
+ cutoff: float | Tensor = 15.0,
56
+ eps: float = 1e-10,
57
+ per_residue: bool = True,
58
+ sequence_id: Tensor | None = None,
59
+ ) -> Tensor:
60
+ """Compute lDDT from coordinate tensors and atom masks."""
61
+
62
+ expanded_mask = all_atom_mask[..., None]
63
+ true_distances = _distance_matrix(all_atom_positions, eps)
64
+ predicted_distances = _distance_matrix(all_atom_pred_pos, eps)
65
+ pair_mask = expanded_mask * expanded_mask.transpose(-2, -1)
66
+ if pairwise_all_atom_mask is not None:
67
+ pair_mask = pair_mask * pairwise_all_atom_mask
68
+ if sequence_id is not None:
69
+ same_sequence = sequence_id[..., None] == sequence_id[..., None, :]
70
+ pair_mask = pair_mask * same_sequence.type_as(pair_mask)
71
+ return compute_lddt_from_dmat(
72
+ predicted_distances,
73
+ true_distances,
74
+ pair_mask,
75
+ cutoff=cutoff,
76
+ eps=eps,
77
+ per_residue=per_residue,
78
+ )
79
+
80
+
81
+ def compute_lddt_ca(
82
+ all_atom_pred_pos: Tensor,
83
+ all_atom_positions: Tensor,
84
+ all_atom_mask: Tensor,
85
+ cutoff: float = 15.0,
86
+ eps: float = 1e-10,
87
+ per_residue: bool = True,
88
+ sequence_id: Tensor | None = None,
89
+ ) -> Tensor:
90
+ """Compute lDDT using only C-alpha coordinates."""
91
+
92
+ ca_index = residue_constants.atom_order["CA"]
93
+ predicted_ca = (
94
+ all_atom_pred_pos if all_atom_pred_pos.dim() == 3 else all_atom_pred_pos[..., ca_index, :]
95
+ )
96
+ return compute_lddt(
97
+ predicted_ca,
98
+ all_atom_positions[..., ca_index, :],
99
+ all_atom_mask[..., ca_index],
100
+ cutoff=cutoff,
101
+ eps=eps,
102
+ per_residue=per_residue,
103
+ sequence_id=sequence_id,
104
+ )
105
+
106
+
107
+ @torch.no_grad()
108
+ @autocast("cuda", enabled=False)
109
+ def compute_rmsd(
110
+ mobile: Tensor,
111
+ target: Tensor,
112
+ atom_exists_mask: Tensor | None = None,
113
+ sequence_id: Tensor | None = None,
114
+ reduction: str = "batch",
115
+ ) -> Tensor:
116
+ """Align ``X`` to ``Y`` and compute RMSD."""
117
+
118
+ centered_mobile, _, centered_target, _, rotation, counts = compute_alignment_tensors(
119
+ mobile,
120
+ target,
121
+ atom_exists_mask,
122
+ sequence_id,
123
+ )
124
+ rmsd = compute_rmsd_no_alignment(
125
+ torch.matmul(centered_mobile, rotation),
126
+ centered_target,
127
+ counts,
128
+ reduction=reduction,
129
+ )
130
+ if reduction == "per_residue" and sequence_id is not None:
131
+ return binpack(rmsd, sequence_id, pad_value=0)
132
+ return rmsd
133
+
134
+
135
+ def compute_gdt_ts(
136
+ mobile: Tensor,
137
+ target: Tensor,
138
+ atom_exists_mask: Tensor | None = None,
139
+ sequence_id: Tensor | None = None,
140
+ reduction: str = "per_sample",
141
+ ) -> Tensor:
142
+ """Align ``X`` to ``Y`` and compute GDT-TS."""
143
+
144
+ if atom_exists_mask is None:
145
+ atom_exists_mask = torch.isfinite(target).all(dim=-1)
146
+ centered_mobile, _, centered_target, _, rotation, _ = compute_alignment_tensors(
147
+ mobile,
148
+ target,
149
+ atom_exists_mask,
150
+ sequence_id,
151
+ )
152
+ if sequence_id is not None:
153
+ atom_exists_mask = unbinpack(atom_exists_mask, sequence_id, pad_value=False)
154
+ return compute_gdt_ts_no_alignment(
155
+ torch.matmul(centered_mobile, rotation),
156
+ centered_target,
157
+ atom_exists_mask,
158
+ reduction,
159
+ )
160
+
161
+
162
+ def _batched_contacts(predictions: Tensor, targets: Tensor) -> tuple[Tensor, Tensor]:
163
+ if predictions.dim() == 2:
164
+ predictions = predictions.unsqueeze(0)
165
+ if targets.dim() == 2:
166
+ targets = targets.unsqueeze(0)
167
+ if predictions.size() != targets.size():
168
+ raise ValueError(
169
+ f"Size mismatch. Received predictions of size {predictions.size()}, "
170
+ f"targets of size {targets.size()}"
171
+ )
172
+ return predictions, targets
173
+
174
+
175
+ def _valid_contact_mask(
176
+ targets: Tensor,
177
+ src_lengths: Tensor,
178
+ minsep: int,
179
+ maxsep: int | None,
180
+ ) -> Tensor:
181
+ sequence_length = targets.shape[-1]
182
+ positions = torch.arange(sequence_length, device=targets.device)
183
+ separation = (positions.unsqueeze(0) - positions.unsqueeze(1)).unsqueeze(0)
184
+ valid = (separation >= minsep) & (targets >= 0)
185
+ if maxsep is not None:
186
+ valid &= separation < maxsep
187
+ within_length = positions.unsqueeze(0) < src_lengths.unsqueeze(1)
188
+ return valid & within_length.unsqueeze(1) & within_length.unsqueeze(2)
189
+
190
+
191
+ def contact_precision(
192
+ predictions: Tensor,
193
+ targets: Tensor,
194
+ src_lengths: Tensor | None = None,
195
+ minsep: int = 6,
196
+ maxsep: int | None = None,
197
+ override_length: int | None = None,
198
+ ) -> dict[str, Tensor]:
199
+ """Compute P@L, P@L/5, and binned area for contact probabilities."""
200
+
201
+ predictions, targets = _batched_contacts(predictions, targets)
202
+ batch_size, sequence_length, _ = predictions.shape
203
+ if src_lengths is None:
204
+ src_lengths = torch.full(
205
+ (batch_size,),
206
+ sequence_length,
207
+ dtype=torch.long,
208
+ device=predictions.device,
209
+ )
210
+ valid = _valid_contact_mask(targets, src_lengths, minsep, maxsep)
211
+ masked_predictions = predictions.masked_fill(~valid, float("-inf"))
212
+ row_index, column_index = np.triu_indices(sequence_length, minsep)
213
+ upper_predictions = masked_predictions[:, row_index, column_index]
214
+ upper_targets = targets[:, row_index, column_index]
215
+
216
+ topk = sequence_length if override_length is None else max(sequence_length, override_length)
217
+ ranked_indices = upper_predictions.argsort(dim=-1, descending=True)[:, :topk]
218
+ batch_indices = torch.arange(batch_size, device=ranked_indices.device).unsqueeze(1)
219
+ ranked_targets = upper_targets[batch_indices, ranked_indices]
220
+ if ranked_targets.size(1) < topk:
221
+ ranked_targets = F.pad(ranked_targets, [0, topk - ranked_targets.size(1)])
222
+ cumulative_contacts = ranked_targets.type_as(predictions).cumsum(dim=-1)
223
+
224
+ gather_lengths = src_lengths.unsqueeze(1)
225
+ if override_length is not None:
226
+ gather_lengths = override_length * torch.ones_like(gather_lengths)
227
+ fractions = torch.arange(0.1, 1.1, 0.1, device=predictions.device).unsqueeze(0)
228
+ gather_indices = (fractions * gather_lengths).type(torch.long).sub(1).clamp_min(0)
229
+ cumulative_bins = cumulative_contacts.gather(1, gather_indices)
230
+ precisions = cumulative_bins / (gather_indices + 1).type_as(cumulative_bins)
231
+ return {
232
+ "AUC": precisions.mean(dim=-1),
233
+ "P@L": precisions[:, 9],
234
+ "P@L5": precisions[:, 1],
235
+ }
fastplms/models/esmfold2/esmfold2_misc.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small tensor, sequence, and annotation utilities used by ESMFold2.
2
+
3
+ The helpers in this module are deliberately free of model state. Importing the
4
+ module therefore performs no device selection, compilation, or remote access.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import defaultdict
10
+ from collections.abc import Generator, Iterable, Sequence
11
+ from contextlib import AbstractContextManager, nullcontext
12
+ from dataclasses import is_dataclass
13
+ from io import BytesIO
14
+ from typing import Any, Protocol, TypeVar, runtime_checkable
15
+ from warnings import warn
16
+
17
+ import numpy as np
18
+ import torch
19
+ import zstandard
20
+
21
+ from .esmfold2_constants_esm3 import CHAIN_BREAK_STR
22
+ from .esmfold2_utils_types import FunctionAnnotation
23
+
24
+ MAX_SUPPORTED_DISTANCE = 1e6
25
+
26
+ TSequence = TypeVar("TSequence", bound=Sequence)
27
+
28
+
29
+ @runtime_checkable
30
+ class Concatable(Protocol):
31
+ """Protocol for sequence-like records with a class-level concatenator."""
32
+
33
+ @classmethod
34
+ def concat(cls, objs: list[Concatable]) -> Concatable: ...
35
+
36
+
37
+ def fp32_autocast_context(
38
+ device_type: str,
39
+ ) -> AbstractContextManager[Any]: # type: ignore
40
+ """Return a context that keeps numerically sensitive work in FP32."""
41
+
42
+ if device_type == "mps":
43
+ return nullcontext()
44
+ if device_type == "cpu":
45
+ return torch.amp.autocast(device_type, enabled=False) # type: ignore
46
+ if device_type == "cuda":
47
+ return torch.amp.autocast(device_type, dtype=torch.float32) # type: ignore
48
+ raise ValueError(f"Unsupported device type: {device_type}")
49
+
50
+
51
+ def maybe_tensor(value, convert_none_to_nan: bool = False) -> torch.Tensor | None:
52
+ """Convert an optional array-like value to a tensor."""
53
+
54
+ if value is None:
55
+ return None
56
+ if isinstance(value, torch.Tensor):
57
+ return value
58
+ if isinstance(value, list) and all(isinstance(element, torch.Tensor) for element in value):
59
+ return torch.stack(value)
60
+ if convert_none_to_nan:
61
+ value = np.asarray(value, dtype=np.float32)
62
+ value = np.where(value is None, np.nan, value)
63
+ return torch.tensor(value)
64
+
65
+
66
+ def maybe_list(value, convert_nan_to_none: bool = False) -> list | None:
67
+ """Convert an optional tensor or NumPy array to nested Python lists."""
68
+
69
+ if value is None:
70
+ return None
71
+ if not convert_nan_to_none:
72
+ return value.tolist()
73
+ if isinstance(value, torch.Tensor):
74
+ nan_mask = torch.isnan(value).cpu().numpy()
75
+ array = value.cpu().numpy().astype(object)
76
+ elif isinstance(value, np.ndarray):
77
+ nan_mask = np.isnan(value)
78
+ array = value.astype(object)
79
+ else:
80
+ raise TypeError("maybe_list can only work with torch.tensor or np.ndarray.")
81
+ array[nan_mask] = None
82
+ return array.tolist()
83
+
84
+
85
+ def replace_inf(data):
86
+ """Replace infinite array values by the ESM API sentinel value."""
87
+
88
+ if data is None:
89
+ return None
90
+ array = np.asarray(data, dtype=np.float32)
91
+ return np.where(np.isinf(array), 1000, array).tolist()
92
+
93
+
94
+ def slice_python_object_as_numpy(
95
+ obj: TSequence,
96
+ idx: int | list[int] | slice | np.ndarray,
97
+ ) -> TSequence:
98
+ """Apply NumPy-style scalar, mask, or index-array slicing to Python data."""
99
+
100
+ normalized_idx: list[int] | slice | np.ndarray = (
101
+ [int(idx)] if np.isscalar(idx) else idx # type: ignore[arg-type]
102
+ )
103
+
104
+ if isinstance(normalized_idx, np.ndarray) and normalized_idx.dtype == bool:
105
+ selected = [obj[position] for position in np.flatnonzero(normalized_idx)]
106
+ elif isinstance(normalized_idx, slice):
107
+ selected = obj[normalized_idx]
108
+ else:
109
+ selected = [obj[position] for position in normalized_idx]
110
+
111
+ if isinstance(obj, str) and isinstance(selected, list):
112
+ return "".join(selected) # type: ignore[return-value]
113
+ return obj.__class__(selected) # type: ignore[call-arg,return-value]
114
+
115
+
116
+ def slice_any_object(
117
+ obj: TSequence,
118
+ idx: int | list[int] | slice | np.ndarray,
119
+ ) -> TSequence:
120
+ """Slice tensors, arrays, dataclasses, and ordinary Python sequences."""
121
+
122
+ if isinstance(obj, (np.ndarray, torch.Tensor)) or is_dataclass(obj):
123
+ return obj[idx] # type: ignore[index,return-value]
124
+ return slice_python_object_as_numpy(obj, idx)
125
+
126
+
127
+ def join_lists(
128
+ lists: Sequence[Sequence[Any]],
129
+ separator: Sequence[Any] | None = None,
130
+ ) -> list[Any]:
131
+ """Join lists, inserting all elements of ``separator`` between inputs."""
132
+
133
+ if len(lists) == 0:
134
+ return []
135
+ joined = list(lists[0])
136
+ for values in lists[1:]:
137
+ if separator:
138
+ joined.extend(separator)
139
+ joined.extend(values)
140
+ return joined
141
+
142
+
143
+ def iterate_with_intermediate(
144
+ lists: Iterable,
145
+ intermediate,
146
+ ) -> Generator[Any, None, None]:
147
+ """Yield an intermediate value between consecutive input values."""
148
+
149
+ iterator = iter(lists)
150
+ yield next(iterator)
151
+ for value in iterator:
152
+ yield intermediate
153
+ yield value
154
+
155
+
156
+ def concat_objects(objs: Sequence[Any], separator: Any | None = None):
157
+ """Concatenate one supported homogeneous collection."""
158
+
159
+ if not objs:
160
+ raise ValueError("objs must contain at least one value.")
161
+ first = objs[0]
162
+ if isinstance(first, Concatable):
163
+ return first.__class__.concat(objs)
164
+ if isinstance(first, str):
165
+ if not isinstance(separator, str):
166
+ raise TypeError("separator must be a string when joining strings.")
167
+ return separator.join(objs)
168
+ if isinstance(first, list):
169
+ return join_lists(objs, None if separator is None else [separator])
170
+ if isinstance(first, np.ndarray):
171
+ pieces = (
172
+ objs
173
+ if separator is None
174
+ else list(iterate_with_intermediate(objs, np.array([separator])))
175
+ )
176
+ return np.concatenate(pieces)
177
+ if isinstance(first, torch.Tensor):
178
+ pieces = (
179
+ objs
180
+ if separator is None
181
+ else list(iterate_with_intermediate(objs, torch.tensor([separator])))
182
+ )
183
+ return torch.cat(pieces) # type: ignore[arg-type]
184
+ raise TypeError(type(first))
185
+
186
+
187
+ def rbf(values, v_min, v_max, n_bins=16):
188
+ """Encode values against evenly spaced radial basis centers."""
189
+
190
+ centers = torch.linspace(
191
+ v_min,
192
+ v_max,
193
+ n_bins,
194
+ dtype=values.dtype,
195
+ device=values.device,
196
+ )
197
+ centers = centers.reshape((1,) * values.ndim + (-1,))
198
+ standardized = (values.unsqueeze(-1) - centers) / ((v_max - v_min) / n_bins)
199
+ return torch.exp(-(standardized**2))
200
+
201
+
202
+ def batched_gather(data, inds, dim=0, no_batch_dims=0):
203
+ """Gather along one data dimension while retaining leading batch axes."""
204
+
205
+ batch_indices = []
206
+ index_rank = len(inds.shape)
207
+ for axis, size in enumerate(data.shape[:no_batch_dims]):
208
+ shape = (1,) * axis + (-1,) + (1,) * (index_rank - axis - 1)
209
+ batch_indices.append(torch.arange(size).view(*shape))
210
+ tail = [slice(None)] * (len(data.shape) - no_batch_dims)
211
+ tail[dim - no_batch_dims if dim >= 0 else dim] = inds
212
+ return data[tuple(batch_indices + tail)]
213
+
214
+
215
+ def node_gather(s: torch.Tensor, edges: torch.Tensor) -> torch.Tensor:
216
+ """Gather node features for each row of an edge-index tensor."""
217
+
218
+ return batched_gather(
219
+ s.unsqueeze(-3),
220
+ edges,
221
+ -2,
222
+ no_batch_dims=len(s.shape) - 1,
223
+ )
224
+
225
+
226
+ def knn_graph(
227
+ coords: torch.Tensor,
228
+ coord_mask: torch.Tensor,
229
+ padding_mask: torch.Tensor,
230
+ sequence_id: torch.Tensor,
231
+ *,
232
+ no_knn: int,
233
+ ):
234
+ """Build nearest-neighbor edges, using sequence distance for missing geometry."""
235
+
236
+ length = coords.shape[-2]
237
+ coords = coords.nan_to_num()
238
+ missing_pair = ~(coord_mask[..., None, :] & coord_mask[..., :, None])
239
+ excluded_pair = padding_mask[..., None, :] | padding_mask[..., :, None]
240
+ if sequence_id is not None:
241
+ excluded_pair |= sequence_id.unsqueeze(1) != sequence_id.unsqueeze(2)
242
+
243
+ distances = (coords.unsqueeze(-2) - coords.unsqueeze(-3)).norm(dim=-1)
244
+ residue_index = torch.arange(length, device=coords.device)
245
+ sequence_distance = (residue_index.unsqueeze(-1) - residue_index.unsqueeze(-2)).abs()
246
+ if not (distances[~missing_pair] < MAX_SUPPORTED_DISTANCE).all():
247
+ raise ValueError(
248
+ "Coordinate pairwise distances exceed max supported distance "
249
+ f"({MAX_SUPPORTED_DISTANCE}). "
250
+ )
251
+
252
+ rank_distance = sequence_distance.to(distances.dtype).mul(1e2).add(MAX_SUPPORTED_DISTANCE)
253
+ rank_distance = rank_distance.where(missing_pair, distances)
254
+ rank_distance = rank_distance.masked_fill(excluded_pair, torch.inf)
255
+ sorted_distance, sorted_edge = rank_distance.sort(dim=-1, descending=False)
256
+ width = min(no_knn, length)
257
+ return sorted_edge[..., :width], sorted_distance[..., :width].isfinite()
258
+
259
+
260
+ def stack_variable_length_tensors(
261
+ sequences: Sequence[torch.Tensor],
262
+ constant_value: int | float = 0,
263
+ dtype: torch.dtype | None = None,
264
+ ) -> torch.Tensor:
265
+ """Pad arbitrary tensor dimensions to their maxima, then stack."""
266
+
267
+ output_shape = [
268
+ len(sequences),
269
+ *np.max([sequence.shape for sequence in sequences], axis=0).tolist(),
270
+ ]
271
+ output = torch.full(
272
+ output_shape,
273
+ constant_value,
274
+ dtype=sequences[0].dtype if dtype is None else dtype,
275
+ device=sequences[0].device,
276
+ )
277
+ for destination, source in zip(output, sequences, strict=True):
278
+ destination[tuple(slice(size) for size in source.shape)] = source
279
+ return output
280
+
281
+
282
+ def binpack(
283
+ tensor: torch.Tensor,
284
+ sequence_id: torch.Tensor | None,
285
+ pad_value: int | float,
286
+ ):
287
+ """Scatter a sequence-major tensor into the packed layout described by IDs."""
288
+
289
+ if sequence_id is None:
290
+ return tensor
291
+ sequence_counts = sequence_id.max(dim=-1).values + 1
292
+ output = torch.full(
293
+ sequence_id.shape + tensor.shape[2:],
294
+ fill_value=pad_value,
295
+ dtype=tensor.dtype,
296
+ device=tensor.device,
297
+ )
298
+ source_index = 0
299
+ for batch_index, (batch_ids, count) in enumerate(
300
+ zip(sequence_id, sequence_counts, strict=True)
301
+ ):
302
+ for seqid in range(count):
303
+ selection = batch_ids == seqid
304
+ output[batch_index, selection] = tensor[source_index, : selection.sum()]
305
+ source_index += 1
306
+ return output
307
+
308
+
309
+ def unbinpack(
310
+ tensor: torch.Tensor,
311
+ sequence_id: torch.Tensor | None,
312
+ pad_value: int | float,
313
+ ):
314
+ """Restore sequence-major rows from a packed tensor and its sequence IDs."""
315
+
316
+ if sequence_id is None:
317
+ return tensor
318
+ rows = []
319
+ sequence_counts = sequence_id.max(dim=-1).values + 1
320
+ for batch_index, (batch_ids, count) in enumerate(
321
+ zip(sequence_id, sequence_counts, strict=True)
322
+ ):
323
+ for seqid in range(count):
324
+ rows.append(tensor[batch_index, batch_ids == seqid])
325
+ return stack_variable_length_tensors(rows, pad_value)
326
+
327
+
328
+ def merge_ranges(
329
+ ranges: list[range],
330
+ merge_gap_max: int | None = None,
331
+ ) -> list[range]:
332
+ """Merge overlapping or sufficiently close ranges in positional order."""
333
+
334
+ maximum_gap = 0 if merge_gap_max is None else merge_gap_max
335
+ if not isinstance(maximum_gap, int) or isinstance(maximum_gap, bool):
336
+ raise TypeError("merge_gap_max must be an integer or None.")
337
+ if maximum_gap < 0:
338
+ raise ValueError(f"merge_gap_max must be non-negative, got {maximum_gap}.")
339
+ merged: list[range] = []
340
+ for current in sorted(ranges, key=lambda item: item.start):
341
+ if not merged or merged[-1].stop + maximum_gap < current.start:
342
+ merged.append(current)
343
+ continue
344
+ previous = merged[-1]
345
+ merged[-1] = range(previous.start, max(previous.stop, current.stop))
346
+ return merged
347
+
348
+
349
+ def merge_annotations(
350
+ annotations: list[FunctionAnnotation],
351
+ merge_gap_max: int | None = None,
352
+ ) -> list[FunctionAnnotation]:
353
+ """Merge overlapping annotations independently for each label."""
354
+
355
+ grouped: dict[str, list[range]] = defaultdict(list)
356
+ for annotation in annotations:
357
+ grouped[annotation.label].append(range(annotation.start, annotation.end + 1))
358
+ result = []
359
+ for label, spans in grouped.items():
360
+ result.extend(
361
+ FunctionAnnotation(label=label, start=span.start, end=span.stop - 1)
362
+ for span in merge_ranges(spans, merge_gap_max=merge_gap_max)
363
+ )
364
+ return result
365
+
366
+
367
+ def get_chainbreak_boundaries_from_sequence(
368
+ sequence: Sequence[str],
369
+ ) -> np.ndarray:
370
+ """Return half-open chain intervals split by chain-break tokens."""
371
+
372
+ boundaries = [0]
373
+ final_index = len(sequence) - 1
374
+ for index, residue in enumerate(sequence):
375
+ if residue != CHAIN_BREAK_STR:
376
+ continue
377
+ if index == final_index:
378
+ raise ValueError(
379
+ "Encountered chain break token at end of sequence, this is unexpected."
380
+ )
381
+ if index == final_index - 1:
382
+ warn(
383
+ "Encountered chain break token at penultimate position, this is unexpected.",
384
+ stacklevel=2,
385
+ )
386
+ boundaries.extend((index, index + 1))
387
+ boundaries.append(len(sequence))
388
+ assert len(boundaries) % 2 == 0
389
+ return np.asarray(boundaries).reshape(-1, 2)
390
+
391
+
392
+ def deserialize_tensors(data: bytes) -> Any:
393
+ """Decompress a tensor-only Torch payload onto CPU."""
394
+
395
+ decompressed = zstandard.ZstdDecompressor().decompress(data)
396
+ return torch.load(
397
+ BytesIO(decompressed),
398
+ map_location="cpu",
399
+ weights_only=True,
400
+ )
fastplms/models/esmfold2/esmfold2_mmcif_parsing.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Biotite-backed mmCIF parsing used by ESMFold2 structure records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import io
7
+ import os
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+
12
+ import biotite.structure as bs
13
+ import biotite.structure.io.pdbx as pdbx
14
+ import numpy as np
15
+ from biotite.structure.io.pdbx import CIFColumn, CIFData, CIFFile
16
+
17
+ from . import esmfold2_residue_constants as residue_constants
18
+
19
+ PathOrBuffer = str | os.PathLike | io.StringIO
20
+
21
+ PLDDT_B_FACTOR_SCALE = 100.0
22
+ _MMCIF_COLUMN_DECIMALS = {
23
+ "Cartn_x": 3,
24
+ "Cartn_y": 3,
25
+ "Cartn_z": 3,
26
+ "B_iso_or_equiv": 2,
27
+ }
28
+ _NONPOLYMER_ENTITY_TYPES = frozenset({"NON-POLYMER", "WATER", "BRANCHED"})
29
+
30
+
31
+ class NoProteinError(Exception):
32
+ """Raised internally when an mmCIF block contains no model-one atoms."""
33
+
34
+
35
+ @dataclass
36
+ class Residue:
37
+ residue_number: int | None = None
38
+ insertion_code: str = ""
39
+ hetflag: bool = False
40
+
41
+
42
+ @dataclass
43
+ class MmcifHeader:
44
+ release_date: datetime | None = None
45
+ resolution: float | None = None
46
+ structure_method: str = "UNKNOWN"
47
+
48
+
49
+ def round_mmcif_columns(cif_file: CIFFile) -> None:
50
+ """Round coordinate and confidence columns in place for stable exports."""
51
+
52
+ if "atom_site" not in cif_file.block:
53
+ return
54
+ atom_site = cif_file.block["atom_site"]
55
+ for name, decimals in _MMCIF_COLUMN_DECIMALS.items():
56
+ if name not in atom_site:
57
+ continue
58
+ original = atom_site[name]
59
+ values = original.as_array(np.float64)
60
+ strings = np.asarray(
61
+ [f"{value:.{decimals}f}" for value in values],
62
+ dtype=np.str_,
63
+ )
64
+ atom_site[name] = CIFColumn(
65
+ data=CIFData(array=strings, dtype=np.str_),
66
+ mask=original.mask,
67
+ )
68
+
69
+
70
+ def _clean_chain_list(value: str) -> list[str]:
71
+ return [chain.strip() for chain in value.split(",") if chain.strip()]
72
+
73
+
74
+ def _empty_residue() -> Residue:
75
+ return Residue(residue_number=None, insertion_code="", hetflag=False)
76
+
77
+
78
+ def _header_from_block(
79
+ block,
80
+ header: MmcifHeader | None = None,
81
+ ) -> MmcifHeader:
82
+ header = MmcifHeader() if header is None else header
83
+ try:
84
+ if "pdbx_database_status" in block:
85
+ category = block["pdbx_database_status"]
86
+ if "recvd_initial_deposition_date" in category:
87
+ value = category["recvd_initial_deposition_date"].as_item()
88
+ if value and value != "?":
89
+ with suppress(ValueError):
90
+ header.release_date = datetime.strptime(value, "%Y-%m-%d")
91
+ if "refine" in block:
92
+ category = block["refine"]
93
+ if "ls_d_res_high" in category:
94
+ value = category["ls_d_res_high"].as_item()
95
+ if value and value != "?":
96
+ with suppress(ValueError):
97
+ header.resolution = float(value)
98
+ if "exptl" in block:
99
+ category = block["exptl"]
100
+ if "method" in category:
101
+ value = category["method"].as_item()
102
+ if value and value != "?":
103
+ header.structure_method = value.upper()
104
+ except Exception:
105
+ pass
106
+ return header
107
+
108
+
109
+ def _entities_from_block(
110
+ block,
111
+ entities: dict[int, list[str]] | None = None,
112
+ ) -> dict[int, list[str]]:
113
+ entities = {} if entities is None else entities
114
+ if "entity" in block:
115
+ category = block["entity"]
116
+ ids = category["id"].as_array(str)
117
+ types = category["type"].as_array(str)
118
+ for entity_id, _ in zip(ids, types, strict=False):
119
+ entities[int(entity_id)] = []
120
+ if "entity_poly" in block:
121
+ category = block["entity_poly"]
122
+ ids = category["entity_id"].as_array(str)
123
+ chain_lists = category["pdbx_strand_id"].as_array(str)
124
+ for raw_id, raw_chains in zip(ids, chain_lists, strict=False):
125
+ entity_id = int(raw_id)
126
+ if entity_id in entities:
127
+ entities[entity_id] = _clean_chain_list(raw_chains)
128
+ if "struct_asym" in block:
129
+ category = block["struct_asym"]
130
+ asym_ids = category["id"].as_array(str)
131
+ entity_ids = category["entity_id"].as_array(str)
132
+ for asym_id, raw_id in zip(asym_ids, entity_ids, strict=False):
133
+ entity_id = int(raw_id)
134
+ if entity_id in entities and not entities[entity_id]:
135
+ entities[entity_id].append(asym_id)
136
+ return entities
137
+
138
+
139
+ def _polymer_sequences(block) -> dict[str, str]:
140
+ sequences: dict[str, str] = {}
141
+ if "entity_poly" not in block:
142
+ return sequences
143
+ category = block["entity_poly"]
144
+ entity_ids = category["entity_id"].as_array(str)
145
+ raw_sequences = category["pdbx_seq_one_letter_code_can"].as_array(str)
146
+ chain_lists = category["pdbx_strand_id"].as_array(str)
147
+ for _, raw_sequence, raw_chains in zip(
148
+ entity_ids,
149
+ raw_sequences,
150
+ chain_lists,
151
+ strict=False,
152
+ ):
153
+ sequence = "".join(raw_sequence.split())
154
+ for chain_id in _clean_chain_list(raw_chains):
155
+ sequences[chain_id] = sequence
156
+ return sequences
157
+
158
+
159
+ def _scheme_columns(category):
160
+ asym_ids = category["asym_id"].as_array(str)
161
+ insertion_codes = (
162
+ category["pdb_ins_code"].as_array(str)
163
+ if "pdb_ins_code" in category
164
+ else [""] * len(asym_ids)
165
+ )
166
+ hetflags = category["hetflag"].as_array(str) if "hetflag" in category else ["N"] * len(asym_ids)
167
+ author_chains = (
168
+ category["pdb_strand_id"].as_array(str) if "pdb_strand_id" in category else asym_ids
169
+ )
170
+ return (
171
+ asym_ids,
172
+ category["seq_id"].as_array(str),
173
+ category["auth_seq_num"].as_array(str),
174
+ insertion_codes,
175
+ hetflags,
176
+ author_chains,
177
+ )
178
+
179
+
180
+ def _scheme_residue_map(category):
181
+ (
182
+ asym_ids,
183
+ sequence_positions,
184
+ author_numbers,
185
+ insertion_codes,
186
+ hetflags,
187
+ author_chains,
188
+ ) = _scheme_columns(category)
189
+ asym_to_author = {
190
+ asym_id: author_id for asym_id, author_id in zip(asym_ids, author_chains, strict=False)
191
+ }
192
+ per_chain: dict[str, dict[int, Residue]] = {}
193
+ for asym_id, raw_position, raw_number, raw_code, raw_hetflag in zip(
194
+ asym_ids,
195
+ sequence_positions,
196
+ author_numbers,
197
+ insertion_codes,
198
+ hetflags,
199
+ strict=False,
200
+ ):
201
+ residues = per_chain.setdefault(asym_id, {})
202
+ try:
203
+ position = int(raw_position) - 1
204
+ residue_number = int(raw_number) if raw_number != "?" else None
205
+ except ValueError:
206
+ continue
207
+ if residue_number is None:
208
+ insertion_code = ""
209
+ else:
210
+ insertion_code = "" if raw_code in (".", "?") else raw_code
211
+ residues[position] = Residue(
212
+ residue_number=residue_number,
213
+ insertion_code=insertion_code,
214
+ hetflag=raw_hetflag.upper() == "Y",
215
+ )
216
+ return per_chain, asym_to_author
217
+
218
+
219
+ def _renumber_duplicate_residues(
220
+ per_chain: dict[str, dict[int, Residue]],
221
+ ) -> None:
222
+ for residues in per_chain.values():
223
+ positions_by_number: dict[int, list[int]] = {}
224
+ for position, residue in residues.items():
225
+ if residue.residue_number is not None:
226
+ positions_by_number.setdefault(residue.residue_number, []).append(position)
227
+ for number, positions in positions_by_number.items():
228
+ if len(positions) <= 1:
229
+ continue
230
+ positions.sort()
231
+ for offset, position in enumerate(positions):
232
+ previous = residues[position]
233
+ residues[position] = Residue(
234
+ residue_number=number + offset,
235
+ insertion_code=previous.insertion_code,
236
+ hetflag=previous.hetflag,
237
+ )
238
+
239
+
240
+ def _ordered_scheme_mapping(
241
+ per_chain: dict[str, dict[int, Residue]],
242
+ asym_to_author: dict[str, str],
243
+ chain_sequences: dict[str, str],
244
+ ) -> dict[str, dict[int, Residue]]:
245
+ result: dict[str, dict[int, Residue]] = {}
246
+ for asym_id, residues in per_chain.items():
247
+ author_chain = asym_to_author.get(asym_id, asym_id)
248
+ if author_chain in chain_sequences:
249
+ result[author_chain] = {
250
+ position: residues.get(position, _empty_residue())
251
+ for position in range(len(chain_sequences[author_chain]))
252
+ }
253
+ elif residues:
254
+ result[author_chain] = {
255
+ index: residues[position] for index, position in enumerate(sorted(residues))
256
+ }
257
+ return result
258
+
259
+
260
+ def _complete_polymer_mappings(
261
+ mappings: dict[str, dict[int, Residue]],
262
+ chain_sequences: dict[str, str],
263
+ ) -> None:
264
+ for chain_id, sequence in chain_sequences.items():
265
+ mapping = mappings.setdefault(chain_id, {})
266
+ for position in range(len(sequence)):
267
+ if position not in mapping:
268
+ mapping[position] = _empty_residue()
269
+
270
+
271
+ def _add_structure_fallbacks(
272
+ mappings: dict[str, dict[int, Residue]],
273
+ structure: bs.AtomArray,
274
+ ) -> None:
275
+ if not (
276
+ structure
277
+ and hasattr(structure, "chain_id")
278
+ and structure.chain_id is not None
279
+ and hasattr(structure.chain_id, "__iter__")
280
+ ):
281
+ return
282
+ for chain_id in set(structure.chain_id):
283
+ if chain_id in mappings:
284
+ continue
285
+ chain = structure[structure.chain_id == chain_id]
286
+ if not (
287
+ hasattr(chain, "res_id")
288
+ and chain.res_id is not None
289
+ and hasattr(chain.res_id, "__iter__")
290
+ ):
291
+ continue
292
+ residue_ids = sorted(set(chain.res_id))
293
+ mappings[chain_id] = {
294
+ index: Residue(
295
+ residue_number=residue_id,
296
+ insertion_code="",
297
+ hetflag=False,
298
+ )
299
+ for index, residue_id in enumerate(residue_ids)
300
+ }
301
+
302
+
303
+ def _nonpolymer_entity_ids(block) -> set[str]:
304
+ result = set()
305
+ if "entity" not in block:
306
+ return result
307
+ category = block["entity"]
308
+ ids = category["id"].as_array(str)
309
+ types = category["type"].as_array(str)
310
+ for entity_id, entity_type in zip(ids, types, strict=False):
311
+ if entity_type.upper() in _NONPOLYMER_ENTITY_TYPES:
312
+ result.add(entity_id)
313
+ return result
314
+
315
+
316
+ def _nonpolymer_component_map(block, entity_ids: set[str]) -> dict[str, str]:
317
+ result = {}
318
+ if "pdbx_entity_nonpoly" not in block:
319
+ return result
320
+ category = block["pdbx_entity_nonpoly"]
321
+ ids = category["entity_id"].as_array(str)
322
+ components = category["comp_id"].as_array(str)
323
+ for entity_id, component in zip(ids, components, strict=False):
324
+ if entity_id in entity_ids:
325
+ result[entity_id] = component
326
+ return result
327
+
328
+
329
+ class MmcifWrapper:
330
+ """Parsed model-one structure, metadata, sequences, and residue mappings."""
331
+
332
+ def __init__(self, id: str | None = None):
333
+ self.id = id or ""
334
+ self.raw: pdbx.CIFFile | None = None
335
+ self.structure: bs.AtomArray
336
+ self.header = MmcifHeader()
337
+ self.entities: dict[int, list[str]] = {}
338
+ self.chain_to_seqres: dict[str, str] = {}
339
+ self.seqres_to_structure: dict[str, dict[int, Residue]] = {}
340
+
341
+ @classmethod
342
+ def read(cls, path: PathOrBuffer, id: str | None = None) -> MmcifWrapper:
343
+ wrapper = cls(id=id)
344
+ wrapper._load(path)
345
+ return wrapper
346
+
347
+ def _load(self, path: PathOrBuffer, fileid: str | None = None) -> None:
348
+ self.raw = pdbx.CIFFile.read(path)
349
+ self._parse_structure()
350
+ self._parse_header()
351
+ self._parse_entities()
352
+ self._parse_sequences()
353
+
354
+ def _parse_structure(self) -> None:
355
+ try:
356
+ structure = pdbx.get_structure(self.raw, model=1)
357
+ if structure is None or not isinstance(structure, bs.AtomArray):
358
+ raise NoProteinError("No structure found in mmCIF file")
359
+ if len(structure) == 0:
360
+ raise NoProteinError("Empty structure in mmCIF file")
361
+ self.structure = structure
362
+ except Exception as error:
363
+ raise ValueError(f"Failed to parse structure: {error}") from error
364
+
365
+ def _parse_header(self) -> None:
366
+ if self.raw:
367
+ self.header = _header_from_block(self.raw.block, self.header)
368
+
369
+ def _parse_entities(self) -> None:
370
+ if not self.raw:
371
+ return
372
+ try:
373
+ self.entities = _entities_from_block(self.raw.block, self.entities)
374
+ except Exception:
375
+ if (
376
+ self.structure
377
+ and hasattr(self.structure, "chain_id")
378
+ and self.structure.chain_id is not None
379
+ and hasattr(self.structure.chain_id, "__iter__")
380
+ ):
381
+ self.entities = {1: list(set(self.structure.chain_id))}
382
+
383
+ def _parse_sequences(self) -> None:
384
+ if not self.raw:
385
+ return
386
+ block = self.raw.block
387
+ self.chain_to_seqres.update(_polymer_sequences(block))
388
+ if "pdbx_poly_seq_scheme" in block:
389
+ per_chain, asym_to_author = _scheme_residue_map(block["pdbx_poly_seq_scheme"])
390
+ _renumber_duplicate_residues(per_chain)
391
+ self.seqres_to_structure.update(
392
+ _ordered_scheme_mapping(
393
+ per_chain,
394
+ asym_to_author,
395
+ self.chain_to_seqres,
396
+ )
397
+ )
398
+ _complete_polymer_mappings(
399
+ self.seqres_to_structure,
400
+ self.chain_to_seqres,
401
+ )
402
+ _add_structure_fallbacks(self.seqres_to_structure, self.structure)
403
+
404
+ def _parse_nonpoly_from_mmcif(self) -> dict[tuple, bs.AtomArray]:
405
+ assert self.raw is not None
406
+ block = self.raw.block
407
+ entity_ids = _nonpolymer_entity_ids(block)
408
+ _nonpolymer_component_map(block, entity_ids)
409
+ groups: dict[tuple[str, str], list[int]] = {}
410
+ if "atom_site" in block:
411
+ category = block["atom_site"]
412
+ chain_ids = category["label_asym_id"].as_array(str)
413
+ atom_entity_ids = category["label_entity_id"].as_array(str)
414
+ component_ids = category["label_comp_id"].as_array(str)
415
+ for index, (chain_id, entity_id, component_id) in enumerate(
416
+ zip(chain_ids, atom_entity_ids, component_ids, strict=False)
417
+ ):
418
+ if entity_id in entity_ids:
419
+ groups.setdefault((component_id, chain_id), []).append(index)
420
+
421
+ coordinates = {}
422
+ for component_id, chain_id in groups:
423
+ selection = (self.structure.chain_id == chain_id) & (
424
+ self.structure.res_name == component_id
425
+ )
426
+ if not selection.any():
427
+ continue
428
+ atoms = self.structure[selection]
429
+ if isinstance(atoms, (bs.AtomArray, bs.AtomArrayStack)) and len(atoms) > 0:
430
+ coordinates[(component_id, chain_id)] = atoms
431
+ return coordinates
432
+
433
+ def _parse_nonpoly_fallback(self) -> dict[tuple, bs.AtomArray]:
434
+ result = {}
435
+ if not (self.structure and hasattr(self.structure, "chain_id")):
436
+ return result
437
+ standard_residues = set(residue_constants.resnames[:-1])
438
+ standard_residues.update({"A", "C", "G", "T", "U"})
439
+ if self.structure.chain_id is None:
440
+ return result
441
+ for chain_id in set(self.structure.chain_id):
442
+ chain = self.structure[self.structure.chain_id == chain_id]
443
+ if not (
444
+ hasattr(chain, "res_name")
445
+ and chain.res_name is not None
446
+ and hasattr(chain.res_name, "__iter__")
447
+ ):
448
+ continue
449
+ for residue_name in set(chain.res_name):
450
+ if residue_name in standard_residues:
451
+ continue
452
+ selection = (chain.chain_id == chain_id) & (chain.res_name == residue_name)
453
+ if selection.any() and isinstance(
454
+ chain,
455
+ (bs.AtomArray, bs.AtomArrayStack),
456
+ ):
457
+ result[(residue_name, chain_id)] = chain[selection]
458
+ return result
459
+
460
+ @functools.cached_property
461
+ def non_polymer_coords(self) -> dict[tuple, bs.AtomArray]:
462
+ """Map each non-polymer component and chain to its atoms."""
463
+
464
+ if not self.structure or not self.raw:
465
+ return {}
466
+ try:
467
+ return self._parse_nonpoly_from_mmcif()
468
+ except Exception:
469
+ return self._parse_nonpoly_fallback()
fastplms/models/esmfold2/esmfold2_molecular_complex.py ADDED
@@ -0,0 +1,1016 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flat molecular-complex records used by the ESMFold2 public API.
2
+
3
+ The folding model operates on tokens and a single atom table. This module owns
4
+ that representation, its protein-only bridge, mmCIF I/O, structure metrics, and
5
+ the compact wire format. It deliberately has no dependency on the upstream
6
+ Biohub package; the pinned submodule is used only by differential tests.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import os
13
+ import re
14
+ from dataclasses import asdict, dataclass
15
+ from pathlib import Path
16
+ from subprocess import check_output
17
+ from tempfile import TemporaryDirectory
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import biotite.structure as bs
21
+ import biotite.structure.io.pdbx as pdbx
22
+ import brotli
23
+ import msgpack
24
+ import numpy as np
25
+ import torch
26
+ from biotite.structure.io.pdbx import (
27
+ CIFCategory,
28
+ CIFColumn,
29
+ CIFData,
30
+ CIFFile,
31
+ set_structure,
32
+ )
33
+
34
+ from . import esmfold2_residue_constants as residue_constants
35
+ from .esmfold2_metrics import compute_lddt, compute_rmsd
36
+ from .esmfold2_mmcif_parsing import PLDDT_B_FACTOR_SCALE, round_mmcif_columns
37
+ from .esmfold2_protein_complex import ProteinComplex, ProteinComplexMetadata
38
+
39
+
40
+ @dataclass
41
+ class MolecularComplexResult:
42
+ """One folded complex and the optional model outputs associated with it."""
43
+
44
+ complex: MolecularComplex
45
+ plddt: torch.Tensor | None = None
46
+ ptm: float | None = None
47
+ iptm: float | None = None
48
+ pae: torch.Tensor | None = None
49
+ distogram: torch.Tensor | None = None
50
+ pair_chains_iptm: torch.Tensor | None = None
51
+ output_embedding_sequence: torch.Tensor | None = None
52
+ output_embedding_pair_pooled: torch.Tensor | None = None
53
+ residue_index: torch.Tensor | None = None
54
+ entity_id: torch.Tensor | None = None
55
+ sae_features: np.ndarray | None = None # X has shape (l, n_features).
56
+ ttt_metrics: dict[str, Any] | None = None
57
+
58
+
59
+ @dataclass
60
+ class MolecularComplexMetadata:
61
+ """Entity and chain labels carried with a molecular complex."""
62
+
63
+ entity_lookup: dict[int, str]
64
+ chain_lookup: dict[int, str]
65
+ assembly_composition: dict[str, list[str]] | None = None
66
+
67
+
68
+ @dataclass
69
+ class Molecule:
70
+ """The atom slice represented by one model token."""
71
+
72
+ token: str
73
+ token_idx: int
74
+ atom_positions: np.ndarray # P has shape (n_atoms, 3).
75
+ atom_elements: np.ndarray # E has shape (n_atoms,).
76
+ atom_names: np.ndarray | None = None # N has shape (n_atoms,) when present.
77
+ atom_hetero: np.ndarray | None = None # M has shape (n_atoms,) when present.
78
+ residue_type: int = 0
79
+ molecule_type: int = 0
80
+ confidence: float = 0.0
81
+
82
+
83
+ _NUCLEOTIDE_NAMES = frozenset({"A", "T", "G", "C", "U", "DA", "DT", "DG", "DC"})
84
+ _SERIALIZED_ARRAYS = frozenset(
85
+ {
86
+ "atom_positions",
87
+ "atom_elements",
88
+ "atom_names",
89
+ "atom_hetero",
90
+ "token_to_atoms",
91
+ "chain_id",
92
+ "entity_id",
93
+ "sym_id",
94
+ "plddt",
95
+ }
96
+ )
97
+
98
+
99
+ def _assert_table_lengths(complex_value: MolecularComplex) -> None:
100
+ """Check that token and atom annotations align with their tables."""
101
+ if not isinstance(complex_value.sequence, list) or any(
102
+ not isinstance(token, str) for token in complex_value.sequence
103
+ ):
104
+ raise TypeError("sequence must be a list of token strings.")
105
+ n_tokens = len(complex_value.sequence)
106
+ if not isinstance(complex_value.atom_positions, np.ndarray):
107
+ raise TypeError("atom_positions must be a NumPy array.")
108
+ if complex_value.atom_positions.ndim != 2 or complex_value.atom_positions.shape[1:] != (
109
+ 3,
110
+ ):
111
+ raise ValueError(
112
+ "atom_positions must have shape (n_atoms, 3), got "
113
+ f"{complex_value.atom_positions.shape}."
114
+ )
115
+ if not np.issubdtype(complex_value.atom_positions.dtype, np.number):
116
+ raise TypeError("atom_positions must use a numeric dtype.")
117
+ n_atoms = len(complex_value.atom_positions)
118
+ if not isinstance(complex_value.atom_elements, np.ndarray):
119
+ raise TypeError("atom_elements must be a NumPy array.")
120
+ if complex_value.atom_elements.shape != (n_atoms,):
121
+ raise ValueError(
122
+ f"atom_elements shape {complex_value.atom_elements.shape} != {n_atoms} atoms"
123
+ )
124
+ token_tables = {
125
+ "token_to_atoms": complex_value.token_to_atoms,
126
+ "chain_id": complex_value.chain_id,
127
+ "plddt": complex_value.plddt,
128
+ }
129
+ if complex_value.entity_id is not None:
130
+ token_tables["entity_id"] = complex_value.entity_id
131
+ if complex_value.sym_id is not None:
132
+ token_tables["sym_id"] = complex_value.sym_id
133
+ for label, values in token_tables.items():
134
+ if not isinstance(values, np.ndarray):
135
+ raise TypeError(f"{label} must be a NumPy array, got {type(values).__name__}.")
136
+ if values.ndim == 0 or values.shape[0] != n_tokens:
137
+ raise ValueError(f"{label} shape {values.shape} != {n_tokens} tokens")
138
+ if complex_value.token_to_atoms.shape != (n_tokens, 2):
139
+ raise ValueError(
140
+ "token_to_atoms must have shape "
141
+ f"({n_tokens}, 2), got {complex_value.token_to_atoms.shape}."
142
+ )
143
+ if not np.issubdtype(complex_value.token_to_atoms.dtype, np.integer):
144
+ raise TypeError("token_to_atoms must use an integer dtype.")
145
+ if complex_value.chain_id.shape != (n_tokens,):
146
+ raise ValueError(f"chain_id must have shape ({n_tokens},).")
147
+ for label, values in (
148
+ ("chain_id", complex_value.chain_id),
149
+ ("entity_id", complex_value.entity_id),
150
+ ("sym_id", complex_value.sym_id),
151
+ ):
152
+ if values is not None and values.shape != (n_tokens,):
153
+ raise ValueError(f"{label} must have shape ({n_tokens},).")
154
+ if values is not None and not np.issubdtype(values.dtype, np.integer):
155
+ raise TypeError(f"{label} must use an integer dtype.")
156
+ if complex_value.plddt.shape != (n_tokens,):
157
+ raise ValueError(f"plddt must have shape ({n_tokens},).")
158
+ if not np.issubdtype(complex_value.plddt.dtype, np.number):
159
+ raise TypeError("plddt must use a numeric dtype.")
160
+ if n_tokens:
161
+ starts = complex_value.token_to_atoms[:, 0]
162
+ stops = complex_value.token_to_atoms[:, 1]
163
+ if np.any(starts < 0) or np.any(stops < starts) or np.any(stops > n_atoms):
164
+ raise ValueError("token_to_atoms contains an invalid or out-of-bounds atom span.")
165
+ for label, values in (
166
+ ("atom_names", complex_value.atom_names),
167
+ ("atom_hetero", complex_value.atom_hetero),
168
+ ):
169
+ if values is not None and not isinstance(values, np.ndarray):
170
+ raise TypeError(f"{label} must be a NumPy array, got {type(values).__name__}.")
171
+ if isinstance(values, np.ndarray) and values.shape != (n_atoms,):
172
+ raise ValueError(f"{label} shape {values.shape} != {n_atoms} atoms")
173
+
174
+
175
+ def _flat_protein_atoms(
176
+ protein: ProteinComplex,
177
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
178
+ """Flatten the populated atom37 entries of a protein complex."""
179
+ positions: list[np.ndarray] = []
180
+ elements: list[str] = []
181
+ names: list[str] = []
182
+ hetero: list[bool] = []
183
+ spans: list[tuple[int, int]] = []
184
+
185
+ for sequence_index, residue in enumerate(protein.sequence):
186
+ if residue == "|":
187
+ continue
188
+ start = len(positions)
189
+ mask = protein.atom37_mask[sequence_index]
190
+ residue_positions = protein.atom37_positions[sequence_index]
191
+ for atom_index in np.flatnonzero(mask):
192
+ atom_name = residue_constants.atom_types[int(atom_index)]
193
+ positions.append(residue_positions[atom_index])
194
+ elements.append(atom_name[0] if atom_name else "C")
195
+ names.append(atom_name)
196
+ hetero.append(False)
197
+ spans.append((start, len(positions)))
198
+
199
+ return (
200
+ np.asarray(positions, dtype=np.float32),
201
+ np.asarray(elements, dtype=object),
202
+ np.asarray(names, dtype=object),
203
+ np.asarray(hetero, dtype=bool),
204
+ np.asarray(spans, dtype=np.int32),
205
+ )
206
+
207
+
208
+ def _protein_sequence_and_indices(
209
+ complex_value: MolecularComplex,
210
+ ) -> tuple[list[int], str, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
211
+ protein_indices = [
212
+ index
213
+ for index, token in enumerate(complex_value.sequence)
214
+ if token in residue_constants.restype_3to1
215
+ ]
216
+ if not protein_indices:
217
+ raise ValueError("No protein tokens found in MolecularComplex")
218
+
219
+ chain_ids = complex_value.chain_id[protein_indices]
220
+ entity_ids = (
221
+ chain_ids
222
+ if complex_value.entity_id is None
223
+ else complex_value.entity_id[protein_indices]
224
+ )
225
+ sym_ids = (
226
+ np.zeros_like(chain_ids)
227
+ if complex_value.sym_id is None
228
+ else complex_value.sym_id[protein_indices]
229
+ )
230
+ confidences = complex_value.plddt[protein_indices]
231
+ sequence: list[str] = []
232
+ previous_instance: Any = None
233
+ preserve_instances = complex_value.sym_id is not None
234
+ for index, chain_id, sym_id in zip(
235
+ protein_indices, chain_ids, sym_ids, strict=True
236
+ ):
237
+ instance = (int(chain_id), int(sym_id)) if preserve_instances else int(chain_id)
238
+ if previous_instance is not None and instance != previous_instance:
239
+ sequence.append("|")
240
+ sequence.append(residue_constants.restype_3to1[complex_value.sequence[index]])
241
+ previous_instance = instance
242
+ return protein_indices, "".join(sequence), chain_ids, entity_ids, sym_ids, confidences
243
+
244
+
245
+ def _protein_entity_metadata_value(value: int | str) -> int | str:
246
+ """Restore the numeric entity labels used by ProteinComplex metadata."""
247
+ if isinstance(value, str):
248
+ try:
249
+ return int(value)
250
+ except ValueError:
251
+ pass
252
+ return value
253
+
254
+
255
+ def _atom37_from_flat(
256
+ complex_value: MolecularComplex, protein_indices: list[int]
257
+ ) -> tuple[np.ndarray, np.ndarray]:
258
+ n_residues = len(protein_indices)
259
+ positions = np.full((n_residues, 37, 3), np.nan, dtype=np.float32)
260
+ mask = np.zeros((n_residues, 37), dtype=bool)
261
+ if complex_value.atom_names is None:
262
+ return positions, mask
263
+
264
+ for residue_index, token_index in enumerate(protein_indices):
265
+ start, stop = complex_value.token_to_atoms[token_index]
266
+ seen: set[str] = set()
267
+ for atom_name, atom_position in zip(
268
+ complex_value.atom_names[start:stop],
269
+ complex_value.atom_positions[start:stop],
270
+ strict=True,
271
+ ):
272
+ normalized = str(atom_name).upper().strip()
273
+ if normalized in seen:
274
+ continue
275
+ seen.add(normalized)
276
+ atom37_index = residue_constants.atom_order.get(normalized)
277
+ if atom37_index is not None:
278
+ positions[residue_index, atom37_index] = atom_position
279
+ mask[residue_index, atom37_index] = True
280
+ return positions, mask
281
+
282
+
283
+ def _expand_protein_rows(
284
+ sequence: str,
285
+ protein_chain_ids: np.ndarray,
286
+ protein_entity_ids: np.ndarray,
287
+ protein_sym_ids: np.ndarray,
288
+ confidences: np.ndarray,
289
+ compact_positions: np.ndarray,
290
+ compact_mask: np.ndarray,
291
+ ) -> dict[str, np.ndarray]:
292
+ """Insert empty rows at chain separators in a protein representation."""
293
+ n_positions = len(sequence)
294
+ expanded = {
295
+ "chain_id": np.full(n_positions, -1, dtype=np.int64),
296
+ "entity_id": np.full(n_positions, -1, dtype=np.int64),
297
+ "sym_id": np.zeros(n_positions, dtype=np.int64),
298
+ "residue_index": np.zeros(n_positions, dtype=np.int64),
299
+ "insertion_code": np.asarray([""] * n_positions, dtype=object),
300
+ "confidence": np.zeros(n_positions, dtype=np.float32),
301
+ "atom37_positions": np.full((n_positions, 37, 3), np.nan, dtype=np.float32),
302
+ "atom37_mask": np.zeros((n_positions, 37), dtype=bool),
303
+ }
304
+ residue_number = 0
305
+ compact_index = 0
306
+ for sequence_index, residue in enumerate(sequence):
307
+ if residue == "|":
308
+ residue_number = 0
309
+ continue
310
+ chain_id = protein_chain_ids[compact_index]
311
+ residue_number += 1
312
+ expanded["chain_id"][sequence_index] = chain_id
313
+ expanded["entity_id"][sequence_index] = protein_entity_ids[compact_index]
314
+ expanded["sym_id"][sequence_index] = protein_sym_ids[compact_index]
315
+ expanded["residue_index"][sequence_index] = residue_number
316
+ expanded["confidence"][sequence_index] = confidences[compact_index]
317
+ expanded["atom37_positions"][sequence_index] = compact_positions[compact_index]
318
+ expanded["atom37_mask"][sequence_index] = compact_mask[compact_index]
319
+ compact_index += 1
320
+ return expanded
321
+
322
+
323
+ def _read_cif(source: str) -> CIFFile:
324
+ if os.path.exists(source):
325
+ return pdbx.CIFFile.read(source)
326
+ return pdbx.CIFFile.read(io.StringIO(source))
327
+
328
+
329
+ def _read_structure(cif_file: CIFFile) -> Any:
330
+ try:
331
+ return pdbx.get_structure(cif_file, model=1, extra_fields=["b_factor"])
332
+ except (KeyError, ValueError):
333
+ try:
334
+ return pdbx.get_structure(cif_file)
335
+ except Exception:
336
+ return pdbx.get_structure(cif_file, model=None)
337
+
338
+
339
+ def _column_array(category: Any, name: str) -> np.ndarray:
340
+ column = category[name]
341
+ if hasattr(column, "as_array"):
342
+ return column.as_array(str)
343
+ return np.asarray(list(column), dtype=str)
344
+
345
+
346
+ def _label_asym_ids(cif_file: CIFFile, n_structure_atoms: int) -> list[str] | None:
347
+ """Return label-asym identifiers after applying Biohub's atom filters."""
348
+ block = cif_file.block
349
+ if "atom_site" not in block or "label_asym_id" not in block["atom_site"]:
350
+ return None
351
+ atom_site = block["atom_site"]
352
+ labels = _column_array(atom_site, "label_asym_id")
353
+ keep = np.ones(len(labels), dtype=bool)
354
+ if "pdbx_PDB_model_num" in atom_site:
355
+ keep &= _column_array(atom_site, "pdbx_PDB_model_num") == "1"
356
+ if "label_alt_id" in atom_site:
357
+ keep &= np.isin(_column_array(atom_site, "label_alt_id"), [".", "?", "", "A"])
358
+ filtered = labels[keep]
359
+ return filtered.tolist() if len(filtered) == n_structure_atoms else None
360
+
361
+
362
+ def _entity_metadata(cif_file: CIFFile) -> dict[Any, Any]:
363
+ result: dict[Any, Any] = {}
364
+ try:
365
+ category = cif_file.block["entity"]
366
+ if "id" not in category or "type" not in category:
367
+ return result
368
+ for entity_id, entity_type in zip(category["id"], category["type"], strict=False):
369
+ result[entity_id] = entity_type
370
+ except Exception:
371
+ return {}
372
+ return result
373
+
374
+
375
+ def _group_structure_atoms(
376
+ structure: Any, labels: list[str] | None
377
+ ) -> dict[str, dict[tuple[int, str], dict[str, Any]]]:
378
+ grouped: dict[str, dict[tuple[int, str], dict[str, Any]]] = {}
379
+ for atom_index, atom in enumerate(structure):
380
+ chain = labels[atom_index] if labels is not None else atom.chain_id
381
+ residues = grouped.setdefault(chain, {})
382
+ key = (atom.res_id, atom.res_name)
383
+ record = residues.setdefault(
384
+ key,
385
+ {"atoms": [], "res_name": atom.res_name, "is_hetero": atom.hetero},
386
+ )
387
+ record["atoms"].append(atom)
388
+ return grouped
389
+
390
+
391
+ def _flatten_structure_groups(
392
+ grouped: dict[str, dict[tuple[int, str], dict[str, Any]]],
393
+ ) -> tuple[
394
+ list[str],
395
+ list[np.ndarray],
396
+ list[str],
397
+ list[str],
398
+ list[bool],
399
+ list[tuple[int, int]],
400
+ list[float],
401
+ list[int],
402
+ dict[str, int],
403
+ ]:
404
+ tokens: list[str] = []
405
+ positions: list[np.ndarray] = []
406
+ elements: list[str] = []
407
+ names: list[str] = []
408
+ hetero: list[bool] = []
409
+ spans: list[tuple[int, int]] = []
410
+ confidences: list[float] = []
411
+ token_chains: list[int] = []
412
+ chain_numbers = {chain: index for index, chain in enumerate(sorted(grouped))}
413
+
414
+ for chain in sorted(grouped):
415
+ for residue_key in sorted(grouped[chain]):
416
+ record = grouped[chain][residue_key]
417
+ if record["res_name"] == "HOH":
418
+ continue
419
+ atoms = record["atoms"]
420
+ tokens.append(record["res_name"])
421
+ token_chains.append(chain_numbers[chain])
422
+ start = len(positions)
423
+ positions.extend(atom.coord for atom in atoms)
424
+ elements.extend(atom.element for atom in atoms)
425
+ names.extend(atom.atom_name for atom in atoms)
426
+ hetero.extend(atom.hetero for atom in atoms)
427
+ spans.append((start, len(positions)))
428
+ b_factor = getattr(atoms[0], "b_factor", 50.0) if atoms else 50.0
429
+ confidences.append(min(b_factor / PLDDT_B_FACTOR_SCALE, 1.0))
430
+ return (
431
+ tokens,
432
+ positions,
433
+ elements,
434
+ names,
435
+ hetero,
436
+ spans,
437
+ confidences,
438
+ token_chains,
439
+ chain_numbers,
440
+ )
441
+
442
+
443
+ def _chain_entity_maps(
444
+ complex_value: MolecularComplex,
445
+ ) -> tuple[dict[str, list[str]], dict[str, int], dict[int, tuple[str, ...]]]:
446
+ chains: dict[str, list[str]] = {}
447
+ for token_index, numeric_chain in enumerate(complex_value.chain_id):
448
+ numeric = int(numeric_chain)
449
+ label = complex_value.metadata.chain_lookup.get(numeric, chr(65 + numeric))
450
+ chains.setdefault(label, []).append(complex_value.sequence[token_index])
451
+
452
+ sequence_entities: dict[tuple[str, ...], int] = {}
453
+ chain_entities: dict[str, int] = {}
454
+ entity_sequences: dict[int, tuple[str, ...]] = {}
455
+ for label, sequence in chains.items():
456
+ key = tuple(sequence)
457
+ entity_id = sequence_entities.get(key)
458
+ if entity_id is None:
459
+ entity_id = len(sequence_entities) + 1
460
+ sequence_entities[key] = entity_id
461
+ entity_sequences[entity_id] = key
462
+ chain_entities[label] = entity_id
463
+ return chains, chain_entities, entity_sequences
464
+
465
+
466
+ def _cif_column(values: list[str]) -> CIFColumn:
467
+ return CIFColumn(data=CIFData(array=np.asarray(values), dtype=np.str_))
468
+
469
+
470
+ def _add_entity_categories(
471
+ cif_file: CIFFile,
472
+ complex_value: MolecularComplex,
473
+ entity_sequences: dict[int, tuple[str, ...]],
474
+ ) -> None:
475
+ ids: list[str] = []
476
+ types: list[str] = []
477
+ descriptions: list[str] = []
478
+ for entity_id in sorted(entity_sequences):
479
+ sequence = entity_sequences[entity_id]
480
+ protein = any(token in residue_constants.restype_3to1 for token in sequence)
481
+ nucleic = any(token in _NUCLEOTIDE_NAMES for token in sequence)
482
+ ids.append(str(entity_id))
483
+ types.append("polymer" if protein or nucleic else "non-polymer")
484
+ if protein:
485
+ descriptions.append(f"Polymer entity {entity_id} (protein)")
486
+ elif nucleic:
487
+ descriptions.append(f"Polymer entity {entity_id} (nucleic acid)")
488
+ else:
489
+ descriptions.append(f"Non-polymer entity {entity_id}")
490
+
491
+ if ids:
492
+ cif_file.block["entity"] = CIFCategory(
493
+ name="entity",
494
+ columns={
495
+ "id": _cif_column(ids),
496
+ "type": _cif_column(types),
497
+ "pdbx_description": _cif_column(descriptions),
498
+ },
499
+ )
500
+
501
+ _, chain_entities, _ = _chain_entity_maps(complex_value)
502
+ if chain_entities:
503
+ labels = sorted(chain_entities)
504
+ cif_file.block["struct_asym"] = CIFCategory(
505
+ name="struct_asym",
506
+ columns={
507
+ "id": _cif_column(labels),
508
+ "entity_id": _cif_column([str(chain_entities[label]) for label in labels]),
509
+ },
510
+ )
511
+
512
+ entity_chains: dict[int, list[str]] = {}
513
+ for chain, entity_id in chain_entities.items():
514
+ entity_chains.setdefault(entity_id, []).append(chain)
515
+ polymer_rows: list[tuple[str, str, str, str]] = []
516
+ residue_rows: list[tuple[str, str, str, str]] = []
517
+ for entity_id in sorted(entity_sequences):
518
+ sequence = entity_sequences[entity_id]
519
+ protein = any(token in residue_constants.restype_3to1 for token in sequence)
520
+ nucleic = any(token in _NUCLEOTIDE_NAMES for token in sequence)
521
+ if not (protein or nucleic):
522
+ continue
523
+ if protein:
524
+ polymer_type = "polypeptide(L)"
525
+ canonical = "".join(
526
+ residue_constants.restype_3to1.get(token, "(X)") for token in sequence
527
+ )
528
+ else:
529
+ polymer_type = (
530
+ "polyribonucleotide"
531
+ if "U" in sequence
532
+ else (
533
+ "polydeoxyribonucleotide"
534
+ if any(token in {"DA", "DT", "DG", "DC"} for token in sequence)
535
+ else "polyribonucleotide"
536
+ )
537
+ )
538
+ nucleotide_letters = {"DA": "A", "DT": "T", "DG": "G", "DC": "C"}
539
+ canonical = "".join(nucleotide_letters.get(token, token) for token in sequence)
540
+ strand_ids = ",".join(sorted(entity_chains.get(entity_id, []))) or "?"
541
+ polymer_rows.append((str(entity_id), polymer_type, strand_ids, canonical))
542
+ residue_rows.extend(
543
+ (str(entity_id), str(number), token, "n")
544
+ for number, token in enumerate(sequence, start=1)
545
+ )
546
+
547
+ if polymer_rows:
548
+ columns = list(zip(*polymer_rows, strict=True))
549
+ cif_file.block["entity_poly"] = CIFCategory(
550
+ name="entity_poly",
551
+ columns={
552
+ "entity_id": _cif_column(list(columns[0])),
553
+ "type": _cif_column(list(columns[1])),
554
+ "pdbx_strand_id": _cif_column(list(columns[2])),
555
+ "pdbx_seq_one_letter_code_can": _cif_column(list(columns[3])),
556
+ },
557
+ )
558
+ if residue_rows:
559
+ columns = list(zip(*residue_rows, strict=True))
560
+ cif_file.block["entity_poly_seq"] = CIFCategory(
561
+ name="entity_poly_seq",
562
+ columns={
563
+ "entity_id": _cif_column(list(columns[0])),
564
+ "num": _cif_column(list(columns[1])),
565
+ "mon_id": _cif_column(list(columns[2])),
566
+ "hetero": _cif_column(list(columns[3])),
567
+ },
568
+ )
569
+
570
+
571
+ def _fallback_atom_names(token: str, count: int) -> list[str]:
572
+ if token in residue_constants.restype_3to1:
573
+ names = list(residue_constants.residue_atoms.get(token, ["N", "CA", "C", "O"]))[:count]
574
+ names.extend(f"X{index + 1}" for index in range(len(names), count))
575
+ return names
576
+ return [f"C{index + 1}" for index in range(count)]
577
+
578
+
579
+ def _as_atom_array(complex_value: MolecularComplex, chain_entities: dict[str, int]) -> bs.AtomArray:
580
+ n_atoms = len(complex_value.atom_positions)
581
+ atom_array = bs.AtomArray(length=n_atoms)
582
+ atom_array.coord = complex_value.atom_positions
583
+ residue_ids = np.zeros(n_atoms, dtype=np.int32)
584
+ chain_labels = np.empty(n_atoms, dtype=object)
585
+ residue_names = np.empty(n_atoms, dtype=object)
586
+ hetero = np.zeros(n_atoms, dtype=bool)
587
+ b_factors = np.zeros(n_atoms, dtype=np.float32)
588
+ atom_names = np.empty(n_atoms, dtype=object)
589
+ entity_ids = np.zeros(n_atoms, dtype=np.int32)
590
+ next_residue: dict[Any, int] = {}
591
+
592
+ for token_index, (start, stop) in enumerate(complex_value.token_to_atoms):
593
+ token = complex_value.sequence[token_index]
594
+ numeric_chain = complex_value.chain_id[token_index]
595
+ numeric = int(numeric_chain)
596
+ chain = complex_value.metadata.chain_lookup.get(numeric, chr(65 + numeric))
597
+ residue_id = next_residue.get(numeric_chain, 0) + 1
598
+ next_residue[numeric_chain] = residue_id
599
+ count = int(stop - start)
600
+ names = (
601
+ list(complex_value.atom_names[start:stop])
602
+ if complex_value.atom_names is not None
603
+ else _fallback_atom_names(token, count)
604
+ )
605
+ residue_ids[start:stop] = residue_id
606
+ chain_labels[start:stop] = chain
607
+ residue_names[start:stop] = token
608
+ hetero[start:stop] = (
609
+ complex_value.atom_hetero[start:stop]
610
+ if complex_value.atom_hetero is not None
611
+ else token not in residue_constants.restype_3to1
612
+ )
613
+ b_factors[start:stop] = complex_value.plddt[token_index] * PLDDT_B_FACTOR_SCALE
614
+ atom_names[start:stop] = names
615
+ entity_ids[start:stop] = chain_entities.get(chain, 1)
616
+
617
+ atom_array.res_id = residue_ids
618
+ atom_array.chain_id = np.asarray(chain_labels, dtype="U16")
619
+ atom_array.res_name = np.asarray(residue_names, dtype="U8")
620
+ atom_array.hetero = hetero
621
+ atom_array.atom_name = np.asarray(atom_names, dtype="U4")
622
+ atom_array.add_annotation("b_factor", dtype=float)
623
+ atom_array.b_factor = b_factors
624
+ atom_array.add_annotation("occupancy", dtype=float)
625
+ atom_array.occupancy = np.ones(n_atoms, dtype=np.float32)
626
+ atom_array.add_annotation("entity_id", dtype=int)
627
+ atom_array.entity_id = entity_ids
628
+ if complex_value.atom_elements is not None and len(complex_value.atom_elements) == n_atoms:
629
+ atom_array.element = np.asarray(complex_value.atom_elements, dtype="U4")
630
+ else:
631
+ atom_array.element = bs.infer_elements(atom_array)
632
+ return atom_array
633
+
634
+
635
+ def _repair_label_entity_ids(cif_file: CIFFile, chain_entities: dict[str, int]) -> None:
636
+ if "atom_site" not in cif_file.block:
637
+ return
638
+ atom_site = cif_file.block["atom_site"]
639
+ if "label_asym_id" not in atom_site or "label_entity_id" not in atom_site:
640
+ return
641
+ labels = _column_array(atom_site, "label_asym_id").tolist()
642
+ if labels:
643
+ atom_site["label_entity_id"] = _cif_column(
644
+ [str(chain_entities.get(label, 1)) for label in labels]
645
+ )
646
+
647
+
648
+ def _centroid_tensors(
649
+ mobile: MolecularComplex,
650
+ target: MolecularComplex,
651
+ *,
652
+ retain_missing: bool,
653
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
654
+ if len(mobile) != len(target):
655
+ raise ValueError(
656
+ f"Complexes must have the same number of tokens: {len(mobile)} vs {len(target)}"
657
+ )
658
+ mobile_centers: list[np.ndarray] = []
659
+ target_centers: list[np.ndarray] = []
660
+ valid: list[bool] = []
661
+ for token_index in range(len(mobile)):
662
+ mobile_start, mobile_stop = mobile.token_to_atoms[token_index]
663
+ target_start, target_stop = target.token_to_atoms[token_index]
664
+ mobile_atoms = mobile.atom_positions[mobile_start:mobile_stop]
665
+ target_atoms = target.atom_positions[target_start:target_stop]
666
+ present = len(mobile_atoms) > 0 and len(target_atoms) > 0
667
+ if not present and not retain_missing:
668
+ continue
669
+ if present:
670
+ mobile_centers.append(mobile_atoms.mean(axis=0))
671
+ target_centers.append(target_atoms.mean(axis=0))
672
+ else:
673
+ mobile_centers.append(np.full(3, np.nan))
674
+ target_centers.append(np.full(3, np.nan))
675
+ valid.append(present)
676
+ if not any(valid):
677
+ metric = "LDDT" if retain_missing else "RMSD"
678
+ raise ValueError(f"No valid atoms found for {metric} computation")
679
+ return (
680
+ torch.from_numpy(np.stack(mobile_centers)).unsqueeze(0),
681
+ torch.from_numpy(np.stack(target_centers)).unsqueeze(0),
682
+ torch.as_tensor(valid, dtype=torch.bool).unsqueeze(0),
683
+ )
684
+
685
+
686
+ @dataclass(frozen=True)
687
+ class MolecularComplex:
688
+ """A token sequence backed by one contiguous atom table.
689
+
690
+ P stores atom coordinates with shape (n_atoms, 3). Token span ``i`` is
691
+ ``P[token_to_atoms[i, 0]:token_to_atoms[i, 1]]``. ``chain_id`` identifies
692
+ the author chain, while optional ``entity_id`` and ``sym_id`` distinguish
693
+ biological entities and repeated chain instances.
694
+ """
695
+
696
+ id: str
697
+ sequence: list[str]
698
+ atom_positions: np.ndarray # P has shape (n_atoms, 3).
699
+ atom_elements: np.ndarray # E has shape (n_atoms,).
700
+ token_to_atoms: np.ndarray # I has shape (n_tokens, 2).
701
+ chain_id: np.ndarray # C has shape (n_tokens,).
702
+ plddt: np.ndarray # S has shape (n_tokens,).
703
+ metadata: MolecularComplexMetadata
704
+ atom_names: np.ndarray | None = None # N has shape (n_atoms,) when present.
705
+ atom_hetero: np.ndarray | None = None # M has shape (n_atoms,) when present.
706
+ # These token-aligned IDs are optional for compatibility with older blobs.
707
+ # ProteinComplex adapters populate them so homomers and repeated author-chain
708
+ # labels survive a MolecularComplex round trip.
709
+ entity_id: np.ndarray | None = None
710
+ sym_id: np.ndarray | None = None
711
+
712
+ def __post_init__(self) -> None:
713
+ _assert_table_lengths(self)
714
+
715
+ def __len__(self) -> int:
716
+ return len(self.sequence)
717
+
718
+ def __getitem__(self, idx: int) -> Molecule:
719
+ if idx < 0 or idx >= len(self):
720
+ raise IndexError(f"Token index {idx} out of range for {len(self)} tokens")
721
+ start, stop = self.token_to_atoms[idx]
722
+ return Molecule(
723
+ token=self.sequence[idx],
724
+ token_idx=idx,
725
+ atom_positions=self.atom_positions[start:stop],
726
+ atom_elements=self.atom_elements[start:stop],
727
+ atom_names=None if self.atom_names is None else self.atom_names[start:stop],
728
+ atom_hetero=(None if self.atom_hetero is None else self.atom_hetero[start:stop]),
729
+ residue_type=0,
730
+ molecule_type=0,
731
+ confidence=self.plddt[idx],
732
+ )
733
+
734
+ @property
735
+ def atom_coordinates(self) -> np.ndarray:
736
+ """Return P, the flat atom-coordinate table with shape (n_atoms, 3)."""
737
+ return self.atom_positions
738
+
739
+ @classmethod
740
+ def from_protein_complex(cls, pc: ProteinComplex) -> MolecularComplex:
741
+ positions, elements, names, hetero, spans = _flat_protein_atoms(pc)
742
+ residue_positions = [index for index, value in enumerate(pc.sequence) if value != "|"]
743
+ metadata = MolecularComplexMetadata(
744
+ entity_lookup={key: str(value) for key, value in pc.metadata.entity_lookup.items()},
745
+ chain_lookup=dict(pc.metadata.chain_lookup),
746
+ assembly_composition=pc.metadata.assembly_composition,
747
+ )
748
+ return cls(
749
+ id=pc.id,
750
+ sequence=[
751
+ residue_constants.restype_1to3.get(pc.sequence[index], "UNK")
752
+ for index in residue_positions
753
+ ],
754
+ atom_positions=positions,
755
+ atom_elements=elements,
756
+ token_to_atoms=spans,
757
+ chain_id=np.asarray(pc.chain_id[residue_positions], dtype=np.int64),
758
+ plddt=np.asarray(pc.confidence[residue_positions], dtype=np.float32),
759
+ metadata=metadata,
760
+ atom_names=names,
761
+ atom_hetero=hetero,
762
+ entity_id=np.asarray(pc.entity_id[residue_positions], dtype=np.int64),
763
+ sym_id=np.asarray(pc.sym_id[residue_positions], dtype=np.int64),
764
+ )
765
+
766
+ def to_protein_complex(self) -> ProteinComplex:
767
+ (
768
+ protein_indices,
769
+ sequence,
770
+ chain_ids,
771
+ entity_ids,
772
+ sym_ids,
773
+ confidences,
774
+ ) = _protein_sequence_and_indices(self)
775
+ compact_positions, compact_mask = _atom37_from_flat(self, protein_indices)
776
+ arrays = _expand_protein_rows(
777
+ sequence,
778
+ chain_ids,
779
+ entity_ids,
780
+ sym_ids,
781
+ confidences,
782
+ compact_positions,
783
+ compact_mask,
784
+ )
785
+ unique_chains = np.unique(chain_ids)
786
+ unique_entities = np.unique(entity_ids)
787
+ metadata = ProteinComplexMetadata(
788
+ entity_lookup={
789
+ int(entity): _protein_entity_metadata_value(
790
+ self.metadata.entity_lookup.get(int(entity), int(entity))
791
+ )
792
+ for entity in unique_entities
793
+ },
794
+ chain_lookup={
795
+ int(chain): self.metadata.chain_lookup.get(int(chain), chr(65 + int(chain)))
796
+ for chain in unique_chains
797
+ },
798
+ assembly_composition=self.metadata.assembly_composition,
799
+ )
800
+ return ProteinComplex(
801
+ id=self.id,
802
+ sequence=sequence,
803
+ entity_id=arrays["entity_id"],
804
+ chain_id=arrays["chain_id"],
805
+ sym_id=arrays["sym_id"],
806
+ residue_index=arrays["residue_index"],
807
+ insertion_code=arrays["insertion_code"],
808
+ atom37_positions=arrays["atom37_positions"],
809
+ atom37_mask=arrays["atom37_mask"],
810
+ confidence=arrays["confidence"],
811
+ metadata=metadata,
812
+ )
813
+
814
+ @classmethod
815
+ def from_mmcif(cls, inp: str, id: str | None = None) -> MolecularComplex:
816
+ cif_file = _read_cif(inp)
817
+ structure = _read_structure(cif_file)
818
+ if TYPE_CHECKING:
819
+ structure: Any = structure
820
+ labels = _label_asym_ids(cif_file, len(structure))
821
+ grouped = _group_structure_atoms(structure, labels)
822
+ (
823
+ tokens,
824
+ positions,
825
+ elements,
826
+ names,
827
+ hetero,
828
+ spans,
829
+ confidences,
830
+ token_chains,
831
+ chain_numbers,
832
+ ) = _flatten_structure_groups(grouped)
833
+ n_tokens = len(tokens)
834
+ if positions:
835
+ position_array = np.asarray(positions, dtype=np.float32)
836
+ element_array = np.asarray(elements, dtype=object)
837
+ name_array = np.asarray(names, dtype=object)
838
+ hetero_array = np.asarray(hetero, dtype=bool)
839
+ span_array = np.asarray(spans, dtype=np.int32)
840
+ chain_array = np.asarray(token_chains, dtype=np.int64)
841
+ else:
842
+ position_array = np.zeros((0, 3), dtype=np.float32)
843
+ element_array = np.zeros(0, dtype=object)
844
+ name_array = np.zeros(0, dtype=object)
845
+ hetero_array = np.zeros(0, dtype=bool)
846
+ span_array = np.zeros((n_tokens, 2), dtype=np.int32)
847
+ chain_array = (
848
+ np.asarray(token_chains, dtype=np.int64)
849
+ if token_chains
850
+ else np.zeros(n_tokens, dtype=np.int64)
851
+ )
852
+ complex_id = id or (Path(inp).stem if os.path.exists(inp) else "complex_from_string")
853
+ return cls(
854
+ id=complex_id,
855
+ sequence=tokens,
856
+ atom_positions=position_array,
857
+ atom_elements=element_array,
858
+ token_to_atoms=span_array,
859
+ chain_id=chain_array,
860
+ plddt=np.asarray(confidences, dtype=np.float32),
861
+ metadata=MolecularComplexMetadata(
862
+ entity_lookup=_entity_metadata(cif_file),
863
+ chain_lookup={number: chain for chain, number in chain_numbers.items()},
864
+ assembly_composition=None,
865
+ ),
866
+ atom_names=name_array,
867
+ atom_hetero=hetero_array,
868
+ )
869
+
870
+ def _get_entity_mapping(
871
+ self,
872
+ ) -> tuple[dict[str, list[str]], dict[str, int], dict[int, tuple[str, ...]]]:
873
+ return _chain_entity_maps(self)
874
+
875
+ def _add_entity_information(
876
+ self, cif_file: CIFFile, entity_sequences: dict[int, tuple[str, ...]]
877
+ ) -> None:
878
+ _add_entity_categories(cif_file, self, entity_sequences)
879
+
880
+ def to_mmcif(self) -> str:
881
+ _, chain_entities, entity_sequences = _chain_entity_maps(self)
882
+ atom_array = _as_atom_array(self, chain_entities)
883
+ cif_file = CIFFile()
884
+ set_structure(cif_file, atom_array, data_block=self.id)
885
+ _repair_label_entity_ids(cif_file, chain_entities)
886
+ _add_entity_categories(cif_file, self, entity_sequences)
887
+ round_mmcif_columns(cif_file)
888
+ output = io.StringIO()
889
+ cif_file.write(output)
890
+ return output.getvalue()
891
+
892
+ def dockq(self, native: MolecularComplex) -> Any:
893
+ try:
894
+ mobile = self.to_protein_complex().normalize_chain_ids_for_pdb()
895
+ target = native.to_protein_complex().normalize_chain_ids_for_pdb()
896
+ except ValueError as error:
897
+ raise ValueError(
898
+ f"Cannot convert MolecularComplex to ProteinComplex for DockQ: {error}"
899
+ ) from None
900
+ try:
901
+ return mobile.dockq(target)
902
+ except Exception:
903
+ return self._compute_dockq_manual(native)
904
+
905
+ def _compute_dockq_manual(self, native: MolecularComplex) -> Any:
906
+ try:
907
+ mobile = self.to_protein_complex().normalize_chain_ids_for_pdb()
908
+ target = native.to_protein_complex().normalize_chain_ids_for_pdb()
909
+ except ValueError as error:
910
+ raise ValueError(
911
+ f"Cannot convert MolecularComplex to ProteinComplex for DockQ: {error}"
912
+ ) from None
913
+ with TemporaryDirectory() as directory:
914
+ mobile_path = Path(directory) / "self.pdb"
915
+ target_path = Path(directory) / "native.pdb"
916
+ mobile.to_pdb(mobile_path)
917
+ target.to_pdb(target_path)
918
+ try:
919
+ raw_output = check_output(["DockQ", str(mobile_path), str(target_path)])
920
+ output = raw_output.decode()
921
+ score: float | None = None
922
+ for line in output.split("\n"):
923
+ if "Total DockQ" in line:
924
+ match = re.search(r"Total DockQ.*: ([\d.]+)", line)
925
+ if match:
926
+ score = float(match.group(1))
927
+ break
928
+ if score is None:
929
+ for line in output.split("\n"):
930
+ if line.startswith("DockQ") and ":" in line:
931
+ try:
932
+ score = float(line.split(":")[1].strip())
933
+ break
934
+ except (ValueError, IndexError):
935
+ continue
936
+ if score is None:
937
+ raise ValueError("Could not parse DockQ score from output")
938
+ return {"total_dockq": score, "raw_output": output, "aligned": self}
939
+ except FileNotFoundError:
940
+ raise RuntimeError(
941
+ "DockQ is not installed. Please install DockQ to use this method."
942
+ ) from None
943
+ except Exception as error:
944
+ raise RuntimeError(f"DockQ computation failed: {error}") from error
945
+
946
+ def rmsd(self, target: MolecularComplex, **kwargs: Any) -> float:
947
+ mobile, reference, mask = _centroid_tensors(self, target, retain_missing=False)
948
+ value = compute_rmsd(
949
+ mobile=mobile,
950
+ target=reference,
951
+ atom_exists_mask=mask,
952
+ reduction="batch",
953
+ **kwargs,
954
+ )
955
+ return float(value)
956
+
957
+ def lddt_ca(self, target: MolecularComplex, **kwargs: Any) -> float:
958
+ mobile, reference, mask = _centroid_tensors(self, target, retain_missing=True)
959
+ value = compute_lddt(
960
+ all_atom_pred_pos=mobile,
961
+ all_atom_positions=reference,
962
+ all_atom_mask=mask,
963
+ per_residue=False,
964
+ **kwargs,
965
+ )
966
+ return float(value)
967
+
968
+ def state_dict(self) -> dict[str, Any]:
969
+ state = dict(vars(self))
970
+ for optional_identity in ("entity_id", "sym_id"):
971
+ if state[optional_identity] is None:
972
+ state.pop(optional_identity)
973
+ for key, value in tuple(state.items()):
974
+ if isinstance(value, MolecularComplexMetadata):
975
+ state[key] = asdict(value)
976
+ elif isinstance(value, np.ndarray):
977
+ if value.dtype == np.int64:
978
+ value = value.astype(np.int32)
979
+ elif value.dtype in (np.dtype(np.float64), np.dtype(np.float32)):
980
+ value = value.astype(np.float16)
981
+ state[key] = value.tolist()
982
+ return state
983
+
984
+ def to_blob(self) -> bytes:
985
+ return brotli.compress(msgpack.dumps(self.state_dict()), quality=5)
986
+
987
+ @classmethod
988
+ def from_state_dict(cls, dct: dict[str, Any]) -> MolecularComplex:
989
+ dct = dict(dct)
990
+ for key, value in tuple(dct.items()):
991
+ if isinstance(value, list) and key in _SERIALIZED_ARRAYS:
992
+ dct[key] = np.asarray(value)
993
+ for key, value in tuple(dct.items()):
994
+ if not isinstance(value, np.ndarray):
995
+ continue
996
+ if key in {"atom_positions", "plddt"}:
997
+ dct[key] = value.astype(np.float32)
998
+ elif key == "token_to_atoms":
999
+ dct[key] = value.astype(np.int32)
1000
+ elif key in {"chain_id", "entity_id", "sym_id"}:
1001
+ dct[key] = value.astype(np.int64)
1002
+ dct["metadata"] = MolecularComplexMetadata(**dct["metadata"])
1003
+ if "chain_id" not in dct:
1004
+ dct["chain_id"] = np.zeros(len(dct["sequence"]), dtype=np.int64)
1005
+ return cls(**dct)
1006
+
1007
+ @classmethod
1008
+ def from_blob(cls, input: Path | str | io.BytesIO | bytes) -> MolecularComplex:
1009
+ if isinstance(input, (Path, str)):
1010
+ payload = Path(input).read_bytes()
1011
+ elif isinstance(input, io.BytesIO):
1012
+ payload = input.getvalue()
1013
+ else:
1014
+ payload = input
1015
+ state = msgpack.loads(brotli.decompress(payload), strict_map_key=False)
1016
+ return cls.from_state_dict(state)
fastplms/models/esmfold2/esmfold2_msa.py ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multiple-sequence-alignment value objects and lossless encodings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import string
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from functools import cached_property
10
+ from itertools import islice
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from Bio import SeqIO
15
+ from scipy.spatial.distance import cdist
16
+
17
+ from .esmfold2_misc import slice_any_object
18
+ from .esmfold2_msa_filter_sequences import greedy_select_indices, hhfilter
19
+ from .esmfold2_parsing import FastaEntry, read_sequences, write_sequences
20
+ from .esmfold2_sequential_dataclass import SequentialDataclass
21
+ from .esmfold2_system import PathOrBuffer
22
+
23
+ _A3M_INSERTION_DELETE_TABLE = str.maketrans(
24
+ dict.fromkeys(string.ascii_lowercase + ".")
25
+ )
26
+ _SERIALIZATION_VERSION = 1
27
+ _UINT32_BYTES = 4
28
+
29
+
30
+ def is_a3m_insertion(character: str) -> bool:
31
+ """Return whether a character is an A3M insertion marker."""
32
+
33
+ return character == "." or character.islower()
34
+
35
+
36
+ def remove_insertions_from_sequence(sequence: str) -> str:
37
+ """Remove lowercase residues and dot insertion markers from an A3M row."""
38
+
39
+ return sequence.translate(_A3M_INSERTION_DELETE_TABLE)
40
+
41
+
42
+ def a3m_deletion_counts(sequence: str) -> np.ndarray:
43
+ """Count insertions preceding each A3M match column."""
44
+
45
+ codes = np.frombuffer(sequence.encode("ascii"), dtype=np.uint8)
46
+ lowercase = (codes >= ord("a")) & (codes <= ord("z"))
47
+ insertion_mask = lowercase | (codes == ord("."))
48
+ prefix_counts = np.concatenate(([0], np.cumsum(insertion_mask)))
49
+ match_positions = np.flatnonzero(~insertion_mask)
50
+ return np.diff(prefix_counts[match_positions], prepend=0)
51
+
52
+
53
+ def _parse_full_payload(data: bytes) -> tuple[np.ndarray, list[str]]:
54
+ version = int.from_bytes(data[:1], "little")
55
+ if version != _SERIALIZATION_VERSION:
56
+ raise ValueError(f"Unsupported version: {version}")
57
+ seqlen = int.from_bytes(data[1:5], "little")
58
+ depth = int.from_bytes(data[5:9], "little")
59
+ body = data[9:]
60
+ split = seqlen * depth
61
+ array = np.frombuffer(body[:split], dtype="|S1").reshape(depth, seqlen)
62
+ headers = [header for header in body[split:].decode().split("\n") if header]
63
+ if not headers and depth > 0:
64
+ headers = [""] * depth
65
+ return array, headers
66
+
67
+
68
+ def _parse_sequence_payload(data: bytes) -> np.ndarray:
69
+ seqlen = int.from_bytes(data[:_UINT32_BYTES], "little")
70
+ return np.frombuffer(data[_UINT32_BYTES:], dtype="|S1").reshape(-1, seqlen)
71
+
72
+
73
+ def _full_payload(array: np.ndarray, headers: Sequence[str]) -> bytes:
74
+ depth, seqlen = array.shape
75
+ prefix = b"".join(
76
+ (
77
+ _SERIALIZATION_VERSION.to_bytes(1, "little"),
78
+ seqlen.to_bytes(_UINT32_BYTES, "little"),
79
+ depth.to_bytes(_UINT32_BYTES, "little"),
80
+ )
81
+ )
82
+ return prefix + array.tobytes() + "\n".join(headers).encode()
83
+
84
+
85
+ def _sequence_payload(array: np.ndarray) -> bytes:
86
+ return array.shape[1].to_bytes(_UINT32_BYTES, "little") + array.tobytes()
87
+
88
+
89
+ def _random_row_indices(depth: int, count: int) -> np.ndarray:
90
+ sampled = np.random.choice(depth - 1, count - 1, replace=False) + 1
91
+ return np.sort(np.append(0, sampled))
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class FastMSA(SequentialDataclass):
96
+ """An MSA stored as a two-dimensional NumPy byte array."""
97
+
98
+ array: np.ndarray
99
+ headers: list[str] | None = None
100
+
101
+ def __post_init__(self) -> None:
102
+ if not isinstance(self.array, np.ndarray):
103
+ raise TypeError("FastMSA array must be a NumPy array.")
104
+ if self.array.ndim != 2 or self.array.shape[0] == 0 or self.array.shape[1] == 0:
105
+ raise ValueError(
106
+ f"FastMSA array must have non-empty shape (depth, length), got {self.array.shape}."
107
+ )
108
+ if self.headers is not None and len(self.headers) != self.depth:
109
+ raise ValueError("Number of headers must match depth.")
110
+
111
+ @property
112
+ def depth(self) -> int:
113
+ return self.array.shape[0]
114
+
115
+ @property
116
+ def seqlen(self) -> int:
117
+ return self.array.shape[1]
118
+
119
+ def __len__(self) -> int:
120
+ return self.seqlen
121
+
122
+ @classmethod
123
+ def from_bytes(cls, data: bytes) -> FastMSA:
124
+ array, headers = _parse_full_payload(data)
125
+ return cls(array, headers)
126
+
127
+ @classmethod
128
+ def from_sequence_bytes(cls, data: bytes) -> FastMSA:
129
+ return cls(_parse_sequence_payload(data))
130
+
131
+ def __getitem__(
132
+ self,
133
+ indices: int | list[int] | slice | np.ndarray,
134
+ ) -> FastMSA:
135
+ column_indices = [indices] if isinstance(indices, int) else indices
136
+ return dataclasses.replace(self, array=self.array[:, column_indices])
137
+
138
+ def select_sequences(
139
+ self,
140
+ indices: Sequence[int] | np.ndarray,
141
+ ) -> FastMSA:
142
+ headers = None
143
+ if self.headers is not None:
144
+ headers = [self.headers[index] for index in indices]
145
+ return dataclasses.replace(
146
+ self,
147
+ array=self.array[indices],
148
+ headers=headers,
149
+ )
150
+
151
+ def select_random_sequences(self, num_seqs: int) -> FastMSA:
152
+ if num_seqs >= self.depth:
153
+ return self
154
+ return self.select_sequences(_random_row_indices(self.depth, num_seqs))
155
+
156
+ def pad_to_depth(self, depth: int) -> FastMSA:
157
+ if depth < self.depth:
158
+ raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}")
159
+ if depth == self.depth:
160
+ return self
161
+ row_count = depth - self.depth
162
+ pad_value = ord("-") if self.array.dtype == np.uint8 else b"-"
163
+ array = np.pad(
164
+ self.array,
165
+ ((0, row_count), (0, 0)),
166
+ constant_values=pad_value,
167
+ )
168
+ headers = None if self.headers is None else self.headers + [""] * row_count
169
+ return dataclasses.replace(self, array=array, headers=headers)
170
+
171
+ @classmethod
172
+ def concat(
173
+ cls,
174
+ msas: Sequence[FastMSA],
175
+ join_token: str | None = None,
176
+ allow_depth_mismatch: bool = False,
177
+ ) -> FastMSA:
178
+ if not msas:
179
+ raise ValueError("Cannot concatenate an empty list of MSAs")
180
+ if join_token not in (None, ""):
181
+ raise NotImplementedError("join_token is not supported for FastMSA")
182
+ depths = [msa.depth for msa in msas]
183
+ if len(set(depths)) != 1:
184
+ if not allow_depth_mismatch:
185
+ raise ValueError("Depth mismatch in concatenating MSAs")
186
+ maximum_depth = max(depths)
187
+ msas = [msa.pad_to_depth(maximum_depth) for msa in msas]
188
+ header_columns = (
189
+ msa.headers if msa.headers is not None else [""] * msa.depth for msa in msas
190
+ )
191
+ headers = [
192
+ "|".join(str(header) for header in row) for row in zip(*header_columns, strict=False)
193
+ ]
194
+ return cls(
195
+ np.concatenate([msa.array for msa in msas], axis=1),
196
+ headers,
197
+ )
198
+
199
+ @classmethod
200
+ def stack(
201
+ cls,
202
+ msas: Sequence[FastMSA],
203
+ remove_query_from_later_msas: bool = True,
204
+ ) -> FastMSA:
205
+ if not msas:
206
+ raise ValueError("Cannot stack an empty list of MSAs")
207
+ arrays: list[np.ndarray] = []
208
+ headers: list[str] | None = [] if any(msa.headers is not None for msa in msas) else None
209
+ for index, msa in enumerate(msas):
210
+ start = 1 if index > 0 and remove_query_from_later_msas else 0
211
+ arrays.append(msa.array[start:])
212
+ if headers is not None:
213
+ source_headers = msa.headers or [""] * msa.depth
214
+ headers.extend(source_headers[start:])
215
+ return cls(np.concatenate(arrays, axis=0), headers)
216
+
217
+ def to_msa(self) -> MSA:
218
+ headers = self.headers
219
+ if headers is None:
220
+ headers = [f"seq{index}" for index in range(self.depth)]
221
+ entries = [
222
+ FastaEntry(header, b"".join(row).decode())
223
+ for header, row in zip(headers, self.array, strict=False)
224
+ ]
225
+ return MSA(entries)
226
+
227
+
228
+ @dataclass(frozen=True)
229
+ class MSA(SequentialDataclass):
230
+ """An ordered set of aligned protein sequences and optional A3M metadata."""
231
+
232
+ entries: list[FastaEntry]
233
+ deletions: np.ndarray | None = dataclasses.field(default=None, compare=False)
234
+
235
+ def __post_init__(self) -> None:
236
+ if not isinstance(self.entries, list):
237
+ raise TypeError("MSA entries must be a list of FastaEntry rows.")
238
+ if not self.entries:
239
+ raise ValueError("MSA requires at least one aligned sequence.")
240
+ if any(not isinstance(entry, FastaEntry) for entry in self.entries):
241
+ raise TypeError("Every MSA entry must be a FastaEntry.")
242
+ expected_length = len(self.entries[0].sequence)
243
+ if expected_length == 0:
244
+ raise ValueError("MSA sequences must be non-empty.")
245
+ for row, entry in enumerate(self.entries[1:], start=1):
246
+ if len(entry.sequence) != expected_length:
247
+ raise ValueError(
248
+ "MSA row length mismatch: "
249
+ f"row 0 has {expected_length} columns, row {row} has "
250
+ f"{len(entry.sequence)}."
251
+ )
252
+ deletions = self.deletions
253
+ if deletions is not None and not isinstance(deletions, np.ndarray):
254
+ raise TypeError("MSA deletions must be a NumPy array when provided.")
255
+ if isinstance(deletions, np.ndarray) and deletions.shape != (
256
+ len(self.entries),
257
+ expected_length,
258
+ ):
259
+ raise ValueError(
260
+ "MSA deletion matrix must have shape "
261
+ f"({len(self.entries)}, {expected_length}), got {deletions.shape}."
262
+ )
263
+
264
+ @cached_property
265
+ def sequences(self) -> list[str]:
266
+ return [entry.sequence for entry in self.entries]
267
+
268
+ @cached_property
269
+ def headers(self) -> list[str]:
270
+ return [entry.header for entry in self.entries]
271
+
272
+ @property
273
+ def depth(self) -> int:
274
+ return len(self.entries)
275
+
276
+ @property
277
+ def seqlen(self) -> int:
278
+ return len(self.entries[0].sequence)
279
+
280
+ @property
281
+ def query(self) -> str:
282
+ return self.entries[0].sequence
283
+
284
+ @cached_property
285
+ def array(self) -> np.ndarray:
286
+ return np.array([list(sequence) for sequence in self.sequences], dtype="|S1")
287
+
288
+ @cached_property
289
+ def seqid(self) -> np.ndarray:
290
+ byte_array = self.array.view(np.uint8)
291
+ return (1 - cdist(byte_array[0][None], byte_array, "hamming"))[0]
292
+
293
+ def __len__(self) -> int:
294
+ return self.seqlen
295
+
296
+ def __repr__(self) -> str:
297
+ return f"MSA({self.entries[0].header}: Depth={self.depth}, Length={self.seqlen})"
298
+
299
+ @classmethod
300
+ def from_a3m(
301
+ cls,
302
+ path: PathOrBuffer,
303
+ remove_insertions: bool = True,
304
+ max_sequences: int | None = None,
305
+ ) -> MSA:
306
+ entries = []
307
+ deletion_rows = []
308
+ for header, raw_sequence in islice(read_sequences(path), max_sequences):
309
+ if remove_insertions:
310
+ deletion_rows.append(a3m_deletion_counts(raw_sequence))
311
+ sequence = (
312
+ remove_insertions_from_sequence(raw_sequence) if remove_insertions else raw_sequence
313
+ )
314
+ if entries:
315
+ expected_length = len(entries[0].sequence)
316
+ if len(sequence) != expected_length:
317
+ raise ValueError(
318
+ "Sequence length mismatch. "
319
+ f"Expected: {expected_length}, Received: {len(sequence)}"
320
+ )
321
+ entries.append(FastaEntry(header, sequence))
322
+ deletions = None
323
+ if remove_insertions and deletion_rows:
324
+ deletions = np.stack(deletion_rows).astype(np.float32)
325
+ return cls(entries, deletions=deletions)
326
+
327
+ @classmethod
328
+ def from_stockholm(
329
+ cls,
330
+ path: PathOrBuffer,
331
+ remove_insertions: bool = True,
332
+ max_sequences: int | None = None,
333
+ ) -> MSA:
334
+ entries = []
335
+ for record in islice(SeqIO.parse(path, "stockholm"), max_sequences):
336
+ sequence = str(record.seq)
337
+ if entries:
338
+ expected_length = len(entries[0].sequence)
339
+ if len(sequence) != expected_length:
340
+ raise ValueError(
341
+ "Sequence length mismatch. "
342
+ f"Expected: {expected_length}, Received: {len(sequence)}"
343
+ )
344
+ entries.append(FastaEntry(f"{record.id} {record.description}", sequence))
345
+ msa = cls(entries)
346
+ if remove_insertions:
347
+ msa = msa.select_positions(
348
+ [index for index, residue in enumerate(msa.query) if residue != "-"]
349
+ )
350
+ return msa
351
+
352
+ @classmethod
353
+ def from_sequences(
354
+ cls,
355
+ sequences: list[str],
356
+ remove_insertions: bool = False,
357
+ ) -> MSA:
358
+ transform = (
359
+ remove_insertions_from_sequence if remove_insertions else lambda sequence: sequence
360
+ )
361
+ return cls([FastaEntry("", transform(sequence)) for sequence in sequences])
362
+
363
+ @classmethod
364
+ def from_bytes(cls, data: bytes) -> MSA:
365
+ array, headers = _parse_full_payload(data)
366
+ return cls(
367
+ [
368
+ FastaEntry(header, b"".join(row).decode())
369
+ for header, row in zip(headers, array, strict=False)
370
+ ]
371
+ )
372
+
373
+ @classmethod
374
+ def from_sequence_bytes(cls, data: bytes) -> MSA:
375
+ array = _parse_sequence_payload(data)
376
+ return cls([FastaEntry("", b"".join(row).decode()) for row in array])
377
+
378
+ @classmethod
379
+ def from_state_dict(cls, dct: dict[str, Any]) -> MSA:
380
+ deletions = dct.get("deletions")
381
+ return cls(
382
+ [FastaEntry("", sequence) for sequence in dct["sequences"]],
383
+ deletions=(None if deletions is None else np.asarray(deletions, dtype=np.float32)),
384
+ )
385
+
386
+ def to_a3m(self, path: PathOrBuffer) -> None:
387
+ write_sequences(self.entries, path)
388
+
389
+ def to_fast_msa(self) -> FastMSA:
390
+ return FastMSA(self.array, self.headers)
391
+
392
+ def to_bytes(self) -> bytes:
393
+ return _full_payload(self.array, self.headers)
394
+
395
+ def to_sequence_bytes(self) -> bytes:
396
+ """Serialize aligned sequences without their headers."""
397
+
398
+ return _sequence_payload(self.array)
399
+
400
+ def state_dict(self, json_serializable: bool = False) -> dict[str, Any]:
401
+ result: dict[str, Any] = {"sequences": self.sequences}
402
+ if self.deletions is not None:
403
+ result["deletions"] = self.deletions.tolist() if json_serializable else self.deletions
404
+ return result
405
+
406
+ def _aligned_deletions(self) -> np.ndarray | None:
407
+ if self.deletions is None:
408
+ return None
409
+ if self.deletions.shape != (self.depth, self.seqlen):
410
+ return None
411
+ return self.deletions
412
+
413
+ def _select_deletion_columns(self, indices) -> np.ndarray | None:
414
+ if self.deletions is None or self.deletions.shape[1] != self.seqlen:
415
+ return None
416
+ return self.deletions[:, indices]
417
+
418
+ def select_sequences(
419
+ self,
420
+ indices: Sequence[int] | np.ndarray,
421
+ ) -> MSA:
422
+ deletions = None if self.deletions is None else self.deletions[np.asarray(indices)]
423
+ return dataclasses.replace(
424
+ self,
425
+ entries=[self.entries[index] for index in indices],
426
+ deletions=deletions,
427
+ )
428
+
429
+ def select_positions(
430
+ self,
431
+ indices: Sequence[int] | np.ndarray,
432
+ ) -> MSA:
433
+ entries = [
434
+ FastaEntry(
435
+ entry.header,
436
+ "".join(entry.sequence[index] for index in indices),
437
+ )
438
+ for entry in self.entries
439
+ ]
440
+ return dataclasses.replace(
441
+ self,
442
+ entries=entries,
443
+ deletions=self._select_deletion_columns(indices),
444
+ )
445
+
446
+ def __getitem__(
447
+ self,
448
+ indices: int | list[int] | slice | np.ndarray,
449
+ ) -> MSA:
450
+ column_indices = [indices] if isinstance(indices, int) else indices
451
+ entries = [
452
+ FastaEntry(
453
+ entry.header,
454
+ slice_any_object(entry.sequence, column_indices),
455
+ )
456
+ for entry in self.entries
457
+ ]
458
+ return dataclasses.replace(
459
+ self,
460
+ entries=entries,
461
+ deletions=self._select_deletion_columns(column_indices),
462
+ )
463
+
464
+ def greedy_select(self, num_seqs: int, mode: str = "max") -> MSA:
465
+ if mode not in ("max", "min"):
466
+ raise ValueError(f"Unsupported MSA selection mode: {mode!r}.")
467
+ if self.depth <= num_seqs:
468
+ return self
469
+ return self.select_sequences(greedy_select_indices(self.array, num_seqs, mode))
470
+
471
+ def hhfilter(
472
+ self,
473
+ seqid: int = 90,
474
+ diff: int = 0,
475
+ cov: int = 0,
476
+ qid: int = 0,
477
+ qsc: float = -20.0,
478
+ binary: str = "hhfilter",
479
+ ) -> MSA:
480
+ indices = hhfilter(
481
+ self.sequences,
482
+ seqid=seqid,
483
+ diff=diff,
484
+ cov=cov,
485
+ qid=qid,
486
+ qsc=qsc,
487
+ binary=binary,
488
+ )
489
+ return self.select_sequences(indices)
490
+
491
+ def select_random_sequences(self, num_seqs: int) -> MSA:
492
+ if num_seqs >= self.depth:
493
+ return self
494
+ return self.select_sequences(_random_row_indices(self.depth, num_seqs))
495
+
496
+ def select_diverse_sequences(self, num_seqs: int) -> MSA:
497
+ if num_seqs >= self.depth:
498
+ return self
499
+ filtered = self.hhfilter(diff=num_seqs)
500
+ if num_seqs < filtered.depth:
501
+ filtered = filtered.select_random_sequences(num_seqs)
502
+ return filtered
503
+
504
+ def pad_to_depth(self, depth: int) -> MSA:
505
+ if depth < self.depth:
506
+ raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}")
507
+ if depth == self.depth:
508
+ return self
509
+ count = depth - self.depth
510
+ extra = [FastaEntry("", "-" * self.seqlen) for _ in range(count)]
511
+ deletions = self._aligned_deletions()
512
+ if deletions is not None:
513
+ zero_rows = np.zeros((count, self.seqlen), dtype=deletions.dtype)
514
+ deletions = np.concatenate((deletions, zero_rows), axis=0)
515
+ return dataclasses.replace(
516
+ self,
517
+ entries=self.entries + extra,
518
+ deletions=deletions,
519
+ )
520
+
521
+ @classmethod
522
+ def stack(
523
+ cls,
524
+ msas: Sequence[MSA],
525
+ remove_query_from_later_msas: bool = True,
526
+ ) -> MSA:
527
+ entries = []
528
+ deletion_arrays = []
529
+ for index, msa in enumerate(msas):
530
+ start = 1 if index > 0 and remove_query_from_later_msas else 0
531
+ entries.extend(msa.entries[start:])
532
+ aligned = msa._aligned_deletions()
533
+ if aligned is not None:
534
+ deletion_arrays.append(aligned[start:])
535
+ deletions = None
536
+ if (
537
+ len(deletion_arrays) == len(msas)
538
+ and len({array.shape[1] for array in deletion_arrays}) == 1
539
+ ):
540
+ deletions = np.concatenate(deletion_arrays, axis=0)
541
+ return cls(entries=entries, deletions=deletions)
542
+
543
+ @classmethod
544
+ def concat(
545
+ cls,
546
+ msas: Sequence[MSA],
547
+ join_token: str | None = "|",
548
+ allow_depth_mismatch: bool = False,
549
+ ) -> MSA:
550
+ if not msas:
551
+ raise ValueError("Cannot concatenate an empty list of MSAs")
552
+ depths = [msa.depth for msa in msas]
553
+ if len(set(depths)) != 1:
554
+ if not allow_depth_mismatch:
555
+ raise ValueError("Depth mismatch in concatenating MSAs")
556
+ maximum_depth = max(depths)
557
+ msas = [msa.pad_to_depth(maximum_depth) for msa in msas]
558
+ headers = [
559
+ "|".join(str(header) for header in row)
560
+ for row in zip(*(msa.headers for msa in msas), strict=False)
561
+ ]
562
+ separator = "" if join_token is None else join_token
563
+ sequences = [
564
+ separator.join(row) for row in zip(*(msa.sequences for msa in msas), strict=False)
565
+ ]
566
+ deletions = None
567
+ if separator == "":
568
+ arrays = [msa._aligned_deletions() for msa in msas]
569
+ if all(array is not None for array in arrays):
570
+ deletions = np.concatenate(arrays, axis=1) # type: ignore[arg-type]
571
+ return cls(
572
+ [
573
+ FastaEntry(header, sequence)
574
+ for header, sequence in zip(headers, sequences, strict=False)
575
+ ],
576
+ deletions=deletions,
577
+ )
fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sequence selection for multiple-sequence alignments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+
11
+ from .esmfold2_system import run_subprocess_with_errorcheck
12
+
13
+
14
+ def _byte_matrix(array: np.ndarray) -> np.ndarray:
15
+ """Return a two-dimensional byte view used for Hamming comparisons."""
16
+
17
+ matrix = np.asarray(array).view(np.uint8)
18
+ return matrix.reshape(matrix.shape[0], -1)
19
+
20
+
21
+ def _hamming_to_all(query: np.ndarray, sequences: np.ndarray) -> np.ndarray:
22
+ return np.not_equal(sequences, query).mean(axis=1, dtype=np.float64)
23
+
24
+
25
+ def greedy_select_indices(array: np.ndarray, num_seqs: int, mode: str = "max") -> list[int]:
26
+ """Select MSA rows by greedy mean Hamming distance from the query row.
27
+
28
+ Row zero is always retained. At each step the selector chooses the remaining
29
+ row with greatest distance for ``mode="max"`` or least distance for
30
+ ``mode="min"``. Returned indices follow source order.
31
+ """
32
+
33
+ if not isinstance(array, np.ndarray):
34
+ raise TypeError("array must be a NumPy array")
35
+ if array.ndim != 2 or array.shape[0] == 0 or array.shape[1] == 0:
36
+ raise ValueError(
37
+ f"array must have non-empty shape (depth, length), got {array.shape}"
38
+ )
39
+ if isinstance(num_seqs, bool) or not isinstance(num_seqs, int):
40
+ raise TypeError("num_seqs must be an integer")
41
+ if num_seqs <= 0:
42
+ raise ValueError("num_seqs must be greater than zero")
43
+ if not isinstance(mode, str):
44
+ raise TypeError("mode must be a string")
45
+ if mode not in {"max", "min"}:
46
+ raise ValueError(f"unsupported selection mode: {mode}")
47
+ depth = array.shape[0]
48
+ if depth <= num_seqs:
49
+ return list(range(depth))
50
+
51
+ sequences = _byte_matrix(array)
52
+ selected = [0]
53
+ available = np.ones(depth, dtype=bool)
54
+ available[0] = False
55
+ distance_sum = _hamming_to_all(sequences[0], sequences)
56
+ choose = np.argmax if mode == "max" else np.argmin
57
+
58
+ while len(selected) < num_seqs:
59
+ candidates = np.flatnonzero(available)
60
+ candidate_scores = distance_sum[candidates] / len(selected)
61
+ next_index = int(candidates[int(choose(candidate_scores))])
62
+ selected.append(next_index)
63
+ available[next_index] = False
64
+ distance_sum += _hamming_to_all(sequences[next_index], sequences)
65
+ return sorted(selected)
66
+
67
+
68
+ def _temporary_root() -> str | None:
69
+ shared_memory = Path("/dev/shm")
70
+ return os.fspath(shared_memory) if shared_memory.is_dir() else None
71
+
72
+
73
+ def hhfilter(
74
+ sequences: list[str],
75
+ seqid: int = 90,
76
+ diff: int = 0,
77
+ cov: int = 0,
78
+ qid: int = 0,
79
+ qsc: float = -20.0,
80
+ binary: str = "hhfilter",
81
+ ) -> list[int]:
82
+ """Run HH-suite filtering and return source indices from its FASTA headers."""
83
+
84
+ with tempfile.TemporaryDirectory(dir=_temporary_root()) as directory:
85
+ work = Path(directory)
86
+ source_path = work / "input.fasta"
87
+ result_path = work / "output.fasta"
88
+ records = (f">{index}\n{sequence}" for index, sequence in enumerate(sequences))
89
+ source_path.write_text("\n".join(records), encoding="utf-8")
90
+ command = [
91
+ binary,
92
+ "-i",
93
+ os.fspath(source_path),
94
+ "-M",
95
+ "a3m",
96
+ "-o",
97
+ os.fspath(result_path),
98
+ "-id",
99
+ str(seqid),
100
+ "-diff",
101
+ str(diff),
102
+ "-cov",
103
+ str(cov),
104
+ "-qid",
105
+ str(qid),
106
+ "-qsc",
107
+ str(qsc),
108
+ ]
109
+ run_subprocess_with_errorcheck(command, capture_output=True)
110
+ headers = result_path.read_text(encoding="utf-8").splitlines()
111
+ return [int(line[1:].strip()) for line in headers if line.startswith(">")]
fastplms/models/esmfold2/esmfold2_normalize_coordinates.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rigid-frame normalization for atom37 coordinates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypeVar
6
+
7
+ import numpy as np
8
+ import torch
9
+ from torch import Tensor
10
+
11
+ from . import esmfold2_residue_constants as residue_constants
12
+ from .esmfold2_affine3d import Affine3D
13
+
14
+ ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor)
15
+
16
+
17
+ def atom3_to_backbone_frames(bb_positions: Tensor) -> Affine3D:
18
+ """Construct a frame from N, C-alpha, and C positions in ``X``."""
19
+
20
+ n_position, ca_position, c_position = bb_positions.unbind(dim=-2)
21
+ return Affine3D.from_graham_schmidt(c_position, ca_position, n_position)
22
+
23
+
24
+ def index_by_atom_name(
25
+ atom37: ArrayOrTensor,
26
+ atom_names: str | list[str],
27
+ dim: int = -2,
28
+ ) -> ArrayOrTensor:
29
+ """Select one or more named atoms along an atom37 axis."""
30
+
31
+ single_atom = isinstance(atom_names, str)
32
+ names = [atom_names] if single_atom else atom_names
33
+ indices = [residue_constants.atom_order[name] for name in names]
34
+ axis = dim % atom37.ndim
35
+ if isinstance(atom37, Tensor):
36
+ index = torch.tensor(indices, dtype=torch.long, device=atom37.device)
37
+ selected = torch.index_select(atom37, axis, index)
38
+ else:
39
+ selected = np.take(atom37, indices, axis=axis)
40
+ return selected.squeeze(axis) if single_atom else selected # type: ignore[return-value]
41
+
42
+
43
+ def get_protein_normalization_frame(coords: Tensor) -> Affine3D:
44
+ """Build one frame from backbone coordinates ``X`` with shape (l, 37, 3)."""
45
+
46
+ backbone = index_by_atom_name(coords, ["N", "CA", "C"], dim=-2)
47
+ residue_is_valid = torch.isfinite(backbone).all(dim=-1).all(dim=-1)
48
+ weights = residue_is_valid[..., None, None]
49
+ coordinate_sum = backbone.masked_fill(~weights, 0).sum(dim=-3)
50
+ count = residue_is_valid.sum(dim=-1)[..., None, None]
51
+ mean_backbone = coordinate_sum / (count + 1e-8)
52
+ return atom3_to_backbone_frames(mean_backbone.float())
53
+
54
+
55
+ def apply_frame_to_coords(coords: Tensor, frame: Affine3D) -> Tensor:
56
+ """Express atom coordinates ``X`` in the inverse of ``frame``."""
57
+
58
+ transformed = frame[..., None, None].invert().apply(coords)
59
+ frame_is_valid = frame.trans.norm(dim=-1) > 0
60
+ normalized = torch.where(frame_is_valid[..., None, None, None], transformed, coords)
61
+ return normalized.masked_fill(torch.isinf(coords), torch.inf)
62
+
63
+
64
+ def normalize_coordinates(coords: Tensor) -> Tensor:
65
+ """Normalize ``X`` with shape (..., l, 37, 3) to its backbone frame."""
66
+
67
+ return apply_frame_to_coords(coords, get_protein_normalization_frame(coords))
fastplms/models/esmfold2/esmfold2_output.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert ESMFold2 coordinate tensors into molecular-complex records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass, field
7
+ from itertools import groupby
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import torch
12
+
13
+ from .esmfold2_constants import ELEMENT_NUMBER_TO_SYMBOL, MOL_TYPE_NONPOLYMER
14
+ from .esmfold2_molecular_complex import MolecularComplex, MolecularComplexMetadata
15
+
16
+
17
+ def get_element_symbol(atomic_number: int) -> str:
18
+ """Map a training-time atomic number to a chemical symbol."""
19
+
20
+ return ELEMENT_NUMBER_TO_SYMBOL.get(atomic_number, "X")
21
+
22
+
23
+ def _decode_atom_name(encoded_name: Any) -> str:
24
+ values = encoded_name.tolist() if hasattr(encoded_name, "tolist") else encoded_name
25
+ return "".join(chr(int(value) + 32) for value in values if int(value)).strip()
26
+
27
+
28
+ @dataclass
29
+ class _ComplexRecords:
30
+ sequence: list[str] = field(default_factory=list)
31
+ chain_ids: list[int] = field(default_factory=list)
32
+ token_to_atoms: list[list[int]] = field(default_factory=list)
33
+ confidence: list[float] = field(default_factory=list)
34
+ positions: list[list[float]] = field(default_factory=list)
35
+ elements: list[str] = field(default_factory=list)
36
+ atom_names: list[str] = field(default_factory=list)
37
+ atom_hetero: list[bool] = field(default_factory=list)
38
+ chain_lookup: dict[int, str] = field(default_factory=dict)
39
+ entity_lookup: dict[int, str] = field(default_factory=dict)
40
+
41
+ def add_token(
42
+ self,
43
+ *,
44
+ residue_name: str,
45
+ asym_id: int,
46
+ plddt: float,
47
+ atoms: Iterable[tuple[list[float], str, str]],
48
+ hetero: bool,
49
+ ) -> None:
50
+ atom_start = len(self.positions)
51
+ for position, element, atom_name in atoms:
52
+ self.positions.append(position)
53
+ self.elements.append(element)
54
+ self.atom_names.append(atom_name)
55
+ self.atom_hetero.append(hetero)
56
+ self.sequence.append(residue_name)
57
+ self.chain_ids.append(asym_id)
58
+ self.confidence.append(plddt)
59
+ self.token_to_atoms.append([atom_start, len(self.positions)])
60
+
61
+ def build(self, complex_id: str) -> MolecularComplex:
62
+ return MolecularComplex(
63
+ id=complex_id,
64
+ sequence=self.sequence,
65
+ atom_positions=np.asarray(self.positions, dtype=np.float32).reshape(-1, 3),
66
+ atom_elements=np.asarray(self.elements, dtype=object),
67
+ token_to_atoms=np.asarray(self.token_to_atoms, dtype=np.int32).reshape(-1, 2),
68
+ chain_id=np.asarray(self.chain_ids, dtype=np.int64),
69
+ plddt=np.asarray(self.confidence, dtype=np.float32),
70
+ atom_names=np.asarray(self.atom_names, dtype=object),
71
+ atom_hetero=np.asarray(self.atom_hetero, dtype=bool),
72
+ metadata=MolecularComplexMetadata(
73
+ entity_lookup=self.entity_lookup,
74
+ chain_lookup=self.chain_lookup,
75
+ assembly_composition=None,
76
+ ),
77
+ )
78
+
79
+
80
+ def build_molecular_complex_from_features(
81
+ coords: torch.Tensor,
82
+ plddt: torch.Tensor,
83
+ atom_mask: torch.Tensor,
84
+ ref_element: torch.Tensor,
85
+ ref_atom_name_chars: torch.Tensor,
86
+ chain_infos: list[Any],
87
+ complex_id: str,
88
+ ) -> MolecularComplex:
89
+ """Decode model features into one complex without intermediate structure files.
90
+
91
+ Protein, DNA, and RNA tokens are grouped by residue index. Ligand atom
92
+ tokens are collapsed into one non-polymer residue per chain.
93
+ """
94
+
95
+ M = atom_mask.bool().cpu().numpy()
96
+ X = coords.float().cpu().numpy()
97
+ atom_names = ref_atom_name_chars.cpu().numpy()
98
+ elements = ref_element.cpu().numpy()
99
+ confidence = plddt.float().cpu().numpy()
100
+ records = _ComplexRecords()
101
+
102
+ def decode_atoms(tokens: Iterable[Any]):
103
+ for token in tokens:
104
+ for atom_index in range(token.atom_start, token.atom_start + token.atom_count):
105
+ if M[atom_index]:
106
+ yield (
107
+ X[atom_index].tolist(),
108
+ get_element_symbol(int(elements[atom_index])),
109
+ _decode_atom_name(atom_names[atom_index]),
110
+ )
111
+
112
+ for chain in chain_infos:
113
+ is_nonpolymer = chain.mol_type == MOL_TYPE_NONPOLYMER
114
+ records.chain_lookup[chain.asym_id] = chain.chain_id
115
+ records.entity_lookup[chain.entity_id] = "non-polymer" if is_nonpolymer else "polymer"
116
+
117
+ if is_nonpolymer:
118
+ mean_confidence = (
119
+ float(np.mean([confidence[token.token_index] for token in chain.tokens]))
120
+ if chain.tokens
121
+ else 0.0
122
+ )
123
+ records.add_token(
124
+ residue_name=chain.tokens[0].residue_name if chain.tokens else "LIG",
125
+ asym_id=chain.asym_id,
126
+ plddt=mean_confidence,
127
+ atoms=decode_atoms(chain.tokens),
128
+ hetero=True,
129
+ )
130
+ continue
131
+
132
+ residue_groups = groupby(chain.tokens, key=lambda token: token.residue_index)
133
+ for _residue_index, group in residue_groups:
134
+ residue_tokens = list(group)
135
+ records.add_token(
136
+ residue_name=residue_tokens[0].residue_name,
137
+ asym_id=chain.asym_id,
138
+ plddt=float(np.mean([confidence[token.token_index] for token in residue_tokens])),
139
+ atoms=decode_atoms(residue_tokens),
140
+ hetero=False,
141
+ )
142
+
143
+ return records.build(complex_id)
144
+
145
+
146
+ def build_molecular_complex(
147
+ structure: Any,
148
+ coords: torch.Tensor,
149
+ plddt: torch.Tensor,
150
+ complex_id: str,
151
+ ) -> MolecularComplex:
152
+ """Decode coordinates using the atom and residue arrays of a prepared structure."""
153
+
154
+ records = _ComplexRecords()
155
+ coordinate_index = 0
156
+ confidence_index = 0
157
+
158
+ for chain in structure.chains:
159
+ asym_id = int(chain["asym_id"])
160
+ mol_type = int(chain["mol_type"])
161
+ is_nonpolymer = mol_type == MOL_TYPE_NONPOLYMER
162
+ records.chain_lookup[asym_id] = str(chain["name"])
163
+ records.entity_lookup[int(chain["entity_id"])] = (
164
+ "non-polymer" if is_nonpolymer else "polymer"
165
+ )
166
+
167
+ residue_start = int(chain["res_idx"])
168
+ residue_stop = residue_start + int(chain["res_num"])
169
+ for residue in structure.residues[residue_start:residue_stop]:
170
+ atom_start = int(residue["atom_idx"])
171
+ atom_stop = atom_start + int(residue["atom_num"])
172
+ decoded_atoms: list[tuple[list[float], str, str]] = []
173
+ for atom in structure.atoms[atom_start:atom_stop]:
174
+ if not atom["is_present"]:
175
+ continue
176
+ decoded_atoms.append(
177
+ (
178
+ coords[coordinate_index].tolist(),
179
+ get_element_symbol(int(atom["element"].item())),
180
+ _decode_atom_name(atom["name"]),
181
+ )
182
+ )
183
+ coordinate_index += 1
184
+
185
+ records.add_token(
186
+ residue_name=str(residue["name"]),
187
+ asym_id=asym_id,
188
+ plddt=float(plddt[confidence_index].item()),
189
+ atoms=decoded_atoms,
190
+ hetero=is_nonpolymer,
191
+ )
192
+ confidence_index += 1
193
+
194
+ return records.build(complex_id)
195
+
196
+
197
+ __all__ = [
198
+ "build_molecular_complex",
199
+ "build_molecular_complex_from_features",
200
+ "get_element_symbol",
201
+ ]
fastplms/models/esmfold2/esmfold2_paired_msa.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Construct taxonomy-paired MSA features for multichain folding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+
10
+ from .esmfold2_constants import (
11
+ MSA_GAP_TOKEN_ID,
12
+ PROTEIN_3TO1,
13
+ PROTEIN_RESIDUE_TO_RES_TYPE,
14
+ PROTEIN_UNK_RES_TYPE,
15
+ )
16
+ from .esmfold2_msa import MSA
17
+
18
+ _TAXONOMY_PATTERN = re.compile(r"key=(-?\d+)")
19
+
20
+
21
+ def protein_letter_to_res_type() -> dict[str, int]:
22
+ """Return the one-letter residue vocabulary used by the MSA encoder."""
23
+
24
+ vocabulary = {
25
+ one_letter: PROTEIN_RESIDUE_TO_RES_TYPE[three_letter]
26
+ for three_letter, one_letter in PROTEIN_3TO1.items()
27
+ if three_letter in PROTEIN_RESIDUE_TO_RES_TYPE
28
+ }
29
+ vocabulary.update({"-": MSA_GAP_TOKEN_ID, "X": PROTEIN_UNK_RES_TYPE})
30
+ return vocabulary
31
+
32
+
33
+ def _taxonomy_from_header(header: str) -> int:
34
+ match = _TAXONOMY_PATTERN.search(header) if header else None
35
+ return int(match.group(1)) if match is not None else -1
36
+
37
+
38
+ def _emitted_length(sequence: str) -> int:
39
+ return sum(character != "." and not character.islower() for character in sequence)
40
+
41
+
42
+ def _decode_a3m_row(
43
+ sequence: str,
44
+ sequence_length: int,
45
+ vocabulary: dict[str, int],
46
+ ) -> tuple[np.ndarray, np.ndarray]:
47
+ residues = np.full(sequence_length, MSA_GAP_TOKEN_ID, dtype=np.int64)
48
+ deletions = np.zeros(sequence_length, dtype=np.float32)
49
+ column = 0
50
+ insertion_count = 0
51
+ for character in sequence:
52
+ if character == "." or character.islower():
53
+ insertion_count += 1
54
+ continue
55
+ if column == sequence_length:
56
+ break
57
+ residues[column] = (
58
+ MSA_GAP_TOKEN_ID
59
+ if character == "-"
60
+ else vocabulary.get(character.upper(), PROTEIN_UNK_RES_TYPE)
61
+ )
62
+ if insertion_count:
63
+ deletions[column] = float(insertion_count)
64
+ insertion_count = 0
65
+ column += 1
66
+ return residues, deletions
67
+
68
+
69
+ def msa_to_res_type_and_deletions(
70
+ msa: MSA,
71
+ letter_to_res_type: dict[str, int],
72
+ ) -> tuple[np.ndarray, np.ndarray]:
73
+ """Decode an A3M alignment into arrays ``X`` and ``D`` with shape (m, l)."""
74
+
75
+ sequence_length = _emitted_length(msa.entries[0].sequence)
76
+ residue_rows: list[np.ndarray] = []
77
+ deletion_rows: list[np.ndarray] = []
78
+ for entry in msa.entries:
79
+ residues, deletions = _decode_a3m_row(
80
+ entry.sequence,
81
+ sequence_length,
82
+ letter_to_res_type,
83
+ )
84
+ residue_rows.append(residues)
85
+ deletion_rows.append(deletions)
86
+ return np.stack(residue_rows), np.stack(deletion_rows)
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class _ChainAlignment:
91
+ residues: np.ndarray
92
+ deletions: np.ndarray
93
+ taxonomies: list[int]
94
+
95
+
96
+ def _chain_alignment(
97
+ msa: MSA | None,
98
+ query_res_types: np.ndarray,
99
+ vocabulary: dict[str, int],
100
+ ) -> _ChainAlignment:
101
+ if msa is None or msa.depth == 0:
102
+ return _ChainAlignment(
103
+ residues=query_res_types[None, :],
104
+ deletions=np.zeros((1, query_res_types.shape[0]), dtype=np.float32),
105
+ taxonomies=[-1],
106
+ )
107
+ residues, deletions = msa_to_res_type_and_deletions(msa, vocabulary)
108
+ taxonomies = [_taxonomy_from_header(entry.header) for entry in msa.entries]
109
+ return _ChainAlignment(residues, deletions, taxonomies)
110
+
111
+
112
+ def _taxonomy_groups(
113
+ chain_ids: list[int],
114
+ alignments: dict[int, _ChainAlignment],
115
+ ) -> dict[int, list[tuple[int, int]]]:
116
+ groups: dict[int, list[tuple[int, int]]] = {}
117
+ for chain_id in chain_ids:
118
+ for row, taxonomy in enumerate(alignments[chain_id].taxonomies):
119
+ if row and taxonomy != -1:
120
+ groups.setdefault(taxonomy, []).append((chain_id, row))
121
+ return {taxonomy: rows for taxonomy, rows in groups.items() if len(rows) > 1}
122
+
123
+
124
+ def _available_rows(
125
+ chain_ids: list[int],
126
+ alignments: dict[int, _ChainAlignment],
127
+ groups: dict[int, list[tuple[int, int]]],
128
+ ) -> dict[int, list[int]]:
129
+ used = {row for group in groups.values() for row in group}
130
+ return {
131
+ chain_id: [
132
+ row
133
+ for row in range(1, len(alignments[chain_id].taxonomies))
134
+ if (chain_id, row) not in used
135
+ ]
136
+ for chain_id in chain_ids
137
+ }
138
+
139
+
140
+ def _append_taxonomy_rows(
141
+ rows: list[dict[int, int]],
142
+ paired_flags: list[dict[int, int]],
143
+ chain_ids: list[int],
144
+ groups: dict[int, list[tuple[int, int]]],
145
+ available: dict[int, list[int]],
146
+ max_pairs: int,
147
+ ) -> None:
148
+ ordered_groups = sorted(
149
+ groups.values(),
150
+ key=lambda group: len({chain_id for chain_id, _row in group}),
151
+ reverse=True,
152
+ )
153
+ for group in ordered_groups:
154
+ rows_by_chain: dict[int, list[int]] = {}
155
+ for chain_id, row in group:
156
+ rows_by_chain.setdefault(chain_id, []).append(row)
157
+ for occurrence in range(max(map(len, rows_by_chain.values()))):
158
+ selected: dict[int, int] = {}
159
+ flags: dict[int, int] = {}
160
+ for chain_id, candidates in rows_by_chain.items():
161
+ selected[chain_id] = candidates[occurrence % len(candidates)]
162
+ flags[chain_id] = 1
163
+ for chain_id in chain_ids:
164
+ if chain_id not in selected:
165
+ flags[chain_id] = 0
166
+ selected[chain_id] = available[chain_id].pop(0) if available[chain_id] else -1
167
+ rows.append(selected)
168
+ paired_flags.append(flags)
169
+ if len(rows) >= max_pairs:
170
+ break
171
+ if len(rows) >= max_pairs:
172
+ break
173
+
174
+
175
+ def _append_unpaired_rows(
176
+ rows: list[dict[int, int]],
177
+ paired_flags: list[dict[int, int]],
178
+ chain_ids: list[int],
179
+ available: dict[int, list[int]],
180
+ max_total: int,
181
+ ) -> None:
182
+ max_remaining = max((len(indices) for indices in available.values()), default=0)
183
+ for _ in range(min(max_total - len(rows), max_remaining)):
184
+ rows.append(
185
+ {
186
+ chain_id: available[chain_id].pop(0) if available[chain_id] else -1
187
+ for chain_id in chain_ids
188
+ }
189
+ )
190
+ paired_flags.append({chain_id: 0 for chain_id in chain_ids})
191
+ if len(rows) >= max_total:
192
+ break
193
+
194
+
195
+ def _pairing_plan(
196
+ chain_ids: list[int],
197
+ alignments: dict[int, _ChainAlignment],
198
+ max_pairs: int,
199
+ max_total: int,
200
+ max_seqs: int,
201
+ ) -> tuple[list[dict[int, int]], list[dict[int, int]]]:
202
+ groups = _taxonomy_groups(chain_ids, alignments)
203
+ available = _available_rows(chain_ids, alignments, groups)
204
+ rows = [{chain_id: 0 for chain_id in chain_ids}]
205
+ flags = [{chain_id: 1 for chain_id in chain_ids}]
206
+ _append_taxonomy_rows(rows, flags, chain_ids, groups, available, max_pairs)
207
+ _append_unpaired_rows(rows, flags, chain_ids, available, max_total)
208
+ return rows[:max_seqs], flags[:max_seqs]
209
+
210
+
211
+ def _project_alignment_rows(
212
+ chain_ids: list[int],
213
+ alignments: dict[int, _ChainAlignment],
214
+ rows: list[dict[int, int]],
215
+ flags: list[dict[int, int]],
216
+ token_asym_ids: np.ndarray,
217
+ token_res_ids: np.ndarray,
218
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
219
+ m, t = len(rows), len(token_asym_ids)
220
+ residues = np.full((m, t), MSA_GAP_TOKEN_ID, dtype=np.int64)
221
+ deletions = np.zeros((m, t), dtype=np.float32)
222
+ paired_mask = np.zeros((m, t), dtype=np.float32)
223
+ for chain_id in chain_ids:
224
+ alignment = alignments[chain_id]
225
+ selected_rows = np.asarray([row[chain_id] for row in rows], dtype=np.int64)
226
+ chain_flags = np.asarray([row[chain_id] for row in flags], dtype=np.float32)
227
+ token_mask = token_asym_ids == chain_id
228
+ if not token_mask.any():
229
+ continue
230
+ columns = np.minimum(token_res_ids[token_mask], alignment.residues.shape[1] - 1)
231
+ valid_rows = selected_rows >= 0
232
+ if valid_rows.any():
233
+ output_rows = np.flatnonzero(valid_rows)
234
+ output_columns = np.flatnonzero(token_mask)
235
+ residues[np.ix_(output_rows, output_columns)] = alignment.residues[
236
+ selected_rows[valid_rows]
237
+ ][:, columns]
238
+ deletions[np.ix_(output_rows, output_columns)] = alignment.deletions[
239
+ selected_rows[valid_rows]
240
+ ][:, columns]
241
+ paired_mask[:, token_mask] = chain_flags[:, None]
242
+ return residues, deletions, paired_mask
243
+
244
+
245
+ def construct_paired_msa(
246
+ chain_msas: dict[int, MSA | None],
247
+ chain_query_res_types: dict[int, np.ndarray],
248
+ token_asym_ids: np.ndarray,
249
+ token_res_ids: np.ndarray,
250
+ letter_to_res_type: dict[str, int] | None = None,
251
+ *,
252
+ max_pairs: int = 8192,
253
+ max_total: int = 16384,
254
+ max_seqs: int = 16384,
255
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
256
+ """Return residue, deletion, and pairing arrays with shape (m, t)."""
257
+
258
+ vocabulary = protein_letter_to_res_type() if letter_to_res_type is None else letter_to_res_type
259
+ chain_ids = sorted(chain_msas)
260
+ alignments = {
261
+ chain_id: _chain_alignment(
262
+ chain_msas[chain_id],
263
+ chain_query_res_types[chain_id],
264
+ vocabulary,
265
+ )
266
+ for chain_id in chain_ids
267
+ }
268
+ rows, flags = _pairing_plan(
269
+ chain_ids,
270
+ alignments,
271
+ max_pairs,
272
+ max_total,
273
+ max_seqs,
274
+ )
275
+ return _project_alignment_rows(
276
+ chain_ids,
277
+ alignments,
278
+ rows,
279
+ flags,
280
+ token_asym_ids,
281
+ token_res_ids,
282
+ )
fastplms/models/esmfold2/esmfold2_parsing.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FASTA parsing and writing with explicit stream ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import io
7
+ from collections.abc import Generator, Iterable
8
+ from contextlib import nullcontext
9
+ from pathlib import Path
10
+ from typing import NamedTuple, TextIO
11
+
12
+ from .esmfold2_utils_types import PathOrBuffer
13
+
14
+
15
+ class FastaEntry(NamedTuple):
16
+ """One FASTA record in source order."""
17
+
18
+ header: str
19
+ sequence: str
20
+
21
+
22
+ def parse_fasta(text: str) -> Generator[FastaEntry, None, None]:
23
+ """Yield records from FASTA text without normalizing sequence symbols."""
24
+
25
+ header: str | None = None
26
+ sequence_lines: list[str] = []
27
+ found_record = False
28
+
29
+ for line in text.splitlines():
30
+ if not line or line.startswith("#"):
31
+ continue
32
+ if line.startswith(">"):
33
+ if header is not None:
34
+ found_record = True
35
+ yield FastaEntry(header, "".join(sequence_lines))
36
+ header = line[1:].strip()
37
+ sequence_lines.clear()
38
+ elif header is not None:
39
+ sequence_lines.append(line)
40
+
41
+ if header is not None:
42
+ found_record = True
43
+ yield FastaEntry(header, "".join(sequence_lines))
44
+ if not found_record:
45
+ raise ValueError("Found no sequences in input")
46
+
47
+
48
+ def _open_reader(source: PathOrBuffer):
49
+ if isinstance(source, io.TextIOBase):
50
+ return nullcontext(source)
51
+ path = Path(source)
52
+ if path.suffix.lower() == ".gz":
53
+ return gzip.open(path, mode="rt", encoding="utf-8")
54
+ return path.open(mode="r", encoding="utf-8")
55
+
56
+
57
+ def read_sequences(source: PathOrBuffer) -> Generator[FastaEntry, None, None]:
58
+ """Read FASTA records while leaving caller-owned streams open."""
59
+
60
+ with _open_reader(source) as handle:
61
+ yield from parse_fasta(handle.read())
62
+
63
+
64
+ def read_first_sequence(source: PathOrBuffer) -> FastaEntry:
65
+ """Return the first FASTA record from a path or text stream."""
66
+
67
+ return next(read_sequences(source))
68
+
69
+
70
+ def count_fasta_sequences(path: str | Path) -> int:
71
+ """Count FASTA headers without parsing sequence bodies."""
72
+
73
+ source = Path(path)
74
+ if not source.exists():
75
+ return 0
76
+ with source.open(encoding="utf-8") as handle:
77
+ return sum(line.startswith(">") for line in handle)
78
+
79
+
80
+ def append_fasta_sequence(header: str, sequence: str, path: str | Path) -> None:
81
+ """Append one record, inserting a separator if the file lacks a final newline."""
82
+
83
+ destination = Path(path)
84
+ destination.parent.mkdir(parents=True, exist_ok=True)
85
+ needs_separator = (
86
+ destination.exists()
87
+ and destination.stat().st_size > 0
88
+ and destination.read_bytes()[-1:] != b"\n"
89
+ )
90
+ with destination.open(mode="a", encoding="utf-8") as handle:
91
+ if needs_separator:
92
+ handle.write("\n")
93
+ handle.write(f">{header}\n{sequence}\n")
94
+
95
+
96
+ def _open_writer(destination: PathOrBuffer):
97
+ if isinstance(destination, io.TextIOBase):
98
+ return nullcontext(destination)
99
+ path = Path(destination)
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ return path.open(mode="w", encoding="utf-8")
102
+
103
+
104
+ def write_sequences(sequences: Iterable[tuple[str, str]], destination: PathOrBuffer) -> None:
105
+ """Write records with one blank-line-free separator between entries."""
106
+
107
+ with _open_writer(destination) as handle:
108
+ _write_records(handle, sequences)
109
+
110
+
111
+ def _write_records(handle: TextIO, sequences: Iterable[tuple[str, str]]) -> None:
112
+ for index, (header, sequence) in enumerate(sequences):
113
+ if index:
114
+ handle.write("\n")
115
+ handle.write(f">{header}\n{sequence}")
116
+
117
+
118
+ __all__ = [
119
+ "FastaEntry",
120
+ "append_fasta_sequence",
121
+ "count_fasta_sequences",
122
+ "parse_fasta",
123
+ "read_first_sequence",
124
+ "read_sequences",
125
+ "write_sequences",
126
+ ]
fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Predicted-aligned-error scores and training loss."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from torch import Tensor
8
+
9
+ from .esmfold2_affine3d import Affine3D
10
+
11
+ _CPU_DEVICE = torch.device("cpu")
12
+
13
+
14
+ def _compute_pae_masks(mask: Tensor) -> Tensor:
15
+ residue_mask = mask.bool()
16
+ return residue_mask.unsqueeze(-1) & residue_mask.unsqueeze(-2)
17
+
18
+
19
+ def _pae_bins(
20
+ max_bin: float = 31,
21
+ num_bins: int = 64,
22
+ device: torch.device = _CPU_DEVICE,
23
+ ) -> Tensor:
24
+ """Return the representative distance for each PAE probability bin."""
25
+
26
+ boundaries = torch.linspace(0, max_bin, steps=num_bins - 1, device=device)
27
+ width = max_bin / (num_bins - 2)
28
+ centers = boundaries + width / 2
29
+ overflow_center = centers[-1:] + width
30
+ return torch.cat((centers, overflow_center))
31
+
32
+
33
+ def _masked_probabilities(logits: Tensor, pair_mask: Tensor) -> Tensor:
34
+ masked_logits = logits.masked_fill(
35
+ ~pair_mask.unsqueeze(-1),
36
+ torch.finfo(logits.dtype).min,
37
+ )
38
+ return masked_logits.softmax(dim=-1)
39
+
40
+
41
+ def masked_mean(
42
+ mask: Tensor,
43
+ value: Tensor,
44
+ dim: int | tuple[int, ...] | None = None,
45
+ eps: float = 1e-10,
46
+ ) -> Tensor:
47
+ """Average values over true entries of a broadcast-compatible mask."""
48
+
49
+ weights = mask.expand_as(value)
50
+ weighted_sum = torch.sum(weights * value, dim=dim)
51
+ weight_sum = torch.sum(weights, dim=dim)
52
+ return weighted_sum / (weight_sum + eps)
53
+
54
+
55
+ def compute_predicted_aligned_error(
56
+ logits: Tensor,
57
+ aa_mask: Tensor,
58
+ sequence_id: Tensor | None = None,
59
+ max_bin: float = 31,
60
+ ) -> Tensor:
61
+ """Convert PAE logits ``X`` with shape (..., l, l, n) to distances."""
62
+
63
+ del sequence_id
64
+ pair_mask = _compute_pae_masks(aa_mask)
65
+ probabilities = _masked_probabilities(logits, pair_mask)
66
+ centers = _pae_bins(max_bin, logits.shape[-1], logits.device)
67
+ return torch.sum(probabilities * centers, dim=-1)
68
+
69
+
70
+ @torch.no_grad()
71
+ def compute_tm(logits: Tensor, aa_mask: Tensor, max_bin: float = 31.0) -> Tensor:
72
+ """Estimate TM score from pairwise PAE logits."""
73
+
74
+ pair_mask = _compute_pae_masks(aa_mask)
75
+ sequence_lengths = aa_mask.sum(dim=-1, keepdim=True)
76
+ centers = _pae_bins(max_bin, logits.shape[-1], logits.device)
77
+ distance_scale = 1.24 * (sequence_lengths.clamp_min(19) - 15) ** (1 / 3) - 1.8
78
+ tm_weights = 1.0 / (1 + (centers / distance_scale.unsqueeze(-1)) ** 2)
79
+ probabilities = _masked_probabilities(logits, pair_mask)
80
+ score_per_pair = torch.sum(probabilities * tm_weights.unsqueeze(-2), dim=-1)
81
+ score_per_anchor = masked_mean(pair_mask, score_per_pair, dim=-1)
82
+ return score_per_anchor.max(dim=-1).values
83
+
84
+
85
+ def _local_coordinates(frames: Affine3D) -> Tensor:
86
+ origins = frames.trans[..., None, :, :]
87
+ return frames.invert()[..., None].apply(origins)
88
+
89
+
90
+ def tm_loss(
91
+ logits: Tensor,
92
+ pred_affine: Tensor,
93
+ targ_affine: Tensor,
94
+ targ_mask: Tensor,
95
+ tm_mask: Tensor | None = None,
96
+ sequence_id: Tensor | None = None,
97
+ max_bin: float = 31,
98
+ ) -> Tensor:
99
+ """Cross-entropy loss for discretized aligned-position errors."""
100
+
101
+ del sequence_id
102
+ predicted_frames = Affine3D.from_tensor(pred_affine)
103
+ target_frames = Affine3D.from_tensor(targ_affine)
104
+ with torch.no_grad():
105
+ squared_error = (
106
+ (_local_coordinates(predicted_frames) - _local_coordinates(target_frames))
107
+ .square()
108
+ .sum(dim=-1)
109
+ )
110
+ boundaries = torch.linspace(
111
+ 0,
112
+ max_bin,
113
+ logits.shape[-1] - 1,
114
+ device=logits.device,
115
+ ).square()
116
+ target_bins = (squared_error[..., None] > boundaries).sum(dim=-1).long()
117
+
118
+ cross_entropy = F.cross_entropy(
119
+ logits.movedim(3, 1),
120
+ target_bins,
121
+ reduction="none",
122
+ )
123
+ pair_mask = _compute_pae_masks(targ_mask)
124
+ loss_per_sample = masked_mean(pair_mask, cross_entropy, dim=(-1, -2))
125
+ if tm_mask is None:
126
+ return loss_per_sample.mean()
127
+ return masked_mean(tm_mask, loss_per_sample)
fastplms/models/esmfold2/esmfold2_prepare_input.py ADDED
@@ -0,0 +1,1130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Translate typed sequence inputs into the tensors consumed by ESMFold2.
2
+
3
+ The conversion has four explicit stages: entity and chain assignment, residue
4
+ tokenization, structural feature construction, and atom-table padding. Keeping
5
+ those stages separate makes the biological indexing rules testable without
6
+ loading model weights.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import warnings
13
+ from collections import defaultdict
14
+ from contextlib import suppress
15
+ from dataclasses import dataclass, field
16
+ from itertools import combinations
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+ import torch
21
+
22
+ from .esmfold2_conformers import (
23
+ get_ccd_leaving_atoms,
24
+ get_idealized_atom_pos,
25
+ get_ligand_ccd_atoms_with_charges,
26
+ get_ligand_ccd_bonds,
27
+ get_ligand_idealized_atom_pos,
28
+ )
29
+ from .esmfold2_constants import (
30
+ CHARGED_ATOMS,
31
+ DNA_1TO3,
32
+ DNA_BACKBONE_ATOMS,
33
+ DNA_HEAVY_ATOMS,
34
+ DNA_RESIDUE_TO_RES_TYPE,
35
+ DNA_RNA_LIGAND_INPUT_ID,
36
+ DNA_UNK_RES_TYPE,
37
+ ELEMENT_TO_ATOMIC_NUM,
38
+ ESM_PROTEIN_VOCAB,
39
+ MOL_TYPE_DNA,
40
+ MOL_TYPE_NONPOLYMER,
41
+ MOL_TYPE_PROTEIN,
42
+ MOL_TYPE_RNA,
43
+ MSA_GAP_TOKEN_ID,
44
+ PROTEIN_1TO3,
45
+ PROTEIN_3TO1,
46
+ PROTEIN_HEAVY_ATOMS,
47
+ PROTEIN_RESIDUE_TO_RES_TYPE,
48
+ PROTEIN_UNK_RES_TYPE,
49
+ RNA_1TO3,
50
+ RNA_BACKBONE_ATOMS,
51
+ RNA_HEAVY_ATOMS,
52
+ RNA_RESIDUE_TO_RES_TYPE,
53
+ RNA_UNK_RES_TYPE,
54
+ )
55
+ from .esmfold2_types import (
56
+ MSA,
57
+ DNAInput,
58
+ LigandInput,
59
+ Modification,
60
+ ProteinInput,
61
+ RNAInput,
62
+ StructurePredictionInput,
63
+ )
64
+
65
+ _ZERO_POS = np.zeros(3, dtype=np.float32)
66
+ _ENCODE_ATOM_NAME_CACHE: dict[str, list[int]] = {}
67
+ _ELEMENT_ATOMIC_NUM_CACHE: dict[str, int] = {}
68
+ _TWO_LETTER_ELEMENTS = frozenset({"FE", "ZN", "MG", "MN", "CO", "NI", "CU", "SE", "BR"})
69
+
70
+
71
+ @dataclass
72
+ class AtomInfo:
73
+ """One row in the unpadded atom table."""
74
+
75
+ name: str
76
+ element: str
77
+ charge: int
78
+ ref_pos: np.ndarray # R has shape (3,).
79
+ pos: np.ndarray # X has shape (3,).
80
+ token_index: int = -1
81
+ atom_index: int = -1
82
+ space_uid: int = -1
83
+ is_valid: bool = True
84
+
85
+
86
+ @dataclass
87
+ class TokenInfo:
88
+ """Biological and atom-span annotations for one model token."""
89
+
90
+ token_index: int
91
+ residue_index: int
92
+ residue_name: str
93
+ mol_type: int
94
+ res_type: int
95
+ input_id: int
96
+ asym_id: int
97
+ sym_id: int
98
+ entity_id: int
99
+ atom_start: int
100
+ atom_count: int
101
+
102
+
103
+ @dataclass
104
+ class ChainInfo:
105
+ """One input chain after entity and symmetry assignment."""
106
+
107
+ chain_id: str
108
+ asym_id: int
109
+ entity_id: int
110
+ sym_id: int
111
+ mol_type: int
112
+ tokens: list[TokenInfo] = field(default_factory=list)
113
+ ligand_bonds: list[tuple[str, str]] = field(default_factory=list)
114
+
115
+
116
+ @dataclass
117
+ class _TokenizationState:
118
+ """Mutable cursor shared by residue tokenizers."""
119
+
120
+ token_index: int
121
+ atom_index: int
122
+ space_uid: int
123
+ tokens: list[TokenInfo] = field(default_factory=list)
124
+ atoms: list[AtomInfo] = field(default_factory=list)
125
+
126
+ def _append_atom(
127
+ self,
128
+ name: str,
129
+ element: str,
130
+ charge: int,
131
+ ref_pos: np.ndarray | None,
132
+ ) -> None:
133
+ self.atoms.append(
134
+ AtomInfo(
135
+ name=name,
136
+ element=element,
137
+ charge=charge,
138
+ ref_pos=(ref_pos.copy() if ref_pos is not None else _ZERO_POS.copy()),
139
+ pos=_ZERO_POS.copy(),
140
+ token_index=self.token_index,
141
+ atom_index=self.atom_index,
142
+ space_uid=self.space_uid,
143
+ )
144
+ )
145
+ self.atom_index += 1
146
+
147
+ def _append_token(
148
+ self,
149
+ *,
150
+ residue_index: int,
151
+ residue_name: str,
152
+ mol_type: int,
153
+ res_type: int,
154
+ input_id: int,
155
+ asym_id: int,
156
+ sym_id: int,
157
+ entity_id: int,
158
+ atom_start: int,
159
+ atom_count: int,
160
+ ) -> None:
161
+ self.tokens.append(
162
+ TokenInfo(
163
+ token_index=self.token_index,
164
+ residue_index=residue_index,
165
+ residue_name=residue_name,
166
+ mol_type=mol_type,
167
+ res_type=res_type,
168
+ input_id=input_id,
169
+ asym_id=asym_id,
170
+ sym_id=sym_id,
171
+ entity_id=entity_id,
172
+ atom_start=atom_start,
173
+ atom_count=atom_count,
174
+ )
175
+ )
176
+ self.token_index += 1
177
+
178
+ def add_residue_token(
179
+ self,
180
+ atom_specs: list[tuple[str, str, int, np.ndarray | None]],
181
+ **token_fields: Any,
182
+ ) -> None:
183
+ start = self.atom_index
184
+ for atom_spec in atom_specs:
185
+ self._append_atom(*atom_spec)
186
+ self._append_token(
187
+ atom_start=start,
188
+ atom_count=len(atom_specs),
189
+ **token_fields,
190
+ )
191
+ self.space_uid += 1
192
+
193
+ def add_atom_tokens(
194
+ self,
195
+ atom_specs: list[tuple[str, str, int, np.ndarray | None]],
196
+ **token_fields: Any,
197
+ ) -> None:
198
+ for atom_spec in atom_specs:
199
+ start = self.atom_index
200
+ self._append_atom(*atom_spec)
201
+ self._append_token(atom_start=start, atom_count=1, **token_fields)
202
+ self.space_uid += 1
203
+
204
+
205
+ def encode_atom_name(name: str) -> list[int]:
206
+ """Encode a four-character atom name with the model's ASCII offset."""
207
+ cached = _ENCODE_ATOM_NAME_CACHE.get(name)
208
+ if cached is None:
209
+ cached = [0 if char == " " else ord(char) - 32 for char in name.ljust(4)[:4]]
210
+ _ENCODE_ATOM_NAME_CACHE[name] = cached
211
+ return cached
212
+
213
+
214
+ def get_element_atomic_num(element: str) -> int:
215
+ """Map an element symbol to the model's atomic-number vocabulary."""
216
+ cached = _ELEMENT_ATOMIC_NUM_CACHE.get(element)
217
+ if cached is None:
218
+ cached = ELEMENT_TO_ATOMIC_NUM.get(element.upper(), 0)
219
+ _ELEMENT_ATOMIC_NUM_CACHE[element] = cached
220
+ return cached
221
+
222
+
223
+ def _infer_element(atom_name: str) -> str:
224
+ normalized = atom_name.strip()
225
+ if not normalized:
226
+ return "C"
227
+ if normalized[0].isdigit():
228
+ return normalized[1] if len(normalized) > 1 else "H"
229
+ if len(normalized) == 2 and normalized in _TWO_LETTER_ELEMENTS:
230
+ return normalized
231
+ return normalized[0]
232
+
233
+
234
+ def _compute_res_type(name: str, mol_type: int) -> int:
235
+ if mol_type == MOL_TYPE_PROTEIN:
236
+ return PROTEIN_RESIDUE_TO_RES_TYPE.get(name, PROTEIN_UNK_RES_TYPE)
237
+ if mol_type == MOL_TYPE_DNA:
238
+ return DNA_RESIDUE_TO_RES_TYPE.get(
239
+ name, RNA_RESIDUE_TO_RES_TYPE.get(name, DNA_UNK_RES_TYPE)
240
+ )
241
+ if mol_type == MOL_TYPE_RNA:
242
+ return RNA_RESIDUE_TO_RES_TYPE.get(
243
+ name, DNA_RESIDUE_TO_RES_TYPE.get(name, RNA_UNK_RES_TYPE)
244
+ )
245
+ return PROTEIN_UNK_RES_TYPE
246
+
247
+
248
+ def _compute_esm_input_id(name: str, mol_type: int) -> int:
249
+ if mol_type != MOL_TYPE_PROTEIN:
250
+ return DNA_RNA_LIGAND_INPUT_ID
251
+ letter = PROTEIN_3TO1.get(name)
252
+ return (
253
+ DNA_RNA_LIGAND_INPUT_ID
254
+ if letter is None
255
+ else ESM_PROTEIN_VOCAB.get(letter, ESM_PROTEIN_VOCAB["X"])
256
+ )
257
+
258
+
259
+ def _apply_modifications(residues: list[str], modifications: list[Modification] | None) -> set[int]:
260
+ changed: set[int] = set()
261
+ for modification in modifications or ():
262
+ residues[modification.position] = modification.ccd
263
+ changed.add(modification.position)
264
+ return changed
265
+
266
+
267
+ def _ideal_atom_specs(
268
+ residue_name: str,
269
+ residue_type: int,
270
+ atom_names: list[str],
271
+ *,
272
+ charges: bool = True,
273
+ ) -> list[tuple[str, str, int, np.ndarray | None]]:
274
+ return [
275
+ (
276
+ atom_name,
277
+ _infer_element(atom_name),
278
+ CHARGED_ATOMS.get((residue_name, atom_name), 0) if charges else 0,
279
+ get_idealized_atom_pos(residue_type, atom_name),
280
+ )
281
+ for atom_name in atom_names
282
+ ]
283
+
284
+
285
+ def _ccd_atom_specs(
286
+ residue_name: str,
287
+ atoms: list[tuple[str, str, int]],
288
+ excluded: set[str],
289
+ *,
290
+ force_zero: bool = False,
291
+ ) -> list[tuple[str, str, int, np.ndarray | None]]:
292
+ return [
293
+ (
294
+ atom_name,
295
+ element,
296
+ charge,
297
+ None if force_zero else get_ligand_idealized_atom_pos(residue_name, atom_name),
298
+ )
299
+ for atom_name, element, charge in atoms
300
+ if atom_name not in excluded
301
+ ]
302
+
303
+
304
+ def tokenize_protein(
305
+ sequence: str,
306
+ modifications: list[Modification] | None,
307
+ entity_id: int,
308
+ asym_id: int,
309
+ sym_id: int,
310
+ token_offset: int,
311
+ atom_offset: int,
312
+ space_uid_offset: int,
313
+ ) -> tuple[list[TokenInfo], list[AtomInfo]]:
314
+ """Tokenize protein residues, atom-tokenizing modified CCD components."""
315
+ residues = [PROTEIN_1TO3.get(letter, "UNK") for letter in sequence]
316
+ modified = _apply_modifications(residues, modifications)
317
+ state = _TokenizationState(token_offset, atom_offset, space_uid_offset)
318
+
319
+ for residue_index, residue_name in enumerate(residues):
320
+ canonical_name = "MET" if residue_name == "MSE" else residue_name
321
+ common_fields = {
322
+ "residue_index": residue_index,
323
+ "mol_type": MOL_TYPE_PROTEIN,
324
+ "asym_id": asym_id,
325
+ "sym_id": sym_id,
326
+ "entity_id": entity_id,
327
+ }
328
+ if residue_index not in modified and canonical_name in PROTEIN_HEAVY_ATOMS:
329
+ residue_type = _compute_res_type(canonical_name, MOL_TYPE_PROTEIN)
330
+ state.add_residue_token(
331
+ _ideal_atom_specs(
332
+ canonical_name,
333
+ residue_type,
334
+ PROTEIN_HEAVY_ATOMS[canonical_name],
335
+ ),
336
+ residue_name=canonical_name,
337
+ res_type=residue_type,
338
+ input_id=_compute_esm_input_id(canonical_name, MOL_TYPE_PROTEIN),
339
+ **common_fields,
340
+ )
341
+ continue
342
+
343
+ ccd_atoms = get_ligand_ccd_atoms_with_charges(residue_name)
344
+ if ccd_atoms is None:
345
+ ccd_atoms = [
346
+ (_infer_element(name), _infer_element(name), 0) for name in ("N", "CA", "C", "O")
347
+ ]
348
+ excluded = (
349
+ set() if residue_index == len(residues) - 1 else get_ccd_leaving_atoms(residue_name)
350
+ )
351
+ retained = [atom for atom in ccd_atoms if atom[0] not in excluded]
352
+ state.add_atom_tokens(
353
+ _ccd_atom_specs(
354
+ residue_name,
355
+ retained,
356
+ set(),
357
+ force_zero=len(retained) == 1,
358
+ ),
359
+ residue_name=residue_name,
360
+ res_type=PROTEIN_UNK_RES_TYPE,
361
+ input_id=DNA_RNA_LIGAND_INPUT_ID,
362
+ **common_fields,
363
+ )
364
+ return state.tokens, state.atoms
365
+
366
+
367
+ def tokenize_nucleotide(
368
+ sequence: str,
369
+ modifications: list[Modification] | None,
370
+ mol_type: int,
371
+ entity_id: int,
372
+ asym_id: int,
373
+ sym_id: int,
374
+ token_offset: int,
375
+ atom_offset: int,
376
+ space_uid_offset: int,
377
+ ) -> tuple[list[TokenInfo], list[AtomInfo]]:
378
+ """Tokenize DNA or RNA, retaining backbone atoms for unknown bases."""
379
+ dna = mol_type == MOL_TYPE_DNA
380
+ letter_map = DNA_1TO3 if dna else RNA_1TO3
381
+ heavy_atoms = DNA_HEAVY_ATOMS if dna else RNA_HEAVY_ATOMS
382
+ backbone_atoms = DNA_BACKBONE_ATOMS if dna else RNA_BACKBONE_ATOMS
383
+ unknown_type = DNA_UNK_RES_TYPE if dna else RNA_UNK_RES_TYPE
384
+ residues = [letter_map.get(letter, "UNK") for letter in sequence]
385
+ modified = _apply_modifications(residues, modifications)
386
+ state = _TokenizationState(token_offset, atom_offset, space_uid_offset)
387
+
388
+ for residue_index, residue_name in enumerate(residues):
389
+ common_fields = {
390
+ "residue_index": residue_index,
391
+ "residue_name": residue_name,
392
+ "mol_type": mol_type,
393
+ "asym_id": asym_id,
394
+ "sym_id": sym_id,
395
+ "entity_id": entity_id,
396
+ "input_id": DNA_RNA_LIGAND_INPUT_ID,
397
+ }
398
+ if residue_index not in modified and residue_name in heavy_atoms:
399
+ residue_type = _compute_res_type(residue_name, mol_type)
400
+ state.add_residue_token(
401
+ _ideal_atom_specs(residue_name, residue_type, heavy_atoms[residue_name]),
402
+ res_type=residue_type,
403
+ **common_fields,
404
+ )
405
+ continue
406
+ if residue_index not in modified and residue_name == "UNK":
407
+ state.add_residue_token(
408
+ [(atom_name, _infer_element(atom_name), 0, None) for atom_name in backbone_atoms],
409
+ res_type=unknown_type,
410
+ **common_fields,
411
+ )
412
+ continue
413
+
414
+ ccd_atoms = get_ligand_ccd_atoms_with_charges(residue_name)
415
+ if ccd_atoms is None:
416
+ ccd_atoms = [(_infer_element(name), _infer_element(name), 0) for name in backbone_atoms]
417
+ excluded = (
418
+ set() if residue_index == len(residues) - 1 else get_ccd_leaving_atoms(residue_name)
419
+ )
420
+ state.add_atom_tokens(
421
+ _ccd_atom_specs(residue_name, ccd_atoms, excluded),
422
+ res_type=PROTEIN_UNK_RES_TYPE,
423
+ **common_fields,
424
+ )
425
+ return state.tokens, state.atoms
426
+
427
+
428
+ def tokenize_ligand_ccd(
429
+ ccd_codes: list[str],
430
+ entity_id: int,
431
+ asym_id: int,
432
+ sym_id: int,
433
+ token_offset: int,
434
+ atom_offset: int,
435
+ space_uid_offset: int,
436
+ has_covalent_bond: bool,
437
+ ) -> tuple[list[TokenInfo], list[AtomInfo]]:
438
+ """Tokenize CCD ligands with one model token per retained atom."""
439
+ state = _TokenizationState(token_offset, atom_offset, space_uid_offset)
440
+ for residue_index, code in enumerate(ccd_codes):
441
+ ccd_atoms = get_ligand_ccd_atoms_with_charges(code)
442
+ if ccd_atoms is None:
443
+ raise ValueError(f"CCD component {code} not found")
444
+ excluded = get_ccd_leaving_atoms(code) if has_covalent_bond else set()
445
+ state.add_atom_tokens(
446
+ _ccd_atom_specs(code, ccd_atoms, excluded),
447
+ residue_index=residue_index,
448
+ residue_name=code,
449
+ mol_type=MOL_TYPE_NONPOLYMER,
450
+ res_type=PROTEIN_UNK_RES_TYPE,
451
+ input_id=DNA_RNA_LIGAND_INPUT_ID,
452
+ asym_id=asym_id,
453
+ sym_id=sym_id,
454
+ entity_id=entity_id,
455
+ )
456
+ return state.tokens, state.atoms
457
+
458
+
459
+ def tokenize_ligand_smiles(
460
+ smiles: str,
461
+ entity_id: int,
462
+ asym_id: int,
463
+ sym_id: int,
464
+ token_offset: int,
465
+ atom_offset: int,
466
+ space_uid_offset: int,
467
+ seed: int | None = None,
468
+ ) -> tuple[list[TokenInfo], list[AtomInfo], list[tuple[str, str]]]:
469
+ """Generate a conformer and tokenize each heavy atom of a SMILES ligand."""
470
+ from rdkit import Chem
471
+ from rdkit.Chem import AllChem
472
+
473
+ molecule = Chem.MolFromSmiles(smiles)
474
+ if molecule is None:
475
+ raise ValueError(f"Failed to parse SMILES: {smiles}")
476
+ molecule = Chem.AddHs(molecule)
477
+ canonical_order = AllChem.CanonicalRankAtoms(molecule) # type: ignore[attr-defined]
478
+ for atom, canonical_index in zip(molecule.GetAtoms(), canonical_order, strict=True):
479
+ name = atom.GetSymbol().upper() + str(canonical_index + 1)
480
+ if len(name) > 4:
481
+ raise ValueError(f"SMILES {smiles} has atom name longer than 4 chars: {name}")
482
+ atom.SetProp("name", name)
483
+
484
+ options = AllChem.ETKDGv3() # type: ignore[attr-defined]
485
+ options.clearConfs = False
486
+ if seed is not None:
487
+ options.randomSeed = seed
488
+ conformer_id = AllChem.EmbedMolecule(molecule, options) # type: ignore[attr-defined]
489
+ if conformer_id == -1:
490
+ options.useRandomCoords = True
491
+ conformer_id = AllChem.EmbedMolecule(molecule, options) # type: ignore[attr-defined]
492
+ if conformer_id != -1:
493
+ with suppress(RuntimeError, ValueError):
494
+ AllChem.UFFOptimizeMolecule( # type: ignore[attr-defined]
495
+ molecule, confId=conformer_id, maxIters=1000
496
+ )
497
+
498
+ molecule = Chem.RemoveHs(molecule)
499
+ if molecule.GetNumConformers() == 0:
500
+ raise ValueError(f"Failed to generate conformer for SMILES: {smiles}")
501
+ conformer = molecule.GetConformer(0)
502
+ atom_specs: list[tuple[str, str, int, np.ndarray | None]] = []
503
+ for atom in molecule.GetAtoms():
504
+ position = conformer.GetAtomPosition(atom.GetIdx())
505
+ atom_specs.append(
506
+ (
507
+ atom.GetProp("name"),
508
+ atom.GetSymbol(),
509
+ atom.GetFormalCharge(),
510
+ np.asarray([position.x, position.y, position.z], dtype=np.float32),
511
+ )
512
+ )
513
+ state = _TokenizationState(token_offset, atom_offset, space_uid_offset)
514
+ state.add_atom_tokens(
515
+ atom_specs,
516
+ residue_index=0,
517
+ residue_name="LIG",
518
+ mol_type=MOL_TYPE_NONPOLYMER,
519
+ res_type=PROTEIN_UNK_RES_TYPE,
520
+ input_id=DNA_RNA_LIGAND_INPUT_ID,
521
+ asym_id=asym_id,
522
+ sym_id=sym_id,
523
+ entity_id=entity_id,
524
+ )
525
+ bonds = [
526
+ (
527
+ bond.GetBeginAtom().GetProp("name"),
528
+ bond.GetEndAtom().GetProp("name"),
529
+ )
530
+ for bond in molecule.GetBonds()
531
+ ]
532
+ return state.tokens, state.atoms, bonds
533
+
534
+
535
+ def _get_sequence_key(item: Any) -> str:
536
+ if isinstance(item, ProteinInput):
537
+ return f"PROTEIN:{item.sequence}"
538
+ if isinstance(item, DNAInput):
539
+ return f"DNA:{item.sequence}"
540
+ if isinstance(item, RNAInput):
541
+ return f"RNA:{item.sequence}"
542
+ if isinstance(item, LigandInput):
543
+ return f"LIGAND_CCD:{','.join(item.ccd)}" if item.ccd else f"LIGAND_SMILES:{item.smiles}"
544
+ raise ValueError(f"Unknown input type: {type(item)}")
545
+
546
+
547
+ def _tokenize_chain(
548
+ item: Any,
549
+ chain_id: str,
550
+ *,
551
+ entity_id: int,
552
+ asym_id: int,
553
+ sym_id: int,
554
+ token_offset: int,
555
+ atom_offset: int,
556
+ space_uid_offset: int,
557
+ covalent_chains: set[str],
558
+ seed: int | None,
559
+ ) -> tuple[list[TokenInfo], list[AtomInfo], list[tuple[str, str]]]:
560
+ common = {
561
+ "entity_id": entity_id,
562
+ "asym_id": asym_id,
563
+ "sym_id": sym_id,
564
+ "token_offset": token_offset,
565
+ "atom_offset": atom_offset,
566
+ "space_uid_offset": space_uid_offset,
567
+ }
568
+ if isinstance(item, ProteinInput):
569
+ if item.msa is None:
570
+ warnings.warn(
571
+ f"No MSA provided for {item.id}, using single sequence mode",
572
+ stacklevel=2,
573
+ )
574
+ tokens, atoms = tokenize_protein(item.sequence, item.modifications, **common)
575
+ return tokens, atoms, []
576
+ if isinstance(item, (DNAInput, RNAInput)):
577
+ mol_type = MOL_TYPE_DNA if isinstance(item, DNAInput) else MOL_TYPE_RNA
578
+ tokens, atoms = tokenize_nucleotide(
579
+ item.sequence, item.modifications, mol_type=mol_type, **common
580
+ )
581
+ return tokens, atoms, []
582
+ if not isinstance(item, LigandInput):
583
+ raise ValueError(f"Unknown input type: {type(item)}")
584
+ if item.ccd is not None:
585
+ if item.smiles is not None:
586
+ warnings.warn("Both ccd and smiles provided, using ccd", stacklevel=2)
587
+ tokens, atoms = tokenize_ligand_ccd(
588
+ item.ccd,
589
+ has_covalent_bond=chain_id in covalent_chains,
590
+ **common,
591
+ )
592
+ return tokens, atoms, []
593
+ if item.smiles is not None:
594
+ return tokenize_ligand_smiles(item.smiles, seed=seed, **common)
595
+ raise ValueError("LigandInput must have either ccd or smiles")
596
+
597
+
598
+ def build_chains_from_input(
599
+ input: StructurePredictionInput, seed: int | None = None
600
+ ) -> tuple[list[ChainInfo], list[TokenInfo], list[AtomInfo]]:
601
+ """Assign entities and symmetry copies, then tokenize every input chain."""
602
+ chains: list[ChainInfo] = []
603
+ tokens: list[TokenInfo] = []
604
+ atoms: list[AtomInfo] = []
605
+ entity_for_sequence: dict[str, int] = {}
606
+ next_symmetry: dict[int, int] = {}
607
+ covalent_chains = {
608
+ chain_id
609
+ for bond in input.covalent_bonds or ()
610
+ for chain_id in (bond.chain_id1, bond.chain_id2)
611
+ }
612
+ space_uid_offset = 0
613
+
614
+ for item in input.sequences:
615
+ key = _get_sequence_key(item)
616
+ entity_id = entity_for_sequence.setdefault(key, len(entity_for_sequence))
617
+ chain_ids = [item.id] if isinstance(item.id, str) else item.id
618
+ for chain_id in chain_ids:
619
+ sym_id = next_symmetry.get(entity_id, 0)
620
+ next_symmetry[entity_id] = sym_id + 1
621
+ asym_id = len(chains)
622
+ new_tokens, new_atoms, ligand_bonds = _tokenize_chain(
623
+ item,
624
+ chain_id,
625
+ entity_id=entity_id,
626
+ asym_id=asym_id,
627
+ sym_id=sym_id,
628
+ token_offset=len(tokens),
629
+ atom_offset=len(atoms),
630
+ space_uid_offset=space_uid_offset,
631
+ covalent_chains=covalent_chains,
632
+ seed=seed,
633
+ )
634
+ chains.append(
635
+ ChainInfo(
636
+ chain_id=chain_id,
637
+ asym_id=asym_id,
638
+ entity_id=entity_id,
639
+ sym_id=sym_id,
640
+ mol_type=(new_tokens[0].mol_type if new_tokens else MOL_TYPE_PROTEIN),
641
+ tokens=new_tokens,
642
+ ligand_bonds=ligand_bonds,
643
+ )
644
+ )
645
+ tokens.extend(new_tokens)
646
+ atoms.extend(new_atoms)
647
+ space_uid_offset += len({atom.space_uid for atom in new_atoms})
648
+ return chains, tokens, atoms
649
+
650
+
651
+ def _atom_indices_by_name(atoms: list[AtomInfo]) -> dict[int, dict[str, int]]:
652
+ result: dict[int, dict[str, int]] = defaultdict(dict)
653
+ for atom in atoms:
654
+ if atom.is_valid:
655
+ result[atom.token_index][atom.name] = atom.atom_index
656
+ return result
657
+
658
+
659
+ def _ligand_frames(
660
+ tokens: list[TokenInfo],
661
+ atoms: list[AtomInfo],
662
+ atom_indices: dict[int, dict[str, int]],
663
+ ) -> dict[int, tuple[int, int, int]]:
664
+ atom_for_token: dict[int, int] = {}
665
+ tokens_by_residue: dict[tuple[int, int], list[int]] = defaultdict(list)
666
+ for token in tokens:
667
+ if token.mol_type != MOL_TYPE_NONPOLYMER:
668
+ continue
669
+ named_atoms = atom_indices.get(token.token_index)
670
+ if named_atoms:
671
+ atom_for_token[token.token_index] = next(iter(named_atoms.values()))
672
+ tokens_by_residue[(token.asym_id, token.residue_index)].append(token.token_index)
673
+
674
+ frames: dict[int, tuple[int, int, int]] = {}
675
+ for residue_tokens in tokens_by_residue.values():
676
+ residue_atoms = [
677
+ atom_for_token[token] for token in residue_tokens if token in atom_for_token
678
+ ]
679
+ if len(residue_atoms) < 3:
680
+ for token in residue_tokens:
681
+ if token in atom_for_token:
682
+ atom_index = atom_for_token[token]
683
+ frames[token] = (atom_index, atom_index, atom_index)
684
+ continue
685
+ R = np.asarray([atoms[index].ref_pos for index in residue_atoms])
686
+ distances = np.sqrt(((R[:, None] - R[None]) ** 2).sum(-1))
687
+ nearest = np.argsort(distances, axis=1)
688
+ local = np.column_stack((nearest[:, 1], nearest[:, 0], nearest[:, 2]))
689
+ local_index = {atom_index: index for index, atom_index in enumerate(residue_atoms)}
690
+ for token in residue_tokens:
691
+ atom_index = atom_for_token.get(token)
692
+ if atom_index is None:
693
+ continue
694
+ selected = local[local_index[atom_index]]
695
+ frames[token] = tuple(residue_atoms[int(index)] for index in selected)
696
+ return frames
697
+
698
+
699
+ def _frame_for_token(
700
+ token: TokenInfo,
701
+ named_atoms: dict[str, int],
702
+ ligand_frames: dict[int, tuple[int, int, int]],
703
+ ) -> tuple[int, int, int]:
704
+ fallback = next(iter(named_atoms.values()), 0)
705
+ if token.mol_type == MOL_TYPE_PROTEIN:
706
+ return (
707
+ (fallback, fallback, fallback)
708
+ if token.res_type == PROTEIN_UNK_RES_TYPE
709
+ else (
710
+ named_atoms.get("N", 0),
711
+ named_atoms.get("CA", 0),
712
+ named_atoms.get("C", 0),
713
+ )
714
+ )
715
+ if token.mol_type in (MOL_TYPE_DNA, MOL_TYPE_RNA):
716
+ return (
717
+ (fallback, fallback, fallback)
718
+ if token.res_type == PROTEIN_UNK_RES_TYPE
719
+ else (
720
+ named_atoms.get("C1'", 0),
721
+ named_atoms.get("C3'", 0),
722
+ named_atoms.get("C4'", 0),
723
+ )
724
+ )
725
+ if token.mol_type == MOL_TYPE_NONPOLYMER:
726
+ return ligand_frames.get(token.token_index, (fallback, fallback, fallback))
727
+ return fallback, fallback, fallback
728
+
729
+
730
+ def _resolved_frames(
731
+ frames: np.ndarray, tokens: list[TokenInfo], atoms: list[AtomInfo]
732
+ ) -> np.ndarray:
733
+ if not tokens:
734
+ return np.zeros(0, dtype=bool)
735
+ X = (
736
+ np.asarray([atom.pos for atom in atoms], dtype=np.float32)
737
+ if atoms
738
+ else np.zeros((0, 3), dtype=np.float32)
739
+ )
740
+ valid_atoms = (
741
+ np.asarray([atom.is_valid for atom in atoms], dtype=bool)
742
+ if atoms
743
+ else np.zeros(0, dtype=bool)
744
+ )
745
+ resolved_atoms = valid_atoms & np.any(X != 0, axis=1)
746
+ origin = X[frames[:, 1]]
747
+ left = X[frames[:, 0]] - origin
748
+ right = X[frames[:, 2]] - origin
749
+ left_norm = np.linalg.norm(left, axis=1)
750
+ right_norm = np.linalg.norm(right, axis=1)
751
+ valid_norms = (left_norm >= 1e-6) & (right_norm >= 1e-6)
752
+ cosine = np.zeros(len(tokens), dtype=np.float32)
753
+ if np.any(valid_norms):
754
+ cosine[valid_norms] = np.sum(left[valid_norms] * right[valid_norms], axis=1) / (
755
+ left_norm[valid_norms] * right_norm[valid_norms]
756
+ )
757
+ angle = np.degrees(np.arccos(np.abs(np.clip(cosine, -1, 1))))
758
+ all_resolved = resolved_atoms[frames].all(axis=1)
759
+ repeated = (frames[:, 0] == frames[:, 1]) & (frames[:, 1] == frames[:, 2])
760
+ return all_resolved & ~repeated & valid_norms & (angle >= 25)
761
+
762
+
763
+ def compute_frame_indices(
764
+ tokens: list[TokenInfo], atoms: list[AtomInfo]
765
+ ) -> tuple[np.ndarray, np.ndarray]:
766
+ """Return frame atom indices F with shape (l, 3) and validity M with shape (l,)."""
767
+ named_atoms = _atom_indices_by_name(atoms)
768
+ ligand_frames = _ligand_frames(tokens, atoms, named_atoms)
769
+ frames = np.asarray(
770
+ [
771
+ _frame_for_token(token, named_atoms.get(token.token_index, {}), ligand_frames)
772
+ for token in tokens
773
+ ],
774
+ dtype=np.int64,
775
+ )
776
+ return frames, _resolved_frames(frames, tokens, atoms)
777
+
778
+
779
+ def _atom_tokenized_residues(
780
+ tokens: list[TokenInfo], atoms: list[AtomInfo]
781
+ ) -> dict[tuple[int, int], list[tuple[str, int]]]:
782
+ grouped: dict[tuple[int, int], list[tuple[str, int]]] = defaultdict(list)
783
+ for atom in atoms:
784
+ if not atom.is_valid or atom.token_index >= len(tokens):
785
+ continue
786
+ token = tokens[atom.token_index]
787
+ if token.mol_type == MOL_TYPE_NONPOLYMER or token.res_type == PROTEIN_UNK_RES_TYPE:
788
+ grouped[(token.asym_id, token.residue_index)].append((atom.name, atom.token_index))
789
+ return grouped
790
+
791
+
792
+ def _backbone_token(
793
+ residue_tokens: list[TokenInfo], atom_name: str, atoms: list[AtomInfo]
794
+ ) -> int | None:
795
+ if len(residue_tokens) == 1 and residue_tokens[0].res_type != PROTEIN_UNK_RES_TYPE:
796
+ return residue_tokens[0].token_index
797
+ for token in residue_tokens:
798
+ for atom_index in range(token.atom_start, token.atom_start + token.atom_count):
799
+ if atom_index < len(atoms) and atoms[atom_index].name == atom_name:
800
+ return token.token_index
801
+ return residue_tokens[0].token_index if residue_tokens else None
802
+
803
+
804
+ def compute_token_bonds(
805
+ tokens: list[TokenInfo],
806
+ atoms: list[AtomInfo],
807
+ input: StructurePredictionInput,
808
+ chains: list[ChainInfo],
809
+ ) -> torch.Tensor:
810
+ """Build the symmetric token-bond matrix M with shape (l, l, 1)."""
811
+ edges: set[tuple[int, int]] = set()
812
+
813
+ def connect(left: int | None, right: int | None) -> None:
814
+ if left is not None and right is not None and left != right:
815
+ edges.add((min(left, right), max(left, right)))
816
+
817
+ explicit_bonds = {
818
+ (chain.asym_id, 0): chain.ligand_bonds for chain in chains if chain.ligand_bonds
819
+ }
820
+ for residue_key, atom_list in _atom_tokenized_residues(tokens, atoms).items():
821
+ if not atom_list:
822
+ continue
823
+ residue_name = tokens[atom_list[0][1]].residue_name
824
+ token_for_name = {name: token_index for name, token_index in atom_list}
825
+ bonds = explicit_bonds.get(residue_key)
826
+ if bonds is None:
827
+ bonds = get_ligand_ccd_bonds(residue_name)
828
+ if bonds:
829
+ for left_name, right_name in bonds:
830
+ if left_name in token_for_name and right_name in token_for_name:
831
+ connect(token_for_name[left_name], token_for_name[right_name])
832
+ else:
833
+ for left, right in combinations([token_index for _, token_index in atom_list], 2):
834
+ connect(left, right)
835
+
836
+ if input.covalent_bonds:
837
+ chain_for_id = {chain.chain_id: chain for chain in chains}
838
+ residue_atoms: dict[tuple[int, int], list[AtomInfo]] = defaultdict(list)
839
+ for atom in atoms:
840
+ if atom.is_valid and atom.token_index < len(tokens):
841
+ token = tokens[atom.token_index]
842
+ residue_atoms[(token.asym_id, token.residue_index)].append(atom)
843
+ for bond in input.covalent_bonds:
844
+ left_chain = chain_for_id.get(bond.chain_id1)
845
+ right_chain = chain_for_id.get(bond.chain_id2)
846
+ if left_chain is None or right_chain is None:
847
+ continue
848
+ left_atoms = residue_atoms.get((left_chain.asym_id, bond.res_idx1), [])
849
+ right_atoms = residue_atoms.get((right_chain.asym_id, bond.res_idx2), [])
850
+ if bond.atom_idx1 < len(left_atoms) and bond.atom_idx2 < len(right_atoms):
851
+ connect(
852
+ left_atoms[bond.atom_idx1].token_index,
853
+ right_atoms[bond.atom_idx2].token_index,
854
+ )
855
+
856
+ protein_residues: dict[tuple[int, int], list[TokenInfo]] = defaultdict(list)
857
+ for token in tokens:
858
+ if token.mol_type == MOL_TYPE_PROTEIN:
859
+ protein_residues[(token.asym_id, token.residue_index)].append(token)
860
+ for (asym_id, residue_index), residue_tokens in protein_residues.items():
861
+ if not any(token.res_type == PROTEIN_UNK_RES_TYPE for token in residue_tokens):
862
+ continue
863
+ previous = protein_residues.get((asym_id, residue_index - 1))
864
+ following = protein_residues.get((asym_id, residue_index + 1))
865
+ if previous:
866
+ connect(
867
+ _backbone_token(previous, "C", atoms),
868
+ _backbone_token(residue_tokens, "N", atoms),
869
+ )
870
+ if following:
871
+ connect(
872
+ _backbone_token(residue_tokens, "C", atoms),
873
+ _backbone_token(following, "N", atoms),
874
+ )
875
+
876
+ matrix = torch.zeros(len(tokens), len(tokens), 1, dtype=torch.float32)
877
+ for left, right in edges:
878
+ matrix[left, right, 0] = 1.0
879
+ matrix[right, left, 0] = 1.0
880
+ return matrix
881
+
882
+
883
+ def compute_representative_atoms(tokens: list[TokenInfo], atoms: list[AtomInfo]) -> torch.Tensor:
884
+ """Choose one distogram atom per token and return indices I with shape (l,)."""
885
+ named_atoms = _atom_indices_by_name(atoms)
886
+ representatives = torch.zeros(len(tokens), dtype=torch.int64)
887
+ for token in tokens:
888
+ names = named_atoms.get(token.token_index, {})
889
+ fallback = next(iter(names.values()), 0)
890
+ if token.mol_type == MOL_TYPE_PROTEIN:
891
+ representative = names.get("CB", names.get("CA", fallback))
892
+ elif token.mol_type in (MOL_TYPE_DNA, MOL_TYPE_RNA):
893
+ if token.res_type in (27, 32):
894
+ representative = names.get("C1'", fallback)
895
+ elif token.res_type in (23, 24, 28, 29):
896
+ representative = names.get("C4", names.get("C1'", fallback))
897
+ else:
898
+ representative = names.get("C2", names.get("C1'", fallback))
899
+ else:
900
+ representative = fallback
901
+ representatives[token.token_index] = representative
902
+ return representatives
903
+
904
+
905
+ def _msa_assignments(
906
+ input: StructurePredictionInput, chains: list[ChainInfo]
907
+ ) -> dict[int, MSA | None]:
908
+ chain_msas: dict[int, MSA | None] = {}
909
+ chain_index = 0
910
+ for item in input.sequences:
911
+ chain_ids = [item.id] if isinstance(item.id, str) else list(item.id)
912
+ for _ in chain_ids:
913
+ chain = chains[chain_index]
914
+ if isinstance(item, ProteinInput):
915
+ chain_msas[chain.asym_id] = (
916
+ MSA.from_sequences([item.sequence]) if item.msa is None else item.msa
917
+ )
918
+ else:
919
+ chain_msas[chain.asym_id] = None
920
+ chain_index += 1
921
+ return chain_msas
922
+
923
+
924
+ def compute_msa_features(
925
+ input: StructurePredictionInput,
926
+ chains: list[ChainInfo],
927
+ tokens: list[TokenInfo],
928
+ max_seqs: int = 16384,
929
+ ) -> dict[str, torch.Tensor]:
930
+ """Pair per-chain MSAs and return row features with shape (m, l)."""
931
+ from .esmfold2_paired_msa import (
932
+ construct_paired_msa,
933
+ protein_letter_to_res_type,
934
+ )
935
+
936
+ chain_msas = _msa_assignments(input, chains)
937
+ query_types = {
938
+ chain.asym_id: np.asarray(
939
+ [token.res_type for token in tokens if token.asym_id == chain.asym_id],
940
+ dtype=np.int64,
941
+ )
942
+ for chain in chains
943
+ }
944
+ msa_residues, deletion_counts, _ = construct_paired_msa(
945
+ chain_msas,
946
+ query_types,
947
+ np.asarray([token.asym_id for token in tokens], dtype=np.int64),
948
+ np.asarray([token.residue_index for token in tokens], dtype=np.int64),
949
+ letter_to_res_type=protein_letter_to_res_type(),
950
+ max_seqs=max_seqs,
951
+ )
952
+ for token in tokens:
953
+ if chain_msas.get(token.asym_id) is None:
954
+ msa_residues[:, token.token_index] = MSA_GAP_TOKEN_ID
955
+ msa_residues[0, token.token_index] = token.res_type
956
+ if msa_residues.shape[0] == 0:
957
+ msa_residues = np.full((1, len(tokens)), MSA_GAP_TOKEN_ID, dtype=np.int64)
958
+ deletion_counts = np.zeros((1, len(tokens)), dtype=np.float32)
959
+
960
+ msa = torch.from_numpy(msa_residues)
961
+ deletion_count = torch.from_numpy(deletion_counts)
962
+ deletion_value = (np.pi / 2) * torch.arctan(deletion_count / 3)
963
+ return {
964
+ "msa": msa,
965
+ "deletion_value": deletion_value,
966
+ "has_deletion": deletion_count > 0,
967
+ "deletion_mean": deletion_value.mean(dim=0),
968
+ "msa_attention_mask": torch.ones_like(msa, dtype=torch.bool),
969
+ }
970
+
971
+
972
+ def compute_distogram_conditioning(
973
+ input: StructurePredictionInput,
974
+ chains: list[ChainInfo],
975
+ tokens: list[TokenInfo],
976
+ disto_center: torch.Tensor,
977
+ min_dist: float = 2.0,
978
+ max_dist: float = 22.0,
979
+ num_bins: int = 64,
980
+ ) -> tuple[torch.Tensor, torch.Tensor]:
981
+ """Bin user distances into D and return D plus its Boolean mask M."""
982
+ del disto_center
983
+ n_tokens = len(tokens)
984
+ bins = torch.zeros((n_tokens, n_tokens), dtype=torch.long)
985
+ mask = torch.zeros((n_tokens, n_tokens), dtype=torch.bool)
986
+ if not input.distogram_conditioning:
987
+ return bins, mask
988
+ asym_for_chain = {chain.chain_id: chain.asym_id for chain in chains}
989
+ tokens_for_asym: dict[int, list[int]] = defaultdict(list)
990
+ for token in tokens:
991
+ tokens_for_asym[token.asym_id].append(token.token_index)
992
+ boundaries = torch.linspace(min_dist, max_dist, num_bins + 1)
993
+
994
+ for conditioning in input.distogram_conditioning:
995
+ asym_id = asym_for_chain.get(conditioning.chain_id)
996
+ if asym_id is None:
997
+ continue
998
+ indices = tokens_for_asym[asym_id]
999
+ distances = torch.as_tensor(conditioning.distogram, dtype=torch.float32)
1000
+ expected_shape = (len(indices), len(indices))
1001
+ if distances.shape != expected_shape:
1002
+ raise ValueError(
1003
+ f"Distogram shape {distances.shape} doesn't match chain length {len(indices)}"
1004
+ )
1005
+ selected = torch.bucketize(distances, boundaries[:-1]).sub(1).clamp(0, num_bins - 1)
1006
+ token_indices_tensor = torch.as_tensor(indices, dtype=torch.long)
1007
+ bins[token_indices_tensor[:, None], token_indices_tensor[None, :]] = selected
1008
+ mask[token_indices_tensor[:, None], token_indices_tensor[None, :]] = True
1009
+ return bins, mask
1010
+
1011
+
1012
+ def _padded_atoms(atoms: list[AtomInfo]) -> list[AtomInfo]:
1013
+ target = math.ceil(len(atoms) / 32) * 32 if atoms else 32
1014
+ padding = [
1015
+ AtomInfo(
1016
+ name="",
1017
+ element="",
1018
+ charge=0,
1019
+ ref_pos=_ZERO_POS.copy(),
1020
+ pos=_ZERO_POS.copy(),
1021
+ token_index=0,
1022
+ atom_index=index,
1023
+ space_uid=0,
1024
+ is_valid=False,
1025
+ )
1026
+ for index in range(len(atoms), target)
1027
+ ]
1028
+ return [*atoms, *padding]
1029
+
1030
+
1031
+ def _token_tensors(tokens: list[TokenInfo]) -> dict[str, torch.Tensor]:
1032
+ fields = {
1033
+ "token_index": "token_index",
1034
+ "residue_index": "residue_index",
1035
+ "asym_id": "asym_id",
1036
+ "sym_id": "sym_id",
1037
+ "entity_id": "entity_id",
1038
+ "mol_type": "mol_type",
1039
+ "res_type": "res_type",
1040
+ "input_ids": "input_id",
1041
+ }
1042
+ return {
1043
+ output_name: torch.from_numpy(
1044
+ np.asarray([getattr(token, attribute) for token in tokens], dtype=np.int64)
1045
+ )
1046
+ for output_name, attribute in fields.items()
1047
+ }
1048
+
1049
+
1050
+ def _atom_tensors(atoms: list[AtomInfo]) -> dict[str, torch.Tensor]:
1051
+ n_atoms = len(atoms)
1052
+ ref_pos = np.zeros((n_atoms, 3), dtype=np.float32)
1053
+ ref_element = np.zeros(n_atoms, dtype=np.int64)
1054
+ ref_charge = np.zeros(n_atoms, dtype=np.int8)
1055
+ ref_name = np.zeros((n_atoms, 4), dtype=np.int64)
1056
+ ref_space = np.zeros(n_atoms, dtype=np.int64)
1057
+ atom_mask = np.zeros(n_atoms, dtype=np.bool_)
1058
+ atom_to_token = np.zeros(n_atoms, dtype=np.int64)
1059
+ positions = np.zeros((n_atoms, 3), dtype=np.float64)
1060
+ valid = np.zeros(n_atoms, dtype=np.bool_)
1061
+ for index, atom in enumerate(atoms):
1062
+ if atom.ref_pos is not None:
1063
+ ref_pos[index] = atom.ref_pos
1064
+ ref_charge[index] = atom.charge
1065
+ ref_space[index] = atom.space_uid if atom.space_uid >= 0 else atom.token_index
1066
+ atom_mask[index] = atom.is_valid
1067
+ valid[index] = atom.is_valid
1068
+ positions[index] = atom.pos
1069
+ if atom.is_valid:
1070
+ ref_element[index] = get_element_atomic_num(atom.element)
1071
+ ref_name[index] = encode_atom_name(atom.name)
1072
+ atom_to_token[index] = atom.token_index
1073
+
1074
+ resolved = valid & np.any(positions != 0, axis=1)
1075
+ X = torch.from_numpy(positions)
1076
+ resolved_mask = torch.from_numpy(resolved)
1077
+ valid_mask = torch.from_numpy(valid)
1078
+ if resolved_mask.any():
1079
+ X = X - X[resolved_mask].mean(dim=0, keepdim=True)
1080
+ X[~valid_mask] = 0.0
1081
+ return {
1082
+ "ref_pos": torch.from_numpy(ref_pos),
1083
+ "ref_element": torch.from_numpy(ref_element),
1084
+ "ref_charge": torch.from_numpy(ref_charge),
1085
+ "ref_atom_name_chars": torch.from_numpy(ref_name),
1086
+ "ref_space_uid": torch.from_numpy(ref_space),
1087
+ "gt_coords": X.float().unsqueeze(0),
1088
+ "atom_attention_mask": torch.from_numpy(atom_mask),
1089
+ "atom_to_token": torch.from_numpy(atom_to_token),
1090
+ "is_resolved": torch.tensor(resolved, dtype=torch.bool),
1091
+ }
1092
+
1093
+
1094
+ def build_feature_tensors(
1095
+ chains: list[ChainInfo],
1096
+ tokens: list[TokenInfo],
1097
+ atoms: list[AtomInfo],
1098
+ input: StructurePredictionInput,
1099
+ ) -> dict[str, torch.Tensor]:
1100
+ """Assemble the complete unbatched ESMFold2 feature dictionary."""
1101
+ token_features = _token_tensors(tokens)
1102
+ atom_features = _atom_tensors(_padded_atoms(atoms))
1103
+ frames, _ = compute_frame_indices(tokens, atoms)
1104
+ msa_features = compute_msa_features(input, chains, tokens)
1105
+ distogram, distogram_mask = compute_distogram_conditioning(
1106
+ input,
1107
+ chains,
1108
+ tokens,
1109
+ torch.zeros(len(tokens), 3, dtype=torch.float32),
1110
+ )
1111
+ return {
1112
+ **token_features,
1113
+ "token_bonds": compute_token_bonds(tokens, atoms, input, chains),
1114
+ "token_attention_mask": torch.ones(len(tokens), dtype=torch.bool),
1115
+ "pocket_feature": torch.zeros(len(tokens), dtype=torch.long),
1116
+ **atom_features,
1117
+ "distogram_atom_idx": compute_representative_atoms(tokens, atoms),
1118
+ "frames_idx": torch.from_numpy(frames).to(torch.int64),
1119
+ "disto_cond": distogram,
1120
+ "disto_cond_mask": distogram_mask,
1121
+ **msa_features,
1122
+ }
1123
+
1124
+
1125
+ def prepare_esmfold2_input(
1126
+ input: StructurePredictionInput, seed: int | None = None
1127
+ ) -> tuple[dict[str, torch.Tensor], list[ChainInfo]]:
1128
+ """Convert one typed request to model features and output-chain metadata."""
1129
+ chains, tokens, atoms = build_chains_from_input(input, seed)
1130
+ return build_feature_tensors(chains, tokens, atoms, input), chains
fastplms/models/esmfold2/esmfold2_processor.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Input preparation and output decoding for ESMFold2 inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import torch
12
+ from torch import Tensor
13
+
14
+ from .esmfold2_conformers import load_ccd
15
+ from .esmfold2_molecular_complex import MolecularComplexResult
16
+ from .esmfold2_output import build_molecular_complex_from_features
17
+ from .esmfold2_prepare_input import ChainInfo, prepare_esmfold2_input
18
+ from .esmfold2_types import MSA, Modification, ProteinInput, StructurePredictionInput
19
+ from .modeling_esmfold2_common import MSA_CONDITIONING_INPUT_NAMES
20
+ from .reproducibility import seed_context
21
+
22
+ # Backward-compatible private alias for the pinned parity helpers. New callers
23
+ # should import ``seed_context`` from the public ``fastplms.models.esmfold2``
24
+ # package instead of reaching into implementation modules.
25
+ _seed_context = seed_context
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class _SplitProteinState:
30
+ ids: dict[str, list[str]]
31
+ modifications: dict[str, list[Modification]]
32
+ msas: dict[str, MSA | None]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _PendingProtein:
37
+ source: ProteinInput
38
+ sequence: str
39
+ state: _SplitProteinState
40
+
41
+
42
+ def _chain_starts(chains: list[str]) -> list[int]:
43
+ starts: list[int] = []
44
+ position = 0
45
+ for chain in chains:
46
+ starts.append(position)
47
+ position += len(chain) + 1
48
+ return starts
49
+
50
+
51
+ def _split_modifications(
52
+ item: ProteinInput,
53
+ chains: list[str],
54
+ starts: list[int],
55
+ ) -> dict[str, list[Modification]]:
56
+ grouped: dict[str, list[Modification]] = {}
57
+ if item.modifications is None:
58
+ return grouped
59
+ for chain, start in zip(chains, starts, strict=True):
60
+ end = start + len(chain)
61
+ adjusted = [
62
+ Modification(position=modification.position - start, ccd=modification.ccd)
63
+ for modification in item.modifications
64
+ if start <= modification.position < end
65
+ ]
66
+ grouped.setdefault(chain, []).extend(adjusted)
67
+ return grouped
68
+
69
+
70
+ def _split_msas(
71
+ item: ProteinInput,
72
+ chains: list[str],
73
+ starts: list[int],
74
+ ) -> dict[str, MSA | None]:
75
+ grouped: dict[str, MSA | None] = {}
76
+ if item.msa is None:
77
+ return grouped
78
+ for chain, start in zip(chains, starts, strict=True):
79
+ if chain not in grouped:
80
+ grouped[chain] = item.msa.select_positions(np.arange(start, start + len(chain)))
81
+ return grouped
82
+
83
+
84
+ def _split_protein(item: ProteinInput) -> tuple[list[_PendingProtein], _SplitProteinState]:
85
+ chains = ":".join(item.sequence.split("|")).split(":")
86
+ starts = _chain_starts(chains)
87
+ base_id = item.id[0] if isinstance(item.id, list) else item.id
88
+ ids: dict[str, list[str]] = {}
89
+ for index, chain in enumerate(chains):
90
+ chain_ids = ids.setdefault(chain, [])
91
+ chain_ids.append(f"{base_id}_{index}")
92
+ state = _SplitProteinState(
93
+ ids=ids,
94
+ modifications=_split_modifications(item, chains, starts),
95
+ msas=_split_msas(item, chains, starts),
96
+ )
97
+ pending = [
98
+ _PendingProtein(item, chain, state)
99
+ for chain, chain_ids in ids.items()
100
+ if chain_ids
101
+ ]
102
+ return pending, state
103
+
104
+
105
+ def _resolve_pending(pending: _PendingProtein) -> ProteinInput:
106
+ item = pending.source
107
+ sequence = pending.sequence
108
+ state = pending.state
109
+ return ProteinInput(
110
+ id=state.ids[sequence],
111
+ sequence=sequence,
112
+ msa=state.msas.get(sequence) if item.msa else None,
113
+ modifications=(state.modifications.get(sequence) if item.modifications else None),
114
+ )
115
+
116
+
117
+ def clean_esmfold2_input(input: StructurePredictionInput) -> StructurePredictionInput:
118
+ """Expand chain delimiters and group repeated protein sequences by entity."""
119
+
120
+ if input.pocket is not None:
121
+ raise NotImplementedError(
122
+ "ESMFold2 pocket conditioning is present in the upstream input schema but "
123
+ "the published ESMFold2 feature pipeline drops it. FastPLMs refuses this "
124
+ "input instead of silently emitting an all-zero pocket feature."
125
+ )
126
+
127
+ cleaned: list[Any] = []
128
+ for item in input.sequences:
129
+ if not isinstance(item, ProteinInput):
130
+ cleaned.append(item)
131
+ continue
132
+ sequence = ":".join(item.sequence.split("|"))
133
+ if ":" not in sequence:
134
+ cleaned.append(item)
135
+ continue
136
+ if input.covalent_bonds is not None:
137
+ raise ValueError(
138
+ "Covalent bonds are not supported when using chainbreaks. "
139
+ "Chains must be separated into multiple ProteinInput objects."
140
+ )
141
+ pending, _state = _split_protein(item)
142
+ cleaned.extend(pending)
143
+
144
+ resolved = [
145
+ _resolve_pending(item) if isinstance(item, _PendingProtein) else item
146
+ for item in cleaned
147
+ ]
148
+ return StructurePredictionInput(
149
+ sequences=resolved,
150
+ pocket=input.pocket,
151
+ distogram_conditioning=input.distogram_conditioning,
152
+ covalent_bonds=input.covalent_bonds,
153
+ )
154
+
155
+
156
+ def _batch_features(
157
+ features: dict[str, Any],
158
+ device: torch.device | str | None,
159
+ ) -> dict[str, Any]:
160
+ return {
161
+ name: (value[None].to(device) if device is not None else value[None])
162
+ if isinstance(value, Tensor)
163
+ else value
164
+ for name, value in features.items()
165
+ }
166
+
167
+
168
+ def _sampler_overrides(
169
+ noise_scale: float | None,
170
+ step_scale: float | None,
171
+ max_inference_sigma: int | None,
172
+ ) -> dict[str, Any]:
173
+ values = {
174
+ "noise_scale": noise_scale,
175
+ "step_scale": step_scale,
176
+ "max_inference_sigma": max_inference_sigma,
177
+ }
178
+ return {name: value for name, value in values.items() if value is not None}
179
+
180
+
181
+ class ESMFold2InputBuilder:
182
+ """Prepare public input objects, run folding, and decode model tensors."""
183
+
184
+ def __init__(self, ccd_cache: Path | None = None) -> None:
185
+ load_ccd(ccd_cache)
186
+
187
+ def prepare_input(
188
+ self,
189
+ input: StructurePredictionInput,
190
+ seed: int | None = None,
191
+ device: torch.device | str | None = None,
192
+ ) -> tuple[dict[str, Any], list[ChainInfo]]:
193
+ cleaned = clean_esmfold2_input(input)
194
+ with seed_context(seed):
195
+ features, chain_infos = prepare_esmfold2_input(cleaned, seed=seed)
196
+ return _batch_features(features, device), chain_infos
197
+
198
+ def prepare_model_input(
199
+ self,
200
+ model: Any,
201
+ input: StructurePredictionInput,
202
+ seed: int | None = None,
203
+ device: torch.device | str | None = None,
204
+ ) -> tuple[dict[str, Any], list[ChainInfo]]:
205
+ """Prepare features while enforcing the checkpoint's MSA contract."""
206
+
207
+ msa_conditioning = getattr(model.config, "msa_conditioning", None)
208
+ if not isinstance(msa_conditioning, bool):
209
+ raise RuntimeError("The ESMFold2 config has no Boolean msa_conditioning contract.")
210
+ if not msa_conditioning:
211
+ explicit_msa_ids = [
212
+ item.id
213
+ for item in input.sequences
214
+ if isinstance(item, ProteinInput) and item.msa is not None
215
+ ]
216
+ if explicit_msa_ids:
217
+ raise ValueError(
218
+ "This ESMFold2 checkpoint was trained without MSA conditioning and "
219
+ f"rejects explicit MSAs for protein inputs {explicit_msa_ids!r}."
220
+ )
221
+ features, chain_infos = self.prepare_input(input, seed=seed, device=device)
222
+ if not msa_conditioning:
223
+ for name in MSA_CONDITIONING_INPUT_NAMES:
224
+ features.pop(name, None)
225
+ return features, chain_infos
226
+
227
+ def __call__(
228
+ self,
229
+ input: StructurePredictionInput,
230
+ seed: int | None = None,
231
+ device: torch.device | str | None = None,
232
+ ) -> tuple[dict[str, Any], list[ChainInfo]]:
233
+ return self.prepare_input(input, seed=seed, device=device)
234
+
235
+ def _decode_sample(
236
+ self,
237
+ output: Mapping[str, Tensor],
238
+ features: dict[str, Tensor],
239
+ chain_infos: list[ChainInfo],
240
+ sample: int,
241
+ complex_id: str,
242
+ ) -> MolecularComplexResult:
243
+ plddt = output["plddt"][sample]
244
+ molecular_complex = build_molecular_complex_from_features(
245
+ coords=output["sample_atom_coords"][sample],
246
+ plddt=plddt,
247
+ atom_mask=features["atom_attention_mask"][0],
248
+ ref_element=features["ref_element"][0],
249
+ ref_atom_name_chars=features["ref_atom_name_chars"][0],
250
+ chain_infos=chain_infos,
251
+ complex_id=complex_id,
252
+ )
253
+
254
+ def sample_tensor(name: str) -> Tensor | None:
255
+ value = output.get(name)
256
+ return None if value is None else value[sample].detach().cpu()
257
+
258
+ def shared_tensor(name: str) -> Tensor | None:
259
+ value = output.get(name)
260
+ return None if value is None else value[0].detach().cpu()
261
+
262
+ ptm = output.get("ptm")
263
+ iptm = output.get("iptm")
264
+ return MolecularComplexResult(
265
+ complex=molecular_complex,
266
+ plddt=plddt.detach().cpu(),
267
+ ptm=float(ptm[sample].item()) if ptm is not None else None,
268
+ iptm=float(iptm[sample].item()) if iptm is not None else None,
269
+ pae=sample_tensor("pae"),
270
+ distogram=shared_tensor("distogram_logits"),
271
+ pair_chains_iptm=sample_tensor("pair_chains_iptm"),
272
+ residue_index=shared_tensor("residue_index"),
273
+ entity_id=shared_tensor("entity_id"),
274
+ )
275
+
276
+ def decode(
277
+ self,
278
+ output: Mapping[str, Tensor],
279
+ features: dict[str, Tensor],
280
+ chain_infos: list[ChainInfo],
281
+ *,
282
+ num_diffusion_samples: int = 1,
283
+ complex_id: str = "pred",
284
+ ) -> MolecularComplexResult | list[MolecularComplexResult]:
285
+ results = [
286
+ self._decode_sample(output, features, chain_infos, sample, complex_id)
287
+ for sample in range(output["sample_atom_coords"].shape[0])
288
+ ]
289
+ return results[0] if num_diffusion_samples == 1 and len(results) == 1 else results
290
+
291
+ def fold(
292
+ self,
293
+ model: Any,
294
+ input: StructurePredictionInput,
295
+ *,
296
+ num_loops: int = 3,
297
+ num_sampling_steps: int = 200,
298
+ num_diffusion_samples: int = 1,
299
+ seed: int | None = None,
300
+ noise_scale: float | None = None,
301
+ step_scale: float | None = None,
302
+ max_inference_sigma: int | None = None,
303
+ early_exit: bool = False,
304
+ complex_id: str = "pred",
305
+ ) -> MolecularComplexResult | list[MolecularComplexResult]:
306
+ features, chain_infos = self.prepare_model_input(
307
+ model,
308
+ input,
309
+ seed=seed,
310
+ device=model.device,
311
+ )
312
+ overrides = _sampler_overrides(noise_scale, step_scale, max_inference_sigma)
313
+ with torch.no_grad(), seed_context(seed):
314
+ output = model(
315
+ **features,
316
+ num_loops=num_loops,
317
+ num_sampling_steps=num_sampling_steps,
318
+ num_diffusion_samples=num_diffusion_samples,
319
+ early_exit=early_exit,
320
+ return_dict=True,
321
+ **overrides,
322
+ )
323
+ return self.decode(
324
+ output,
325
+ features,
326
+ chain_infos,
327
+ num_diffusion_samples=num_diffusion_samples,
328
+ complex_id=complex_id,
329
+ )
330
+
331
+
332
+ __all__ = ["ESMFold2InputBuilder", "clean_esmfold2_input", "seed_context"]
fastplms/models/esmfold2/esmfold2_protein_chain.py ADDED
@@ -0,0 +1,1450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Protein-chain data, geometry, and serialization for ESMFold2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import warnings
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import asdict, dataclass, replace
9
+ from functools import cached_property
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import biotite.structure as bs
14
+ import brotli
15
+ import msgpack
16
+ import msgpack_numpy
17
+ import numpy as np
18
+ import torch
19
+ from biotite.database import rcsb
20
+ from biotite.structure.io.pdb import PDBFile
21
+ from biotite.structure.io.pdbx import CIFCategory, CIFColumn, CIFData, CIFFile
22
+ from biotite.structure.io.pdbx import set_structure as set_structure_pdbx
23
+ from scipy.spatial import ConvexHull, KDTree
24
+ from scipy.spatial.distance import cdist, pdist, squareform
25
+
26
+ from . import esmfold2_residue_constants as residue_constants
27
+ from .esmfold2_affine3d import Affine3D
28
+ from .esmfold2_aligner import Aligner
29
+ from .esmfold2_atom_indexer import AtomIndexer
30
+ from .esmfold2_metrics import compute_gdt_ts, compute_lddt_ca
31
+ from .esmfold2_misc import slice_python_object_as_numpy
32
+ from .esmfold2_mmcif_parsing import (
33
+ PLDDT_B_FACTOR_SCALE,
34
+ MmcifWrapper,
35
+ Residue,
36
+ round_mmcif_columns,
37
+ )
38
+ from .esmfold2_normalize_coordinates import (
39
+ apply_frame_to_coords,
40
+ get_protein_normalization_frame,
41
+ )
42
+ from .esmfold2_protein_structure import index_by_atom_name
43
+ from .esmfold2_utils_types import PathOrBuffer
44
+
45
+ CHAIN_ID_CONST = "A"
46
+
47
+
48
+ def _str_key_to_int_key(values: dict, ignore_keys: list[str] | None = None) -> dict:
49
+ """Restore integer dictionary keys after JSON-compatible serialization."""
50
+ ignored = frozenset(ignore_keys or ())
51
+ restored = {}
52
+ for key, value in values.items():
53
+ if isinstance(value, dict) and key not in ignored:
54
+ value = _str_key_to_int_key(value, ignore_keys=ignore_keys)
55
+ restored_key = int(key) if isinstance(key, str) and key.isdigit() else key
56
+ restored[restored_key] = value
57
+ return restored
58
+
59
+
60
+ def _num_non_null_residues(seqres_to_structure_chain: Mapping[int, Residue]) -> int:
61
+ return sum(residue.residue_number is not None for residue in seqres_to_structure_chain.values())
62
+
63
+
64
+ def infer_cb(
65
+ C,
66
+ N,
67
+ Ca,
68
+ bond_length: float = 1.522,
69
+ bond_angle: float = 1.927,
70
+ dihedral: float = -2.143,
71
+ ):
72
+ """Infer C-beta coordinates from C, N, and C-alpha coordinates."""
73
+
74
+ def normalize(X: np.ndarray) -> np.ndarray:
75
+ return X / np.sqrt(np.square(X).sum(-1, keepdims=True) + 1e-8)
76
+
77
+ with np.errstate(invalid="ignore"):
78
+ n_to_ca = N - Ca
79
+ n_to_c = N - C
80
+ axis = normalize(n_to_ca)
81
+ normal = normalize(np.cross(n_to_c, axis))
82
+ basis = (axis, np.cross(normal, axis), normal)
83
+ offsets = (
84
+ bond_length * np.cos(bond_angle),
85
+ bond_length * np.sin(bond_angle) * np.cos(dihedral),
86
+ -bond_length * np.sin(bond_angle) * np.sin(dihedral),
87
+ )
88
+ return Ca + sum(vector * offset for vector, offset in zip(basis, offsets, strict=False))
89
+
90
+
91
+ def chain_to_ndarray(
92
+ atom_array: bs.AtomArray, mmcif: MmcifWrapper, chain_id: str, is_predicted=False
93
+ ):
94
+ if not isinstance(atom_array, bs.AtomArray):
95
+ raise TypeError("atom_array must be a biotite AtomArray.")
96
+ if not isinstance(mmcif, MmcifWrapper):
97
+ raise TypeError("mmcif must be an MmcifWrapper.")
98
+ if not isinstance(chain_id, str) or not chain_id:
99
+ raise ValueError("chain_id must be a non-empty string.")
100
+ if chain_id not in mmcif.chain_to_seqres or chain_id not in mmcif.seqres_to_structure:
101
+ raise ValueError(f"mmCIF data does not contain sequence mappings for chain {chain_id!r}.")
102
+ entity_id = None
103
+ for entity, chains in mmcif.entities.items():
104
+ if chain_id in chains:
105
+ entity_id = entity
106
+ num_res = len(mmcif.chain_to_seqres[chain_id])
107
+ sequence = mmcif.chain_to_seqres[chain_id]
108
+
109
+ atom_positions = np.full([num_res, residue_constants.atom_type_num, 3], np.nan)
110
+ atom_mask = np.full([num_res, residue_constants.atom_type_num], False, dtype=bool)
111
+ residue_index = np.full([num_res], -1, dtype=np.int64)
112
+ insertion_code = np.full([num_res], "", dtype="<U4")
113
+
114
+ confidence = np.ones([num_res], dtype=np.float32)
115
+
116
+ chain = atom_array[atom_array.chain_id == chain_id]
117
+ if not isinstance(chain, bs.AtomArray):
118
+ raise RuntimeError("Biotite selection did not return an AtomArray.")
119
+ for res_index in range(num_res):
120
+ res_at_position = mmcif.seqres_to_structure[chain_id][res_index]
121
+
122
+ if res_at_position.residue_number is None:
123
+ continue
124
+
125
+ residue_index[res_index] = res_at_position.residue_number
126
+ insertion_code[res_index] = res_at_position.insertion_code
127
+ res = chain[
128
+ (chain.res_id == res_at_position.residue_number)
129
+ & (chain.ins_code == res_at_position.insertion_code)
130
+ & (chain.hetero == res_at_position.hetflag)
131
+ ]
132
+ if not isinstance(res, bs.AtomArray):
133
+ raise RuntimeError("Biotite residue selection did not return an AtomArray.")
134
+
135
+ # Atom level features
136
+ for atom in res:
137
+ atom_name = atom.atom_name
138
+ if atom_name == "SE" and atom.res_name == "MSE":
139
+ # Put the coords of the selenium atom in the sulphur column
140
+ atom_name = "SD"
141
+
142
+ if atom_name in residue_constants.atom_order:
143
+ atom_positions[res_index, residue_constants.atom_order[atom_name]] = atom.coord
144
+ atom_mask[res_index, residue_constants.atom_order[atom_name]] = True
145
+ if is_predicted and atom_name == "CA":
146
+ confidence[res_index] = atom.b_factor / PLDDT_B_FACTOR_SCALE
147
+
148
+ if not sequence or not all(sequence):
149
+ raise ValueError("Some residue name was not specified correctly.")
150
+ return (
151
+ sequence,
152
+ atom_positions,
153
+ atom_mask,
154
+ residue_index,
155
+ insertion_code,
156
+ confidence,
157
+ entity_id,
158
+ )
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class ProteinChain:
163
+ """Dataclass with atom37 representation of a single protein chain."""
164
+
165
+ id: str
166
+ sequence: str
167
+ chain_id: str # author chain id - mutable
168
+ entity_id: int | None
169
+ residue_index: np.ndarray
170
+ insertion_code: np.ndarray
171
+ atom37_positions: np.ndarray
172
+ atom37_mask: np.ndarray
173
+ confidence: np.ndarray
174
+ mmcif: MmcifWrapper | None = None
175
+ atom37_confidence: np.ndarray | None = None # P has shape (l, 37).
176
+
177
+ # Construction and parsing
178
+ @classmethod
179
+ def chain_iterable_from_mmcif(
180
+ cls,
181
+ path: PathOrBuffer | MmcifWrapper,
182
+ id: str | None = None,
183
+ is_predicted: bool = False,
184
+ keep_source: bool = False,
185
+ ):
186
+ """Yield every protein chain represented in an mmCIF structure."""
187
+ mmcif = path if isinstance(path, MmcifWrapper) else MmcifWrapper.read(path, id)
188
+ for chain in bs.chain_iter(mmcif.structure):
189
+ chain = chain[bs.filter_amino_acids(chain) & ~chain.hetero]
190
+ if len(chain) == 0:
191
+ continue
192
+ chain_id = chain.chain_id[0]
193
+ entity_id = None
194
+ for entity, chains in mmcif.entities.items():
195
+ if chain_id in chains:
196
+ entity_id = entity
197
+ if entity_id is None:
198
+ raise ValueError(
199
+ f"Failed to resolve entity identity for mmCIF chain {chain_id!r}."
200
+ )
201
+ (
202
+ sequence,
203
+ atom_positions,
204
+ atom_mask,
205
+ residue_index,
206
+ insertion_code,
207
+ confidence,
208
+ _,
209
+ ) = chain_to_ndarray(chain, mmcif, chain_id, is_predicted)
210
+ if not all(sequence):
211
+ raise ValueError("Some residue name was not specified correctly.")
212
+
213
+ yield cls(
214
+ id=mmcif.id,
215
+ sequence=sequence,
216
+ chain_id=chain_id,
217
+ entity_id=entity_id,
218
+ atom37_positions=atom_positions,
219
+ atom37_mask=atom_mask,
220
+ residue_index=residue_index,
221
+ insertion_code=insertion_code,
222
+ confidence=confidence,
223
+ mmcif=mmcif if keep_source else None,
224
+ )
225
+
226
+ @classmethod
227
+ def from_mmcif(
228
+ cls,
229
+ path: PathOrBuffer | MmcifWrapper,
230
+ chain_id: str | None = None,
231
+ entity_id: int | None = None,
232
+ id: str | None = None,
233
+ is_predicted: bool = False,
234
+ keep_source: bool = False,
235
+ ):
236
+ """Return a ProteinChain object from an mmcif file.
237
+
238
+ Args:
239
+ path: Uncompressed mmCIF path, buffer, or parsed wrapper.
240
+ id: Optional structure identifier when parsing a path or buffer.
241
+ is_predicted (bool): If True, reads b factor as the confidence readout. Default: False.
242
+ chain_id (str, optional): Select a chain corresponding to (author) chain id.
243
+ entity_id (int, optional): Select a chain corresponding to a particular entity.
244
+
245
+ If neither `chain_id` nor `entity_id` is specified, defaults to the first entity.
246
+ """
247
+ mmcif = path if isinstance(path, MmcifWrapper) else MmcifWrapper.read(path, id)
248
+
249
+ if chain_id is not None and entity_id is not None:
250
+ raise ValueError("Pass at most one of chain_id or entity_id.")
251
+
252
+ # If neither chain_id nor entity_id is specified, default to the first entity
253
+ if chain_id is None and entity_id is None:
254
+ if not mmcif.entities:
255
+ raise ValueError("Structure contains no entities")
256
+ entity_id = min(mmcif.entities.keys()) # Pick the first entity by ID
257
+
258
+ if entity_id is not None:
259
+ if entity_id not in mmcif.entities:
260
+ raise ValueError(
261
+ f"Structure does not contain entity `{entity_id}`. "
262
+ f"Valid entities: {mmcif.entities.keys()}"
263
+ )
264
+ chains = mmcif.entities[entity_id]
265
+
266
+ # Prefer the chain with the most resolved residues; ties preserve source order.
267
+ chain_id = max(
268
+ chains,
269
+ key=lambda chain: _num_non_null_residues(mmcif.seqres_to_structure[chain]),
270
+ )
271
+ else:
272
+ if chain_id is None:
273
+ raise RuntimeError("Failed to resolve an mmCIF chain selection.")
274
+ for entity, chains in mmcif.entities.items():
275
+ if chain_id in chains:
276
+ entity_id = entity
277
+ if entity_id is None:
278
+ warnings.warn(
279
+ "Failed to detect entity_id from mmcif file, it may be malformed.",
280
+ stacklevel=2,
281
+ )
282
+
283
+ atom_array = mmcif.structure
284
+ (
285
+ sequence,
286
+ atom_positions,
287
+ atom_mask,
288
+ residue_index,
289
+ insertion_code,
290
+ confidence,
291
+ _,
292
+ ) = chain_to_ndarray(atom_array, mmcif, chain_id, is_predicted)
293
+ if not all(sequence):
294
+ raise ValueError("Some residue name was not specified correctly.")
295
+
296
+ return cls(
297
+ id=mmcif.id,
298
+ sequence=sequence,
299
+ chain_id=chain_id,
300
+ entity_id=entity_id,
301
+ atom37_positions=atom_positions,
302
+ atom37_mask=atom_mask.astype(bool),
303
+ residue_index=residue_index,
304
+ insertion_code=insertion_code,
305
+ confidence=confidence,
306
+ mmcif=mmcif if keep_source else None,
307
+ )
308
+
309
+ @classmethod
310
+ def from_atom37(
311
+ cls,
312
+ atom37_positions: np.ndarray | torch.Tensor,
313
+ *,
314
+ id: str | None = None,
315
+ sequence: str | None = None,
316
+ chain_id: str | None = None,
317
+ entity_id: int | None = None,
318
+ residue_index: np.ndarray | torch.Tensor | None = None,
319
+ insertion_code: np.ndarray | None = None,
320
+ confidence: np.ndarray | torch.Tensor | None = None,
321
+ ):
322
+ if isinstance(atom37_positions, torch.Tensor):
323
+ atom37_positions = atom37_positions.cpu().numpy()
324
+ if atom37_positions.ndim == 4:
325
+ if atom37_positions.shape[0] != 1:
326
+ raise ValueError(
327
+ "Cannot handle batched inputs, atom37_positions has shape "
328
+ f"{atom37_positions.shape}"
329
+ )
330
+ atom37_positions = atom37_positions[0]
331
+
332
+ if not isinstance(atom37_positions, np.ndarray):
333
+ raise TypeError("atom37_positions must be a NumPy array or Torch tensor.")
334
+ if atom37_positions.ndim != 3 or atom37_positions.shape[1:] != (37, 3):
335
+ raise ValueError(
336
+ "atom37_positions must have shape (length, 37, 3), got "
337
+ f"{atom37_positions.shape}."
338
+ )
339
+ seqlen = atom37_positions.shape[0]
340
+
341
+ atom_mask = np.isfinite(atom37_positions).all(-1)
342
+
343
+ if id is None:
344
+ id = ""
345
+
346
+ if sequence is None:
347
+ sequence = "A" * seqlen
348
+
349
+ if chain_id is None:
350
+ chain_id = "A"
351
+
352
+ if residue_index is None:
353
+ residue_index = np.arange(1, seqlen + 1)
354
+ elif isinstance(residue_index, torch.Tensor):
355
+ residue_index = residue_index.cpu().numpy()
356
+ if residue_index.ndim == 2:
357
+ if residue_index.shape[0] != 1:
358
+ raise ValueError(
359
+ "Cannot handle batched inputs, residue_index has shape "
360
+ f"{residue_index.shape}"
361
+ )
362
+ residue_index = residue_index[0]
363
+ if not isinstance(residue_index, np.ndarray):
364
+ raise TypeError("residue_index must be a NumPy array or Torch tensor.")
365
+
366
+ if insertion_code is None:
367
+ insertion_code = np.array(["" for _ in range(seqlen)])
368
+
369
+ if confidence is None:
370
+ confidence = np.ones(seqlen, dtype=np.float32)
371
+ elif isinstance(confidence, torch.Tensor):
372
+ confidence = confidence.cpu().numpy()
373
+ if confidence.ndim == 2:
374
+ if confidence.shape[0] != 1:
375
+ raise ValueError(
376
+ f"Cannot handle batched inputs, confidence has shape {confidence.shape}"
377
+ )
378
+ confidence = confidence[0]
379
+ if not isinstance(confidence, np.ndarray):
380
+ raise TypeError("confidence must be a NumPy array or Torch tensor.")
381
+
382
+ return cls(
383
+ id=id,
384
+ sequence=sequence, # type: ignore
385
+ chain_id=chain_id,
386
+ entity_id=entity_id,
387
+ atom37_positions=atom37_positions,
388
+ atom37_mask=atom_mask.astype(bool),
389
+ residue_index=residue_index,
390
+ insertion_code=insertion_code,
391
+ confidence=confidence,
392
+ )
393
+
394
+ @classmethod
395
+ def from_backbone_atom_coordinates(
396
+ cls, backbone_atom_coordinates: np.ndarray | torch.Tensor, **kwargs
397
+ ):
398
+ """Create a ProteinChain from a set of backbone atom coordinates.
399
+
400
+ This function simply expands the seqlen x 3 x 3 array of backbone atom
401
+ coordinates to a seqlen x 37 x 3 array of all atom coordinates, with the padded
402
+ positions set to infinity. This allows us to use from_atom37 to create the
403
+ appropriate ProteinChain object with the appropriate atom37_mask.
404
+
405
+ This function passes all kwargs to from_atom37.
406
+ """
407
+ if isinstance(backbone_atom_coordinates, torch.Tensor):
408
+ backbone_atom_coordinates = backbone_atom_coordinates.cpu().numpy()
409
+ if backbone_atom_coordinates.ndim == 4:
410
+ if backbone_atom_coordinates.shape[0] != 1:
411
+ raise ValueError(
412
+ f"Cannot handle batched inputs, backbone_atom_coordinates has "
413
+ f"shape {backbone_atom_coordinates.shape}"
414
+ )
415
+ backbone_atom_coordinates = backbone_atom_coordinates[0]
416
+
417
+ if not isinstance(backbone_atom_coordinates, np.ndarray):
418
+ raise TypeError(
419
+ "backbone_atom_coordinates must be a NumPy array or Torch tensor."
420
+ )
421
+ if backbone_atom_coordinates.ndim != 3 or backbone_atom_coordinates.shape[-2:] != (
422
+ 3,
423
+ 3,
424
+ ):
425
+ raise ValueError(
426
+ "backbone_atom_coordinates must have shape (length, 3, 3), got "
427
+ f"{backbone_atom_coordinates.shape}."
428
+ )
429
+
430
+ atom37_positions = np.full(
431
+ (backbone_atom_coordinates.shape[0], 37, 3),
432
+ np.inf,
433
+ dtype=backbone_atom_coordinates.dtype,
434
+ )
435
+ atom37_positions[:, :3, :] = backbone_atom_coordinates
436
+
437
+ return cls.from_atom37(atom37_positions=atom37_positions, **kwargs)
438
+
439
+ @classmethod
440
+ def from_pdb(
441
+ cls,
442
+ path: PathOrBuffer,
443
+ chain_id: str = "detect",
444
+ id: str | None = None,
445
+ is_predicted: bool = False,
446
+ ) -> ProteinChain:
447
+ """Return a ProteinChain object from an pdb file. NOTE: prefer mmcif for rcsb PDB files.
448
+ This function is mostly to interface with old PDB files and predicted structures -
449
+ it will not fill out the entity id correctly
450
+
451
+ Args:
452
+ path: PDB path or text buffer.
453
+ id: Optional structure identifier.
454
+ is_predicted (bool): If True, reads b factor as the confidence readout. Default: False.
455
+ chain_id: Author chain identifier. ``"detect"`` selects the first chain.
456
+ """
457
+
458
+ if id is not None:
459
+ file_id = id
460
+ else:
461
+ match path:
462
+ case Path() | str():
463
+ file_id = Path(path).with_suffix("").name
464
+ case _:
465
+ file_id = "null"
466
+
467
+ atom_array = PDBFile.read(path).get_structure(model=1, extra_fields=["b_factor"])
468
+ if len(atom_array) == 0:
469
+ raise ValueError("PDB contains no atoms.")
470
+ if chain_id == "detect":
471
+ chain_id = atom_array.chain_id[0]
472
+ atom_array = atom_array[
473
+ bs.filter_amino_acids(atom_array)
474
+ & ~atom_array.hetero
475
+ & (atom_array.chain_id == chain_id)
476
+ ]
477
+ if len(atom_array) == 0:
478
+ raise ValueError(f"PDB contains no amino-acid atoms for chain {chain_id!r}.")
479
+
480
+ entity_id = 1 # Not supplied in PDBfiles
481
+
482
+ sequence = "".join(
483
+ residue_constants.restype_3to1.get(monomer[0].res_name, "X")
484
+ for monomer in bs.residue_iter(atom_array)
485
+ )
486
+ num_res = len(sequence)
487
+
488
+ atom_positions = np.full(
489
+ [num_res, residue_constants.atom_type_num, 3], np.nan, dtype=np.float32
490
+ )
491
+ atom_mask = np.full([num_res, residue_constants.atom_type_num], False, dtype=bool)
492
+ residue_index = np.full([num_res], -1, dtype=np.int64)
493
+ insertion_code = np.full([num_res], "", dtype="<U4")
494
+
495
+ confidence = np.ones([num_res], dtype=np.float32)
496
+
497
+ for i, res in enumerate(bs.residue_iter(atom_array)):
498
+ res_index = res[0].res_id
499
+ residue_index[i] = res_index
500
+ insertion_code[i] = res[0].ins_code
501
+
502
+ # Atom level features
503
+ for atom in res:
504
+ atom_name = atom.atom_name
505
+ if atom_name == "SE" and atom.res_name == "MSE":
506
+ # Put the coords of the selenium atom in the sulphur column
507
+ atom_name = "SD"
508
+
509
+ if atom_name in residue_constants.atom_order:
510
+ atom_positions[i, residue_constants.atom_order[atom_name]] = atom.coord
511
+ atom_mask[i, residue_constants.atom_order[atom_name]] = True
512
+ if is_predicted and atom_name == "CA":
513
+ confidence[i] = atom.b_factor / PLDDT_B_FACTOR_SCALE
514
+
515
+ if not sequence or not all(sequence):
516
+ raise ValueError("Some residue name was not specified correctly.")
517
+
518
+ return cls(
519
+ id=file_id,
520
+ sequence=sequence,
521
+ chain_id=chain_id,
522
+ entity_id=entity_id,
523
+ atom37_positions=atom_positions,
524
+ atom37_mask=atom_mask.astype(bool),
525
+ residue_index=residue_index,
526
+ insertion_code=insertion_code,
527
+ confidence=confidence,
528
+ mmcif=None,
529
+ )
530
+
531
+ @classmethod
532
+ def from_mds(cls, data: dict[str, Any]) -> ProteinChain:
533
+ return cls(
534
+ id=data["id"],
535
+ chain_id=data["chain_id"],
536
+ entity_id=data["entity_id"],
537
+ sequence=data["sequence"],
538
+ residue_index=data["residue_index"],
539
+ insertion_code=np.asarray(data["insertion_code"]),
540
+ atom37_positions=data["atom37_positions"],
541
+ atom37_mask=data["atom37_mask"].astype(bool),
542
+ confidence=data["confidence"],
543
+ mmcif=None,
544
+ )
545
+
546
+ @classmethod
547
+ def from_rcsb(
548
+ cls,
549
+ pdb_id: str,
550
+ chain_id: str | None = None,
551
+ entity_id: int | None = None,
552
+ keep_source: bool = False,
553
+ ) -> ProteinChain:
554
+ f: io.StringIO = rcsb.fetch(pdb_id, "cif") # type: ignore
555
+ return cls.from_mmcif(
556
+ f,
557
+ id=pdb_id,
558
+ chain_id=chain_id,
559
+ entity_id=entity_id,
560
+ keep_source=keep_source,
561
+ is_predicted=False,
562
+ )
563
+
564
+ @classmethod
565
+ def from_atomarray(
566
+ cls, atom_array: bs.AtomArray, id: str | None = None, is_predicted: bool = False
567
+ ) -> ProteinChain:
568
+ """A simple converter from bs.AtomArray -> ProteinChain.
569
+ Uses PDB file format as intermediate."""
570
+ atom_array = atom_array.copy()
571
+ atom_array.box = None # remove surrounding box, from_pdb won't handle this
572
+ pdb_file = PDBFile() # pyright: ignore
573
+ pdb_file.set_structure(atom_array)
574
+
575
+ buf = io.StringIO()
576
+ pdb_file.write(buf)
577
+ buf.seek(0)
578
+ return cls.from_pdb(buf, id=id, is_predicted=is_predicted)
579
+
580
+ # Object invariants and atom views
581
+ def __post_init__(self):
582
+ if not isinstance(self.id, str):
583
+ raise TypeError("id must be a string.")
584
+ if not isinstance(self.sequence, str):
585
+ raise TypeError("sequence must be a string.")
586
+ if not isinstance(self.chain_id, str) or not self.chain_id:
587
+ raise ValueError("chain_id must be a non-empty string.")
588
+ if self.entity_id is not None and (
589
+ not isinstance(self.entity_id, int) or isinstance(self.entity_id, bool)
590
+ ):
591
+ raise TypeError("entity_id must be an integer or None.")
592
+ sequence_length = len(self.sequence)
593
+ aligned = {
594
+ "atom37_positions": self.atom37_positions,
595
+ "atom37_mask": self.atom37_mask,
596
+ "residue_index": self.residue_index,
597
+ "insertion_code": self.insertion_code,
598
+ "confidence": self.confidence,
599
+ }
600
+ for name, values in aligned.items():
601
+ if not isinstance(values, np.ndarray):
602
+ raise TypeError(f"{name} must be a NumPy array, got {type(values).__name__}.")
603
+ if values.ndim == 0 or values.shape[0] != sequence_length:
604
+ raise ValueError(
605
+ f"{name} shape {values.shape} does not align with "
606
+ f"sequence length {sequence_length}."
607
+ )
608
+ if self.atom37_positions.shape != (sequence_length, 37, 3):
609
+ raise ValueError(
610
+ "atom37_positions must have shape "
611
+ f"({sequence_length}, 37, 3), got {self.atom37_positions.shape}."
612
+ )
613
+ if self.atom37_mask.shape != (sequence_length, 37):
614
+ raise ValueError(
615
+ "atom37_mask must have shape "
616
+ f"({sequence_length}, 37), got {self.atom37_mask.shape}."
617
+ )
618
+ if self.atom37_mask.dtype != bool:
619
+ raise TypeError(f"atom37_mask must have Boolean dtype, got {self.atom37_mask.dtype}.")
620
+ if not np.issubdtype(self.atom37_positions.dtype, np.number):
621
+ raise TypeError("atom37_positions must use a numeric dtype.")
622
+ if not np.issubdtype(self.residue_index.dtype, np.integer):
623
+ raise TypeError("residue_index must use an integer dtype.")
624
+ if self.insertion_code.dtype.kind not in {"U", "S", "O"}:
625
+ raise TypeError("insertion_code must use a string-compatible dtype.")
626
+ if any(not isinstance(value, str) for value in self.insertion_code.tolist()):
627
+ raise TypeError("insertion_code must contain only strings.")
628
+ for name, values in (
629
+ ("residue_index", self.residue_index),
630
+ ("insertion_code", self.insertion_code),
631
+ ("confidence", self.confidence),
632
+ ):
633
+ if values.shape != (sequence_length,):
634
+ raise ValueError(
635
+ f"{name} must have shape ({sequence_length},), got {values.shape}."
636
+ )
637
+ if not np.issubdtype(self.confidence.dtype, np.number):
638
+ raise TypeError("confidence must use a numeric dtype.")
639
+ atom37_confidence = self.atom37_confidence
640
+ if atom37_confidence is not None and not isinstance(atom37_confidence, np.ndarray):
641
+ raise TypeError("atom37_confidence must be a NumPy array when provided.")
642
+ if (
643
+ isinstance(atom37_confidence, np.ndarray)
644
+ and atom37_confidence.shape != self.atom37_mask.shape
645
+ ):
646
+ raise ValueError(
647
+ "atom37_confidence shape must match atom37_mask: "
648
+ f"{atom37_confidence.shape} != {self.atom37_mask.shape}."
649
+ )
650
+ if isinstance(atom37_confidence, np.ndarray) and not np.issubdtype(
651
+ atom37_confidence.dtype, np.number
652
+ ):
653
+ raise TypeError("atom37_confidence must use a numeric dtype.")
654
+
655
+ @cached_property
656
+ def atoms(self) -> AtomIndexer:
657
+ return AtomIndexer(self, property="atom37_positions", dim=-2)
658
+
659
+ @cached_property
660
+ def atom_mask(self) -> AtomIndexer:
661
+ return AtomIndexer(self, property="atom37_mask", dim=-1)
662
+
663
+ @cached_property
664
+ def atom_array(self) -> bs.AtomArray:
665
+ atoms = []
666
+ for res_idx_i, (
667
+ res_name,
668
+ res_idx,
669
+ ins_code,
670
+ positions,
671
+ mask,
672
+ conf,
673
+ ) in enumerate(
674
+ zip(
675
+ self.sequence,
676
+ self.residue_index,
677
+ self.insertion_code,
678
+ self.atom37_positions,
679
+ self.atom37_mask.astype(bool),
680
+ self.confidence,
681
+ strict=False,
682
+ )
683
+ ):
684
+ for i, pos in zip(np.where(mask)[0], positions[mask], strict=False):
685
+ b_factor = (
686
+ self.atom37_confidence[res_idx_i, i]
687
+ if self.atom37_confidence is not None
688
+ else conf
689
+ )
690
+ atom = bs.Atom(
691
+ coord=pos,
692
+ chain_id="A" if self.chain_id is None else self.chain_id,
693
+ res_id=res_idx,
694
+ ins_code=ins_code,
695
+ res_name=residue_constants.restype_1to3.get(res_name, "UNK"),
696
+ hetero=False,
697
+ atom_name=residue_constants.atom_types[i],
698
+ element=residue_constants.atom_types[i][0],
699
+ b_factor=float(b_factor) * PLDDT_B_FACTOR_SCALE,
700
+ occupancy=1.0,
701
+ )
702
+ atoms.append(atom)
703
+ return bs.array(atoms)
704
+
705
+ # Coordinate transformations and dataset adapters
706
+ def get_normalization_frame(self) -> Affine3D:
707
+ """Given a set of coordinates, compute a single frame.
708
+ The frame is built from the mean N, C-alpha, and C coordinates with
709
+ Gram-Schmidt orthogonalization. Its origin is the mean C-alpha position.
710
+
711
+ Returns:
712
+ Affine3D: [] tensor of Affine3D frame
713
+ """
714
+ coords = torch.from_numpy(self.atom37_positions)
715
+ frame = get_protein_normalization_frame(coords)
716
+
717
+ return frame
718
+
719
+ def apply_frame(self, frame: Affine3D) -> ProteinChain:
720
+ """Given a frame, apply the frame to the protein's coordinates.
721
+
722
+ Args:
723
+ frame (Affine3D): [] tensor of Affine3D frame
724
+
725
+ Returns:
726
+ ProteinChain: Transformed protein chain
727
+ """
728
+ coords = torch.from_numpy(self.atom37_positions).to(frame.trans.dtype)
729
+ coords = apply_frame_to_coords(coords, frame)
730
+ atom37_positions = coords.numpy()
731
+ return replace(self, atom37_positions=atom37_positions)
732
+
733
+ def normalize_coordinates(self) -> ProteinChain:
734
+ """Normalize the coordinates of the protein chain."""
735
+ return self.apply_frame(self.get_normalization_frame())
736
+
737
+ def infer_oxygen(self) -> ProteinChain:
738
+ """Oxygen position is fixed given N, CA, C atoms. Infer it if not provided."""
739
+ O_missing_indices = np.argwhere(~np.isfinite(self.atoms["O"]).all(axis=1)).squeeze()
740
+
741
+ O_vector = torch.tensor([0.6240, -1.0613, 0.0103], dtype=torch.float32)
742
+ N, CA, C = torch.from_numpy(self.atoms[["N", "CA", "C"]]).float().unbind(dim=1)
743
+ N = torch.roll(N, -3)
744
+ N[..., -1, :] = torch.nan
745
+
746
+ # Get the frame defined by the CA-C-N atom
747
+ frames = Affine3D.from_graham_schmidt(CA, C, N)
748
+ oxygen_coordinates = frames.apply(O_vector)
749
+ atom37_positions = self.atom37_positions.copy()
750
+ atom37_mask = self.atom37_mask.copy()
751
+
752
+ atom37_positions[O_missing_indices, residue_constants.atom_order["O"]] = oxygen_coordinates[
753
+ O_missing_indices
754
+ ].numpy()
755
+ atom37_mask[O_missing_indices, residue_constants.atom_order["O"]] = ~np.isnan(
756
+ atom37_positions[O_missing_indices, residue_constants.atom_order["O"]]
757
+ ).any(-1)
758
+ new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask)
759
+ return new_chain
760
+
761
+ @cached_property
762
+ def inferred_cbeta(self) -> np.ndarray:
763
+ """Infer cbeta positions based on N, C, CA."""
764
+ N, CA, C = np.moveaxis(self.atoms[["N", "CA", "C"]], 1, 0)
765
+ # See usage in trDesign codebase.
766
+ # https://github.com/gjoni/trDesign/blob/f2d5930b472e77bfacc2f437b3966e7a708a8d37/02-GD/utils.py#L140
767
+ CB = infer_cb(C, N, CA, 1.522, 1.927, -2.143)
768
+ return CB
769
+
770
+ def infer_cbeta(self, infer_cbeta_for_glycine: bool = False) -> ProteinChain:
771
+ """Return a new chain with inferred CB atoms at all residues except GLY.
772
+
773
+ Args:
774
+ infer_cbeta_for_glycine (bool): If True, infers a beta carbon for glycine
775
+ residues, even though that residue doesn't have one. Default off.
776
+
777
+ NOTE(rverkuil): The reason for having this switch in the first place
778
+ is that sometimes we want a (inferred) CB coordinate for every residue,
779
+ for example for making a pairwise distance matrix, or doing an RMSD
780
+ calculation between two designs for a given structural template, w/
781
+ CB atoms.
782
+ """
783
+ atom37_positions = self.atom37_positions.copy()
784
+ atom37_mask = self.atom37_mask.copy()
785
+
786
+ inferred_cbeta_positions = self.inferred_cbeta
787
+ if not infer_cbeta_for_glycine:
788
+ inferred_cbeta_positions[np.array(list(self.sequence)) == "G", :] = np.nan
789
+
790
+ atom37_positions[:, residue_constants.atom_order["CB"]] = inferred_cbeta_positions
791
+ atom37_mask[:, residue_constants.atom_order["CB"]] = ~np.isnan(
792
+ atom37_positions[:, residue_constants.atom_order["CB"]]
793
+ ).any(-1)
794
+ new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask)
795
+ return new_chain
796
+
797
+ @cached_property
798
+ def pdist_CA(self) -> np.ndarray:
799
+ CA = self.atoms["CA"]
800
+ pdist_CA = squareform(pdist(CA))
801
+ return pdist_CA
802
+
803
+ @cached_property
804
+ def pdist_CB(self) -> np.ndarray:
805
+ pdist_CB = squareform(pdist(self.inferred_cbeta))
806
+ return pdist_CB
807
+
808
+ @classmethod
809
+ def as_complex(cls, chains: Sequence[ProteinChain]):
810
+ raise RuntimeError(
811
+ ".as_complex() has been deprecated in favor of .concat(). "
812
+ ".concat() will eventually be deprecated in favor of ProteinComplex..."
813
+ )
814
+
815
+ @classmethod
816
+ def concat(cls, chains: Sequence[ProteinChain], use_chainbreak: bool = True):
817
+ if not chains:
818
+ raise ValueError("chains must contain at least one ProteinChain.")
819
+ if any(not isinstance(chain, ProteinChain) for chain in chains):
820
+ raise TypeError("chains must contain only ProteinChain instances.")
821
+ sep_tokens = {
822
+ "residue_index": np.array([-1]),
823
+ "insertion_code": np.array([""]),
824
+ "atom37_positions": np.full([1, 37, 3], np.inf),
825
+ "atom37_mask": np.zeros([1, 37], dtype=bool),
826
+ "confidence": np.array([0]),
827
+ }
828
+
829
+ def join_arrays(arrays: Sequence[np.ndarray], sep: np.ndarray):
830
+ if use_chainbreak:
831
+ full_array = []
832
+ for array in arrays:
833
+ full_array.append(array)
834
+ full_array.append(sep)
835
+ full_array = full_array[:-1]
836
+ return np.concatenate(full_array, 0)
837
+ else:
838
+ return np.concatenate(arrays, 0)
839
+
840
+ array_args: dict[str, np.ndarray] = {
841
+ name: join_arrays([getattr(chain, name) for chain in chains], sep)
842
+ for name, sep in sep_tokens.items()
843
+ }
844
+
845
+ chain_break = residue_constants.CHAIN_BREAK_TOKEN if use_chainbreak else ""
846
+ return cls(
847
+ id=chains[0].id,
848
+ sequence=chain_break.join(chain.sequence for chain in chains),
849
+ chain_id="A",
850
+ entity_id=None,
851
+ mmcif=None,
852
+ **array_args,
853
+ )
854
+
855
+ def find_nonpolymer_contacts(self):
856
+ if self.mmcif is None:
857
+ raise ValueError(
858
+ "find_nonpolymer_contacts requires a chain loaded with keep_source=True."
859
+ )
860
+ nonpolymer_and_chain_id_to_array = self.mmcif.non_polymer_coords
861
+
862
+ results = []
863
+ for (
864
+ nonpolymer,
865
+ _,
866
+ ), nonpolymer_array in nonpolymer_and_chain_id_to_array.items():
867
+ if nonpolymer_array.coord is None:
868
+ raise ValueError(
869
+ f"Non-polymer {nonpolymer.comp_id!r} has no coordinate table."
870
+ )
871
+ chain_coords = self.atom37_positions[self.atom37_mask]
872
+ distance = cdist(nonpolymer_array.coord, chain_coords)
873
+
874
+ is_contact = distance < 5
875
+ if not is_contact.any():
876
+ continue
877
+ contacting_atoms = np.where(is_contact.any(0))[0]
878
+ chain_index = np.where(self.atom37_mask)[0]
879
+ contacting_residues = np.unique(chain_index[contacting_atoms])
880
+
881
+ result = {
882
+ "ligand": nonpolymer.name,
883
+ "ligand_id": nonpolymer.comp_id,
884
+ "contacting_residues": contacting_residues.tolist(),
885
+ }
886
+ results.append(result)
887
+ return results
888
+
889
+ def select_residue_indices(
890
+ self, indices: list[int | str], ignore_x_mismatch: bool = False
891
+ ) -> ProteinChain:
892
+ numeric_indices = [idx if isinstance(idx, int) else int(idx[1:]) for idx in indices]
893
+ mask = np.isin(self.residue_index, numeric_indices)
894
+ new = self[mask]
895
+ mismatches = []
896
+ for aa, idx in zip(new.sequence, indices, strict=False):
897
+ if isinstance(idx, int):
898
+ continue
899
+ if aa == "X" and ignore_x_mismatch:
900
+ continue
901
+ if aa != idx[0]:
902
+ mismatches.append((aa, idx))
903
+ if mismatches:
904
+ mismatch_str = "; ".join(
905
+ f"Position {idx[1:]}, Expected: {idx[0]}, Received: {aa}" for aa, idx in mismatches
906
+ )
907
+ raise RuntimeError(mismatch_str)
908
+
909
+ return new
910
+
911
+ def to_structure_encoder_inputs(
912
+ self,
913
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
914
+ """Convert protein chain to structure encoder inputs.
915
+
916
+ Returns:
917
+ tuple: (coordinates, plddt, residue_index) where:
918
+ - coordinates: X with shape (1, l, 37, 3), containing atom positions
919
+ - plddt: P with shape (1, l), containing confidence scores
920
+ - residue_index: R with shape (1, l), containing residue indices
921
+ """
922
+ # Convert to tensors and add batch dimension
923
+ coordinates = (
924
+ torch.from_numpy(self.atom37_positions).float().unsqueeze(0)
925
+ ) # X has shape (1, l, 37, 3).
926
+ plddt = torch.from_numpy(self.confidence).float().unsqueeze(0) # P: (1, l)
927
+ residue_index = (
928
+ torch.from_numpy(self.residue_index).long().unsqueeze(0)
929
+ ) # R has shape (1, l).
930
+
931
+ return coordinates, plddt, residue_index
932
+
933
+ # Sequence access, interchange, and compact storage
934
+ def __getitem__(self, idx: int | list[int] | slice | np.ndarray | torch.Tensor):
935
+ if isinstance(idx, int):
936
+ idx = [idx]
937
+ if isinstance(idx, torch.Tensor):
938
+ idx = idx.cpu().numpy()
939
+
940
+ sequence = slice_python_object_as_numpy(self.sequence, idx)
941
+ return replace(
942
+ self,
943
+ sequence=sequence,
944
+ residue_index=self.residue_index[..., idx],
945
+ insertion_code=self.insertion_code[..., idx],
946
+ atom37_positions=self.atom37_positions[..., idx, :, :],
947
+ atom37_mask=self.atom37_mask[..., idx, :],
948
+ confidence=self.confidence[..., idx],
949
+ atom37_confidence=self.atom37_confidence[..., idx, :]
950
+ if self.atom37_confidence is not None
951
+ else None,
952
+ )
953
+
954
+ def __len__(self):
955
+ return len(self.sequence)
956
+
957
+ def cbeta_contacts(self, distance_threshold: float = 8.0) -> np.ndarray:
958
+ distance = self.pdist_CB
959
+ contacts = (distance < distance_threshold).astype(np.int64)
960
+ contacts[np.isnan(distance)] = -1
961
+ np.fill_diagonal(contacts, -1)
962
+ return contacts
963
+
964
+ def to_pdb(self, path: PathOrBuffer, include_insertions: bool = True):
965
+ """Dssp works better w/o insertions."""
966
+ f = PDBFile()
967
+ if not include_insertions:
968
+ f.set_structure(self.atom_array_no_insertions)
969
+ else:
970
+ f.set_structure(self.atom_array)
971
+ f.write(path)
972
+
973
+ def to_pdb_string(self, include_insertions: bool = True) -> str:
974
+ buf = io.StringIO()
975
+ self.to_pdb(buf, include_insertions=include_insertions)
976
+ buf.seek(0)
977
+ return buf.read()
978
+
979
+ def to_mmcif(self, path: PathOrBuffer):
980
+ f = CIFFile()
981
+ set_structure_pdbx(f, self.atom_array, data_block=self.id)
982
+
983
+ # incantations molstar needs to render pLDDT / confidence onto
984
+ # the structure with "alphafold-view"
985
+ f.block["ma_qa_metric"] = CIFCategory(
986
+ name="ma_qa_metric",
987
+ columns={
988
+ "id": CIFColumn(data=CIFData(array=np.array([1, 2]), dtype=np.int64)),
989
+ "mode": CIFColumn(data=CIFData(array=np.array(["global", "local"]), dtype=np.str_)),
990
+ "name": CIFColumn(data=CIFData(array=np.array(["pLDDT", "pLDDT"]), dtype=np.str_)),
991
+ },
992
+ )
993
+
994
+ # table is a duplicate of data already in the atom array, but
995
+ # needed by molstar to render pLDDT / confidence
996
+ resid_pldd_table = {
997
+ # hard coded to as we currently only support single chain structures
998
+ "label_asym_id": CIFColumn(
999
+ data=CIFData(array=[CHAIN_ID_CONST] * len(self.residue_index), dtype=np.str_)
1000
+ ),
1001
+ "label_comp_id": CIFColumn(
1002
+ data=CIFData(
1003
+ array=[residue_constants.restype_1to3.get(c, "UNK") for c in self.sequence],
1004
+ dtype=np.str_,
1005
+ )
1006
+ ),
1007
+ "label_seq_id": CIFColumn(data=CIFData(array=self.residue_index, dtype=np.int64)),
1008
+ "ordinal_id": CIFColumn(data=CIFData(array=self.residue_index, dtype=np.int64)),
1009
+ # hard coded to show these are all local plDDT values
1010
+ "metric_id": CIFColumn(
1011
+ data=CIFData(array=["2"] * len(self.residue_index), dtype=np.str_)
1012
+ ),
1013
+ "metric_value": CIFColumn(
1014
+ data=CIFData(
1015
+ array=self.confidence * PLDDT_B_FACTOR_SCALE,
1016
+ dtype=np.float32,
1017
+ )
1018
+ ),
1019
+ # hard coded to show there are the initial version, there are no revisions
1020
+ "model_id": CIFColumn(
1021
+ data=CIFData(array=["1"] * len(self.residue_index), dtype=np.str_)
1022
+ ),
1023
+ }
1024
+ f.block["ma_qa_metric_local"] = CIFCategory(
1025
+ name="ma_qa_metric_local", columns=resid_pldd_table
1026
+ )
1027
+ round_mmcif_columns(f)
1028
+ f.write(path)
1029
+
1030
+ def to_mmcif_string(self) -> str:
1031
+ buf = io.StringIO()
1032
+ self.to_mmcif(buf)
1033
+ buf.seek(0)
1034
+ return buf.read()
1035
+
1036
+ def state_dict(self, backbone_only=False, json_serializable=False):
1037
+ """This state dict is optimized for storage, so it turns things to fp16 whenever
1038
+ possible. Note that we also only support int32 residue indices, I'm hoping we don't
1039
+ need more than 2**32 residues..."""
1040
+ dct = {k: v for k, v in asdict(self).items() if k not in ["mmcif"]}
1041
+ if backbone_only:
1042
+ dct["atom37_mask"][:, 3:] = False
1043
+ dct["atom37_positions"] = dct["atom37_positions"][dct["atom37_mask"]]
1044
+ if dct.get("atom37_confidence") is not None:
1045
+ dct["atom37_confidence"] = dct["atom37_confidence"][dct["atom37_mask"]]
1046
+ else:
1047
+ dct.pop("atom37_confidence", None)
1048
+
1049
+ for k, v in dct.items():
1050
+ if isinstance(v, np.ndarray):
1051
+ match v.dtype:
1052
+ case np.int64:
1053
+ dct[k] = v.astype(np.int32)
1054
+ case np.float64 | np.float32:
1055
+ dct[k] = v.astype(np.float16)
1056
+ case _:
1057
+ pass
1058
+ if json_serializable:
1059
+ dct[k] = v.tolist()
1060
+ return dct
1061
+
1062
+ def to_blob(self, backbone_only=False) -> bytes:
1063
+ payload = msgpack.dumps(self.state_dict(backbone_only), default=msgpack_numpy.encode)
1064
+ return brotli.compress(payload, quality=5)
1065
+
1066
+ @classmethod
1067
+ def from_open_source(cls, pc: ProteinChain):
1068
+ return cls(**vars(pc))
1069
+
1070
+ @classmethod
1071
+ def from_state_dict(cls, dct):
1072
+ # Note: assembly_composition is *supposed* to have string keys.
1073
+ dct = _str_key_to_int_key(dct, ignore_keys=["assembly_composition"])
1074
+
1075
+ for k, v in dct.items():
1076
+ if isinstance(v, list):
1077
+ dct[k] = np.array(v)
1078
+
1079
+ atom37 = np.full((*dct["atom37_mask"].shape, 3), np.nan)
1080
+ atom37[dct["atom37_mask"]] = dct["atom37_positions"]
1081
+ dct["atom37_positions"] = atom37
1082
+ if "atom37_confidence" in dct:
1083
+ atom37_conf = np.full(dct["atom37_mask"].shape, np.nan, dtype=np.float32)
1084
+ atom37_conf[dct["atom37_mask"]] = dct["atom37_confidence"]
1085
+ dct["atom37_confidence"] = atom37_conf
1086
+ dct = {
1087
+ k: (
1088
+ v.astype(np.float32)
1089
+ if k in ["atom37_positions", "confidence", "atom37_confidence"]
1090
+ else v
1091
+ )
1092
+ for k, v in dct.items()
1093
+ if not (k == "atom37_confidence" and v is None)
1094
+ }
1095
+ return cls(**dct, mmcif=None)
1096
+
1097
+ @classmethod
1098
+ def from_blob(cls, input: Path | str | io.BytesIO | bytes):
1099
+ """NOTE(@zlin): blob + sparse coding + brotli + fp16 reduces memory
1100
+ of chains from 52G/1M chains to 20G/1M chains, I think this is a good first
1101
+ shot at compressing and dumping chains to disk. I'm sure there's better ways."""
1102
+ match input:
1103
+ case Path() | str():
1104
+ bytes = Path(input).read_bytes()
1105
+ case io.BytesIO():
1106
+ bytes = input.getvalue()
1107
+ case _:
1108
+ bytes = input
1109
+ state = msgpack.loads(brotli.decompress(bytes), object_hook=msgpack_numpy.decode)
1110
+ return cls.from_state_dict(state)
1111
+
1112
+ # Surface and structural comparison metrics
1113
+ def sasa(self, by_residue: bool = True):
1114
+ arr = self.atom_array_no_insertions
1115
+ if len(arr) == 0:
1116
+ raise ValueError("SASA requires at least one resolved atom.")
1117
+ sasa_per_atom = bs.sasa(arr) # type: ignore
1118
+ if by_residue:
1119
+ # Sum per-atom SASA into residue "bins", with np.bincount.
1120
+ if arr.res_id is None:
1121
+ raise RuntimeError("Biotite AtomArray is missing residue identifiers.")
1122
+ # Residue IDs are one-indexed, so discard the unused zero bin.
1123
+ # NOTE(aderry): We compute only for residues with coordinates, return NaN otherwise.
1124
+ num_trailing_residues = len(self) - arr.res_id.max()
1125
+ sasa_per_residue = np.concatenate(
1126
+ [
1127
+ np.bincount(arr.res_id, weights=sasa_per_atom)[1:],
1128
+ np.zeros(num_trailing_residues),
1129
+ ]
1130
+ )
1131
+ sasa_per_residue[~self.atom37_mask.any(-1)] = np.nan
1132
+ if len(sasa_per_residue) != len(self):
1133
+ raise RuntimeError("Residue SASA output does not align with the protein chain.")
1134
+ return sasa_per_residue
1135
+ return sasa_per_atom
1136
+
1137
+ def sap_score(self, aggregation: str = "atom") -> np.ndarray:
1138
+ """Compute per-atom spatial aggregation propensity (SAP).
1139
+
1140
+ Residue aggregation averages resolved atoms and omits unresolved residues.
1141
+ Protein aggregation sums positive atom scores, following Lauer et al. 2011.
1142
+ """
1143
+ sap_radius = 5.0
1144
+ arr = self.atom_array_no_insertions
1145
+ if len(arr) == 0:
1146
+ raise ValueError("SAP requires at least one resolved atom.")
1147
+
1148
+ for name in ("res_id", "res_name", "atom_name", "coord"):
1149
+ if getattr(arr, name) is None:
1150
+ raise RuntimeError(f"Biotite AtomArray is missing required {name!r} data.")
1151
+
1152
+ # compute SASA and residue-specific properties
1153
+ sasa_per_atom = self.sasa(by_residue=False)
1154
+ resid_to_resname = dict(zip(arr.res_id, arr.res_name, strict=False))
1155
+
1156
+ max_side_chain_asa = np.full(len(self), np.nan)
1157
+ res_hydrophobicity = np.full(len(self), np.nan)
1158
+ resolved_res_mask = self.atom37_mask.any(-1)
1159
+ num_trailing_residues = len(self) - arr.res_id.max()
1160
+
1161
+ max_side_chain_asa[resolved_res_mask] = np.array(
1162
+ [residue_constants.side_chain_asa[resid_to_resname[i]] for i in np.unique(arr.res_id)]
1163
+ )
1164
+ res_hydrophobicity[resolved_res_mask] = np.array(
1165
+ [residue_constants.hydrophobicity[resid_to_resname[i]] for i in np.unique(arr.res_id)]
1166
+ )
1167
+
1168
+ # compute SAP score
1169
+ is_side_chain = ~bs.filter_peptide_backbone(arr)
1170
+ sasa_per_atom[is_side_chain] = 0
1171
+ kdtree = KDTree(arr.coord)
1172
+ neighbors = kdtree.query_ball_tree(kdtree, sap_radius, p=2.0)
1173
+ sap_by_atom = np.zeros_like(sasa_per_atom)
1174
+ for i, nn_list in enumerate(neighbors):
1175
+ saa_nn = np.zeros_like(sasa_per_atom)
1176
+ saa_nn[nn_list] = sasa_per_atom[nn_list]
1177
+ sasa_within_r = np.concatenate(
1178
+ [
1179
+ np.bincount(arr.res_id, weights=saa_nn)[1:],
1180
+ np.zeros(num_trailing_residues),
1181
+ ]
1182
+ )
1183
+ sap = np.nansum((sasa_within_r / max_side_chain_asa) * res_hydrophobicity)
1184
+ sap_by_atom[i] = sap
1185
+
1186
+ match aggregation:
1187
+ case "atom":
1188
+ return sap_by_atom
1189
+ case "residue":
1190
+ sap_by_residue = np.concatenate(
1191
+ [
1192
+ np.bincount(arr.res_id, weights=sap_by_atom)[1:],
1193
+ np.zeros(num_trailing_residues),
1194
+ ]
1195
+ ) / (
1196
+ np.concatenate([np.bincount(arr.res_id)[1:], np.zeros(num_trailing_residues)])
1197
+ + 1e-8
1198
+ )
1199
+ sap_by_residue[~resolved_res_mask] = np.nan
1200
+ if len(sap_by_residue) != len(self):
1201
+ raise RuntimeError("Residue SAP output does not align with the protein chain.")
1202
+ return sap_by_residue
1203
+ case "protein":
1204
+ return sum(sap_by_atom[sap_by_atom > 0]) # pyright: ignore[reportReturnType]
1205
+ case _:
1206
+ raise ValueError(
1207
+ f"Invalid aggregation method: {aggregation}. Must be one of "
1208
+ "'atom', 'residue', or 'protein'"
1209
+ )
1210
+
1211
+ def globularity(self) -> float:
1212
+ # Computes globularity using total volumes divided by MVEE.
1213
+ # We make the simplifying approximation that atoms never overlap.
1214
+ # The globularity is only computed where structure exists.
1215
+ # Besides the approximation above, this is inspired by:
1216
+
1217
+ # https://www.mdpi.com/2073-4352/11/12/1539
1218
+ # The non-overlapping-atom approximation can produce globularity above one.
1219
+ mask = self.atom37_mask.any(-1)
1220
+ points = self.atom37_positions[self.atom37_mask]
1221
+ sequence = [aa for aa, m in zip(self.sequence, mask, strict=False) if m] # type: ignore
1222
+ A, _ = self._mvee(points, tol=1e-3)
1223
+ mvee_volume = (4 * np.pi) / (3 * np.sqrt(np.linalg.det(A)))
1224
+ volume = sum(residue_constants.amino_acid_volumes[x] for x in sequence)
1225
+ ratio = volume / mvee_volume
1226
+
1227
+ # The paper compares the ellipsoidal profile with scalar t, a measurement
1228
+ # of elongation. We want a single number, so we multiply by 1/(2t), so
1229
+ # that value is normalized between 0-1
1230
+ eigenvalues = np.linalg.eigvals(A)
1231
+ R = 1 / np.sqrt(eigenvalues)
1232
+ # ellipsoid radii length triangle inequality coefficient
1233
+ t = max(R[0] / (R[1] + R[2]), R[1] / (R[0] + R[2]), R[2] / (R[0] + R[1]))
1234
+ elongation_metric = 1 / max(t, 1)
1235
+ return ratio * elongation_metric
1236
+
1237
+ @staticmethod
1238
+ def _mvee(P: np.ndarray, tol, max_iter=10000):
1239
+ # Finds minimum volume enclosing ellipsoid of a set of points.
1240
+ # Returns A, c where the ellipse is defined as:
1241
+ # (x-c).T @ A @ (x-c) = 1
1242
+ hull = ConvexHull(P)
1243
+ P = P[hull.vertices]
1244
+ P = P.T
1245
+
1246
+ # Data points
1247
+ d, n = P.shape
1248
+ Q = np.zeros((d + 1, n))
1249
+ Q[:d, :] = P[:d, :n]
1250
+ Q[d, :] = np.ones((1, n))
1251
+
1252
+ # Initializations
1253
+ count = 1
1254
+ err = 1.0
1255
+ u = np.full((n, 1), 1 / n) # First iteration.
1256
+
1257
+ # Khachiyan Algorithm
1258
+ for _ in range(max_iter):
1259
+ X = Q.dot(np.diag(u.squeeze())) @ Q.T
1260
+ M = np.diag(Q.T @ np.linalg.inv(X) @ Q)
1261
+ maximum, j = np.max(M), np.argmax(M)
1262
+ step_size = (maximum - d - 1) / ((d + 1) * (maximum - 1))
1263
+ new_u = (1 - step_size) * u
1264
+ new_u[j] += step_size
1265
+ count += 1
1266
+ err = np.linalg.norm(new_u - u)
1267
+ u = new_u
1268
+ if err < tol:
1269
+ break
1270
+ else:
1271
+ raise ValueError("MVEE did not converge")
1272
+
1273
+ d = P.shape[0] # Fixed: use P.shape[0] instead of P.shape
1274
+ U = np.diag(u.squeeze())
1275
+
1276
+ # The A matrix for the ellipse
1277
+ A = (1 / d) * np.linalg.inv(P @ U @ P.T - (P @ u) @ (P @ u).T)
1278
+
1279
+ # Center of the ellipse
1280
+ c = P @ u
1281
+
1282
+ return A, c
1283
+
1284
+ def radius_of_gyration(self):
1285
+ arr = self.atom_array_no_insertions
1286
+ return bs.gyration_radius(arr)
1287
+
1288
+ def align(
1289
+ self,
1290
+ target: ProteinChain,
1291
+ mobile_inds: list[int] | np.ndarray | None = None,
1292
+ target_inds: list[int] | np.ndarray | None = None,
1293
+ only_use_backbone: bool = False,
1294
+ ):
1295
+ """
1296
+ Aligns the current protein to the provided target.
1297
+
1298
+ Args:
1299
+ target (ProteinChain): The target protein to align to.
1300
+ mobile_inds: Mobile atom indices, not residue indices.
1301
+ target_inds: Target atom indices, not residue indices.
1302
+ only_use_backbone (bool, optional): If True, only align the backbone atoms.
1303
+ """
1304
+ aligner = Aligner(
1305
+ self if mobile_inds is None else self[mobile_inds],
1306
+ target if target_inds is None else target[target_inds],
1307
+ only_use_backbone,
1308
+ )
1309
+
1310
+ return aligner.apply(self)
1311
+
1312
+ def rmsd(
1313
+ self,
1314
+ target: ProteinChain,
1315
+ also_check_reflection: bool = False,
1316
+ mobile_inds: list[int] | np.ndarray | None = None,
1317
+ target_inds: list[int] | np.ndarray | None = None,
1318
+ only_compute_backbone_rmsd: bool = False,
1319
+ ):
1320
+ """
1321
+ Compute the RMSD between this protein chain and another.
1322
+
1323
+ Args:
1324
+ target (ProteinChain): The target (other) protein chain to compare to.
1325
+ also_check_reflection: Compare the reflected mobile coordinates too.
1326
+ mobile_inds: Mobile atom indices, not residue indices.
1327
+ target_inds: Target atom indices, not residue indices.
1328
+ only_compute_backbone_rmsd: Restrict the score to backbone atoms.
1329
+ """
1330
+ if isinstance(target, bs.AtomArray):
1331
+ raise ValueError(
1332
+ "Support for bs.AtomArray removed, use ProteinChain.from_atomarry for ProteinChain."
1333
+ )
1334
+ aligner = Aligner(
1335
+ self if mobile_inds is None else self[mobile_inds],
1336
+ target if target_inds is None else target[target_inds],
1337
+ only_compute_backbone_rmsd,
1338
+ )
1339
+ avg_rmsd = aligner.rmsd
1340
+
1341
+ if not also_check_reflection:
1342
+ return avg_rmsd
1343
+
1344
+ aligner = Aligner(
1345
+ self if mobile_inds is None else self[mobile_inds],
1346
+ target if target_inds is None else target[target_inds],
1347
+ only_compute_backbone_rmsd,
1348
+ use_reflection=True,
1349
+ )
1350
+ avg_rmsd_neg = aligner.rmsd
1351
+
1352
+ return min(avg_rmsd, avg_rmsd_neg)
1353
+
1354
+ def lddt_ca(
1355
+ self,
1356
+ native: ProteinChain,
1357
+ mobile_inds: list[int] | np.ndarray | None = None,
1358
+ target_inds: list[int] | np.ndarray | None = None,
1359
+ **kwargs,
1360
+ ) -> float | np.ndarray:
1361
+ """Compute the LDDT between this protein chain and another. NOTE: LDDT IS NOT SYMMETRIC.
1362
+ The call should always be prediction.lddt_ca(native).
1363
+
1364
+ Arguments:
1365
+ native (ProteinChain): The ground truth protein chain
1366
+ mobile_inds: Mobile atom indices, not residue indices.
1367
+ target_inds: Target atom indices, not residue indices.
1368
+
1369
+ Returns:
1370
+ float | np.ndarray: The LDDT score between the two protein chains, either
1371
+ a single float or per-residue LDDT scores if `per_residue` is True.
1372
+ """
1373
+ lddt = compute_lddt_ca(
1374
+ torch.tensor(self.atom37_positions[mobile_inds]).unsqueeze(0),
1375
+ torch.tensor(native.atom37_positions[target_inds]).unsqueeze(0),
1376
+ torch.tensor(native.atom37_mask[mobile_inds]).unsqueeze(0),
1377
+ **kwargs,
1378
+ )
1379
+ return float(lddt) if lddt.numel() == 1 else lddt.numpy().flatten()
1380
+
1381
+ def gdt_ts(
1382
+ self,
1383
+ target: ProteinChain,
1384
+ mobile_inds: list[int] | np.ndarray | None = None,
1385
+ target_inds: list[int] | np.ndarray | None = None,
1386
+ **kwargs,
1387
+ ) -> float | np.ndarray:
1388
+ """Compute the GDT_TS between this protein chain and another.
1389
+
1390
+ Arguments:
1391
+ target (ProteinChain): The other protein chain to compare to.
1392
+ mobile_inds: Mobile atom indices, not residue indices.
1393
+ target_inds: Target atom indices, not residue indices.
1394
+
1395
+ Returns:
1396
+ float: The GDT_TS score between the two protein chains.
1397
+ """
1398
+ gdt_ts = compute_gdt_ts(
1399
+ mobile=torch.tensor(
1400
+ index_by_atom_name(self.atom37_positions[mobile_inds], "CA"),
1401
+ dtype=torch.float32,
1402
+ ).unsqueeze(0),
1403
+ target=torch.tensor(
1404
+ index_by_atom_name(target.atom37_positions[target_inds], "CA"),
1405
+ dtype=torch.float32,
1406
+ ).unsqueeze(0),
1407
+ atom_exists_mask=torch.tensor(
1408
+ index_by_atom_name(self.atom37_mask[mobile_inds], "CA", dim=-1)
1409
+ & index_by_atom_name(target.atom37_mask[target_inds], "CA", dim=-1)
1410
+ ).unsqueeze(0),
1411
+ **kwargs,
1412
+ )
1413
+ return float(gdt_ts) if gdt_ts.numel() == 1 else gdt_ts.numpy().flatten()
1414
+
1415
+ @cached_property
1416
+ def residue_index_no_insertions(self) -> np.ndarray:
1417
+ return self.residue_index + np.cumsum(self.insertion_code != "")
1418
+
1419
+ @cached_property
1420
+ def atom_array_no_insertions(self) -> bs.AtomArray:
1421
+ atoms = []
1422
+ for res_idx, (res_name, positions, mask, conf) in enumerate(
1423
+ zip(
1424
+ self.sequence,
1425
+ self.atom37_positions,
1426
+ self.atom37_mask.astype(bool),
1427
+ self.confidence,
1428
+ strict=False,
1429
+ )
1430
+ ):
1431
+ for i, pos in zip(np.where(mask)[0], positions[mask], strict=False):
1432
+ b_factor = (
1433
+ self.atom37_confidence[res_idx, i]
1434
+ if self.atom37_confidence is not None
1435
+ else conf
1436
+ )
1437
+ atom = bs.Atom(
1438
+ coord=pos,
1439
+ # hard coded to as we currently only support single chain structures
1440
+ chain_id=CHAIN_ID_CONST,
1441
+ res_id=res_idx + 1,
1442
+ res_name=residue_constants.restype_1to3.get(res_name, "UNK"),
1443
+ hetero=False,
1444
+ atom_name=residue_constants.atom_types[i],
1445
+ element=residue_constants.atom_types[i][0],
1446
+ b_factor=float(b_factor) * PLDDT_B_FACTOR_SCALE,
1447
+ occupancy=1.0,
1448
+ )
1449
+ atoms.append(atom)
1450
+ return bs.array(atoms)
fastplms/models/esmfold2/esmfold2_protein_complex.py ADDED
@@ -0,0 +1,1240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Protein-complex data, assembly expansion, and geometry for ESMFold2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import itertools
7
+ import random
8
+ import re
9
+ import warnings
10
+ from collections.abc import Iterable, Sequence
11
+ from dataclasses import asdict, dataclass, replace
12
+ from functools import cached_property
13
+ from pathlib import Path
14
+ from subprocess import check_output
15
+ from tempfile import TemporaryDirectory
16
+ from typing import Any
17
+
18
+ import biotite.structure as bs
19
+ import brotli
20
+ import msgpack
21
+ import msgpack_numpy
22
+ import numpy as np
23
+ import torch
24
+ from biotite.database import rcsb
25
+ from biotite.file import InvalidFileError
26
+ from biotite.structure.io.pdb import PDBFile
27
+ from biotite.structure.io.pdbx import CIFCategory, CIFColumn, CIFData, CIFFile
28
+ from biotite.structure.io.pdbx import set_structure as set_structure_pdbx
29
+ from biotite.structure.io.pdbx.convert import _get_transformations, get_structure
30
+ from biotite.structure.util import matrix_rotate
31
+ from scipy.spatial import KDTree
32
+
33
+ from . import esmfold2_residue_constants as residue_constants
34
+ from .esmfold2_affine3d import Affine3D
35
+ from .esmfold2_aligner import Aligner
36
+ from .esmfold2_atom_indexer import AtomIndexer
37
+ from .esmfold2_metrics import compute_gdt_ts, compute_lddt_ca
38
+ from .esmfold2_misc import slice_python_object_as_numpy
39
+ from .esmfold2_mmcif_parsing import (
40
+ MmcifWrapper,
41
+ NoProteinError,
42
+ round_mmcif_columns,
43
+ )
44
+ from .esmfold2_protein_chain import (
45
+ ProteinChain,
46
+ _str_key_to_int_key,
47
+ chain_to_ndarray,
48
+ index_by_atom_name,
49
+ infer_cb,
50
+ )
51
+ from .esmfold2_utils_types import PathOrBuffer
52
+
53
+ SINGLE_LETTER_CHAIN_IDS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
54
+
55
+
56
+ def _parse_operation_expression(expression: str) -> list[tuple[str, ...]]:
57
+ """Expand an mmCIF operation expression in application order."""
58
+
59
+ def expand_group(group: str) -> list[str]:
60
+ operation_ids: list[str] = []
61
+ for term in group.split(","):
62
+ if "-" not in term:
63
+ operation_ids.append(term)
64
+ continue
65
+ first, last = (int(value) for value in term.split("-"))
66
+ operation_ids.extend(str(value) for value in range(first, last + 1))
67
+ return operation_ids
68
+
69
+ groups = [group for group in expression.replace(")", "").split("(") if group]
70
+ groups.reverse()
71
+ return list(itertools.product(*(expand_group(group) for group in groups)))
72
+
73
+
74
+ def _apply_transformations_fast(chains, transformation_dict, operations):
75
+ """Return transformed copies of each affected protein chain."""
76
+ transformed_chains = []
77
+ for chain in chains:
78
+ for operation in operations:
79
+ coordinates = chain.atom37_positions.copy()
80
+ for op_step in operation:
81
+ transform = transformation_dict[op_step]
82
+ coordinates = matrix_rotate(coordinates, transform.rotation)
83
+ coordinates += transform.target_translation
84
+ transformed_chains.append(replace(chain, atom37_positions=coordinates))
85
+ return transformed_chains
86
+
87
+
88
+ @dataclass
89
+ class ProteinComplexMetadata:
90
+ entity_lookup: dict[int, int | str]
91
+ chain_lookup: dict[int, str]
92
+ mmcif: MmcifWrapper | None = None
93
+ # This is a dictionary that maps assembly ids to the list of unique chains
94
+ # in that assembly. Allows for usage of `switch_assembly`.
95
+ assembly_composition: dict[str, list[str]] | None = None
96
+
97
+
98
+ @dataclass
99
+ class DockQSingleScore:
100
+ native_chains: tuple[str, str]
101
+ DockQ: float
102
+ interface_rms: float
103
+ ligand_rms: float
104
+ fnat: float
105
+ fnonnat: float
106
+ clashes: float
107
+ F1: float
108
+ DockQ_F1: float
109
+
110
+
111
+ @dataclass
112
+ class DockQResult:
113
+ total_dockq: float
114
+ native_interfaces: int
115
+ chain_mapping: dict[str, str]
116
+ interfaces: dict[tuple[str, str], DockQSingleScore]
117
+ # zip(aligned.chain_iter(), native.chain_iter()) gives you the pairing
118
+ # aligned.rmsd(native) should give you a low rmsd irrespective of shuffling
119
+ aligned: ProteinComplex
120
+ aligned_rmsd: float
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class ProteinComplex:
125
+ """Dataclass with atom37 representation of an entire protein complex."""
126
+
127
+ id: str
128
+ sequence: str
129
+ entity_id: np.ndarray # entities map to unique sequences
130
+ chain_id: np.ndarray # multiple chains might share an entity id
131
+ sym_id: np.ndarray # complexes might be copies of the same chain
132
+ residue_index: np.ndarray
133
+ insertion_code: np.ndarray
134
+ atom37_positions: np.ndarray
135
+ atom37_mask: np.ndarray
136
+ confidence: np.ndarray
137
+ # This metadata is parsed from the MMCIF file. For synthetic data, we do a best effort.
138
+ metadata: ProteinComplexMetadata
139
+ atom37_confidence: np.ndarray | None = None # P has shape (l, 37).
140
+
141
+ # Coordinate completion, concatenation, and comparison
142
+ def infer_oxygen(self) -> ProteinComplex:
143
+ """Oxygen position is fixed given N, CA, C atoms. Infer it if not provided."""
144
+ O_missing_indices = np.argwhere(~np.isfinite(self.atoms["O"]).all(axis=1)).squeeze()
145
+
146
+ O_vector = torch.tensor([0.6240, -1.0613, 0.0103], dtype=torch.float32)
147
+ N, CA, C = torch.from_numpy(self.atoms[["N", "CA", "C"]]).float().unbind(dim=1)
148
+ N = torch.roll(N, -3)
149
+ N[..., -1, :] = torch.nan
150
+
151
+ # Get the frame defined by the CA-C-N atom
152
+ frames = Affine3D.from_graham_schmidt(CA, C, N)
153
+ oxygen_coordinates = frames.apply(O_vector)
154
+ atom37_positions = self.atom37_positions.copy()
155
+ atom37_mask = self.atom37_mask.copy()
156
+
157
+ atom37_positions[O_missing_indices, residue_constants.atom_order["O"]] = oxygen_coordinates[
158
+ O_missing_indices
159
+ ].numpy()
160
+ atom37_mask[O_missing_indices, residue_constants.atom_order["O"]] = ~np.isnan(
161
+ atom37_positions[O_missing_indices, residue_constants.atom_order["O"]]
162
+ ).any(-1)
163
+ new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask)
164
+ return new_chain
165
+
166
+ def infer_cbeta(self, infer_cbeta_for_glycine: bool = False) -> ProteinComplex:
167
+ """Return a new chain with inferred CB atoms at all residues except GLY.
168
+
169
+ Args:
170
+ infer_cbeta_for_glycine (bool): If True, infers a beta carbon for glycine
171
+ residues, even though that residue doesn't have one. Default off.
172
+
173
+ NOTE(rverkuil): The reason for having this switch in the first place
174
+ is that sometimes we want a (inferred) CB coordinate for every residue,
175
+ for example for making a pairwise distance matrix, or doing an RMSD
176
+ calculation between two designs for a given structural template, w/
177
+ CB atoms.
178
+ """
179
+ atom37_positions = self.atom37_positions.copy()
180
+ atom37_mask = self.atom37_mask.copy()
181
+
182
+ N, CA, C = np.moveaxis(self.atoms[["N", "CA", "C"]], 1, 0)
183
+ # See usage in trDesign codebase.
184
+ # https://github.com/gjoni/trDesign/blob/f2d5930b472e77bfacc2f437b3966e7a708a8d37/02-GD/utils.py#L140
185
+ inferred_cbeta_positions = infer_cb(C, N, CA, 1.522, 1.927, -2.143)
186
+ if not infer_cbeta_for_glycine:
187
+ inferred_cbeta_positions[np.array(list(self.sequence)) == "G", :] = np.nan
188
+
189
+ atom37_positions[:, residue_constants.atom_order["CB"]] = inferred_cbeta_positions
190
+ atom37_mask[:, residue_constants.atom_order["CB"]] = ~np.isnan(
191
+ atom37_positions[:, residue_constants.atom_order["CB"]]
192
+ ).any(-1)
193
+ new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask)
194
+ return new_chain
195
+
196
+ @classmethod
197
+ def from_open_source(cls, pc: ProteinComplex):
198
+ # TODO(@zeming): deprecated, should delete
199
+ return pc
200
+
201
+ @classmethod
202
+ def concat(cls, objs: list[ProteinComplex]) -> ProteinComplex:
203
+ pdb_ids = [obj.id for obj in objs]
204
+ if len(set(pdb_ids)) > 1:
205
+ raise RuntimeError(
206
+ "Concatention of protein complexes across different PDB ids is unsupported"
207
+ )
208
+ return ProteinComplex.from_chains(
209
+ list(itertools.chain.from_iterable(obj.chain_iter() for obj in objs))
210
+ )
211
+
212
+ def _sanity_check_complexes_are_comparable(self, other: ProteinComplex):
213
+ if len(self) != len(other):
214
+ raise ValueError("Protein complexes must have the same length")
215
+ if len(list(self.chain_iter())) != len(list(other.chain_iter())):
216
+ raise ValueError("Protein complexes must have the same number of chains")
217
+
218
+ def rmsd(
219
+ self,
220
+ target: ProteinComplex,
221
+ also_check_reflection: bool = False,
222
+ mobile_inds: list[int] | np.ndarray | None = None,
223
+ target_inds: list[int] | np.ndarray | None = None,
224
+ only_compute_backbone_rmsd: bool = False,
225
+ compute_chain_assignment: bool = True,
226
+ ):
227
+ """
228
+ Compute the RMSD between this protein chain and another.
229
+
230
+ Args:
231
+ target (ProteinComplex): The target (other) protein complex to compare to.
232
+ also_check_reflection: Compare the reflected mobile coordinates too.
233
+ mobile_inds: Mobile atom indices, not residue indices.
234
+ target_inds: Target atom indices, not residue indices.
235
+ only_compute_backbone_rmsd: Restrict the score to backbone atoms.
236
+ """
237
+ aligned = self.dockq(target).aligned if compute_chain_assignment else self
238
+
239
+ aligner = Aligner(
240
+ aligned if mobile_inds is None else aligned[mobile_inds],
241
+ target if target_inds is None else target[target_inds],
242
+ only_compute_backbone_rmsd,
243
+ )
244
+ avg_rmsd = aligner.rmsd
245
+
246
+ if not also_check_reflection:
247
+ return avg_rmsd
248
+
249
+ aligner = Aligner(
250
+ aligned if mobile_inds is None else aligned[mobile_inds],
251
+ target if target_inds is None else target[target_inds],
252
+ only_compute_backbone_rmsd,
253
+ use_reflection=True,
254
+ )
255
+ avg_rmsd_neg = aligner.rmsd
256
+
257
+ return min(avg_rmsd, avg_rmsd_neg)
258
+
259
+ def lddt_ca(
260
+ self,
261
+ target: ProteinComplex,
262
+ mobile_inds: list[int] | np.ndarray | None = None,
263
+ target_inds: list[int] | np.ndarray | None = None,
264
+ compute_chain_assignment: bool = True,
265
+ **kwargs,
266
+ ) -> float | np.ndarray:
267
+ """Compute the LDDT between this protein complex and another.
268
+
269
+ Arguments:
270
+ target (ProteinComplex): The other protein complex to compare to.
271
+ mobile_inds: Mobile atom indices, not residue indices.
272
+ target_inds: Target atom indices, not residue indices.
273
+
274
+ Returns:
275
+ float | np.ndarray: The LDDT score between the two protein chains, either
276
+ a single float or per-residue LDDT scores if `per_residue` is True.
277
+ """
278
+ aligned = self.dockq(target).aligned if compute_chain_assignment else self
279
+ lddt = compute_lddt_ca(
280
+ torch.tensor(aligned.atom37_positions[mobile_inds]).unsqueeze(0),
281
+ torch.tensor(target.atom37_positions[target_inds]).unsqueeze(0),
282
+ torch.tensor(aligned.atom37_mask[mobile_inds]).unsqueeze(0),
283
+ **kwargs,
284
+ )
285
+ return float(lddt) if lddt.numel() == 1 else lddt.numpy().flatten()
286
+
287
+ def gdt_ts(
288
+ self,
289
+ target: ProteinComplex,
290
+ mobile_inds: list[int] | np.ndarray | None = None,
291
+ target_inds: list[int] | np.ndarray | None = None,
292
+ compute_chain_assignment: bool = True,
293
+ **kwargs,
294
+ ) -> float | np.ndarray:
295
+ """Compute the GDT_TS between this protein complex and another.
296
+
297
+ Arguments:
298
+ target (ProteinComplex): The other protein complex to compare to.
299
+ mobile_inds: Mobile atom indices, not residue indices.
300
+ target_inds: Target atom indices, not residue indices.
301
+
302
+ Returns:
303
+ float: The GDT_TS score between the two protein chains.
304
+ """
305
+ aligned = self.dockq(target).aligned if compute_chain_assignment else self
306
+ gdt_ts = compute_gdt_ts(
307
+ mobile=torch.tensor(
308
+ index_by_atom_name(aligned.atom37_positions[mobile_inds], "CA"),
309
+ dtype=torch.float32,
310
+ ).unsqueeze(0),
311
+ target=torch.tensor(
312
+ index_by_atom_name(target.atom37_positions[target_inds], "CA"),
313
+ dtype=torch.float32,
314
+ ).unsqueeze(0),
315
+ atom_exists_mask=torch.tensor(
316
+ index_by_atom_name(aligned.atom37_mask[mobile_inds], "CA", dim=-1)
317
+ & index_by_atom_name(target.atom37_mask[target_inds], "CA", dim=-1)
318
+ ).unsqueeze(0),
319
+ **kwargs,
320
+ )
321
+ return float(gdt_ts) if gdt_ts.numel() == 1 else gdt_ts.numpy().flatten()
322
+
323
+ def dockq(self, native: ProteinComplex):
324
+ # This function uses dockqv2 to compute the DockQ score. Because it does a mapping
325
+ # over all possible chains, it's quite slow. Be careful not to use this in an inference loop
326
+ # or something that requires fast scoring. It defaults to 8 CPUs.
327
+ #
328
+ # TODO(@zeming): Because we haven't properly implemented protein complexes for mmcif,
329
+ # if your protein has multi-letter or repeated chain IDs, this will fail. Please call
330
+ # Normalize chain IDs before DockQ when IDs repeat or use multiple letters.
331
+
332
+ try:
333
+ pass
334
+ except BaseException:
335
+ raise RuntimeError("DockQ is not installed. Please update your environment.") from None
336
+ self._sanity_check_complexes_are_comparable(native)
337
+
338
+ def sanity_check_chain_ids(pc: ProteinComplex):
339
+ ids = []
340
+ for i, chain in enumerate(pc.chain_iter()):
341
+ if i > len(SINGLE_LETTER_CHAIN_IDS):
342
+ raise ValueError("Too many chains to write to PDB file")
343
+ if len(chain.chain_id) > 1:
344
+ raise ValueError("We only supports single letter chain IDs for DockQ")
345
+ ids.append(chain.chain_id)
346
+ if len(set(ids)) != len(ids):
347
+ raise ValueError(f"Duplicate chain IDs in protein complex: {ids}")
348
+ return ids
349
+
350
+ sanity_check_chain_ids(self)
351
+ sanity_check_chain_ids(native)
352
+
353
+ with TemporaryDirectory() as tdir:
354
+ dir = Path(tdir)
355
+ self.to_pdb(dir / "self.pdb")
356
+ native.to_pdb(dir / "native.pdb")
357
+
358
+ output = check_output(["DockQ", dir / "self.pdb", dir / "native.pdb"])
359
+ lines = output.decode().split("\n")
360
+
361
+ # Remove the header comments
362
+ start_index = next(i for i, line in enumerate(lines) if line.startswith("Model"))
363
+ lines = lines[start_index:]
364
+
365
+ result = {}
366
+ interfaces = []
367
+ current_interface: dict = {}
368
+
369
+ for line in lines:
370
+ line = line.strip()
371
+ if not line:
372
+ continue
373
+
374
+ if line.startswith(("Model :", "Native :")):
375
+ pass # Tmp pdb file location, it's useless...
376
+ elif line.startswith("Total DockQ"):
377
+ total_dockq_match = re.search(
378
+ r"Total DockQ over (\d+) native interfaces: ([\d.]+) with "
379
+ r"(.*) model:native mapping",
380
+ line,
381
+ )
382
+ if total_dockq_match:
383
+ result["value"] = float(total_dockq_match.group(2))
384
+ result["native interfaces"] = int(total_dockq_match.group(1))
385
+ native_chains, self_chains = total_dockq_match.group(3).split(":")
386
+ result["mapping"] = dict(zip(native_chains, self_chains, strict=False))
387
+ else:
388
+ raise RuntimeError(
389
+ "Failed to parse DockQ output, maybe your DockQ version is wrong?"
390
+ )
391
+ elif line.startswith("Native chains:"):
392
+ if current_interface:
393
+ interfaces.append(current_interface)
394
+ current_interface = {"Native chains": line.split(":")[1].strip().split(", ")}
395
+ elif line.startswith("Model chains:"):
396
+ current_interface["Model chains"] = line.split(":")[1].strip().split(", ")
397
+ elif ":" in line:
398
+ key, value = line.split(":", 1)
399
+ current_interface[key.strip()] = float(value.strip())
400
+
401
+ if current_interface:
402
+ interfaces.append(current_interface)
403
+
404
+ def parse_dict(d: dict[str, Any]) -> DockQSingleScore:
405
+ return DockQSingleScore(
406
+ native_chains=tuple(d["Native chains"]), # type: ignore
407
+ DockQ=float(d["DockQ"]),
408
+ interface_rms=float(d["irms"]),
409
+ ligand_rms=float(d["Lrms"]), # Note the capitalization difference
410
+ fnat=float(d["fnat"]),
411
+ fnonnat=float(d["fnonnat"]),
412
+ clashes=float(d["clashes"]),
413
+ F1=float(d["F1"]),
414
+ DockQ_F1=float(d["DockQ_F1"]),
415
+ )
416
+
417
+ inv_mapping = {v: k for k, v in result["mapping"].items()}
418
+
419
+ self_chain_map = {c.chain_id: c for c in self.chain_iter()}
420
+ realigned = []
421
+ for chain in native.chain_iter():
422
+ realigned.append(self_chain_map[inv_mapping[chain.chain_id]])
423
+
424
+ realigned = ProteinComplex.from_chains(realigned)
425
+ aligner = Aligner(realigned, native)
426
+ realigned = aligner.apply(realigned)
427
+
428
+ result = DockQResult(
429
+ total_dockq=result["value"],
430
+ native_interfaces=result["native interfaces"],
431
+ chain_mapping=result["mapping"],
432
+ interfaces={
433
+ (i["Model chains"][0], i["Model chains"][1]): parse_dict(i) for i in interfaces
434
+ },
435
+ aligned=realigned,
436
+ aligned_rmsd=aligner.rmsd,
437
+ )
438
+
439
+ return result
440
+
441
+ # Object invariants, slicing, and chain views
442
+ def __post_init__(self):
443
+ if not isinstance(self.sequence, str):
444
+ raise TypeError("sequence must be a string.")
445
+ sequence_length = len(self.sequence)
446
+ aligned = {
447
+ "atom37_positions": self.atom37_positions,
448
+ "atom37_mask": self.atom37_mask,
449
+ "residue_index": self.residue_index,
450
+ "insertion_code": self.insertion_code,
451
+ "confidence": self.confidence,
452
+ "entity_id": self.entity_id,
453
+ "chain_id": self.chain_id,
454
+ "sym_id": self.sym_id,
455
+ }
456
+ for name, values in aligned.items():
457
+ if not isinstance(values, np.ndarray):
458
+ raise TypeError(f"{name} must be a NumPy array, got {type(values).__name__}.")
459
+ if values.ndim == 0 or values.shape[0] != sequence_length:
460
+ raise ValueError(
461
+ f"{name} shape {values.shape} does not align with "
462
+ f"sequence length {sequence_length}."
463
+ )
464
+ if self.atom37_positions.shape != (sequence_length, 37, 3):
465
+ raise ValueError(
466
+ "atom37_positions must have shape "
467
+ f"({sequence_length}, 37, 3), got {self.atom37_positions.shape}."
468
+ )
469
+ if self.atom37_mask.shape != (sequence_length, 37):
470
+ raise ValueError(
471
+ "atom37_mask must have shape "
472
+ f"({sequence_length}, 37), got {self.atom37_mask.shape}."
473
+ )
474
+ if self.atom37_mask.dtype != bool:
475
+ raise TypeError(f"atom37_mask must have Boolean dtype, got {self.atom37_mask.dtype}.")
476
+ if not np.issubdtype(self.atom37_positions.dtype, np.number):
477
+ raise TypeError("atom37_positions must use a numeric dtype.")
478
+ for name, values in (
479
+ ("residue_index", self.residue_index),
480
+ ("insertion_code", self.insertion_code),
481
+ ("confidence", self.confidence),
482
+ ("entity_id", self.entity_id),
483
+ ("chain_id", self.chain_id),
484
+ ("sym_id", self.sym_id),
485
+ ):
486
+ if values.shape != (sequence_length,):
487
+ raise ValueError(
488
+ f"{name} must have shape ({sequence_length},), got {values.shape}."
489
+ )
490
+ if not np.issubdtype(self.confidence.dtype, np.number):
491
+ raise TypeError("confidence must use a numeric dtype.")
492
+ atom37_confidence = self.atom37_confidence
493
+ if atom37_confidence is not None and not isinstance(atom37_confidence, np.ndarray):
494
+ raise TypeError("atom37_confidence must be a NumPy array when provided.")
495
+ if (
496
+ isinstance(atom37_confidence, np.ndarray)
497
+ and atom37_confidence.shape != self.atom37_mask.shape
498
+ ):
499
+ raise ValueError(
500
+ "atom37_confidence shape must match atom37_mask: "
501
+ f"{atom37_confidence.shape} != {self.atom37_mask.shape}."
502
+ )
503
+
504
+ def __getitem__(self, idx: int | list[int] | slice | np.ndarray):
505
+ """This function slices protein complexes without consideration of chain breaks
506
+ NOTE: When slicing with a boolean mask, it's possible that the output array won't
507
+ be the expected length. This is because we do our best to preserve chainbreak tokens.
508
+ """
509
+
510
+ if isinstance(idx, int):
511
+ idx = [idx]
512
+ if isinstance(idx, list):
513
+ raise ValueError("ProteinComplex doesn't supports indexing with lists of indices")
514
+
515
+ if isinstance(idx, np.ndarray):
516
+ is_chainbreak = np.asarray([s == "|" for s in self.sequence])
517
+ idx = idx.astype(bool) | is_chainbreak
518
+
519
+ complex = self._unsafe_slice(idx)
520
+ if len(complex) == 0:
521
+ return complex
522
+
523
+ # detect runs of chainbreaks by searching for instances of '||' in complex.sequence
524
+ chainbreak_runs = np.asarray(
525
+ [complex.sequence[i : i + 2] == "||" for i in range(len(complex.sequence) - 1)]
526
+ + [complex.sequence[-1] == "|"]
527
+ )
528
+ # We should remove as many chainbreaks as possible from the start of the sequence
529
+ for i in range(len(chainbreak_runs)):
530
+ if complex.sequence[i] == "|":
531
+ chainbreak_runs[i] = True
532
+ else:
533
+ break
534
+ complex = complex._unsafe_slice(~chainbreak_runs)
535
+ return complex
536
+
537
+ def _unsafe_slice(self, idx: int | list[int] | slice | np.ndarray):
538
+ sequence = slice_python_object_as_numpy(self.sequence, idx)
539
+ return replace(
540
+ self,
541
+ sequence=sequence,
542
+ entity_id=self.entity_id[..., idx],
543
+ chain_id=self.chain_id[..., idx],
544
+ sym_id=self.sym_id[..., idx],
545
+ residue_index=self.residue_index[..., idx],
546
+ insertion_code=self.insertion_code[..., idx],
547
+ atom37_positions=self.atom37_positions[..., idx, :, :],
548
+ atom37_mask=self.atom37_mask[..., idx, :],
549
+ confidence=self.confidence[..., idx],
550
+ atom37_confidence=self.atom37_confidence[..., idx, :]
551
+ if self.atom37_confidence is not None
552
+ else None,
553
+ )
554
+
555
+ def __len__(self):
556
+ return len(self.sequence)
557
+
558
+ @property
559
+ def num_chains(self):
560
+ return len(self.chain_boundaries)
561
+
562
+ @cached_property
563
+ def atoms(self) -> AtomIndexer:
564
+ return AtomIndexer(self, property="atom37_positions", dim=-2)
565
+
566
+ @cached_property
567
+ def atom_mask(self) -> AtomIndexer:
568
+ return AtomIndexer(self, property="atom37_mask", dim=-1)
569
+
570
+ @cached_property
571
+ def chain_lengths(self) -> np.ndarray:
572
+ return np.diff(self.chain_boundaries, axis=1).flatten()
573
+
574
+ @cached_property
575
+ def chain_boundaries(self) -> list[tuple[int, int]]:
576
+ cb = [-1]
577
+ for i, s in enumerate(self.sequence):
578
+ if s == "|":
579
+ cb.append(i)
580
+ cb.append(len(self))
581
+ return [(cb[i] + 1, cb[i + 1]) for i in range(len(cb) - 1)]
582
+
583
+ def get_chain_by_index(self, index: int) -> ProteinChain:
584
+ try:
585
+ start, end = self.chain_boundaries[index]
586
+ return self[start:end].as_chain()
587
+ except IndexError:
588
+ raise IndexError(f"Chain index {index} out of bounds") from None
589
+
590
+ def get_chain_by_id(
591
+ self, chain_id: str, sample_chain_if_duplicate: bool = True
592
+ ) -> ProteinChain:
593
+ valid_indices = [
594
+ index
595
+ for index, id_of_index in self.metadata.chain_lookup.items()
596
+ if id_of_index == chain_id
597
+ ]
598
+ if not valid_indices:
599
+ raise KeyError(f"Chain ID {chain_id} not found")
600
+ if sample_chain_if_duplicate:
601
+ index_to_return = random.choice(valid_indices)
602
+ return self.get_chain_by_index(index_to_return)
603
+ else:
604
+ if len(valid_indices) > 1:
605
+ raise ValueError(f"Multiple chains with chain ID {chain_id} found")
606
+ return self.get_chain_by_index(valid_indices[0])
607
+
608
+ def chain_iter(self) -> Iterable[ProteinChain]:
609
+ for start, end in self.chain_boundaries:
610
+ c = self[start:end]
611
+ yield c.as_chain()
612
+
613
+ def as_chain(self, force_conversion: bool = False) -> ProteinChain:
614
+ """Convert the ProteinComplex to a ProteinChain.
615
+
616
+ Args:
617
+ force_conversion: Flatten multiple chains to access chain-only utilities.
618
+
619
+ """
620
+ if not force_conversion:
621
+ if len(np.unique(self.chain_id)) != 1:
622
+ raise ValueError(
623
+ f"Protein complex {self.id!r} has multiple chains; "
624
+ "pass force_conversion=True to flatten it."
625
+ )
626
+ if len(np.unique(self.entity_id)) != 1:
627
+ raise ValueError(
628
+ f"Protein complex {self.id!r} has multiple entities; "
629
+ "pass force_conversion=True to flatten it."
630
+ )
631
+ if self.chain_id[0] not in self.metadata.chain_lookup:
632
+ warnings.warn(
633
+ "Chain ID not found in metadata, using 'A' as default",
634
+ stacklevel=2,
635
+ )
636
+ if self.entity_id[0] not in self.metadata.entity_lookup:
637
+ warnings.warn(
638
+ "Entity ID not found in metadata, using None as default",
639
+ stacklevel=2,
640
+ )
641
+ chain_id = self.metadata.chain_lookup.get(self.chain_id[0], "A")
642
+ entity_id = self.metadata.entity_lookup.get(self.entity_id[0], None)
643
+ else:
644
+ chain_id = "A"
645
+ entity_id = None
646
+
647
+ return ProteinChain(
648
+ id=self.id,
649
+ sequence=self.sequence,
650
+ chain_id=chain_id,
651
+ entity_id=entity_id,
652
+ atom37_positions=self.atom37_positions,
653
+ atom37_mask=self.atom37_mask,
654
+ residue_index=self.residue_index,
655
+ insertion_code=self.insertion_code,
656
+ confidence=self.confidence,
657
+ mmcif=self.metadata.mmcif,
658
+ atom37_confidence=self.atom37_confidence,
659
+ )
660
+
661
+ # Contact topology and mmCIF export
662
+ @cached_property
663
+ def per_chain_kd_trees(self):
664
+ # Iterate over chains, build KDTree for each chain
665
+ kdtrees = []
666
+
667
+ CA = self.atoms["CA"]
668
+
669
+ for start, end in self.chain_boundaries:
670
+ chain_CA = CA[start:end]
671
+ chain_CA = chain_CA[np.isfinite(chain_CA).all(axis=-1)]
672
+ kdtrees.append(KDTree(chain_CA))
673
+
674
+ return kdtrees
675
+
676
+ def chain_adjacency(self, cutoff: float = 8.0) -> np.ndarray:
677
+ # Compute adjacency matrix for protein complex
678
+ num_chains = self.num_chains
679
+ adjacency = np.zeros((num_chains, num_chains), dtype=bool)
680
+ for (i, kdtree), (j, kdtree2) in itertools.combinations(
681
+ enumerate(self.per_chain_kd_trees), 2
682
+ ):
683
+ adj = kdtree.query_ball_tree(kdtree2, cutoff)
684
+ any_is_adjacent = any(len(a) > 0 for a in adj)
685
+ adjacency[i, j] = any_is_adjacent
686
+ adjacency[j, i] = any_is_adjacent
687
+ return adjacency
688
+
689
+ def chain_adjacency_by_index(self, index: int, cutoff: float = 8.0) -> np.ndarray:
690
+ num_chains = len(self.chain_boundaries)
691
+ adjacency = np.zeros(num_chains, dtype=bool)
692
+ for i, kdtree in enumerate(self.per_chain_kd_trees):
693
+ if i == index:
694
+ continue
695
+ adj = kdtree.query_ball_tree(self.per_chain_kd_trees[index], cutoff)
696
+ adjacency[i] = any(len(a) > 0 for a in adj)
697
+ return adjacency
698
+
699
+ def add_prefix_to_chain_ids(self, prefix: str) -> ProteinComplex:
700
+ """Rename all chains in the complex with a given prefix.
701
+
702
+ Args:
703
+ prefix (str): The prefix to use for the new chain IDs. Each chain will be
704
+ named as "{prefix}_{chain_id}".
705
+
706
+ Returns:
707
+ ProteinComplex: A new protein complex with renamed chains.
708
+ """
709
+ new_chains = []
710
+ for chain in self.chain_iter():
711
+ # Create new chain with updated chain_id
712
+ new_chain = replace(chain, chain_id=f"{prefix}_{chain.chain_id}")
713
+ new_chains.append(new_chain)
714
+ return ProteinComplex.from_chains(new_chains)
715
+
716
+ def sasa(self, by_residue: bool = True):
717
+ chain = self.as_chain(force_conversion=True)
718
+ return chain.sasa(by_residue=by_residue)
719
+
720
+ def to_mmcif_string(self) -> str:
721
+ """Convert the ProteinComplex to mmCIF format.
722
+
723
+ Returns:
724
+ str: The mmCIF content as a string.
725
+ """
726
+ # Convert the ProteinComplex to a biotite AtomArray
727
+ # Collect all atoms from all chains
728
+ all_atoms = []
729
+ for chain in self.chain_iter():
730
+ chain_atom_array = chain.atom_array
731
+ # Convert AtomArray to list of atoms and add to collection
732
+ all_atoms.extend(chain_atom_array)
733
+
734
+ # Create combined AtomArray from all atoms
735
+ if not all_atoms:
736
+ raise ValueError("No atoms found in protein complex")
737
+
738
+ atom_array = bs.array(all_atoms)
739
+
740
+ # Create CIF file
741
+ f = CIFFile()
742
+ set_structure_pdbx(f, atom_array, data_block=self.id)
743
+
744
+ # Add entity information for proper mmCIF structure
745
+ self._add_entity_information(f)
746
+ round_mmcif_columns(f)
747
+
748
+ # Write to string
749
+ output = io.StringIO()
750
+ f.write(output)
751
+ return output.getvalue()
752
+
753
+ def _add_entity_information(self, cif_file: CIFFile) -> None:
754
+ """Add entity, entity_poly, and struct_asym sections to CIF file."""
755
+
756
+ # Group chains by sequence to create unique entities
757
+ entity_map = {} # sequence -> entity_id
758
+ chain_to_entity = {} # chain_id -> entity_id
759
+ entity_sequences = {} # entity_id -> sequence
760
+ entity_id_counter = 1
761
+
762
+ for chain in self.chain_iter():
763
+ sequence = chain.sequence
764
+ if sequence not in entity_map:
765
+ entity_map[sequence] = entity_id_counter
766
+ entity_sequences[entity_id_counter] = sequence
767
+ entity_id_counter += 1
768
+ chain_to_entity[chain.chain_id] = entity_map[sequence]
769
+
770
+ # Create _entity section
771
+ entity_ids = []
772
+ entity_types = []
773
+ entity_descriptions = []
774
+
775
+ for entity_id in sorted(entity_sequences.keys()):
776
+ entity_ids.append(str(entity_id))
777
+ entity_types.append("polymer")
778
+ entity_descriptions.append(f"Protein chain (entity {entity_id})")
779
+
780
+ cif_file.block["entity"] = CIFCategory(
781
+ name="entity",
782
+ columns={
783
+ "id": CIFColumn(data=CIFData(array=np.array(entity_ids), dtype=np.str_)),
784
+ "type": CIFColumn(data=CIFData(array=np.array(entity_types), dtype=np.str_)),
785
+ "pdbx_description": CIFColumn(
786
+ data=CIFData(array=np.array(entity_descriptions), dtype=np.str_)
787
+ ),
788
+ },
789
+ )
790
+
791
+ # Create _entity_poly section
792
+ poly_entity_ids = []
793
+ poly_types = []
794
+ poly_nstd_linkages = []
795
+ poly_sequences = []
796
+
797
+ for entity_id in sorted(entity_sequences.keys()):
798
+ poly_entity_ids.append(str(entity_id))
799
+ poly_types.append("polypeptide(L)")
800
+ poly_nstd_linkages.append("no")
801
+ poly_sequences.append(entity_sequences[entity_id])
802
+
803
+ cif_file.block["entity_poly"] = CIFCategory(
804
+ name="entity_poly",
805
+ columns={
806
+ "entity_id": CIFColumn(
807
+ data=CIFData(array=np.array(poly_entity_ids), dtype=np.str_)
808
+ ),
809
+ "type": CIFColumn(data=CIFData(array=np.array(poly_types), dtype=np.str_)),
810
+ "nstd_linkage": CIFColumn(
811
+ data=CIFData(array=np.array(poly_nstd_linkages), dtype=np.str_)
812
+ ),
813
+ "pdbx_seq_one_letter_code": CIFColumn(
814
+ data=CIFData(array=np.array(poly_sequences), dtype=np.str_)
815
+ ),
816
+ },
817
+ )
818
+
819
+ # Create _struct_asym section
820
+ asym_ids = []
821
+ asym_entity_ids = []
822
+ asym_details = []
823
+
824
+ for chain in self.chain_iter():
825
+ asym_ids.append(chain.chain_id)
826
+ asym_entity_ids.append(str(chain_to_entity[chain.chain_id]))
827
+ asym_details.append("")
828
+
829
+ cif_file.block["struct_asym"] = CIFCategory(
830
+ name="struct_asym",
831
+ columns={
832
+ "id": CIFColumn(data=CIFData(array=np.array(asym_ids), dtype=np.str_)),
833
+ "entity_id": CIFColumn(
834
+ data=CIFData(array=np.array(asym_entity_ids), dtype=np.str_)
835
+ ),
836
+ "details": CIFColumn(data=CIFData(array=np.array(asym_details), dtype=np.str_)),
837
+ },
838
+ )
839
+
840
+ # Construction, PDB interchange, and compact storage
841
+ @classmethod
842
+ def from_pdb(
843
+ cls, path: PathOrBuffer, id: str | None = None, is_predicted: bool = False
844
+ ) -> ProteinComplex:
845
+ atom_array = PDBFile.read(path).get_structure(model=1, extra_fields=["b_factor"])
846
+
847
+ chains = []
848
+ for chain in bs.chain_iter(atom_array):
849
+ chain = chain[~chain.hetero]
850
+ if len(chain) == 0:
851
+ continue
852
+ chains.append(ProteinChain.from_atomarray(chain, id, is_predicted))
853
+ return ProteinComplex.from_chains(chains)
854
+
855
+ def to_pdb(self, path: PathOrBuffer, include_insertions: bool = True):
856
+ atom_array = None
857
+ for chain in self.chain_iter():
858
+ carr = chain.atom_array if include_insertions else chain.atom_array_no_insertions
859
+ atom_array = carr if atom_array is None else atom_array + carr
860
+ f = PDBFile()
861
+ f.set_structure(atom_array)
862
+ f.write(path)
863
+
864
+ def to_pdb_string(self, include_insertions: bool = True) -> str:
865
+ buf = io.StringIO()
866
+ self.to_pdb(buf, include_insertions=include_insertions)
867
+ buf.seek(0)
868
+ return buf.read()
869
+
870
+ def normalize_chain_ids_for_pdb(self):
871
+ # Since PDB files have 1-letter chain IDs and don't support the idea of a symmetric index,
872
+ # we can normalize it instead which might be necessary for DockQ and to_pdb.
873
+ ids = SINGLE_LETTER_CHAIN_IDS
874
+ chains = []
875
+ for i, chain in enumerate(self.chain_iter()):
876
+ chain = replace(chain, chain_id=ids[i])
877
+ if i > len(ids):
878
+ raise RuntimeError("Too many chains to write to PDB file")
879
+ chains.append(chain)
880
+
881
+ return ProteinComplex.from_chains(chains)
882
+
883
+ def find_assembly_ids_with_chain(self, id: str) -> list[str]:
884
+ good_chains = []
885
+ if (comp := self.metadata.assembly_composition) is not None:
886
+ for assembly_id, chain_ids in comp.items():
887
+ if id in chain_ids:
888
+ good_chains.append(assembly_id)
889
+ else:
890
+ raise ValueError(
891
+ "Cannot switch assemblies on this ProteinComplex; construct it from "
892
+ "mmCIF to retain assembly metadata"
893
+ )
894
+ return good_chains
895
+
896
+ def switch_assembly(self, id: str):
897
+ if self.metadata.mmcif is None:
898
+ raise ValueError(
899
+ "Cannot switch assemblies without retained mmCIF source metadata."
900
+ )
901
+ return get_assembly_fast(self.metadata.mmcif, assembly_id=id)
902
+
903
+ def state_dict(self, backbone_only=False, json_serializable=False):
904
+ """This state dict is optimized for storage, so it turns things to fp16 whenever
905
+ possible. Note that we also only support int32 residue indices, I'm hoping we don't
906
+ need more than 2**32 residues..."""
907
+ dct = {k: v for k, v in vars(self).items()}
908
+ if backbone_only:
909
+ # Frozen dataclasses do not make their NumPy members immutable. Work on a
910
+ # private mask so requesting a compact backbone payload cannot clear the
911
+ # caller's side-chain atoms in-place.
912
+ atom37_mask = dct["atom37_mask"].copy()
913
+ atom37_mask[:, 3:] = False
914
+ dct["atom37_mask"] = atom37_mask
915
+ dct["atom37_positions"] = dct["atom37_positions"][dct["atom37_mask"]]
916
+ if dct.get("atom37_confidence") is not None:
917
+ dct["atom37_confidence"] = dct["atom37_confidence"][dct["atom37_mask"]]
918
+ else:
919
+ dct.pop("atom37_confidence", None)
920
+ for k, v in dct.items():
921
+ if isinstance(v, np.ndarray):
922
+ match v.dtype:
923
+ case np.int64:
924
+ dct[k] = v.astype(np.int32)
925
+ case np.float64 | np.float32:
926
+ dct[k] = v.astype(np.float16)
927
+ case _:
928
+ pass
929
+ if json_serializable:
930
+ dct[k] = v.tolist()
931
+ elif isinstance(v, ProteinComplexMetadata):
932
+ dct[k] = asdict(v)
933
+ dct["metadata"]["mmcif"] = None
934
+ # These can be populated with non-serializable objects and are not needed for reconstruction
935
+ dct.pop("atoms", None)
936
+ dct.pop("atom_mask", None)
937
+ dct.pop("per_chain_kd_trees", None)
938
+ return dct
939
+
940
+ def to_blob(self, backbone_only=False) -> bytes:
941
+ payload = msgpack.dumps(self.state_dict(backbone_only), default=msgpack_numpy.encode)
942
+ return brotli.compress(payload, quality=5)
943
+
944
+ @classmethod
945
+ def from_state_dict(cls, dct):
946
+ # Note: assembly_composition is *supposed* to have string keys.
947
+ dct = _str_key_to_int_key(dct, ignore_keys=["assembly_composition"])
948
+
949
+ for k, v in dct.items():
950
+ if isinstance(v, list):
951
+ dct[k] = np.array(v)
952
+
953
+ atom37 = np.full((*dct["atom37_mask"].shape, 3), np.nan)
954
+ atom37[dct["atom37_mask"]] = dct["atom37_positions"]
955
+ dct["atom37_positions"] = atom37
956
+ if "atom37_confidence" in dct:
957
+ atom37_conf = np.full(dct["atom37_mask"].shape, np.nan, dtype=np.float32)
958
+ atom37_conf[dct["atom37_mask"]] = dct["atom37_confidence"]
959
+ dct["atom37_confidence"] = atom37_conf
960
+ dct = {
961
+ k: (
962
+ v.astype(np.float32)
963
+ if k in ["atom37_positions", "confidence", "atom37_confidence"]
964
+ else v
965
+ )
966
+ for k, v in dct.items()
967
+ }
968
+ if "chain_boundaries" in dct:
969
+ del dct["chain_boundaries"]
970
+ if "chain_boundaries" in dct["metadata"]:
971
+ del dct["metadata"]["chain_boundaries"]
972
+ dct["metadata"] = ProteinComplexMetadata(**dct["metadata"])
973
+ return cls(**dct)
974
+
975
+ @classmethod
976
+ def from_blob(cls, input: Path | str | io.BytesIO | bytes):
977
+ """NOTE(@zlin): blob + sparse coding + brotli + fp16 reduces memory
978
+ of chains from 52G/1M chains to 20G/1M chains, I think this is a good first
979
+ shot at compressing and dumping chains to disk. I'm sure there's better ways."""
980
+ match input:
981
+ case Path() | str():
982
+ bytes = Path(input).read_bytes()
983
+ case io.BytesIO():
984
+ bytes = input.getvalue()
985
+ case _:
986
+ bytes = input
987
+ state = msgpack.loads(
988
+ brotli.decompress(bytes),
989
+ object_hook=msgpack_numpy.decode,
990
+ strict_map_key=False,
991
+ )
992
+ return cls.from_state_dict(state)
993
+
994
+ @classmethod
995
+ def from_rcsb(cls, pdb_id: str, keep_source: bool = False) -> ProteinComplex:
996
+ f: io.StringIO = rcsb.fetch(pdb_id, "cif") # type: ignore
997
+ return cls.from_mmcif(f, id=pdb_id, keep_source=keep_source, is_predicted=False)
998
+
999
+ @classmethod
1000
+ def from_mmcif(
1001
+ cls,
1002
+ path: PathOrBuffer,
1003
+ id: str | None = None,
1004
+ assembly_id: str | None = None,
1005
+ is_predicted: bool = False,
1006
+ keep_source: bool = False,
1007
+ ):
1008
+ """Return a ProteinComplex object from an mmcif file.
1009
+ TODO(@zeming): there's actually multiple complexes per file, but for ease of implementation,
1010
+ we only consider the first defined complex!
1011
+
1012
+ Args:
1013
+ path: Uncompressed mmCIF path or text buffer.
1014
+ id: Optional structure identifier.
1015
+ is_predicted (bool): If True, reads b factor as the confidence readout. Default: False.
1016
+ chain_id (str, optional): Select a chain corresponding to (author) chain id.
1017
+ """
1018
+ mmcif = MmcifWrapper.read(path, id)
1019
+ return get_assembly_fast(mmcif, assembly_id=assembly_id)
1020
+
1021
+ @classmethod
1022
+ def from_chains(
1023
+ cls,
1024
+ chains: Sequence[ProteinChain],
1025
+ mmcif: MmcifWrapper | None = None,
1026
+ all_assembly_metadata_dictionary: dict[str, list[str]] | None = None,
1027
+ ):
1028
+ if not chains:
1029
+ raise ValueError("Cannot create a ProteinComplex from an empty list of chains")
1030
+
1031
+ # TODO(roshan): Make a proper protein complex class
1032
+ def join_arrays(arrays: Sequence[np.ndarray], sep: np.ndarray):
1033
+ full_array = []
1034
+ for array in arrays:
1035
+ full_array.append(array)
1036
+ full_array.append(sep)
1037
+ full_array = full_array[:-1]
1038
+ return np.concatenate(full_array, 0)
1039
+
1040
+ sep_tokens = {
1041
+ "residue_index": np.array([-1]),
1042
+ "insertion_code": np.array([""]),
1043
+ "atom37_positions": np.full([1, 37, 3], np.nan),
1044
+ "atom37_mask": np.zeros([1, 37], dtype=bool),
1045
+ "confidence": np.array([0]),
1046
+ }
1047
+
1048
+ any_has_atom37_conf = any(c.atom37_confidence is not None for c in chains)
1049
+ if any_has_atom37_conf:
1050
+ sep_tokens["atom37_confidence"] = np.full([1, 37], np.nan, dtype=np.float32)
1051
+
1052
+ def _get_chain_attr(chain: ProteinChain, name: str) -> np.ndarray:
1053
+ val = getattr(chain, name)
1054
+ if val is None and name == "atom37_confidence":
1055
+ return np.full([len(chain), 37], np.nan, dtype=np.float32)
1056
+ return val
1057
+
1058
+ array_args: dict[str, np.ndarray] = {
1059
+ name: join_arrays([_get_chain_attr(chain, name) for chain in chains], sep)
1060
+ for name, sep in sep_tokens.items()
1061
+ }
1062
+
1063
+ multimer_arrays = []
1064
+ chain2num_max = -1
1065
+ chain2num = {}
1066
+ ent2num_max = -1
1067
+ ent2num = {}
1068
+ total_index = 0
1069
+ for i, c in enumerate(chains):
1070
+ num_res = c.residue_index.shape[0]
1071
+ if c.chain_id not in chain2num:
1072
+ chain2num[c.chain_id] = (chain2num_max := chain2num_max + 1)
1073
+ chain_id_array = np.full([num_res], chain2num[c.chain_id], dtype=np.int64)
1074
+
1075
+ if c.entity_id is None:
1076
+ entity_num = (ent2num_max := ent2num_max + 1)
1077
+ else:
1078
+ if c.entity_id not in ent2num:
1079
+ ent2num[c.entity_id] = (ent2num_max := ent2num_max + 1)
1080
+ entity_num = ent2num[c.entity_id]
1081
+ entity_id_array = np.full([num_res], entity_num, dtype=np.int64)
1082
+
1083
+ sym_id_array = np.full([num_res], i, dtype=np.int64)
1084
+
1085
+ multimer_arrays.append(
1086
+ {
1087
+ "chain_id": chain_id_array,
1088
+ "entity_id": entity_id_array,
1089
+ "sym_id": sym_id_array,
1090
+ }
1091
+ )
1092
+
1093
+ total_index += num_res + 1
1094
+
1095
+ sep = np.array([-1])
1096
+ update = {
1097
+ name: join_arrays([dct[name] for dct in multimer_arrays], sep=sep)
1098
+ for name in ["chain_id", "entity_id", "sym_id"]
1099
+ }
1100
+ array_args.update(update)
1101
+
1102
+ metadata = ProteinComplexMetadata(
1103
+ mmcif=mmcif,
1104
+ chain_lookup={v: k for k, v in chain2num.items()},
1105
+ entity_lookup={v: k for k, v in ent2num.items()},
1106
+ assembly_composition=all_assembly_metadata_dictionary,
1107
+ )
1108
+
1109
+ return cls(
1110
+ id=chains[0].id,
1111
+ sequence=residue_constants.CHAIN_BREAK_TOKEN.join(chain.sequence for chain in chains),
1112
+ metadata=metadata,
1113
+ **array_args,
1114
+ )
1115
+
1116
+
1117
+ # Biological-assembly expansion
1118
+ def get_assembly_fast(
1119
+ mmcif: MmcifWrapper,
1120
+ assembly_id=None,
1121
+ model=None,
1122
+ data_block=None,
1123
+ altloc="first",
1124
+ use_author_fields=True,
1125
+ ):
1126
+ pdbx_file = mmcif.raw
1127
+ if pdbx_file is None:
1128
+ raise InvalidFileError("No mmCIF data loaded")
1129
+ assembly_gen_category = pdbx_file.block["pdbx_struct_assembly_gen"]
1130
+ if assembly_gen_category is None:
1131
+ raise InvalidFileError("File has no 'pdbx_struct_assembly_gen' category")
1132
+
1133
+ struct_oper_category = pdbx_file.block["pdbx_struct_oper_list"]
1134
+ if struct_oper_category is None:
1135
+ raise InvalidFileError("File has no 'pdbx_struct_oper_list' category")
1136
+
1137
+ if assembly_id is None:
1138
+ assembly_id = assembly_gen_category["assembly_id"].data.array[0]
1139
+ elif assembly_id not in assembly_gen_category["assembly_id"].data.array:
1140
+ raise KeyError(f"File has no Assembly ID '{assembly_id}'")
1141
+
1142
+ ### Calculate all possible transformations
1143
+ transformations = _get_transformations(struct_oper_category)
1144
+
1145
+ ### Get structure according to additional parameters
1146
+ structure = get_structure(
1147
+ pdbx_file, model, data_block, altloc, ["label_asym_id"], use_author_fields
1148
+ )[0] # type: ignore
1149
+ # TODO(@zeming) This line will remove all non-protein structural elements,
1150
+ # we should remove this when we want to parse these too.
1151
+ structure: bs.AtomArray = structure[
1152
+ bs.filter_amino_acids(structure) & ~structure.hetero # type: ignore
1153
+ ]
1154
+ if len(structure) == 0:
1155
+ raise NoProteinError
1156
+ unique_asym_ids = np.unique(structure.label_asym_id) # type: ignore
1157
+ asym2chain = {}
1158
+ asym2auth = {}
1159
+ for asym_id in unique_asym_ids:
1160
+ sub_structure: bs.AtomArray = structure[structure.label_asym_id == asym_id] # type: ignore
1161
+ chain_id: str = sub_structure[0].chain_id # type: ignore
1162
+ (
1163
+ sequence,
1164
+ atom_positions,
1165
+ atom_mask,
1166
+ residue_index,
1167
+ insertion_code,
1168
+ confidence,
1169
+ entity_id,
1170
+ ) = chain_to_ndarray(sub_structure, mmcif, chain_id, False)
1171
+
1172
+ asym2chain[asym_id] = ProteinChain(
1173
+ id=mmcif.id or "unknown",
1174
+ sequence=sequence,
1175
+ chain_id=chain_id,
1176
+ entity_id=entity_id,
1177
+ atom37_positions=atom_positions,
1178
+ atom37_mask=atom_mask,
1179
+ residue_index=residue_index,
1180
+ insertion_code=insertion_code,
1181
+ confidence=confidence,
1182
+ mmcif=None,
1183
+ )
1184
+ asym2auth[asym_id] = chain_id
1185
+
1186
+ ### Get transformations and apply them to the affected asym IDs
1187
+ assembly = []
1188
+ assembly_id_dict: dict[str, list[str]] = {}
1189
+
1190
+ # Process the target assembly ID
1191
+ for aid, op_expr, asym_id_expr in zip(
1192
+ assembly_gen_category["assembly_id"].data.array,
1193
+ assembly_gen_category["oper_expression"].data.array,
1194
+ assembly_gen_category["asym_id_list"].data.array,
1195
+ strict=False,
1196
+ ):
1197
+ if aid == assembly_id:
1198
+ # Parse operations and asym IDs for this specific entry
1199
+ operations = _parse_operation_expression(op_expr)
1200
+ asym_ids = asym_id_expr.split(",")
1201
+
1202
+ # Filter affected asym IDs to only protein chains, preserving order
1203
+ sub_structures = [asym2chain[asym_id] for asym_id in asym_ids if asym_id in asym2chain]
1204
+
1205
+ # Apply transformations
1206
+ sub_assembly = _apply_transformations_fast(sub_structures, transformations, operations)
1207
+ assembly.extend(sub_assembly)
1208
+
1209
+ # Build assembly_id_dict for this entry
1210
+ assembly_id_dict[aid] = assembly_id_dict.get(aid, []) + [
1211
+ asym2auth[id_] for id_ in asym_ids if id_ in asym2auth
1212
+ ]
1213
+
1214
+ if len(assembly) == 0:
1215
+ raise NoProteinError
1216
+ return ProteinComplex.from_chains(assembly, mmcif, assembly_id_dict)
1217
+
1218
+
1219
+ def protein_chain_to_protein_complex(chain: ProteinChain) -> ProteinComplex:
1220
+ if "|" not in chain.sequence:
1221
+ return ProteinComplex.from_chains([chain])
1222
+ chain_breaks = np.array(list(chain.sequence)) == "|"
1223
+ chain_break_inds = np.where(chain_breaks)[0]
1224
+ chain_break_inds = np.concatenate([[0], chain_break_inds, [len(chain)]])
1225
+ chain_break_inds = np.array(list(itertools.pairwise(chain_break_inds)))
1226
+ complex_chains = []
1227
+ for start, end in chain_break_inds:
1228
+ if start != 0:
1229
+ start += 1
1230
+ complex_chains.append(chain[start:end])
1231
+ complex_chains = [
1232
+ ProteinChain.from_atom37(
1233
+ chain.atom37_positions,
1234
+ sequence=chain.sequence,
1235
+ chain_id=SINGLE_LETTER_CHAIN_IDS[i],
1236
+ entity_id=i,
1237
+ )
1238
+ for i, chain in enumerate(complex_chains)
1239
+ ]
1240
+ return ProteinComplex.from_chains(complex_chains)
fastplms/models/esmfold2/esmfold2_protein_structure.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Atom selection, rigid alignment, RMSD, and GDT-TS primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import TypeVar
7
+
8
+ import numpy as np
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from torch import Tensor
12
+ from torch.amp import autocast # type: ignore
13
+
14
+ from .esmfold2_affine3d import Affine3D
15
+ from .esmfold2_misc import unbinpack
16
+ from .esmfold2_normalize_coordinates import index_by_atom_name
17
+
18
+ ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor)
19
+
20
+
21
+ def _coordinate_operations(
22
+ coordinates: ArrayOrTensor,
23
+ ) -> tuple[Callable[[ArrayOrTensor], ArrayOrTensor], Callable[..., ArrayOrTensor]]:
24
+ if isinstance(coordinates, np.ndarray):
25
+
26
+ def normalize(X: ArrayOrTensor) -> ArrayOrTensor:
27
+ return X / np.linalg.norm(X, axis=-1, keepdims=True)
28
+
29
+ return normalize, np.cross
30
+ return F.normalize, torch.cross # type: ignore[return-value]
31
+
32
+
33
+ def infer_cbeta_from_atom37(
34
+ atom37: ArrayOrTensor,
35
+ bond_length: float = 1.522,
36
+ bond_angle: float = 1.927,
37
+ dihedral: float = -2.143,
38
+ ) -> ArrayOrTensor:
39
+ """Infer C-beta coordinates from backbone tensor ``X``.
40
+
41
+ The scalar keyword arguments encode the bond length, bond angle, and
42
+ dihedral in radians used by the checkpoint's training geometry.
43
+ """
44
+
45
+ n_position = index_by_atom_name(atom37, "N", dim=-2)
46
+ ca_position = index_by_atom_name(atom37, "CA", dim=-2)
47
+ c_position = index_by_atom_name(atom37, "C", dim=-2)
48
+ normalize, cross = _coordinate_operations(atom37)
49
+ with np.errstate(invalid="ignore"):
50
+ n_to_ca = n_position - ca_position
51
+ n_to_c = n_position - c_position
52
+ unit_n_to_ca = normalize(n_to_ca)
53
+ normal = normalize(cross(n_to_c, unit_n_to_ca))
54
+ basis = [unit_n_to_ca, cross(normal, unit_n_to_ca), normal]
55
+ coefficients = [
56
+ bond_length * np.cos(bond_angle),
57
+ bond_length * np.sin(bond_angle) * np.cos(dihedral),
58
+ -bond_length * np.sin(bond_angle) * np.sin(dihedral),
59
+ ]
60
+ offset = sum(
61
+ vector * coefficient for vector, coefficient in zip(basis, coefficients, strict=True)
62
+ )
63
+ return ca_position + offset
64
+
65
+
66
+ def _unpack_alignment_inputs(
67
+ mobile: Tensor,
68
+ target: Tensor,
69
+ atom_mask: Tensor | None,
70
+ sequence_id: Tensor | None,
71
+ ) -> tuple[Tensor, Tensor, Tensor | None]:
72
+ if sequence_id is None:
73
+ return mobile, target, atom_mask
74
+ unpacked_mobile = unbinpack(mobile, sequence_id, pad_value=torch.nan)
75
+ unpacked_target = unbinpack(target, sequence_id, pad_value=torch.nan)
76
+ if atom_mask is None:
77
+ unpacked_mask = torch.isfinite(unpacked_target).all(dim=-1)
78
+ else:
79
+ unpacked_mask = unbinpack(atom_mask, sequence_id, pad_value=0)
80
+ return unpacked_mobile, unpacked_target, unpacked_mask
81
+
82
+
83
+ def _flatten_atom_axes(
84
+ mobile: Tensor,
85
+ target: Tensor,
86
+ atom_mask: Tensor | None,
87
+ ) -> tuple[Tensor, Tensor, Tensor | None]:
88
+ b = mobile.shape[0]
89
+ flat_mobile = mobile.view(b, -1, 3) if mobile.dim() == 4 else mobile
90
+ flat_target = target.view(b, -1, 3) if target.dim() == 4 else target
91
+ flat_mask = atom_mask
92
+ if flat_mask is not None and flat_mask.dim() == 3:
93
+ flat_mask = flat_mask.view(b, -1)
94
+ return flat_mobile, flat_target, flat_mask
95
+
96
+
97
+ def _masked_coordinates(
98
+ mobile: Tensor,
99
+ target: Tensor,
100
+ atom_mask: Tensor | None,
101
+ ) -> tuple[Tensor, Tensor, Tensor]:
102
+ if atom_mask is None:
103
+ atom_mask = torch.ones(
104
+ mobile.shape[:2],
105
+ dtype=torch.bool,
106
+ device=mobile.device,
107
+ )
108
+ return mobile, target, atom_mask
109
+ expanded_mask = atom_mask.unsqueeze(-1)
110
+ return (
111
+ mobile.masked_fill(~expanded_mask, 0),
112
+ target.masked_fill(~expanded_mask, 0),
113
+ atom_mask,
114
+ )
115
+
116
+
117
+ @torch.no_grad()
118
+ @autocast("cuda", enabled=False)
119
+ def compute_alignment_tensors(
120
+ mobile: Tensor,
121
+ target: Tensor,
122
+ atom_exists_mask: Tensor | None = None,
123
+ sequence_id: Tensor | None = None,
124
+ ) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor, Tensor]:
125
+ """Center and align coordinate tensors ``X`` and ``Y``.
126
+
127
+ Inputs have shape (b, n, 3), or (b, l, n_atoms, 3). The returned rotation
128
+ tensor ``R`` has shape (b, 3, 3), and atom counts have shape (b, 1).
129
+ """
130
+
131
+ mobile, target, atom_exists_mask = _unpack_alignment_inputs(
132
+ mobile,
133
+ target,
134
+ atom_exists_mask,
135
+ sequence_id,
136
+ )
137
+ if mobile.shape != target.shape:
138
+ raise AssertionError("Batch structure shapes do not match!")
139
+ mobile, target, atom_exists_mask = _flatten_atom_axes(
140
+ mobile,
141
+ target,
142
+ atom_exists_mask,
143
+ )
144
+ mobile, target, atom_exists_mask = _masked_coordinates(
145
+ mobile,
146
+ target,
147
+ atom_exists_mask,
148
+ )
149
+
150
+ num_valid_atoms = atom_exists_mask.sum(dim=-1, keepdim=True)
151
+ centroid_mobile = mobile.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1)
152
+ centroid_target = target.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1)
153
+ centroid_mobile[num_valid_atoms == 0] = 0
154
+ centroid_target[num_valid_atoms == 0] = 0
155
+
156
+ expanded_mask = atom_exists_mask.unsqueeze(-1)
157
+ centered_mobile = (mobile - centroid_mobile).masked_fill(~expanded_mask, 0)
158
+ centered_target = (target - centroid_target).masked_fill(~expanded_mask, 0)
159
+ covariance = torch.matmul(centered_mobile.transpose(1, 2), centered_target)
160
+ left_vectors, _, right_vectors = torch.svd(covariance)
161
+ rotation = torch.matmul(left_vectors, right_vectors.transpose(1, 2))
162
+ return (
163
+ centered_mobile,
164
+ centroid_mobile,
165
+ centered_target,
166
+ centroid_target,
167
+ rotation,
168
+ num_valid_atoms,
169
+ )
170
+
171
+
172
+ def _validate_reduction(reduction: str, allowed: tuple[str, ...]) -> None:
173
+ if reduction not in allowed:
174
+ raise ValueError("Unrecognized reduction: '{reduction}'")
175
+
176
+
177
+ @torch.no_grad()
178
+ @autocast("cuda", enabled=False)
179
+ def compute_rmsd_no_alignment(
180
+ aligned: Tensor,
181
+ target: Tensor,
182
+ num_valid_atoms: Tensor,
183
+ reduction: str = "batch",
184
+ ) -> Tensor:
185
+ """Measure RMSD after alignment using a declared reduction."""
186
+
187
+ _validate_reduction(reduction, ("per_residue", "per_sample", "batch"))
188
+ difference = aligned - target
189
+ if reduction == "per_residue":
190
+ mean_squared_error = difference.square().view(difference.size(0), -1, 9).mean(-1)
191
+ else:
192
+ mean_squared_error = difference.square().sum(dim=(1, 2)) / num_valid_atoms.squeeze(-1)
193
+ rmsd = torch.sqrt(mean_squared_error)
194
+ if reduction in {"per_residue", "per_sample"}:
195
+ return rmsd
196
+ valid_samples = num_valid_atoms.squeeze(-1) > 0
197
+ return rmsd.masked_fill(~valid_samples, 0).sum() / (valid_samples.sum() + 1e-8)
198
+
199
+
200
+ @torch.no_grad()
201
+ @autocast("cuda", enabled=False)
202
+ def compute_affine_and_rmsd(
203
+ mobile: Tensor,
204
+ target: Tensor,
205
+ atom_exists_mask: Tensor | None = None,
206
+ sequence_id: Tensor | None = None,
207
+ ) -> tuple[Affine3D, Tensor]:
208
+ """Fit ``X`` onto ``Y`` and return the rigid transform and batch RMSD."""
209
+
210
+ (
211
+ centered_mobile,
212
+ centroid_mobile,
213
+ centered_target,
214
+ centroid_target,
215
+ rotation,
216
+ num_valid_atoms,
217
+ ) = compute_alignment_tensors(mobile, target, atom_exists_mask, sequence_id)
218
+ translation = torch.matmul(-centroid_mobile, rotation) + centroid_target
219
+ affine = Affine3D.from_tensor_pair(
220
+ translation,
221
+ rotation.unsqueeze(dim=-3).transpose(-2, -1),
222
+ )
223
+ rotated_mobile = torch.matmul(centered_mobile, rotation)
224
+ rmsd = compute_rmsd_no_alignment(
225
+ rotated_mobile,
226
+ centered_target,
227
+ num_valid_atoms,
228
+ reduction="batch",
229
+ )
230
+ return affine, rmsd
231
+
232
+
233
+ def compute_gdt_ts_no_alignment(
234
+ aligned: Tensor,
235
+ target: Tensor,
236
+ atom_exists_mask: Tensor,
237
+ reduction: str = "batch",
238
+ ) -> Tensor:
239
+ """Compute GDT-TS for already aligned coordinate tensors."""
240
+
241
+ _validate_reduction(reduction, ("per_sample", "batch"))
242
+ if atom_exists_mask is None:
243
+ atom_exists_mask = torch.isfinite(target).all(dim=-1)
244
+ deviation = torch.linalg.vector_norm(aligned - target, dim=-1)
245
+ counts = atom_exists_mask.sum(dim=-1)
246
+ score_1 = ((deviation < 1) * atom_exists_mask).sum(dim=-1) / counts
247
+ score_2 = ((deviation < 2) * atom_exists_mask).sum(dim=-1) / counts
248
+ score_4 = ((deviation < 4) * atom_exists_mask).sum(dim=-1) / counts
249
+ score_8 = ((deviation < 8) * atom_exists_mask).sum(dim=-1) / counts
250
+ score = (score_1 + score_2 + score_4 + score_8) * 0.25
251
+ return score.mean() if reduction == "batch" else score