lhallee commited on
Commit
71c67d0
·
verified ·
1 Parent(s): 35887bb

Publish ESMFold2 small backbone checkpoint with FastPLMs runtime

Browse files
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/SOURCE_RECORD.md +8 -0
  7. README.md +235 -0
  8. THIRD_PARTY_NOTICES.md +99 -0
  9. config.json +104 -0
  10. fastplms/__init__.py +49 -0
  11. fastplms/attention/__init__.py +68 -0
  12. fastplms/attention/_core.py +824 -0
  13. fastplms/attention/_kernel_lock.py +175 -0
  14. fastplms/attention/interfaces.py +249 -0
  15. fastplms/embeddings/__init__.py +66 -0
  16. fastplms/embeddings/pooling.py +218 -0
  17. fastplms/embeddings/runner.py +1583 -0
  18. fastplms/embeddings/storage.py +1601 -0
  19. fastplms/embeddings/types.py +186 -0
  20. fastplms/models.toml +1275 -0
  21. fastplms/models/__init__.py +11 -0
  22. fastplms/models/_esm_rotary.py +92 -0
  23. fastplms/models/classification_probe.py +542 -0
  24. fastplms/models/esm_plusplus/__init__.py +0 -0
  25. fastplms/models/esm_plusplus/modeling_esm_plusplus.py +2056 -0
  26. fastplms/models/esm_plusplus/modeling_esm_plusplus_sae.py +266 -0
  27. fastplms/models/esmfold2/__init__.py +49 -0
  28. fastplms/models/esmfold2/attention.py +48 -0
  29. fastplms/models/esmfold2/configuration_esmfold2.py +336 -0
  30. fastplms/models/esmfold2/embedding.py +115 -0
  31. fastplms/models/esmfold2/esmfold2_affine3d.py +605 -0
  32. fastplms/models/esmfold2/esmfold2_aligner.py +87 -0
  33. fastplms/models/esmfold2/esmfold2_atom_indexer.py +30 -0
  34. fastplms/models/esmfold2/esmfold2_conformers.py +402 -0
  35. fastplms/models/esmfold2/esmfold2_constants.py +156 -0
  36. fastplms/models/esmfold2/esmfold2_constants_esm3.py +98 -0
  37. fastplms/models/esmfold2/esmfold2_input_builder.py +244 -0
  38. fastplms/models/esmfold2/esmfold2_metrics.py +235 -0
  39. fastplms/models/esmfold2/esmfold2_misc.py +400 -0
  40. fastplms/models/esmfold2/esmfold2_mmcif_parsing.py +469 -0
  41. fastplms/models/esmfold2/esmfold2_molecular_complex.py +1016 -0
  42. fastplms/models/esmfold2/esmfold2_msa.py +577 -0
  43. fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py +111 -0
  44. fastplms/models/esmfold2/esmfold2_normalize_coordinates.py +67 -0
  45. fastplms/models/esmfold2/esmfold2_output.py +201 -0
  46. fastplms/models/esmfold2/esmfold2_paired_msa.py +282 -0
  47. fastplms/models/esmfold2/esmfold2_parsing.py +126 -0
  48. fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py +127 -0
  49. fastplms/models/esmfold2/esmfold2_prepare_input.py +1130 -0
  50. fastplms/models/esmfold2/esmfold2_processor.py +338 -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/SOURCE_RECORD.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 ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # ESMFold2-600
12
+
13
+ ## Model overview
14
+
15
+ `Synthyra/ESMFold2-600` packages the
16
+ `biohub/ESMFold2-Experimental-Fast-base600M-step1500k` checkpoint with the
17
+ FastPLMs runtime for Hugging Face Transformers. It accepts raw amino-acid
18
+ sequences or typed molecular-complex specifications; low-level forward accepts
19
+ prepared feature tensors.
20
+
21
+ The repository uses the standard Transformers loading interface with
22
+ `trust_remote_code=True`. See Technical details for each registered class and
23
+ whether its weights come from the checkpoint.
24
+
25
+ The sequence- and token-classification classes reuse the pretrained backbone,
26
+ but their task heads are newly initialized. Fine-tune those heads before
27
+ interpreting their logits as predictions.
28
+
29
+ ## Install and platform requirements
30
+
31
+ Install the direct dependencies published with this model:
32
+
33
+ ```bash
34
+ python -m pip install -r \
35
+ "https://huggingface.co/Synthyra/ESMFold2-600/resolve/main/requirements.txt"
36
+ ```
37
+
38
+ The FastPLMs implementation itself is embedded in the model repository.
39
+ Transformers loads it through `trust_remote_code=True`.
40
+
41
+ This model requires Python 3.11-3.14, PyTorch 2.13, and Transformers 5.13.
42
+
43
+ The artifact requirements include the structure dependencies.
44
+
45
+ Validation runs in Docker on any compatible CUDA device. Record the container,
46
+ hardware, precision, and inputs; no GPU product or workstation is required.
47
+
48
+ The Hub quick start needs network access for the first download. For an
49
+ air-gapped run, build the manifest-pinned local artifact first and use the
50
+ offline example.
51
+
52
+ ## Quick start
53
+
54
+ ```python
55
+ from transformers import AutoModel
56
+
57
+ model_id = "Synthyra/ESMFold2-600"
58
+ model = AutoModel.from_pretrained(
59
+ model_id,
60
+ trust_remote_code=True,
61
+ attn_implementation="sdpa",
62
+ ).eval()
63
+ ```
64
+
65
+ For offline validation, replace `model_id` with the manifest-built
66
+ `dist/hub/ESMFold2-600` path. Pass `local_files_only=True`.
67
+
68
+ ## Attention backends
69
+
70
+ The quick start uses `sdpa`.
71
+
72
+ Available backends are `eager`, `sdpa`, `flex_attention`. Requesting an
73
+ unavailable backend raises instead of silently changing implementation.
74
+
75
+ `output_attentions=True` can use the documented one-call eager fallback to
76
+ materialize attention tensors. The configured backend does not change.
77
+
78
+ ## Downstream prediction
79
+
80
+ The sequence and token prediction AutoClasses use the checkpoint backbone and
81
+ create a new, untrained `classifier`. Sequence labels have shape `(b,)`.
82
+ Residue labels have shape `(b, l)` and use `-100` outside biological positions.
83
+ The folding trunk is skipped. The classifier uses the checkpoint's learned pLM
84
+ state mixture and projection, followed by one trainable transformer probe.
85
+
86
+ ```python
87
+ import torch
88
+ from transformers import (
89
+ AutoModelForSequenceClassification,
90
+ AutoModelForTokenClassification,
91
+ )
92
+
93
+ model_id = "Synthyra/ESMFold2-600"
94
+ sequence_model = AutoModelForSequenceClassification.from_pretrained(
95
+ model_id, num_labels=2, trust_remote_code=True
96
+ ).eval()
97
+ token_model = AutoModelForTokenClassification.from_pretrained(
98
+ model_id, num_labels=3, trust_remote_code=True
99
+ ).eval()
100
+ sequences = ["MSTNPKPQRKTKRNT", "MKTIIALSYIFCLVFA"]
101
+ batch = sequence_model.prepare_classifier_inputs(sequences)
102
+ biological = batch["attention_mask"].bool()
103
+
104
+ sequence_labels = torch.zeros(len(sequences), dtype=torch.long)
105
+ token_labels = torch.full_like(batch["input_ids"], -100)
106
+ token_labels[biological] = 0
107
+
108
+ with torch.inference_mode():
109
+ sequence_output = sequence_model(**batch, labels=sequence_labels)
110
+ token_output = token_model(**batch, labels=token_labels)
111
+ print(sequence_output.logits.shape) # (b, 2)
112
+ print(token_output.logits.shape) # (b, l, 3)
113
+ ```
114
+
115
+ ## PEFT fine-tuning
116
+
117
+ Install the training dependencies. Then attach LoRA to the loaded checkpoint:
118
+
119
+ ```bash
120
+ python -m pip install "datasets>=4.8,<5" "peft>=0.19,<0.20"
121
+ ```
122
+
123
+ ```python
124
+ from peft import LoraConfig, TaskType, get_peft_model
125
+
126
+ peft_model = get_peft_model(
127
+ sequence_model,
128
+ LoraConfig(
129
+ task_type=TaskType.SEQ_CLS,
130
+ r=8,
131
+ lora_alpha=16,
132
+ target_modules="all-linear",
133
+ modules_to_save=["classifier"],
134
+ ),
135
+ )
136
+ ```
137
+
138
+ This checkpoint advertises a classification head. Save the separately trained
139
+ `classifier` with the adapter.
140
+ All FastPLMs checkpoints follow the Transformers `PreTrainedModel` contract and
141
+ can use PEFT. The ESM2-specific shipped CLI is an example, not a
142
+ support boundary. Record the target modules, base revision, data identity, and
143
+ trainable parameter scope.
144
+
145
+ ## Protein folding
146
+
147
+ This experimental Fast checkpoint has 24 folding blocks and uses the frozen
148
+ `Synthyra/ESMplusplus_large` backbone. The config-declared step-1500000 backbone and the
149
+ pinned ESM++ weights are tensor-exact in BF16 after layout conversion.
150
+
151
+ ```python
152
+ import torch
153
+
154
+ model = model.cuda().eval()
155
+ with torch.inference_mode():
156
+ output = model.infer_protein(
157
+ "MQYKLILNGKTLKGETTTEAVDAATAEKVFKQYANDNGVDGEWTYDDATKTFTVTE",
158
+ seed=17,
159
+ num_diffusion_samples=1,
160
+ )
161
+ print(output.sample_atom_coords.shape)
162
+ ```
163
+
164
+ Folding parameters remain FP32 with CUDA BF16 autocast. The backbone uses
165
+ BF16; FP8 requests fail. The 15-step sampler and three folding loops remain
166
+ the checkpoint defaults. Protein inputs require `msa=None`.
167
+ This checkpoint was trained without MSA conditioning. It rejects
168
+ `ProteinInput.msa` and MSA-derived features. Typed multichain and multimolecule
169
+ inputs remain supported without MSA conditioning.
170
+
171
+ The confidence head is disabled: pLDDT, pTM, iPTM, and PAE are unavailable.
172
+ The 300 and 600 suffixes describe backbone scale, not total model parameters.
173
+
174
+ ## Learned representation and ESMC precision
175
+
176
+ The learned projection maps `H: (b, l, 37, 1152) -> Z: (b, l, 256)`.
177
+ `embed_dataset` returns one `(l, 256)` residue representation per sequence.
178
+ The experimental architecture does not expose folding TTT.
179
+
180
+
181
+ ## Notes and limitations
182
+
183
+ Experimental Fast checkpoint with a frozen 600M ESM++ backbone, tensor-exact in
184
+ BF16 with the pinned step-1500000 source, 24 folding blocks, no MSA
185
+ conditioning, and no confidence head. BF16 execution uses FP32 folding
186
+ parameters with CUDA autocast. FP8 is unsupported. Configuration, weight
187
+ identities, and artifact reload are verified; this model is not
188
+ inference-validated. Publication is pending.
189
+
190
+ ## Technical details
191
+
192
+ - Inputs: Raw amino-acid sequences or typed molecular-complex specifications; low-level forward accepts prepared feature tensors
193
+ - Transformers classes: `AutoConfig`, `AutoModel`, `AutoModelForSequenceClassification`, `AutoModelForTokenClassification`
194
+ - Checkpoint weights: `AutoConfig` = `FastPLMs extension`, `AutoModel` = `pretrained`, `AutoModelForSequenceClassification` = `base weights + untrained task head`, `AutoModelForTokenClassification` = `base weights + untrained task head`
195
+ - Attention backends: `eager`, `sdpa`, `flex_attention`
196
+ - Precision: `auto`, `fp32`, `bf16`
197
+ - BF16 execution: `fp32_parameters_autocast`
198
+ - Generation contract: `not_applicable`
199
+ - Dependencies: `core + structure`
200
+ - Weight publication allowed: `true`
201
+ - Weight license status: `resolved`
202
+ - Redistributable: `true`
203
+ - Complete weight publication required: `false`
204
+
205
+ ## Validation and provenance
206
+
207
+ FastPLMs pins the checkpoint, upstream source revisions, state transformation,
208
+ and required files in `models.toml`. Built artifacts record exact source
209
+ identities and conversion details in `source-record.json`.
210
+
211
+ - FastPLMs checkpoint: `Synthyra/ESMFold2-600`
212
+ - Runtime revision: recorded separately in the built artifact and published commit
213
+ - Runtime source identities: recorded in `source-record.json`
214
+ - Official checkpoint: `biohub/ESMFold2-Experimental-Fast-base600M-step1500k`
215
+ - Artifact source: `fast`
216
+ - State transform: `identity`
217
+ - Pinned upstreams: `biohub-esm`, `biohub-transformers`, `protein-ttt`
218
+ - Release tiers: `check`, `compliance`, `structure`, `feature`, `artifact`, `benchmark`
219
+ - Unresolved required file identities: `0`
220
+
221
+ ESMFold2-300 passed a Docker BF16 reference comparison on one compact Protein G
222
+ sequence. ESMFold2-600 is not inference-validated. Both have configuration,
223
+ weight identity, and artifact loading checks. This is bounded checkpoint
224
+ evidence, not a full structure benchmark result.
225
+
226
+ Declared tiers compare configuration, tokenizer behavior, state, and
227
+ representative inference with the pinned reference. A nonzero unresolved count
228
+ blocks release. Metadata alone does not show that a build passed, that a backend
229
+ is faster, or that an output is biologically valid.
230
+
231
+ ## License
232
+
233
+ Checkpoint terms: MIT. The Hub model-card identifier is
234
+ `mit`. The local artifact contains applicable source
235
+ licenses, notices, attribution, and conversion records. Review them 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/SOURCE_RECORD.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/SOURCE_RECORD.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 `source-record.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 ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "ESMFold2ExperimentalModel"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "modeling_fastplms.ESMFold2Config",
7
+ "AutoModel": "modeling_fastplms.ESMFold2ExperimentalModel",
8
+ "AutoModelForSequenceClassification": "modeling_fastplms.ESMFold2ExperimentalForSequenceClassification",
9
+ "AutoModelForTokenClassification": "modeling_fastplms.ESMFold2ExperimentalForTokenClassification"
10
+ },
11
+ "confidence_head": {
12
+ "distogram_bins": 128,
13
+ "enabled": false,
14
+ "folding_trunk": {
15
+ "dropout": 0.25,
16
+ "n_heads": 8,
17
+ "n_layers": 4
18
+ },
19
+ "max_dist": 52.0,
20
+ "min_dist": 2.0,
21
+ "num_pae_bins": 64,
22
+ "num_pde_bins": 64,
23
+ "num_plddt_bins": 50
24
+ },
25
+ "d_pair": 256,
26
+ "d_single": 384,
27
+ "disable_msa_features": true,
28
+ "dtype": "float32",
29
+ "esmc_id": "Synthyra/ESMplusplus_large",
30
+ "fastplms_checkpoint_hash": "5c59bffd38186e80d0a5ebd28e9d73129fd96f462ce145be925d289e945b02d8",
31
+ "fastplms_checkpoint_repo_id": "biohub/ESMFold2-Experimental-Fast-base600M-step1500k",
32
+ "fastplms_checkpoint_revision": "15cf2d6648692f6c17cee1297d8a285476fffa9b",
33
+ "fastplms_model_id": "esmfold2_600",
34
+ "fastplms_runtime_bundle_sha256": "ef1f313b70da02019bf0712085e332968c1cecf189633f721fce2554cff6b61f",
35
+ "fastplms_weights_revision": "15cf2d6648692f6c17cee1297d8a285476fffa9b",
36
+ "folding_trunk": {
37
+ "dropout": 0.25,
38
+ "n_heads": 8,
39
+ "n_layers": 24
40
+ },
41
+ "force_lm_dropout_during_inference": false,
42
+ "inputs": {
43
+ "atom_encoder": {
44
+ "d_atom": 128,
45
+ "d_token": 768,
46
+ "expansion_ratio": 2,
47
+ "n_blocks": 3,
48
+ "n_heads": 4,
49
+ "n_spatial_rope_pairs_per_axis": 2,
50
+ "n_uid_rope_pairs": 10,
51
+ "spatial_rope_base_frequency": 20.0,
52
+ "swa_window_size": 128,
53
+ "uid_rope_base_frequency": 10000.0
54
+ },
55
+ "d_inputs": 451
56
+ },
57
+ "lm_d_model": 1152,
58
+ "lm_dropout": 0.0,
59
+ "lm_num_layers": 36,
60
+ "model_type": "esmfold2",
61
+ "msa_conditioning": false,
62
+ "msa_encoder": {
63
+ "d_hidden": 32,
64
+ "d_msa": 128,
65
+ "enabled": false,
66
+ "msa_head_width": 32,
67
+ "n_heads_msa": 8,
68
+ "n_layers": 4
69
+ },
70
+ "n_relative_chain_bins": 2,
71
+ "n_relative_residx_bins": 32,
72
+ "num_diffusion_samples": 16,
73
+ "num_loops": 3,
74
+ "structure_head": {
75
+ "diffusion_module": {
76
+ "atom_num_blocks": 3,
77
+ "atom_num_heads": 4,
78
+ "c_atom": 128,
79
+ "c_s_inputs": 451,
80
+ "c_token": 768,
81
+ "c_z": 256,
82
+ "fourier_dim": 256,
83
+ "relpos_r_max": 32,
84
+ "relpos_s_max": 2,
85
+ "sigma_data": 16.0,
86
+ "token_num_blocks": 12,
87
+ "token_num_heads": 16,
88
+ "transition_multiplier": 2
89
+ },
90
+ "distogram_bins": 128,
91
+ "gamma_0": 0.605,
92
+ "gamma_min": 1.107,
93
+ "inference_num_steps": 15,
94
+ "inference_p": 8.0,
95
+ "inference_s_max": 160.0,
96
+ "inference_s_min": 0.0004,
97
+ "noise_scale": 0.901,
98
+ "step_scale": 1.638,
99
+ "train_noise_log_mean": -1.2,
100
+ "train_noise_log_std": 1.5
101
+ },
102
+ "transformers_version": "4.57.6",
103
+ "type": "experimental"
104
+ }
fastplms/__init__.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
13
+ __version__ = "1.0.0"
14
+
15
+ _LAZY_EXPORTS = {
16
+ "CheckpointSource": ("fastplms.registry", "CheckpointSource"),
17
+ "EmbeddingInput": ("fastplms.embeddings", "EmbeddingInput"),
18
+ "EmbeddingRecord": ("fastplms.embeddings", "EmbeddingRecord"),
19
+ "EmbeddingResult": ("fastplms.embeddings", "EmbeddingResult"),
20
+ "FileDigest": ("fastplms.registry", "FileDigest"),
21
+ "ModelFamily": ("fastplms.registry", "ModelFamily"),
22
+ "ModelRegistry": ("fastplms.registry", "ModelRegistry"),
23
+ "ModelSpec": ("fastplms.registry", "ModelSpec"),
24
+ "OracleAsset": ("fastplms.registry", "OracleAsset"),
25
+ "RegistryError": ("fastplms.registry", "RegistryError"),
26
+ "RuntimeProfile": ("fastplms.runtime", "RuntimeProfile"),
27
+ "UpstreamSource": ("fastplms.registry", "UpstreamSource"),
28
+ "embed_dataset": ("fastplms.embeddings", "embed_dataset"),
29
+ "get_model_registry": ("fastplms.registry", "get_model_registry"),
30
+ "get_model_spec": ("fastplms.registry", "get_model_spec"),
31
+ "load_model_registry": ("fastplms.registry", "load_model_registry"),
32
+ "runtime_profile": ("fastplms.runtime", "runtime_profile"),
33
+ }
34
+
35
+ __all__ = ["__version__", *_LAZY_EXPORTS]
36
+
37
+
38
+ def __getattr__(name: str) -> Any:
39
+ try:
40
+ module_name, attribute_name = _LAZY_EXPORTS[name]
41
+ except KeyError as error:
42
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from error
43
+ value = getattr(import_module(module_name), attribute_name)
44
+ globals()[name] = value
45
+ return value
46
+
47
+
48
+ def __dir__() -> list[str]:
49
+ return sorted(set(globals()).union(__all__))
fastplms/attention/__init__.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared attention backends, masks, and optional optimized kernels."""
2
+
3
+ from ._core import (
4
+ LEGACY_CHECKPOINT_ATTENTION_BACKENDS,
5
+ VALID_ATTENTION_BACKENDS,
6
+ AttentionBackend,
7
+ BlockMask,
8
+ _ensure_flash_kernels_loaded,
9
+ _get_flex_attention_fn,
10
+ _get_flex_block_mask,
11
+ _kernels_flash_forward,
12
+ _kernels_flash_varlen_forward,
13
+ _unpad_input,
14
+ bool_to_additive_mask,
15
+ canonical_checkpoint_attention_backend,
16
+ clear_flex_attention_caches,
17
+ create_block_mask,
18
+ flex_attention,
19
+ get_attention_mask,
20
+ get_attn_implementation,
21
+ index_first_axis,
22
+ index_put_first_axis,
23
+ kernels_flash_attention_func,
24
+ pad_input,
25
+ resolve_attention_backend,
26
+ resolve_attention_backend_for_call,
27
+ set_config_attn_implementation,
28
+ warn_attention_backend_fallback,
29
+ )
30
+ from .interfaces import (
31
+ FASTPLMS_ATTENTION_FUNCTIONS,
32
+ FASTPLMS_ATTENTION_MASKS,
33
+ FastPLMsAttentionMixin,
34
+ validate_transformers_attention_interfaces,
35
+ )
36
+
37
+
38
+ __all__ = [
39
+ "FASTPLMS_ATTENTION_FUNCTIONS",
40
+ "FASTPLMS_ATTENTION_MASKS",
41
+ "LEGACY_CHECKPOINT_ATTENTION_BACKENDS",
42
+ "VALID_ATTENTION_BACKENDS",
43
+ "AttentionBackend",
44
+ "BlockMask",
45
+ "FastPLMsAttentionMixin",
46
+ "_ensure_flash_kernels_loaded",
47
+ "_get_flex_attention_fn",
48
+ "_get_flex_block_mask",
49
+ "_kernels_flash_forward",
50
+ "_kernels_flash_varlen_forward",
51
+ "_unpad_input",
52
+ "bool_to_additive_mask",
53
+ "canonical_checkpoint_attention_backend",
54
+ "clear_flex_attention_caches",
55
+ "create_block_mask",
56
+ "flex_attention",
57
+ "get_attention_mask",
58
+ "get_attn_implementation",
59
+ "index_first_axis",
60
+ "index_put_first_axis",
61
+ "kernels_flash_attention_func",
62
+ "pad_input",
63
+ "resolve_attention_backend",
64
+ "resolve_attention_backend_for_call",
65
+ "set_config_attn_implementation",
66
+ "validate_transformers_attention_interfaces",
67
+ "warn_attention_backend_fallback",
68
+ ]
fastplms/attention/_core.py ADDED
@@ -0,0 +1,824 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import torch
12
+ from collections import OrderedDict
13
+ from collections.abc import Callable
14
+ from enum import Enum
15
+ from threading import RLock
16
+ from types import MappingProxyType
17
+ from einops import rearrange
18
+ from torch.nn import functional as F
19
+
20
+ from ._kernel_lock import load_locked_kernel
21
+
22
+
23
+ try:
24
+ from torch.nn.attention.flex_attention import BlockMask, create_block_mask, flex_attention
25
+ except ImportError:
26
+ create_block_mask = None
27
+ flex_attention = None
28
+ BlockMask = None
29
+
30
+ _MAX_FLEX_CACHE_ENTRIES = 128
31
+ _compiled_flex_attention: OrderedDict[tuple, object] = OrderedDict()
32
+ _flex_block_masks: OrderedDict[tuple, BlockMask] = OrderedDict()
33
+ _flex_cache_lock = RLock()
34
+
35
+
36
+ def _remember(cache: OrderedDict, key: tuple, value):
37
+ """Insert an item into a bounded least-recently-used cache."""
38
+ cache[key] = value
39
+ cache.move_to_end(key)
40
+ while len(cache) > _MAX_FLEX_CACHE_ENTRIES:
41
+ cache.popitem(last=False)
42
+ return value
43
+
44
+
45
+ def clear_flex_attention_caches() -> None:
46
+ """Drop FastPLMs-owned compiled Flex callables and block masks.
47
+
48
+ This deliberately does not call :func:`torch.compiler.reset`, which would
49
+ clear process-global Torch compilation state owned by unrelated models.
50
+ Active forwards retain their local references and can complete safely.
51
+ """
52
+
53
+ with _flex_cache_lock:
54
+ _compiled_flex_attention.clear()
55
+ _flex_block_masks.clear()
56
+
57
+
58
+ def _get_flex_attention_fn(
59
+ *,
60
+ device: torch.device | None = None,
61
+ dtype: torch.dtype | None = None,
62
+ shape: tuple[int, ...] | None = None,
63
+ sequence_lengths: tuple[int, ...] | None = None,
64
+ mask_semantics: str = "padding",
65
+ ):
66
+ """Return a compiled Flex callable for an explicit execution signature.
67
+
68
+ Compilation depends on execution shape, device, dtype, and mask semantics.
69
+ Per-example padding lengths are represented by the ``BlockMask`` argument
70
+ and must not create a new compiled graph for every batch composition.
71
+ """
72
+ if flex_attention is None:
73
+ return None
74
+ # Retain the keyword for compatibility with remote-code artifacts while
75
+ # deliberately excluding data-dependent lengths from the compile key.
76
+ del sequence_lengths
77
+ flex_mod = torch.nn.attention.flex_attention
78
+ if getattr(flex_mod, "_FLEX_ATTENTION_DISABLE_COMPILE_DEBUG", False):
79
+ return flex_attention
80
+ key = (
81
+ None if device is None else str(device),
82
+ None if dtype is None else str(dtype),
83
+ shape,
84
+ mask_semantics,
85
+ )
86
+ with _flex_cache_lock:
87
+ compiled = _compiled_flex_attention.get(key)
88
+ if compiled is None:
89
+ compiled = torch.compile(flex_attention, dynamic=False)
90
+ _remember(_compiled_flex_attention, key, compiled)
91
+ else:
92
+ _compiled_flex_attention.move_to_end(key)
93
+ return compiled
94
+
95
+
96
+ def _get_flex_block_mask(
97
+ *,
98
+ mask_pattern: torch.Tensor,
99
+ batch_size: int,
100
+ query_length: int,
101
+ key_value_length: int,
102
+ device: torch.device,
103
+ dtype: torch.dtype | None,
104
+ mask_semantics: str,
105
+ mask_mod: Callable[
106
+ [torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor],
107
+ torch.Tensor,
108
+ ],
109
+ ) -> BlockMask:
110
+ """Return a bounded, exact-pattern cached Flex ``BlockMask``.
111
+
112
+ The complete pattern is transferred to the host once to avoid a CUDA
113
+ synchronization per batch row. Execution dtype remains part of the key
114
+ because compiled Flex plans can specialize on it even though the pattern
115
+ tensor itself is boolean or integer.
116
+ """
117
+ if create_block_mask is None:
118
+ raise RuntimeError(
119
+ "'flex_attention' was requested, but torch.create_block_mask is unavailable."
120
+ )
121
+ pattern = mask_pattern.detach().to(device=device).contiguous() # mask_pattern.shape
122
+ # One device-to-host transfer is required for an exact cache identity. Use
123
+ # the contiguous buffer directly instead of materializing one Python int
124
+ # per byte, which is prohibitively expensive for long batched sequences.
125
+ host_pattern = pattern.to(device="cpu").contiguous() # mask_pattern.shape
126
+ pattern_bytes = host_pattern.view(torch.uint8).numpy().tobytes(order="C") # bytes
127
+ cache_key = (
128
+ str(device),
129
+ None if dtype is None else str(dtype),
130
+ (batch_size, query_length, key_value_length),
131
+ str(pattern.dtype),
132
+ pattern_bytes,
133
+ mask_semantics,
134
+ )
135
+ with _flex_cache_lock:
136
+ flex_block_mask = _flex_block_masks.get(cache_key)
137
+ if flex_block_mask is None:
138
+ flex_block_mask = create_block_mask(
139
+ mask_mod,
140
+ batch_size,
141
+ 1,
142
+ query_length,
143
+ key_value_length,
144
+ device=device,
145
+ )
146
+ _remember(_flex_block_masks, cache_key, flex_block_mask)
147
+ else:
148
+ _flex_block_masks.move_to_end(cache_key)
149
+ return flex_block_mask
150
+
151
+
152
+ # Hugging Face `kernels` exposes slightly different APIs for FlashAttention 2
153
+ # and 3. Detect the loaded variant once so every caller uses the same dispatch.
154
+ def _infer_kernels_flash_variant(kernel) -> str | None:
155
+ if hasattr(kernel, "fwd") and hasattr(kernel, "varlen_fwd"):
156
+ return "flash_attn2"
157
+ if hasattr(kernel, "flash_attn_func") and hasattr(kernel, "flash_attn_varlen_func"):
158
+ return "flash_attn3"
159
+ return None
160
+
161
+
162
+ def _load_kernels_flash(implementation: str) -> tuple[object, str]:
163
+ """Load exactly the requested FlashAttention kernel.
164
+
165
+ Loading is deferred until backend selection. A FlashAttention-2 request
166
+ never falls through to FlashAttention-3, or vice versa.
167
+ """
168
+ from fastplms.registry import get_model_registry
169
+
170
+ kernel_spec = get_model_registry().attention_kernels[implementation]
171
+ repository = kernel_spec.repository
172
+ try:
173
+ flash_kernel = load_locked_kernel(repository, kernel_spec.revision)
174
+ except Exception as error:
175
+ raise RuntimeError(
176
+ f"Unable to load the manifest-pinned kernel "
177
+ f"{repository}@{kernel_spec.revision} for {implementation!r}."
178
+ ) from error
179
+ flash_kernel_variant = _infer_kernels_flash_variant(flash_kernel)
180
+ if flash_kernel_variant != kernel_spec.expected_variant:
181
+ raise RuntimeError(
182
+ f"{repository}@{kernel_spec.revision} exposed {flash_kernel_variant!r}; "
183
+ f"expected {kernel_spec.expected_variant!r}."
184
+ )
185
+ if not all(
186
+ callable(getattr(flash_kernel, name, None))
187
+ for name in ("flash_attn_func", "flash_attn_varlen_func")
188
+ ):
189
+ raise RuntimeError(
190
+ f"{repository}@{kernel_spec.revision} does not expose the "
191
+ "autograd-enabled flash_attn_func and flash_attn_varlen_func APIs."
192
+ )
193
+ return flash_kernel, flash_kernel_variant
194
+
195
+
196
+ _FLASH_KERNELS: dict[str, tuple[object, str]] = {}
197
+
198
+
199
+ def _validate_kernels_flash_dtype(
200
+ query_states: torch.Tensor,
201
+ key_states: torch.Tensor,
202
+ value_states: torch.Tensor,
203
+ implementation: str,
204
+ ) -> torch.dtype:
205
+ """Reject dtypes outside the immutable kernel manifest before dispatch."""
206
+
207
+ # query_states, key_states, value_states: (b, l, h, d) or (t, h, d)
208
+ tensor_dtypes = {query_states.dtype, key_states.dtype, value_states.dtype}
209
+ if len(tensor_dtypes) != 1:
210
+ observed = ", ".join(sorted(str(dtype) for dtype in tensor_dtypes))
211
+ raise RuntimeError(
212
+ f"{implementation!r} requires Q, K, and V to share one dtype; received {observed}."
213
+ )
214
+ runtime_dtype = query_states.dtype
215
+ if (
216
+ runtime_dtype == torch.float32
217
+ and query_states.is_cuda
218
+ and torch.is_autocast_enabled("cuda")
219
+ ):
220
+ runtime_dtype = torch.get_autocast_dtype("cuda")
221
+ dtype_names = {
222
+ torch.float32: "float32",
223
+ torch.bfloat16: "bfloat16",
224
+ torch.float16: "float16",
225
+ }
226
+ runtime_dtype_name = dtype_names.get(runtime_dtype, str(runtime_dtype))
227
+ from fastplms.registry import get_model_registry
228
+
229
+ supported = get_model_registry().attention_kernels[implementation].dtypes
230
+ if runtime_dtype_name not in supported:
231
+ expected = ", ".join(supported)
232
+ raise RuntimeError(
233
+ f"{implementation!r} supports only manifest-declared dtype(s) {expected}; "
234
+ f"received {runtime_dtype_name}. Use CUDA BF16 autocast for FP32-resident "
235
+ "models."
236
+ )
237
+ return runtime_dtype
238
+
239
+
240
+ def _validate_kernels_flash_device(
241
+ query_states: torch.Tensor,
242
+ key_states: torch.Tensor,
243
+ value_states: torch.Tensor,
244
+ implementation: str,
245
+ ) -> torch.device:
246
+ """Require Q, K, and V on one CUDA device before loading a kernel."""
247
+
248
+ # query_states, key_states, value_states: (b, l, h, d) or (t, h, d)
249
+ devices = (query_states.device, key_states.device, value_states.device)
250
+ if len(set(devices)) != 1:
251
+ observed = ", ".join(str(device) for device in devices)
252
+ raise RuntimeError(
253
+ f"{implementation!r} requires Q, K, and V on one device; received {observed}."
254
+ )
255
+ device = devices[0]
256
+ if device.type != "cuda" or not all(
257
+ tensor.is_cuda for tensor in (query_states, key_states, value_states)
258
+ ):
259
+ raise RuntimeError(
260
+ f"{implementation!r} requires CUDA Q, K, and V; received device {device}."
261
+ )
262
+ return device
263
+
264
+
265
+ def _ensure_flash_kernels_loaded(implementation: str) -> tuple[object, str]:
266
+ cached = _FLASH_KERNELS.get(implementation)
267
+ if cached is not None:
268
+ return cached
269
+ loaded = _load_kernels_flash(implementation)
270
+ _FLASH_KERNELS[implementation] = loaded
271
+ return loaded
272
+
273
+
274
+ def _kernels_flash_forward(
275
+ query_states: torch.Tensor,
276
+ key_states: torch.Tensor,
277
+ value_states: torch.Tensor,
278
+ causal: bool = False,
279
+ softmax_scale: float | None = None,
280
+ implementation: str = "flash_attention_3",
281
+ ) -> torch.Tensor:
282
+ """Flash-attention forward, optionally overriding the softmax scale.
283
+
284
+ When `softmax_scale is None`, the flash kernel applies its default
285
+ `1 / sqrt(head_dim)`. Pass `softmax_scale=1.0` if the caller has already
286
+ pre-scaled Q (the convention used by ESM2, DPLM, DPLM2, E1, ESMFold).
287
+ Failing to override when Q is pre-scaled applies the scale twice and breaks
288
+ parity with eager attention and SDPA.
289
+ """
290
+ # query_states, key_states, value_states: (b, l, h, d)
291
+ flash_kernel, flash_kernel_variant = _ensure_flash_kernels_loaded(implementation)
292
+ if flash_kernel_variant == "flash_attn2":
293
+ output = flash_kernel.flash_attn_func( # (b, l, h, d) or tuple with that first
294
+ q=query_states,
295
+ k=key_states,
296
+ v=value_states,
297
+ dropout_p=0.0,
298
+ softmax_scale=softmax_scale,
299
+ causal=causal,
300
+ )
301
+ return output[0] if isinstance(output, tuple) else output # (b, l, h, d)
302
+ if flash_kernel_variant == "flash_attn3":
303
+ output = flash_kernel.flash_attn_func( # (b, l, h, d) or tuple with that first
304
+ q=query_states,
305
+ k=key_states,
306
+ v=value_states,
307
+ softmax_scale=softmax_scale,
308
+ causal=causal,
309
+ )
310
+ if isinstance(output, tuple):
311
+ return output[0] # (b, l, h, d)
312
+ return output # (b, l, h, d)
313
+ raise RuntimeError(f"Unsupported FlashAttention kernel variant: {flash_kernel_variant}")
314
+
315
+
316
+ def _kernels_flash_varlen_forward(
317
+ query_states: torch.Tensor,
318
+ key_states: torch.Tensor,
319
+ value_states: torch.Tensor,
320
+ cu_seqlens_q: torch.Tensor,
321
+ cu_seqlens_k: torch.Tensor,
322
+ max_seqlen_in_batch_q: int,
323
+ max_seqlen_in_batch_k: int,
324
+ causal: bool = False,
325
+ softmax_scale: float | None = None,
326
+ implementation: str = "flash_attention_3",
327
+ ) -> torch.Tensor:
328
+ """Varlen flash-attention forward, optionally overriding the softmax scale.
329
+
330
+ See `_kernels_flash_forward` docstring for why `softmax_scale=1.0` must be
331
+ passed when Q has been pre-scaled by the caller.
332
+ """
333
+ # query_states, key_states, value_states: (t, h, d)
334
+ # cu_seqlens_q, cu_seqlens_k: (b + 1,)
335
+ flash_kernel, flash_kernel_variant = _ensure_flash_kernels_loaded(implementation)
336
+ if flash_kernel_variant == "flash_attn2":
337
+ output = flash_kernel.flash_attn_varlen_func( # (t, h, d) or tuple with that first
338
+ q=query_states,
339
+ k=key_states,
340
+ v=value_states,
341
+ cu_seqlens_q=cu_seqlens_q,
342
+ cu_seqlens_k=cu_seqlens_k,
343
+ max_seqlen_q=max_seqlen_in_batch_q,
344
+ max_seqlen_k=max_seqlen_in_batch_k,
345
+ dropout_p=0.0,
346
+ softmax_scale=softmax_scale,
347
+ causal=causal,
348
+ )
349
+ return output[0] if isinstance(output, tuple) else output # (t, h, d)
350
+ if flash_kernel_variant == "flash_attn3":
351
+ output = flash_kernel.flash_attn_varlen_func( # (t, h, d) or tuple with that first
352
+ q=query_states,
353
+ k=key_states,
354
+ v=value_states,
355
+ cu_seqlens_q=cu_seqlens_q,
356
+ cu_seqlens_k=cu_seqlens_k,
357
+ max_seqlen_q=max_seqlen_in_batch_q,
358
+ max_seqlen_k=max_seqlen_in_batch_k,
359
+ softmax_scale=softmax_scale,
360
+ causal=causal,
361
+ )
362
+ if isinstance(output, tuple):
363
+ return output[0] # (t, h, d)
364
+ return output # (t, h, d)
365
+ raise RuntimeError(f"Unsupported FlashAttention kernel variant: {flash_kernel_variant}")
366
+
367
+
368
+ # Varlen flash attention runs only on real tokens. These helpers remove padding
369
+ # before the kernel call and restore the original padded batch shape afterward.
370
+ class IndexFirstAxis(torch.autograd.Function):
371
+ @staticmethod
372
+ def forward(ctx, input, indices) -> torch.Tensor:
373
+ # input: (n, ...); indices: (m,)
374
+ ctx.save_for_backward(indices)
375
+ if input.ndim < 2:
376
+ raise ValueError(
377
+ "index_first_axis input must have at least two dimensions; "
378
+ f"received shape {tuple(input.shape)}."
379
+ )
380
+ if indices.ndim != 1:
381
+ raise ValueError(
382
+ "index_first_axis indices must be one-dimensional; "
383
+ f"received shape {tuple(indices.shape)}."
384
+ )
385
+ ctx.first_axis_dim, other_shape = input.shape[0], input.shape[1:]
386
+ second_dim = other_shape.numel()
387
+ return torch.gather( # (m, ...)
388
+ rearrange(input, "b ... -> b (...)"), 0, indices.unsqueeze(1).expand(-1, second_dim)
389
+ ).reshape(-1, *other_shape)
390
+
391
+ @staticmethod
392
+ def backward(ctx, grad_output) -> tuple[torch.Tensor, None]:
393
+ # grad_output: (m, ...)
394
+ (indices,) = ctx.saved_tensors
395
+ if grad_output.ndim < 2:
396
+ raise RuntimeError(
397
+ "index_first_axis received an invalid gradient with fewer than "
398
+ "two dimensions."
399
+ )
400
+ other_shape = grad_output.shape[1:]
401
+ grad_output = rearrange(grad_output, "b ... -> b (...)") # (m, product(...))
402
+ grad_input = torch.zeros( # (n, product(...))
403
+ [ctx.first_axis_dim, grad_output.shape[1]],
404
+ device=grad_output.device,
405
+ dtype=grad_output.dtype,
406
+ )
407
+ grad_input.scatter_(0, indices.unsqueeze(1).expand(-1, grad_output.shape[1]), grad_output)
408
+ return grad_input.reshape(ctx.first_axis_dim, *other_shape), None # (n, ...), None
409
+
410
+
411
+ class IndexPutFirstAxis(torch.autograd.Function):
412
+ @staticmethod
413
+ def forward(ctx, values, indices, first_axis_dim) -> torch.Tensor:
414
+ # values: (m, ...); indices: (m,)
415
+ ctx.save_for_backward(indices)
416
+ if indices.ndim != 1:
417
+ raise ValueError(
418
+ "index_put_first_axis indices must be one-dimensional; "
419
+ f"received shape {tuple(indices.shape)}."
420
+ )
421
+ if values.ndim < 2:
422
+ raise ValueError(
423
+ "index_put_first_axis values must have at least two dimensions; "
424
+ f"received shape {tuple(values.shape)}."
425
+ )
426
+ output = torch.zeros( # (n, ...)
427
+ first_axis_dim, *values.shape[1:], device=values.device, dtype=values.dtype
428
+ )
429
+ output[indices] = values
430
+ return output # (n, ...)
431
+
432
+ @staticmethod
433
+ def backward(ctx, grad_output) -> tuple[torch.Tensor, None, None]:
434
+ # grad_output: (n, ...)
435
+ (indices,) = ctx.saved_tensors
436
+ return grad_output[indices], None, None # (m, ...), None, None
437
+
438
+
439
+ index_first_axis = IndexFirstAxis.apply
440
+ index_put_first_axis = IndexPutFirstAxis.apply
441
+
442
+
443
+ def pad_input(
444
+ hidden_states: torch.Tensor, indices: torch.Tensor, batch: int, seqlen: int
445
+ ) -> torch.Tensor:
446
+ # hidden_states: (t, ...); indices: (t,)
447
+ output = index_put_first_axis(hidden_states, indices, batch * seqlen) # (b * l, ...)
448
+ return rearrange(output, "(b s) ... -> b s ...", b=batch) # (b, l, ...)
449
+
450
+
451
+ def _unpad_input(
452
+ query_layer: torch.Tensor,
453
+ key_layer: torch.Tensor,
454
+ value_layer: torch.Tensor,
455
+ attention_mask_2d: torch.Tensor,
456
+ ) -> tuple[
457
+ torch.Tensor,
458
+ torch.Tensor,
459
+ torch.Tensor,
460
+ torch.Tensor,
461
+ tuple[torch.Tensor, torch.Tensor],
462
+ tuple[int, int],
463
+ ]:
464
+ # query_layer, key_layer, value_layer: (b, l, h, d); attention_mask_2d: (b, l)
465
+ batch_size, seq_len, num_heads, head_dim = query_layer.shape
466
+ seqlens = attention_mask_2d.sum(dim=1).int() # (b,)
467
+ cu_seqlens = F.pad(seqlens.cumsum(0, dtype=torch.int32), (1, 0)) # (b + 1,)
468
+ max_seqlen = int(seqlens.max().item())
469
+ indices = attention_mask_2d.flatten().nonzero(as_tuple=False).flatten() # (t,)
470
+ query_layer = index_first_axis( # (t, h, d)
471
+ query_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
472
+ )
473
+ key_layer = index_first_axis( # (t, h, d)
474
+ key_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
475
+ )
476
+ value_layer = index_first_axis( # (t, h, d)
477
+ value_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
478
+ )
479
+ return (
480
+ query_layer,
481
+ key_layer,
482
+ value_layer,
483
+ indices,
484
+ (cu_seqlens, cu_seqlens),
485
+ (max_seqlen, max_seqlen),
486
+ )
487
+
488
+
489
+ def _validate_flash_padding_mask(
490
+ query_states: torch.Tensor,
491
+ key_states: torch.Tensor,
492
+ value_states: torch.Tensor,
493
+ attention_mask_2d: torch.Tensor,
494
+ ) -> torch.Tensor:
495
+ """Validate the self-attention padding mask used by the varlen kernels."""
496
+
497
+ # query_states, key_states, value_states: (b, l, h, d); attention_mask_2d: (b, l)
498
+ if attention_mask_2d.ndim != 2:
499
+ raise ValueError("FlashAttention padding masks must have shape (batch, sequence_length).")
500
+ expected_shape = query_states.shape[:2]
501
+ if tuple(attention_mask_2d.shape) != tuple(expected_shape):
502
+ raise ValueError(
503
+ "FlashAttention padding mask shape must match the query batch and "
504
+ f"sequence dimensions; expected {tuple(expected_shape)}, received "
505
+ f"{tuple(attention_mask_2d.shape)}."
506
+ )
507
+ if key_states.shape[:2] != expected_shape or value_states.shape[:2] != expected_shape:
508
+ raise ValueError(
509
+ "Masked FlashAttention requires Q, K, and V to share batch and sequence dimensions."
510
+ )
511
+ if attention_mask_2d.device != query_states.device:
512
+ raise ValueError("FlashAttention padding mask and Q, K, and V must be on the same device.")
513
+ return attention_mask_2d.to(dtype=torch.bool) # (b, l)
514
+
515
+
516
+ def kernels_flash_attention_func(
517
+ query_states: torch.Tensor,
518
+ key_states: torch.Tensor,
519
+ value_states: torch.Tensor,
520
+ attention_mask_2d: torch.Tensor | None = None,
521
+ causal: bool = False,
522
+ softmax_scale: float | None = None,
523
+ implementation: str = "flash_attention_3",
524
+ ) -> torch.Tensor:
525
+ """Public flash-attention entry point with optional padding handling.
526
+
527
+ `softmax_scale`:
528
+ None -> kernel applies its default `1 / sqrt(head_dim)`.
529
+ float -> kernel uses the given scale (pass 1.0 when Q is pre-scaled
530
+ by the caller).
531
+
532
+ Caller contract: if a model family pre-scales Q by `1/sqrt(head_dim)`
533
+ before calling this function (ESM2, DPLM, DPLM2, E1, and ESMFold do), pass
534
+ `softmax_scale=1.0`. Otherwise the flash kernel applies its default scale
535
+ again, yielding an effective `1/head_dim` scale that drifts across layers.
536
+ """
537
+ # query_states, key_states, value_states: (b, l, h, d)
538
+ # attention_mask_2d: (b, l) or None
539
+ _validate_kernels_flash_device(
540
+ query_states,
541
+ key_states,
542
+ value_states,
543
+ implementation,
544
+ )
545
+ runtime_dtype = _validate_kernels_flash_dtype(
546
+ query_states,
547
+ key_states,
548
+ value_states,
549
+ implementation,
550
+ )
551
+ if query_states.dtype != runtime_dtype:
552
+ query_states = query_states.to(dtype=runtime_dtype) # (b, l, h, d)
553
+ key_states = key_states.to(dtype=runtime_dtype) # (b, l, h, d)
554
+ value_states = value_states.to(dtype=runtime_dtype) # (b, l, h, d)
555
+ if attention_mask_2d is not None:
556
+ attention_mask_2d = _validate_flash_padding_mask( # (b, l)
557
+ query_states,
558
+ key_states,
559
+ value_states,
560
+ attention_mask_2d,
561
+ )
562
+ _ensure_flash_kernels_loaded(implementation)
563
+ if attention_mask_2d is not None:
564
+ batch_size, q_len = query_states.shape[:2]
565
+ (
566
+ query_states,
567
+ key_states,
568
+ value_states,
569
+ indices_q,
570
+ (cu_seqlens_q, cu_seqlens_k),
571
+ (max_seqlen_q, max_seqlen_k),
572
+ ) = _unpad_input( # (t, h, d), (t, h, d), (t, h, d), (t,), (b + 1,), scalars
573
+ query_states,
574
+ key_states,
575
+ value_states,
576
+ attention_mask_2d,
577
+ )
578
+ attn_output_unpad = _kernels_flash_varlen_forward( # (t, h, d)
579
+ query_states=query_states,
580
+ key_states=key_states,
581
+ value_states=value_states,
582
+ cu_seqlens_q=cu_seqlens_q,
583
+ cu_seqlens_k=cu_seqlens_k,
584
+ max_seqlen_in_batch_q=max_seqlen_q,
585
+ max_seqlen_in_batch_k=max_seqlen_k,
586
+ causal=causal,
587
+ softmax_scale=softmax_scale,
588
+ implementation=implementation,
589
+ )
590
+ output = pad_input(attn_output_unpad, indices_q, batch_size, q_len) # (b, l, h, d)
591
+ return output.masked_fill(~attention_mask_2d[:, :, None, None], 0) # (b, l, h, d)
592
+ else:
593
+ return _kernels_flash_forward( # (b, l, h, d)
594
+ query_states=query_states,
595
+ key_states=key_states,
596
+ value_states=value_states,
597
+ causal=causal,
598
+ softmax_scale=softmax_scale,
599
+ implementation=implementation,
600
+ )
601
+
602
+
603
+ # User-facing backend strings follow the Transformers attention interface.
604
+ # Keep ``str`` plus ``Enum`` so stringification stays compatible with existing
605
+ # configuration serialization rather than adopting ``StrEnum.__str__``.
606
+ class AttentionBackend(str, Enum): # noqa: UP042
607
+ EAGER = "eager"
608
+ SDPA = "sdpa"
609
+ FLEX_ATTENTION = "flex_attention"
610
+ FLASH_ATTENTION_2 = "flash_attention_2"
611
+ FLASH_ATTENTION_3 = "flash_attention_3"
612
+
613
+ # Internal spelling retained to keep attention modules concise. It is an
614
+ # enum alias, not an accepted public backend string.
615
+ FLEX = FLEX_ATTENTION
616
+
617
+ @property
618
+ def is_flash(self) -> bool:
619
+ return self in {
620
+ AttentionBackend.FLASH_ATTENTION_2,
621
+ AttentionBackend.FLASH_ATTENTION_3,
622
+ }
623
+
624
+
625
+ VALID_ATTENTION_BACKENDS = tuple(b.value for b in AttentionBackend)
626
+
627
+ # Official upstream sources predate the Transformers ``flex_attention`` name and
628
+ # store ``"flex"`` in their checkpoint configurations. The two names select the
629
+ # identical implementation, so translating one to the other is a rename rather
630
+ # than a backend substitution. ``"flash"`` is deliberately absent: it does not
631
+ # identify a FlashAttention version, and guessing one would be a substitution.
632
+ LEGACY_CHECKPOINT_ATTENTION_BACKENDS = MappingProxyType(
633
+ {"flex": AttentionBackend.FLEX_ATTENTION.value}
634
+ )
635
+
636
+
637
+ def canonical_checkpoint_attention_backend(stored_backend: str | None) -> str | None:
638
+ """Translate a stored checkpoint backend name to its canonical spelling.
639
+
640
+ Call this where a serialized configuration enters FastPLMs. Imperative
641
+ selection through ``set_attn_implementation`` stays strict and keeps
642
+ rejecting the historical spellings. An unmapped name passes through so the
643
+ backend resolver owns the rejection.
644
+ """
645
+
646
+ if stored_backend is None:
647
+ return None
648
+ return LEGACY_CHECKPOINT_ATTENTION_BACKENDS.get(stored_backend, stored_backend)
649
+
650
+
651
+ def warn_attention_backend_fallback(
652
+ requested_backend: str | AttentionBackend,
653
+ *,
654
+ effective_backend: str | AttentionBackend,
655
+ reason: str,
656
+ ) -> None:
657
+ """Warn when one forward call cannot honor the configured backend."""
658
+
659
+ requested = resolve_attention_backend(requested_backend).value
660
+ effective = resolve_attention_backend(effective_backend).value
661
+ if requested == effective:
662
+ return
663
+ warnings.warn(
664
+ f"{reason} The requested {requested!r} attention implementation cannot "
665
+ f"satisfy this call, so FastPLMs is using {effective!r} attention for this "
666
+ "call only. This can change performance and memory use; the configured "
667
+ "backend remains unchanged for subsequent calls.",
668
+ RuntimeWarning,
669
+ stacklevel=3,
670
+ )
671
+
672
+
673
+ def resolve_attention_backend_for_call(
674
+ requested_backend: str | AttentionBackend,
675
+ *,
676
+ output_attentions: bool,
677
+ ) -> AttentionBackend:
678
+ """Resolve the effective backend for one call and report substitutions once."""
679
+
680
+ requested = resolve_attention_backend(requested_backend)
681
+ if not output_attentions or requested == AttentionBackend.EAGER:
682
+ return requested
683
+ warn_attention_backend_fallback(
684
+ requested,
685
+ effective_backend=AttentionBackend.EAGER,
686
+ reason=(
687
+ "output_attentions=True requires the full materialized attention probability "
688
+ "matrix, which optimized PyTorch attention APIs do not return."
689
+ ),
690
+ )
691
+ return AttentionBackend.EAGER
692
+
693
+
694
+ def resolve_attention_backend(
695
+ requested_backend: str | AttentionBackend | None,
696
+ ) -> AttentionBackend:
697
+ """Validate a backend without silently substituting another implementation."""
698
+ if requested_backend is None:
699
+ requested_backend = AttentionBackend.SDPA.value
700
+ if isinstance(requested_backend, AttentionBackend):
701
+ resolved = requested_backend
702
+ else:
703
+ try:
704
+ resolved = AttentionBackend(requested_backend)
705
+ except ValueError as error:
706
+ raise ValueError(
707
+ f"Unsupported attention implementation {requested_backend!r}; "
708
+ f"expected one of {VALID_ATTENTION_BACKENDS}."
709
+ ) from error
710
+ if resolved == AttentionBackend.FLEX_ATTENTION and flex_attention is None:
711
+ raise RuntimeError(
712
+ "'flex_attention' was requested, but this PyTorch build does not provide it."
713
+ )
714
+ return resolved
715
+
716
+
717
+ def get_attn_implementation(config) -> str:
718
+ """Read the Transformers attention setting, defaulting to SDPA."""
719
+ requested = getattr(config, "_attn_implementation", None)
720
+ if requested is None:
721
+ requested = getattr(config, "attn_backend", None)
722
+ return resolve_attention_backend(requested).value
723
+
724
+
725
+ def set_config_attn_implementation(config, implementation: str) -> str:
726
+ """Set both the Transformers field and the internal dispatch field."""
727
+ resolved = resolve_attention_backend(implementation).value
728
+ if hasattr(config, "_attn_implementation_internal"):
729
+ config._attn_implementation_internal = resolved
730
+ else:
731
+ config._attn_implementation = resolved
732
+ # Existing checkpoint configs contain this field. Keeping it synchronized
733
+ # preserves their state schema while the public API uses attn_implementation.
734
+ config.attn_backend = resolved
735
+ return resolved
736
+
737
+
738
+ @torch.compiler.disable
739
+ def get_attention_mask(
740
+ effective_backend: AttentionBackend,
741
+ batch_size: int,
742
+ seq_len: int,
743
+ device: torch.device,
744
+ attention_mask: torch.Tensor | None = None,
745
+ dtype: torch.dtype | None = None,
746
+ mask_semantics: str = "padding",
747
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None, BlockMask | None]:
748
+ """Build padding masks once for all encoder layers.
749
+
750
+ Returns (attention_mask_2d, attention_mask_4d, flex_block_mask).
751
+ """
752
+ # attention_mask: (b, l) or None
753
+ if attention_mask is None:
754
+ return None, None, None
755
+
756
+ if attention_mask.ndim != 2:
757
+ raise ValueError(
758
+ "attention_mask must have shape (batch, sequence_length); "
759
+ f"received rank {attention_mask.ndim} with shape {tuple(attention_mask.shape)}."
760
+ )
761
+ expected_shape = (batch_size, seq_len)
762
+ if tuple(attention_mask.shape) != expected_shape:
763
+ raise ValueError(
764
+ "attention_mask shape must match the input batch and sequence dimensions; "
765
+ f"expected {expected_shape}, received {tuple(attention_mask.shape)}."
766
+ )
767
+ attention_mask_2d = attention_mask.to(device=device, dtype=torch.bool) # (b, l)
768
+ if not bool(attention_mask_2d.any(dim=1).all()):
769
+ raise ValueError("attention_mask must keep at least one valid key per batch row.")
770
+
771
+ effective_backend = resolve_attention_backend(effective_backend)
772
+
773
+ if effective_backend.is_flash:
774
+ return attention_mask_2d, None, None # (b, l), None, None
775
+
776
+ if effective_backend == AttentionBackend.FLEX_ATTENTION:
777
+ if create_block_mask is None:
778
+ raise RuntimeError(
779
+ "'flex_attention' was requested, but torch.create_block_mask is unavailable."
780
+ )
781
+ def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
782
+ del head_idx, q_idx
783
+ # Match eager and SDPA: padding masks suppress invalid keys only.
784
+ # Invalid queries still attend to real keys and therefore remain
785
+ # finite; downstream residue masks exclude their outputs.
786
+ return attention_mask_2d[batch_idx, kv_idx]
787
+
788
+ flex_block_mask = _get_flex_block_mask(
789
+ mask_pattern=attention_mask_2d,
790
+ batch_size=batch_size,
791
+ query_length=seq_len,
792
+ key_value_length=seq_len,
793
+ device=device,
794
+ dtype=dtype,
795
+ mask_semantics=mask_semantics,
796
+ mask_mod=mask_mod,
797
+ )
798
+ return attention_mask_2d, None, flex_block_mask # (b, l), None, BlockMask
799
+
800
+ # SDPA/manual masks only keys. Padding queries still attend to real keys, so
801
+ # their outputs stay finite instead of softmaxing over all -inf scores.
802
+ attention_mask_4d = attention_mask_2d[:, None, None, :] # (b, 1, 1, l)
803
+ return attention_mask_2d, attention_mask_4d, None # (b, l), (b, 1, 1, l), None
804
+
805
+
806
+ def bool_to_additive_mask(
807
+ bool_mask: torch.Tensor,
808
+ dtype: torch.dtype,
809
+ ) -> torch.Tensor:
810
+ """Convert a bool mask (True = valid) to a float additive mask (0.0 valid, -inf invalid).
811
+
812
+ Why this exists: calling `bool_mask.masked_fill(bool_mask.logical_not(), float('-inf'))`
813
+ directly on a bool tensor returns a bool tensor because `-inf` casts to `True`.
814
+ That silently drops the mask. Always allocate a float tensor first, then fill it.
815
+ This helper is the sanctioned way to build an SDPA additive mask from a bool validity mask.
816
+ """
817
+ # bool_mask: (...)
818
+ if bool_mask.dtype != torch.bool:
819
+ raise TypeError(
820
+ f"bool_to_additive_mask requires a bool tensor, got dtype={bool_mask.dtype}"
821
+ )
822
+ additive = torch.zeros_like(bool_mask, dtype=dtype) # (...)
823
+ additive.masked_fill_(bool_mask.logical_not(), float("-inf"))
824
+ return additive # (...)
fastplms/attention/_kernel_lock.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve and validate Hugging Face kernels before importing their binaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def require_kernels_package() -> None:
12
+ """Fail early when the precompiled-kernel runtime is not installed."""
13
+ try:
14
+ import kernels # noqa: F401
15
+ except ImportError as error:
16
+ raise RuntimeError(
17
+ "Precompiled FlashAttention requires requirements/features/flash.in."
18
+ ) from error
19
+
20
+
21
+ def _kernel_lock_path() -> Path:
22
+ """Return the kernel lock from a Hub artifact or source checkout."""
23
+ source_path = Path(__file__).resolve()
24
+ for candidate in (
25
+ source_path.parents[1] / "kernels.lock",
26
+ source_path.parents[3] / "kernels.lock",
27
+ ):
28
+ if candidate.is_file():
29
+ return candidate
30
+
31
+ raise RuntimeError(
32
+ "kernels.lock is missing from the Hugging Face artifact or source checkout."
33
+ )
34
+
35
+
36
+ def _locked_entry(lock_path: Path, repository: str) -> dict[str, Any]:
37
+ try:
38
+ lock_entries = json.loads(lock_path.read_text(encoding="utf-8"))
39
+ except (OSError, json.JSONDecodeError) as error:
40
+ raise RuntimeError(f"Unable to read the kernel lock: {lock_path}") from error
41
+ if not isinstance(lock_entries, list):
42
+ raise RuntimeError("kernels.lock must contain a JSON list.")
43
+ if any(not isinstance(entry, dict) for entry in lock_entries):
44
+ raise RuntimeError("Every kernels.lock entry must be a JSON object.")
45
+ matches = [entry for entry in lock_entries if entry.get("repo_id") == repository]
46
+ if len(matches) != 1:
47
+ raise RuntimeError(
48
+ f"kernels.lock must contain exactly one entry for {repository!r}; found {len(matches)}."
49
+ )
50
+ return matches[0]
51
+
52
+
53
+ def _offline_mode() -> bool:
54
+ """Return whether Hub access was explicitly disabled for this process."""
55
+
56
+ enabled_values = {"1", "on", "true", "yes"}
57
+ return any(
58
+ os.environ.get(name, "").strip().lower() in enabled_values
59
+ for name in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")
60
+ )
61
+
62
+
63
+ def _offline_snapshot_path(repository: str, revision: str) -> Path:
64
+ """Locate one exact, possibly sparse, kernel snapshot without using Hub APIs."""
65
+
66
+ try:
67
+ from huggingface_hub import constants
68
+ from huggingface_hub.file_download import repo_folder_name
69
+ except ImportError as error:
70
+ raise RuntimeError("Offline kernel loading requires huggingface-hub.") from error
71
+
72
+ cache_root = Path(os.environ.get("KERNELS_CACHE") or constants.HF_HUB_CACHE).resolve()
73
+ repository_root = (
74
+ cache_root / repo_folder_name(repo_id=repository, repo_type="kernel")
75
+ ).resolve()
76
+ snapshot = repository_root / "snapshots" / revision
77
+ if not snapshot.is_dir():
78
+ raise RuntimeError(
79
+ f"The exact offline kernel snapshot {repository}@{revision} is not cached under "
80
+ f"{cache_root}. Run `kernels download` before enabling offline mode."
81
+ )
82
+ if repository_root not in snapshot.resolve().parents:
83
+ raise RuntimeError(f"Refusing kernel snapshot outside its cache repository: {snapshot}")
84
+ return snapshot
85
+
86
+
87
+ def _load_offline_locked_kernel(
88
+ repository: str,
89
+ revision: str,
90
+ variant_locks: dict[str, object],
91
+ ) -> object:
92
+ """Validate and import the one compatible variant from a sparse Hub snapshot."""
93
+ snapshot = _offline_snapshot_path(repository, revision)
94
+ build_root = snapshot / "build"
95
+ if not build_root.is_dir():
96
+ raise RuntimeError(f"The cached kernel snapshot has no build directory: {snapshot}")
97
+
98
+ cached_names = sorted(entry.name for entry in build_root.iterdir() if entry.is_dir())
99
+ unexpected = sorted(set(cached_names).difference(variant_locks))
100
+ if unexpected:
101
+ raise RuntimeError(
102
+ f"The cached {repository}@{revision} snapshot contains unlocked variants: "
103
+ f"{', '.join(unexpected)}"
104
+ )
105
+
106
+ try:
107
+ from kernels import get_local_kernel
108
+ from kernels.utils import validate_kernel
109
+ from kernels.variants import get_variants_local, resolve_variants
110
+ except ImportError as error:
111
+ raise RuntimeError(
112
+ "Precompiled FlashAttention requires requirements/features/flash.in."
113
+ ) from error
114
+
115
+ cached_variants = get_variants_local(build_root)
116
+ parsed_names = {variant.variant_str for variant in cached_variants}
117
+ invalid = sorted(set(cached_names).difference(parsed_names))
118
+ if invalid:
119
+ raise RuntimeError(
120
+ f"The cached {repository}@{revision} snapshot contains invalid variants: "
121
+ f"{', '.join(invalid)}"
122
+ )
123
+
124
+ compatible, _ = resolve_variants(cached_variants)
125
+ if len(compatible) != 1:
126
+ names = ", ".join(variant.variant_str for variant in compatible) or "none"
127
+ raise RuntimeError(
128
+ f"Expected exactly one compatible cached variant for {repository}@{revision}; "
129
+ f"found {names}."
130
+ )
131
+ variant_name = compatible[0].variant_str
132
+ variant_lock = variant_locks.get(variant_name)
133
+ expected_hash = getattr(variant_lock, "hash", None)
134
+ if not isinstance(expected_hash, str) or not expected_hash.startswith("sha256-"):
135
+ raise RuntimeError(f"The kernel lock for {variant_name} has no valid SHA-256 digest.")
136
+
137
+ # Hash validation deliberately happens before import. This operates on the
138
+ # sparse snapshot produced by `kernels download` and avoids Hub 1.23's
139
+ # full-snapshot completeness check in offline mode.
140
+ validate_kernel(repo_path=snapshot, variant=variant_name, hash=expected_hash)
141
+ return get_local_kernel(build_root / variant_name)
142
+
143
+
144
+ def load_locked_kernel(repository: str, revision: str) -> object:
145
+ """Download, hash-validate, then import one immutable precompiled kernel."""
146
+ require_kernels_package()
147
+ try:
148
+ from kernels import get_local_kernel, install_kernel
149
+ from kernels.lockfile import KernelLock
150
+ except ImportError as error:
151
+ raise RuntimeError(
152
+ "Precompiled FlashAttention requires requirements/features/flash.in."
153
+ ) from error
154
+
155
+ lock_path = _kernel_lock_path()
156
+ kernel_lock = KernelLock.from_json(_locked_entry(lock_path, repository))
157
+ if kernel_lock.sha != revision:
158
+ raise RuntimeError(
159
+ f"The typed manifest pins {repository}@{revision}, but kernels.lock pins "
160
+ f"{kernel_lock.sha}."
161
+ )
162
+
163
+ if _offline_mode():
164
+ return _load_offline_locked_kernel(repository, revision, kernel_lock.variants)
165
+
166
+ # `install_kernel` downloads data without importing it and validates the
167
+ # selected build against the tracked variant hash. Only then is the exact
168
+ # validated path imported directly. Offline mode uses the sparse-cache
169
+ # resolver above because Hub 1.23 rejects partial snapshots as incomplete.
170
+ validated_path = install_kernel(
171
+ repository,
172
+ revision=kernel_lock.sha,
173
+ variant_locks=kernel_lock.variants,
174
+ )
175
+ return get_local_kernel(validated_path)
fastplms/attention/interfaces.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transformers-compatible attention selection for FastPLMs models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ from collections.abc import Mapping
7
+ from functools import partial
8
+ from typing import Any
9
+ from transformers import AttentionInterface, AttentionMaskInterface
10
+
11
+ from ._core import (
12
+ AttentionBackend,
13
+ canonical_checkpoint_attention_backend,
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
+ # query, key, value: (b, h, l, d); attention_mask: (b, l) or None
40
+ dropout = float(kwargs.get("dropout", 0.0) or 0.0)
41
+ if module.training and dropout:
42
+ raise RuntimeError(
43
+ "Hugging Face kernels FlashAttention is inference-only when attention dropout "
44
+ "is nonzero. Use SDPA for this training configuration."
45
+ )
46
+ causal = bool(kwargs.get("is_causal", getattr(module, "is_causal", False)))
47
+ softmax_scale = kwargs.get("scaling")
48
+ output = kernels_flash_attention_func(
49
+ query_states=query.transpose(1, 2).contiguous(), # (b, l, h, d)
50
+ key_states=key.transpose(1, 2).contiguous(), # (b, l, h, d)
51
+ value_states=value.transpose(1, 2).contiguous(), # (b, l, h, d)
52
+ attention_mask_2d=attention_mask,
53
+ causal=causal,
54
+ softmax_scale=softmax_scale,
55
+ implementation=implementation,
56
+ ) # (b, l, h, d)
57
+ return output, None # (b, l, h, d), None
58
+
59
+
60
+ # Keep FastPLMs' kernels-only adapters local to this registry instance.
61
+ # ``GeneralInterface.register`` updates Transformers' class-wide mapping, so
62
+ # using it here would replace the canonical FlashAttention handlers for every
63
+ # model in the process, including models unrelated to FastPLMs.
64
+ FASTPLMS_ATTENTION_FUNCTIONS = AttentionInterface()
65
+ FASTPLMS_ATTENTION_MASKS = AttentionMaskInterface()
66
+ FASTPLMS_ATTENTION_FUNCTIONS["flash_attention_2"] = partial(
67
+ _kernels_attention_forward,
68
+ implementation="flash_attention_2",
69
+ )
70
+ FASTPLMS_ATTENTION_FUNCTIONS["flash_attention_3"] = partial(
71
+ _kernels_attention_forward,
72
+ implementation="flash_attention_3",
73
+ )
74
+ for _flash_name in ("flash_attention_2", "flash_attention_3"):
75
+ FASTPLMS_ATTENTION_MASKS[_flash_name] = FASTPLMS_ATTENTION_MASKS[_flash_name]
76
+
77
+
78
+ class FastPLMsAttentionMixin:
79
+ """Synchronize Transformers attention selection with custom model layers.
80
+
81
+ Model families retain their checkpoint parameter names. Only runtime
82
+ attributes are updated when ``set_attn_implementation`` is called.
83
+ """
84
+
85
+ _supports_sdpa = True
86
+ _supports_flex_attn = True
87
+ # Transformers 5.13 uses the singular flag during model construction. A
88
+ # family opts in only when its manifest entry advertises at least one of
89
+ # the two FastPLMs kernels-only FlashAttention implementations.
90
+ _supports_flash_attn = False
91
+ _supports_flash_attn_2 = False
92
+ _supports_flash_attn_3 = False
93
+ _fastplms_attention_implementations = (
94
+ "eager",
95
+ "sdpa",
96
+ "flex_attention",
97
+ )
98
+
99
+ def _validate_attention_name(self, implementation: str) -> None:
100
+ if implementation not in self._fastplms_attention_implementations:
101
+ raise ValueError(
102
+ f"{type(self).__name__} does not support {implementation!r}; expected one of "
103
+ f"{self._fastplms_attention_implementations}."
104
+ )
105
+
106
+ def _check_and_adjust_attn_implementation(
107
+ self,
108
+ attn_implementation: str | None,
109
+ is_init_check: bool = False,
110
+ allow_all_kernels: bool = False,
111
+ ) -> str:
112
+ """Resolve attention without invoking Transformers' source-Flash probe.
113
+
114
+ The standard ``flash_attention_2`` and ``flash_attention_3`` names are
115
+ retained for the Transformers API, but FastPLMs resolves them only
116
+ through the exact Hugging Face ``kernels`` artifacts pinned by
117
+ ``models.toml``. Repository-qualified or otherwise external kernels
118
+ are never accepted through this model hook.
119
+ """
120
+
121
+ if allow_all_kernels:
122
+ raise ValueError("FastPLMs does not load external attention kernels.")
123
+ if attn_implementation is None:
124
+ return super()._check_and_adjust_attn_implementation(
125
+ None,
126
+ is_init_check=is_init_check,
127
+ allow_all_kernels=False,
128
+ )
129
+
130
+ self._validate_attention_name(attn_implementation)
131
+ if attn_implementation in {"flash_attention_2", "flash_attention_3"}:
132
+ if not self._supports_flash_attn:
133
+ raise ValueError(
134
+ f"{type(self).__name__} does not advertise kernels-only FlashAttention."
135
+ )
136
+ # Validate the lightweight Python dependency here, but defer binary
137
+ # download and import until Q, K, and V have passed the CUDA gate.
138
+ require_kernels_package()
139
+ return attn_implementation
140
+
141
+ return super()._check_and_adjust_attn_implementation(
142
+ attn_implementation,
143
+ is_init_check=is_init_check,
144
+ allow_all_kernels=False,
145
+ )
146
+
147
+ def __init__(self, config, *args: Any, **kwargs: Any) -> None:
148
+ sentinel = object()
149
+ internal = getattr(config, "_attn_implementation_internal", sentinel)
150
+ stored = (
151
+ getattr(config, "_attn_implementation", None) if internal is sentinel else internal
152
+ )
153
+ legacy = getattr(config, "attn_backend", None)
154
+ requested = stored if stored is not None else legacy
155
+ if requested is not None:
156
+ if not isinstance(requested, str):
157
+ raise TypeError(
158
+ "The configured attention implementation must be a string or None; "
159
+ f"received {type(requested).__name__}."
160
+ )
161
+ # A serialized configuration can name a backend with the historical
162
+ # spelling used by the official source it was converted from. That
163
+ # names the same implementation, so translate it here rather than
164
+ # rejecting a checkpoint that asked for an implementation FastPLMs has.
165
+ canonical = canonical_checkpoint_attention_backend(requested)
166
+ self._validate_attention_name(canonical)
167
+ # ``PreTrainedModel.__init__`` resolves a missing Transformers
168
+ # implementation to the family default. Legacy FastPLMs configs
169
+ # persist their explicit choice in ``attn_backend``, so forward it
170
+ # into the canonical Transformers field before the base class can
171
+ # replace it with SDPA. A stored canonical value already agrees and
172
+ # is left untouched, including an explicit
173
+ # ``attn_implementation=...`` load override.
174
+ if canonical != stored:
175
+ set_config_attn_implementation(config, canonical)
176
+ super().__init__(config, *args, **kwargs)
177
+ # Transformers resolves an unspecified implementation during the base
178
+ # model initialization. Synchronize that choice before family layers
179
+ # are constructed.
180
+ resolved = get_attn_implementation(config)
181
+ self._validate_attention_name(resolved)
182
+ set_config_attn_implementation(config, resolved)
183
+
184
+ def set_attn_implementation(
185
+ self,
186
+ attn_implementation: str | Mapping[str, str],
187
+ allow_all_kernels: bool = False,
188
+ ) -> None:
189
+ """Select an advertised backend and update every instantiated layer."""
190
+ if isinstance(attn_implementation, Mapping):
191
+ if set(attn_implementation) == {""}:
192
+ attn_implementation = attn_implementation[""]
193
+ else:
194
+ raise ValueError(
195
+ "FastPLMs models have one attention backbone; pass a string or {'': name}."
196
+ )
197
+ resolved_name = self._check_and_adjust_attn_implementation(
198
+ attn_implementation,
199
+ is_init_check=False,
200
+ allow_all_kernels=allow_all_kernels,
201
+ )
202
+ set_config_attn_implementation(self.config, resolved_name)
203
+ resolved = resolve_attention_backend(resolved_name)
204
+ for module in self.modules():
205
+ if module is self:
206
+ continue
207
+ for attribute in ("attn_backend", "attention_backend", "_attn_backend"):
208
+ if attribute not in module.__dict__:
209
+ continue
210
+ current = module.__dict__[attribute]
211
+ module.__dict__[attribute] = (
212
+ resolved if isinstance(current, AttentionBackend) else resolved_name
213
+ )
214
+
215
+
216
+ def validate_transformers_attention_interfaces() -> None:
217
+ """Verify that Transformers exposes functions and masks for every backend.
218
+
219
+ Transformers 5.13 registers these canonical names. The FastPLMs function
220
+ overrides remain instance-local and do not replace process-global handlers.
221
+ """
222
+ function_registry = FASTPLMS_ATTENTION_FUNCTIONS
223
+ mask_registry = FASTPLMS_ATTENTION_MASKS
224
+ missing_functions = [
225
+ name
226
+ for name in (
227
+ "sdpa",
228
+ "flex_attention",
229
+ "flash_attention_2",
230
+ "flash_attention_3",
231
+ )
232
+ if name not in function_registry
233
+ ]
234
+ missing_masks = [
235
+ name
236
+ for name in (
237
+ "eager",
238
+ "sdpa",
239
+ "flex_attention",
240
+ "flash_attention_2",
241
+ "flash_attention_3",
242
+ )
243
+ if name not in mask_registry
244
+ ]
245
+ if missing_functions or missing_masks:
246
+ raise RuntimeError(
247
+ "Transformers attention registry is incomplete: "
248
+ f"functions={missing_functions}, masks={missing_masks}."
249
+ )
fastplms/embeddings/__init__.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
37
+ __all__ = [
38
+ "DEFAULT_SHARD_SIZE",
39
+ "POOLING_NAMES",
40
+ "EmbeddingBatch",
41
+ "EmbeddingInput",
42
+ "EmbeddingMixin",
43
+ "EmbeddingRecord",
44
+ "EmbeddingResult",
45
+ "LazyTensorReference",
46
+ "Pooler",
47
+ "TensorValue",
48
+ "append_sqlite_records",
49
+ "convert_legacy_sqlite",
50
+ "embed_dataset",
51
+ "garbage_collect_safetensors_generations",
52
+ "initialize_sqlite_run",
53
+ "iter_fasta",
54
+ "load_legacy_pth",
55
+ "load_result",
56
+ "load_safetensors_result",
57
+ "load_sqlite_result",
58
+ "pagerank_weights",
59
+ "parse_fasta",
60
+ "save_result",
61
+ "save_safetensors_result",
62
+ "save_sqlite_result",
63
+ "select_hidden_state_embeddings",
64
+ "tensor_sha256",
65
+ "update_sqlite_run_metadata",
66
+ ]
fastplms/embeddings/pooling.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Residue-aware pooling implemented entirely with PyTorch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import torch
7
+ from collections.abc import Sequence
8
+ from torch import Tensor
9
+
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
+ # X: (b, l, d); M: (b, l)
16
+ if not isinstance(X, Tensor) or not isinstance(M, Tensor):
17
+ raise TypeError("X and M must be tensors.")
18
+ if X.ndim != 3:
19
+ raise ValueError(f"X must have shape (b, l, d), got {tuple(X.shape)}.")
20
+ if not X.is_floating_point():
21
+ raise TypeError("X must use a floating-point embedding dtype.")
22
+ if M.shape != X.shape[:2]:
23
+ raise ValueError(f"M must have shape (b, l)={tuple(X.shape[:2])}, got {tuple(M.shape)}.")
24
+ if M.is_complex():
25
+ raise TypeError("M must be a boolean or binary numeric residue mask.")
26
+ if not bool(torch.isfinite(M).all()) or not bool(((M == 0) | (M == 1)).all()):
27
+ raise ValueError("M must contain only finite binary mask values.")
28
+ M = M.to(device=X.device, dtype=torch.bool) # (b, l)
29
+ if not bool(M.any(dim=1).all()):
30
+ raise ValueError("Every sample must contain at least one biological residue.")
31
+ if not bool((torch.isfinite(X) | ~M.unsqueeze(-1)).all()):
32
+ raise ValueError("Biological residue embeddings produced non-finite output.")
33
+ return M # (b, l)
34
+
35
+
36
+ def _pooled_attention(attentions: Tensor | Sequence[Tensor], *, batch_size: int) -> Tensor:
37
+ """Max-pool layer/head attention A to shape ``(b, l, l)``.
38
+
39
+ ``parti`` historically keeps the strongest directed edge across the
40
+ available attention maps before PageRank. Replacing NetworkX with Torch
41
+ must not change that reduction.
42
+ """
43
+
44
+ if isinstance(attentions, Sequence):
45
+ if not attentions:
46
+ raise ValueError("parti received an empty attention sequence.")
47
+ # Each A_i: (b, h, l, l).
48
+ A = torch.stack(tuple(attentions), dim=1) # (b, n, h, l, l)
49
+ else:
50
+ A = attentions # (b, ..., l, l)
51
+
52
+ if A.ndim == 5:
53
+ if A.shape[0] != batch_size and A.shape[1] == batch_size:
54
+ A = A.transpose(0, 1) # (b, n, h, l, l)
55
+ if A.shape[0] != batch_size:
56
+ raise ValueError("Five-dimensional attentions must use (b, n, h, l, l).")
57
+ A = A.flatten(1, 2).amax(dim=1) # (b, l, l)
58
+ elif A.ndim == 4:
59
+ if A.shape[0] != batch_size:
60
+ raise ValueError("Four-dimensional attentions must use (b, h, l, l).")
61
+ A = A.amax(dim=1) # (b, l, l)
62
+ elif A.ndim == 3:
63
+ if A.shape[0] != batch_size:
64
+ raise ValueError("Three-dimensional attentions must use (b, l, l).")
65
+ else:
66
+ raise ValueError("Attentions must have shape (b, l, l), (b, h, l, l), or (b, n, h, l, l).")
67
+ return A # (b, l, l)
68
+
69
+
70
+ def pagerank_weights(
71
+ A: Tensor,
72
+ *,
73
+ damping: float = 0.85,
74
+ tolerance: float = 1e-6,
75
+ max_iterations: int = 100,
76
+ ) -> Tensor:
77
+ """Compute PageRank weights for a non-negative attention matrix A.
78
+
79
+ A has shape ``(l, l)``. Rows are normalized into transition
80
+ probabilities; dangling rows transition uniformly.
81
+ """
82
+
83
+ # A: (l, l)
84
+ if not isinstance(A, Tensor):
85
+ raise TypeError("A must be a tensor.")
86
+ if A.ndim != 2 or A.shape[0] != A.shape[1]:
87
+ raise ValueError(f"A must be square, got shape {tuple(A.shape)}.")
88
+ if not A.is_floating_point():
89
+ raise TypeError("A must use a floating-point attention dtype.")
90
+ if not isinstance(damping, (int, float)) or isinstance(damping, bool):
91
+ raise TypeError("damping must be a finite float in [0, 1).")
92
+ if not math.isfinite(float(damping)) or not 0 <= damping < 1:
93
+ raise ValueError("damping must be a finite float in [0, 1).")
94
+ if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
95
+ raise TypeError("tolerance must be a positive finite float.")
96
+ if not math.isfinite(float(tolerance)) or tolerance <= 0:
97
+ raise ValueError("tolerance must be a positive finite float.")
98
+ if not isinstance(max_iterations, int) or isinstance(max_iterations, bool):
99
+ raise TypeError("max_iterations must be a positive integer.")
100
+ if max_iterations <= 0:
101
+ raise ValueError("max_iterations must be a positive integer.")
102
+ length = A.shape[0]
103
+ if length == 0:
104
+ raise ValueError("PageRank requires at least one residue.")
105
+ if not bool(torch.isfinite(A).all()):
106
+ raise ValueError("A must contain only finite attention values.")
107
+ work_dtype = torch.float64 if A.dtype == torch.float64 else torch.float32
108
+ P = A.detach().to(dtype=work_dtype).clamp_min(0) # (l, l)
109
+ row_sum = P.sum(dim=-1, keepdim=True) # (l, 1)
110
+ uniform = torch.full_like(P, 1.0 / length) # (l, l)
111
+ P = torch.where( # (l, l)
112
+ row_sum > 0,
113
+ P / row_sum.clamp_min(torch.finfo(work_dtype).tiny),
114
+ uniform,
115
+ )
116
+ p = torch.full((length,), 1.0 / length, device=P.device, dtype=work_dtype) # (l,)
117
+ teleport = (1.0 - damping) / length
118
+ for _ in range(max_iterations):
119
+ p_next = teleport + damping * (P.transpose(0, 1) @ p) # (l,)
120
+ if torch.linalg.vector_norm(p_next - p, ord=1) <= tolerance:
121
+ p = p_next # (l,)
122
+ break
123
+ p = p_next # (l,)
124
+ return p / p.sum() # (l,)
125
+
126
+
127
+ class Pooler:
128
+ """Apply one or more pooling operations to biological residue rows."""
129
+
130
+ def __init__(self, pooling: str | Sequence[str] = ("mean",)) -> None:
131
+ pooling_value: object = pooling
132
+ if isinstance(pooling_value, (bytes, bytearray)) or not isinstance(
133
+ pooling_value, (str, Sequence)
134
+ ):
135
+ raise TypeError("pooling must be a name or a sequence of names.")
136
+ names = (pooling_value,) if isinstance(pooling_value, str) else tuple(pooling_value)
137
+ if not all(isinstance(name, str) for name in names):
138
+ raise TypeError("pooling names must be strings.")
139
+ if not names:
140
+ raise ValueError("At least one pooling operation is required.")
141
+ unknown = set(names) - POOLING_NAMES
142
+ if unknown:
143
+ raise ValueError(f"Unknown pooling operations: {sorted(unknown)}.")
144
+ duplicates = sorted({name for name in names if names.count(name) > 1})
145
+ if duplicates:
146
+ raise ValueError(f"Duplicate pooling operations are not supported: {duplicates}.")
147
+ self.names = names
148
+
149
+ def output_slices(self, d: int) -> dict[str, tuple[int, int]]:
150
+ """Return the output interval assigned to each pooler."""
151
+
152
+ if not isinstance(d, int) or isinstance(d, bool):
153
+ raise TypeError("d must be a positive integer.")
154
+ if d <= 0:
155
+ raise ValueError("d must be a positive integer.")
156
+ return {name: (i * d, (i + 1) * d) for i, name in enumerate(self.names)}
157
+
158
+ def __call__(
159
+ self,
160
+ X: Tensor,
161
+ residue_mask: Tensor,
162
+ *,
163
+ attentions: Tensor | Sequence[Tensor] | None = None,
164
+ attention_backend: str | None = None,
165
+ ) -> Tensor:
166
+ # X: (b, l, d); residue_mask: (b, l)
167
+ M = _validate_inputs(X, residue_mask) # (b, l)
168
+ M_expanded = M.unsqueeze(-1) # (b, l, 1)
169
+ count = M_expanded.sum(dim=1).clamp_min(1) # (b, 1)
170
+ X_residues = X.masked_fill(~M_expanded, 0) # (b, l, d)
171
+ outputs: list[Tensor] = []
172
+
173
+ for name in self.names:
174
+ if name == "mean":
175
+ Y = X_residues.sum(dim=1) / count # (b, d)
176
+ elif name == "max":
177
+ Y = X.masked_fill(~M_expanded, -torch.inf).max(dim=1).values # (b, d)
178
+ elif name == "norm":
179
+ Y = torch.linalg.vector_norm(X_residues, ord=2, dim=1) # (b, d)
180
+ elif name == "median":
181
+ Y = X.masked_fill(~M_expanded, torch.nan).nanmedian(dim=1).values # (b, d)
182
+ elif name in {"var", "std"}:
183
+ mean = X_residues.sum(dim=1, keepdim=True) / count.unsqueeze(1) # (b, 1, d)
184
+ centered = (X - mean).masked_fill(~M_expanded, 0) # (b, l, d)
185
+ variance = (centered**2).sum(dim=1) / count # (b, d)
186
+ Y = variance.sqrt() if name == "std" else variance # (b, d)
187
+ elif name == "cls":
188
+ Y = X[:, 0] # (b, d)
189
+ else:
190
+ if attention_backend != "eager":
191
+ raise ValueError(
192
+ "parti requires attn_implementation='eager' so full "
193
+ "attention matrices are available."
194
+ )
195
+ if attentions is None:
196
+ raise ValueError("parti requires model attention matrices.")
197
+ if int(M.sum(dim=1).max().item()) > 2048:
198
+ raise ValueError("parti supports at most 2,048 biological residues.")
199
+ A = _pooled_attention(attentions, batch_size=X.shape[0]).to(X.device) # (b, l, l)
200
+ pooled: list[Tensor] = []
201
+ for X_i, M_i, A_i in zip(X, M, A, strict=True):
202
+ # X_i: (l, d); M_i: (l,); A_i: (l, l)
203
+ indices = M_i.nonzero(as_tuple=True)[0] # (r,)
204
+ A_residue = A_i.index_select(0, indices).index_select(1, indices) # (r, r)
205
+ w = pagerank_weights(A_residue).to(dtype=X.dtype) # (r,)
206
+ pooled.append(w @ X_i.index_select(0, indices)) # (d,)
207
+ Y = torch.stack(pooled) # (b, d)
208
+ if not bool(torch.isfinite(Y).all()):
209
+ raise ValueError(
210
+ f"Pooling operation {name!r} produced non-finite output from "
211
+ "biological residue embeddings."
212
+ )
213
+ outputs.append(Y)
214
+
215
+ return torch.cat(outputs, dim=-1) # (b, len(self.names) * d)
216
+
217
+
218
+ __all__ = ["POOLING_NAMES", "Pooler", "pagerank_weights"]
fastplms/embeddings/runner.py ADDED
@@ -0,0 +1,1583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import torch
11
+ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+ from typing import Any, overload
15
+ from torch import Tensor
16
+
17
+ from .pooling import Pooler
18
+ from .storage import (
19
+ SafetensorsStreamWriter,
20
+ append_sqlite_records,
21
+ initialize_sqlite_run,
22
+ load_result,
23
+ load_sqlite_result,
24
+ safetensors_result_exists,
25
+ save_result,
26
+ tensor_sha256,
27
+ update_sqlite_run_metadata,
28
+ )
29
+ from .types import (
30
+ EmbeddingBatch,
31
+ EmbeddingInput,
32
+ EmbeddingRecord,
33
+ EmbeddingResult,
34
+ LazyTensorReference,
35
+ )
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
+ # M: (b, l)
49
+ n_residues = int(M.to(dtype=torch.int64).sum(dim=1).max().item())
50
+ if n_residues > _MAX_PARTI_RESIDUES:
51
+ raise ValueError(f"parti supports at most {_MAX_PARTI_RESIDUES:,} biological residues.")
52
+
53
+
54
+ def select_hidden_state_embeddings(
55
+ last_hidden_state: Tensor,
56
+ hidden_states: tuple[Tensor, ...] | None,
57
+ *,
58
+ hidden_state_index: int = -1,
59
+ store_all_hidden_states: bool = False,
60
+ ) -> Tensor:
61
+ """Select one hidden state or stack every state without changing values."""
62
+ # last_hidden_state and each hidden_states entry: (b, l, d)
63
+ if store_all_hidden_states:
64
+ if not hidden_states:
65
+ raise ValueError("store_all_hidden_states requires model hidden states.")
66
+ # H has shape (b, n, l, d), where n follows the model's output order.
67
+ return torch.stack(hidden_states, dim=1) # (b, n, l, d)
68
+ if hidden_state_index == -1:
69
+ return last_hidden_state # (b, l, d)
70
+ if not hidden_states:
71
+ raise ValueError("hidden_state_index requires model hidden states.")
72
+ return hidden_states[hidden_state_index] # (b, l, d)
73
+
74
+
75
+ def iter_fasta(path: str | Path) -> Iterator[EmbeddingInput]:
76
+ """Yield FASTA records in source order without reading the file into memory."""
77
+
78
+ identifier: str | None = None
79
+ sequence_parts: list[str] = []
80
+ found_record = False
81
+ with Path(path).open("r", encoding="utf-8") as handle:
82
+ for line_number, raw_line in enumerate(handle, start=1):
83
+ line = raw_line.strip()
84
+ if not line:
85
+ continue
86
+ if line.startswith(">"):
87
+ if identifier is not None:
88
+ found_record = True
89
+ yield EmbeddingInput(identifier, "".join(sequence_parts))
90
+ identifier = line[1:].strip().split(maxsplit=1)[0]
91
+ if not identifier:
92
+ raise ValueError(f"Missing FASTA identifier on line {line_number}.")
93
+ sequence_parts = []
94
+ else:
95
+ if identifier is None:
96
+ raise ValueError(
97
+ f"Sequence data precedes the first FASTA header on line {line_number}."
98
+ )
99
+ sequence_parts.append("".join(line.split()))
100
+ if identifier is not None:
101
+ found_record = True
102
+ yield EmbeddingInput(identifier, "".join(sequence_parts))
103
+ if not found_record:
104
+ raise ValueError(f"No FASTA records found in {path}.")
105
+
106
+
107
+ def parse_fasta(path: str | Path) -> list[EmbeddingInput]:
108
+ """Parse FASTA records while preserving identifiers, order, and duplicates."""
109
+
110
+ return list(iter_fasta(path))
111
+
112
+
113
+ def _normalize_input_item(
114
+ position: int,
115
+ item: str | EmbeddingInput | tuple[str, str],
116
+ ) -> EmbeddingInput:
117
+ if isinstance(item, EmbeddingInput):
118
+ return item
119
+ if isinstance(item, str):
120
+ return EmbeddingInput(str(position), item)
121
+ if isinstance(item, tuple) and len(item) == 2:
122
+ return EmbeddingInput(str(item[0]), str(item[1]))
123
+ raise TypeError(
124
+ "inputs must contain sequences, EmbeddingInput values, or (id, sequence) tuples."
125
+ )
126
+
127
+
128
+ class _InputSpool(Sequence[EmbeddingInput]):
129
+ """Immutable disk-backed normalized inputs with an incremental digest."""
130
+
131
+ def __init__(
132
+ self,
133
+ values: Iterable[str | EmbeddingInput | tuple[str, str]],
134
+ ) -> None:
135
+ self._temporary: tempfile.TemporaryDirectory[str] | None = tempfile.TemporaryDirectory(
136
+ prefix="fastplms-inputs-"
137
+ )
138
+ self.path = Path(self._temporary.name) / "inputs.sqlite"
139
+ self._connection: sqlite3.Connection | None = sqlite3.connect(self.path)
140
+ self._connection.execute(
141
+ "CREATE TABLE inputs ("
142
+ "position INTEGER PRIMARY KEY, input_id TEXT NOT NULL, sequence TEXT NOT NULL)"
143
+ )
144
+ digest = hashlib.sha256()
145
+ count = 0
146
+ pending: list[tuple[int, str, str]] = []
147
+ try:
148
+ for position, item in enumerate(values):
149
+ record = _normalize_input_item(position, item)
150
+ for value in (record.id, record.sequence):
151
+ encoded = value.encode("utf-8")
152
+ digest.update(len(encoded).to_bytes(8, "big"))
153
+ digest.update(encoded)
154
+ pending.append((position, record.id, record.sequence))
155
+ count += 1
156
+ if len(pending) == 1_024:
157
+ self._connection.executemany("INSERT INTO inputs VALUES (?, ?, ?)", pending)
158
+ pending.clear()
159
+ if pending:
160
+ self._connection.executemany("INSERT INTO inputs VALUES (?, ?, ?)", pending)
161
+ if count == 0:
162
+ raise ValueError("inputs must contain at least one sequence.")
163
+ self._connection.commit()
164
+ self._connection.close()
165
+ self._connection = sqlite3.connect(
166
+ f"{self.path.resolve().as_uri()}?mode=ro",
167
+ uri=True,
168
+ )
169
+ except BaseException:
170
+ self.close()
171
+ raise
172
+ digest.update(count.to_bytes(8, "big"))
173
+ self.input_fingerprint = digest.hexdigest()
174
+ self._count = count
175
+
176
+ def _require_connection(self) -> sqlite3.Connection:
177
+ if self._connection is None:
178
+ raise RuntimeError("Input spool is closed.")
179
+ return self._connection
180
+
181
+ def __len__(self) -> int:
182
+ return self._count
183
+
184
+ def __iter__(self) -> Iterator[EmbeddingInput]:
185
+ cursor = self._require_connection().execute(
186
+ "SELECT input_id, sequence FROM inputs ORDER BY position"
187
+ )
188
+ while rows := cursor.fetchmany(1_024):
189
+ for input_id, sequence in rows:
190
+ yield EmbeddingInput(input_id, sequence)
191
+
192
+ @overload
193
+ def __getitem__(self, index: int, /) -> EmbeddingInput: ...
194
+
195
+ @overload
196
+ def __getitem__(self, index: slice, /) -> list[EmbeddingInput]: ...
197
+
198
+ def __getitem__(self, index: int | slice) -> EmbeddingInput | list[EmbeddingInput]:
199
+ connection = self._require_connection()
200
+
201
+ if isinstance(index, slice):
202
+ start, stop, step = index.indices(self._count)
203
+ if step != 1:
204
+ return [self[position] for position in range(start, stop, step)]
205
+ rows = connection.execute(
206
+ "SELECT input_id, sequence FROM inputs "
207
+ "WHERE position >= ? AND position < ? ORDER BY position",
208
+ (start, stop),
209
+ ).fetchall()
210
+ return [EmbeddingInput(input_id, sequence) for input_id, sequence in rows]
211
+ position = index + self._count if index < 0 else index
212
+ if position < 0 or position >= self._count:
213
+ raise IndexError(index)
214
+ row = connection.execute(
215
+ "SELECT input_id, sequence FROM inputs WHERE position = ?", (position,)
216
+ ).fetchone()
217
+ if row is None:
218
+ raise IndexError(index)
219
+ return EmbeddingInput(row[0], row[1])
220
+
221
+ def close(self) -> None:
222
+ connection = getattr(self, "_connection", None)
223
+ if connection is not None:
224
+ connection.close()
225
+ self._connection = None
226
+ temporary = getattr(self, "_temporary", None)
227
+ if temporary is not None:
228
+ temporary.cleanup()
229
+ self._temporary = None
230
+
231
+ def __del__(self) -> None:
232
+ self.close()
233
+
234
+
235
+ def _normalize_inputs(
236
+ inputs: (Iterable[str | EmbeddingInput | tuple[str, str]] | Mapping[str, str] | str | Path),
237
+ *,
238
+ disk_backed: bool,
239
+ ) -> Sequence[EmbeddingInput]:
240
+ is_fasta_path = isinstance(inputs, Path)
241
+ if isinstance(inputs, str):
242
+ try:
243
+ is_fasta_path = Path(inputs).is_file()
244
+ except OSError:
245
+ is_fasta_path = False
246
+ should_spool = disk_backed or is_fasta_path or not isinstance(inputs, (str, Sequence, Mapping))
247
+ values: Iterable[str | EmbeddingInput | tuple[str, str]]
248
+ if isinstance(inputs, Path):
249
+ values = iter_fasta(inputs)
250
+ elif isinstance(inputs, str):
251
+ values = iter_fasta(inputs) if is_fasta_path else [inputs]
252
+ elif isinstance(inputs, Mapping):
253
+ values = inputs.items()
254
+ else:
255
+ values = inputs
256
+ if should_spool:
257
+ return _InputSpool(values)
258
+ records: list[EmbeddingInput] = []
259
+ for position, item in enumerate(values):
260
+ records.append(_normalize_input_item(position, item))
261
+ if not records:
262
+ raise ValueError("inputs must contain at least one sequence.")
263
+ return records
264
+
265
+
266
+ def _validate_untruncated_lengths(
267
+ records: Sequence[EmbeddingInput],
268
+ *,
269
+ max_length: int | None,
270
+ truncate: bool,
271
+ ) -> None:
272
+ """Fail before inference when a biological-residue limit would be exceeded."""
273
+
274
+ if max_length is None or truncate:
275
+ return
276
+ for position, record in enumerate(records):
277
+ residue_count = len(record.sequence)
278
+ if residue_count > max_length:
279
+ raise ValueError(
280
+ f"Input at position {position} with id {record.id!r} has "
281
+ f"{residue_count} biological residues, exceeding max_length={max_length} "
282
+ "while truncate=False."
283
+ )
284
+
285
+
286
+ def _model_device(model: Any) -> torch.device:
287
+ try:
288
+ return torch.device(next(model.parameters()).device)
289
+ except (AttributeError, StopIteration):
290
+ return torch.device("cpu")
291
+
292
+
293
+ def _attention_backend(model: Any) -> str | None:
294
+ config = getattr(model, "config", None)
295
+ for name in ("_attn_implementation", "attn_implementation", "attn_backend"):
296
+ value = getattr(config, name, None)
297
+ if value:
298
+ return str(value)
299
+ return None
300
+
301
+
302
+ def _attention_kernel_metadata(backend: str | None) -> dict[str, Any] | None:
303
+ if backend not in {"flash_attention_2", "flash_attention_3"}:
304
+ return None
305
+ from fastplms.registry import get_model_registry
306
+
307
+ spec = get_model_registry().attention_kernels[backend]
308
+ return {
309
+ "repository": spec.repository,
310
+ "revision": spec.revision,
311
+ "version": spec.version,
312
+ "expected_variant": spec.expected_variant,
313
+ "dtypes": list(spec.dtypes),
314
+ }
315
+
316
+
317
+ def _fingerprint_jsonable(value: Any) -> Any:
318
+ if isinstance(value, Mapping):
319
+ return {str(key): _fingerprint_jsonable(item) for key, item in value.items()}
320
+ if isinstance(value, (list, tuple)):
321
+ return [_fingerprint_jsonable(item) for item in value]
322
+ if isinstance(value, (set, frozenset)):
323
+ return sorted((_fingerprint_jsonable(item) for item in value), key=repr)
324
+ if isinstance(value, Path):
325
+ return str(value)
326
+ if isinstance(value, Tensor):
327
+ return {
328
+ "dtype": str(value.dtype).removeprefix("torch."),
329
+ "shape": list(value.shape),
330
+ "sha256": tensor_sha256(value),
331
+ }
332
+ if isinstance(value, torch.dtype):
333
+ return str(value).removeprefix("torch.")
334
+ if isinstance(value, torch.device):
335
+ return str(value)
336
+ if value is None or isinstance(value, (str, int, float, bool)):
337
+ return value
338
+ return {
339
+ "class": f"{value.__class__.__module__}.{value.__class__.__qualname__}",
340
+ "value": str(value),
341
+ }
342
+
343
+
344
+ def _tokenizer_content_sha256(tokenizer: Any) -> str:
345
+ content: dict[str, Any] = {
346
+ "init_kwargs": getattr(tokenizer, "init_kwargs", None),
347
+ "special_tokens_map": getattr(tokenizer, "special_tokens_map", None),
348
+ "model_max_length": getattr(tokenizer, "model_max_length", None),
349
+ "padding_side": getattr(tokenizer, "padding_side", None),
350
+ "truncation_side": getattr(tokenizer, "truncation_side", None),
351
+ }
352
+ get_vocab = getattr(tokenizer, "get_vocab", None)
353
+ if callable(get_vocab):
354
+ content["vocabulary"] = get_vocab()
355
+ get_added_vocab = getattr(tokenizer, "get_added_vocab", None)
356
+ if callable(get_added_vocab):
357
+ content["added_vocabulary"] = get_added_vocab()
358
+ backend = getattr(tokenizer, "backend_tokenizer", None)
359
+ backend_to_str = getattr(backend, "to_str", None)
360
+ if callable(backend_to_str):
361
+ content["backend"] = backend_to_str()
362
+ serialized = json.dumps(
363
+ _fingerprint_jsonable(content),
364
+ sort_keys=True,
365
+ separators=(",", ":"),
366
+ ensure_ascii=False,
367
+ ).encode()
368
+ return hashlib.sha256(serialized).hexdigest()
369
+
370
+
371
+ def _tokenizer_metadata(model: Any, tokenizer: Any | None) -> dict[str, Any]:
372
+ resolved = tokenizer if tokenizer is not None else getattr(model, "tokenizer", None)
373
+ if resolved is None:
374
+ # Raw-sequence families such as E1 retain their loader context on the
375
+ # model/encoder rather than exposing a Transformers tokenizer. Bind the
376
+ # non-secret source policy to resume identity without serializing a Hub
377
+ # token or forcing lazy tokenizer initialization.
378
+ for candidate in (model, getattr(model, "model", None)):
379
+ settings = getattr(candidate, "__dict__", {}).get("_fastplms_tokenizer_kwargs")
380
+ if isinstance(settings, Mapping):
381
+ token_value = settings.get("token")
382
+ return {
383
+ "mode": "native-sequence",
384
+ "source": (
385
+ str(settings.get("tokenizer_source"))
386
+ if settings.get("tokenizer_source") is not None
387
+ else None
388
+ ),
389
+ "revision": settings.get("revision"),
390
+ "cache_dir": (
391
+ str(settings.get("cache_dir"))
392
+ if settings.get("cache_dir") is not None
393
+ else None
394
+ ),
395
+ "local_files_only": bool(settings.get("local_files_only", False)),
396
+ "token_policy": (
397
+ "disabled"
398
+ if token_value is False
399
+ else "provided"
400
+ if token_value is not None
401
+ else "default"
402
+ ),
403
+ }
404
+ return {"mode": "native-sequence"}
405
+ return {
406
+ "mode": "tokenizer",
407
+ "class": f"{resolved.__class__.__module__}.{resolved.__class__.__qualname__}",
408
+ "name_or_path": getattr(resolved, "name_or_path", None),
409
+ "vocab_size": getattr(resolved, "vocab_size", None),
410
+ "special_token_ids": list(getattr(resolved, "all_special_ids", ())),
411
+ "content_sha256": _tokenizer_content_sha256(resolved),
412
+ }
413
+
414
+
415
+ @contextmanager
416
+ def _temporary_eval(model: Any) -> Iterator[None]:
417
+ was_training = getattr(model, "training", None)
418
+ eval_method = getattr(model, "eval", None)
419
+ train_method = getattr(model, "train", None)
420
+ if (
421
+ not isinstance(was_training, bool)
422
+ or not callable(eval_method)
423
+ or not callable(train_method)
424
+ ):
425
+ yield
426
+ return
427
+ eval_method()
428
+ try:
429
+ yield
430
+ finally:
431
+ train_method(was_training)
432
+
433
+
434
+ def _software_versions() -> dict[str, str | None]:
435
+ try:
436
+ import fastplms
437
+
438
+ fastplms_version = fastplms.__version__
439
+ except (AttributeError, ImportError):
440
+ fastplms_version = None
441
+ try:
442
+ import safetensors
443
+
444
+ safetensors_version = safetensors.__version__
445
+ except ImportError:
446
+ safetensors_version = None
447
+ try:
448
+ import transformers
449
+
450
+ transformers_version = transformers.__version__
451
+ except ImportError:
452
+ transformers_version = None
453
+ return {
454
+ "fastplms": fastplms_version,
455
+ "python": platform.python_version(),
456
+ "safetensors": safetensors_version,
457
+ "torch": torch.__version__,
458
+ "torch_cuda": torch.version.cuda,
459
+ "transformers": transformers_version,
460
+ }
461
+
462
+
463
+ def _adapter_identity_metadata(model: Any) -> dict[str, Any] | None:
464
+ """Return deterministic PEFT/adapter identity without tensor payloads."""
465
+
466
+ peft_config = getattr(model, "peft_config", None)
467
+ if not isinstance(peft_config, Mapping) or not peft_config:
468
+ return None
469
+ configurations: dict[str, Any] = {}
470
+ for name, config in sorted(peft_config.items(), key=lambda item: str(item[0])):
471
+ to_dict = getattr(config, "to_dict", None)
472
+ if callable(to_dict):
473
+ value = to_dict()
474
+ else:
475
+ try:
476
+ value = vars(config)
477
+ except TypeError:
478
+ value = config
479
+ configurations[str(name)] = _fingerprint_jsonable(value)
480
+ active_adapters = getattr(model, "active_adapters", None)
481
+ if callable(active_adapters):
482
+ active_adapters = active_adapters()
483
+ return {
484
+ "active": _fingerprint_jsonable(active_adapters),
485
+ "configurations": configurations,
486
+ }
487
+
488
+
489
+ def _execution_identity_metadata(model: Any) -> dict[str, Any]:
490
+ """Capture runtime policy that can change persisted numerical results."""
491
+
492
+ parameter_dtypes = sorted(
493
+ {
494
+ str(parameter.dtype).removeprefix("torch.")
495
+ for parameter in getattr(model, "parameters", lambda: ())()
496
+ }
497
+ )
498
+ return {
499
+ "device": _model_device(model).type,
500
+ "hf_device_map": _fingerprint_jsonable(getattr(model, "hf_device_map", None)),
501
+ "parameter_dtypes": parameter_dtypes,
502
+ "software": _software_versions(),
503
+ }
504
+
505
+
506
+ def _biological_residue_mask(
507
+ input_ids: Tensor,
508
+ attention_mask: Tensor,
509
+ tokenizer: Any,
510
+ ) -> Tensor:
511
+ """Remove padding and tokenizer-declared special tokens from M."""
512
+
513
+ # input_ids, attention_mask: (b, l)
514
+ M = attention_mask.to(dtype=torch.bool) # (b, l)
515
+ special_ids = tuple(int(token_id) for token_id in getattr(tokenizer, "all_special_ids", ()))
516
+ if special_ids:
517
+ specials = torch.tensor( # (n_special,)
518
+ special_ids,
519
+ device=input_ids.device,
520
+ dtype=input_ids.dtype,
521
+ )
522
+ M = M & ~torch.isin(input_ids, specials) # (b, l)
523
+ return M # (b, l)
524
+
525
+
526
+ def _generic_embedding_batch(
527
+ model: Any,
528
+ sequences: list[str],
529
+ *,
530
+ tokenizer: Any | None,
531
+ max_length: int | None,
532
+ truncate: bool,
533
+ need_attentions: bool,
534
+ model_kwargs: dict[str, Any],
535
+ ) -> EmbeddingBatch:
536
+ config = getattr(model, "config", None)
537
+ model_type = str(getattr(config, "model_type", "")).lower()
538
+ if tokenizer is None:
539
+ tokenizer = getattr(model, "tokenizer", None)
540
+
541
+ if tokenizer is None and model_type == "e1":
542
+ output = model._embed(sequences, return_attention_mask=True, **model_kwargs)
543
+ if not isinstance(output, tuple) or len(output) != 2:
544
+ raise TypeError("E1 _embed must return (X, residue_mask).")
545
+ X, M = output # (b, l, d), (b, l)
546
+ preparer = getattr(model, "prep_tokens", None)
547
+ if preparer is not None and hasattr(preparer, "get_batch_kwargs"):
548
+ prepared = preparer.get_batch_kwargs(sequences, device=X.device)
549
+ input_ids = prepared["input_ids"] # (b, l)
550
+ boundary_ids = preparer.boundary_token_ids.to( # (n_boundary,)
551
+ device=input_ids.device, dtype=input_ids.dtype
552
+ )
553
+ # E1 wraps each raw sequence in BOS, context-label, terminal-label,
554
+ # and EOS tokens. Only amino-acid rows are biological residues.
555
+ M = M.to(dtype=torch.bool) & ~torch.isin(input_ids, boundary_ids) # (b, l)
556
+ if need_attentions:
557
+ raise ValueError("parti is not available for tokenizer-free E1 embedding.")
558
+ return EmbeddingBatch( # X: (b, l, d); residue_mask: (b, l)
559
+ X=X,
560
+ residue_mask=M.to(dtype=torch.bool),
561
+ )
562
+ if tokenizer is None:
563
+ raise ValueError("A tokenizer is required for this model's embedding path.")
564
+
565
+ tokenize_kwargs: dict[str, Any] = {
566
+ "return_tensors": "pt",
567
+ "padding": True,
568
+ "truncation": truncate,
569
+ }
570
+ if max_length is not None and truncate:
571
+ # ``max_length`` is a biological-residue limit. Tokenizer limits include
572
+ # boundary tokens, so reserve their declared width instead of dropping
573
+ # residues at the exact boundary.
574
+ special_token_count = 0
575
+ num_special_tokens_to_add = getattr(tokenizer, "num_special_tokens_to_add", None)
576
+ if callable(num_special_tokens_to_add):
577
+ special_token_count = int(num_special_tokens_to_add(pair=False))
578
+ tokenize_kwargs["max_length"] = max_length + special_token_count
579
+ sequence_tokenizer = getattr(model, "_tokenize_sequence_batch", None)
580
+ if callable(sequence_tokenizer):
581
+ encoded = sequence_tokenizer(sequences, tokenizer=tokenizer, **tokenize_kwargs)
582
+ else:
583
+ encoded = tokenizer(sequences, **tokenize_kwargs)
584
+ device = _model_device(model)
585
+ input_ids = encoded["input_ids"].to(device) # (b, l)
586
+ attention_mask = encoded.get( # (b, l)
587
+ "attention_mask",
588
+ input_ids.new_ones(input_ids.shape),
589
+ ).to(device)
590
+ M = _biological_residue_mask(input_ids, attention_mask, tokenizer) # (b, l)
591
+ if need_attentions:
592
+ # Validate l before either the backbone or its quadratic attention graph
593
+ # is materialized. M has shape (b, l).
594
+ _validate_parti_length(M)
595
+ X = model._embed(input_ids, attention_mask, **model_kwargs) # (b, l, d)
596
+ attentions = None
597
+ if need_attentions:
598
+ output = model(
599
+ input_ids=input_ids,
600
+ attention_mask=attention_mask,
601
+ output_attentions=True,
602
+ return_dict=True,
603
+ )
604
+ attentions = getattr(output, "attentions", None) # each: (b, h, l, l)
605
+ if attentions is None:
606
+ raise ValueError("The model did not return attentions required by parti.")
607
+ return EmbeddingBatch( # X: (b, l, d); M: (b, l)
608
+ X=X,
609
+ residue_mask=M,
610
+ attentions=attentions,
611
+ )
612
+
613
+
614
+ def _first_metadata_value(*values: Any) -> Any:
615
+ for value in values:
616
+ if isinstance(value, str):
617
+ if value.strip():
618
+ return value
619
+ elif value is not None:
620
+ return value
621
+ return None
622
+
623
+
624
+ def _model_identity_metadata(model: Any) -> dict[str, Any]:
625
+ """Resolve model and checkpoint identity, including local artifact fallbacks."""
626
+
627
+ config = getattr(model, "config", None)
628
+ checkpoint_revision = _first_metadata_value(
629
+ getattr(config, "fastplms_checkpoint_revision", None),
630
+ getattr(config, "_commit_hash", None),
631
+ )
632
+ return {
633
+ "model_id": _first_metadata_value(
634
+ getattr(config, "fastplms_model_id", None),
635
+ getattr(config, "_name_or_path", None),
636
+ ),
637
+ "model_revision": _first_metadata_value(
638
+ getattr(config, "_commit_hash", None),
639
+ checkpoint_revision,
640
+ ),
641
+ "checkpoint_repo_id": getattr(config, "fastplms_checkpoint_repo_id", None),
642
+ "checkpoint_revision": checkpoint_revision,
643
+ "checkpoint_hash": _first_metadata_value(
644
+ getattr(model, "checkpoint_hash", None),
645
+ getattr(config, "checkpoint_hash", None),
646
+ getattr(config, "fastplms_checkpoint_hash", None),
647
+ ),
648
+ "weights_revision": getattr(config, "fastplms_weights_revision", None),
649
+ "runtime_revision": getattr(config, "fastplms_runtime_revision", None),
650
+ "source_tree_sha256": getattr(config, "fastplms_source_tree_sha256", None),
651
+ "runtime_bundle_sha256": getattr(config, "fastplms_runtime_bundle_sha256", None),
652
+ }
653
+
654
+
655
+ def _bounded_tensor_chunks(X: Tensor, max_elements: int) -> Iterable[Tensor]:
656
+ """Yield X in logical row-major order without materializing a full copy."""
657
+
658
+ # X: (...)
659
+ if X.numel() == 0:
660
+ return
661
+ if X.ndim == 0:
662
+ yield X
663
+ return
664
+ trailing_elements = 1
665
+ for size in X.shape[1:]:
666
+ trailing_elements *= int(size)
667
+ if trailing_elements <= max_elements:
668
+ rows_per_chunk = max(1, max_elements // trailing_elements)
669
+ for start in range(0, X.shape[0], rows_per_chunk):
670
+ yield X[start : start + rows_per_chunk] # (chunk_rows, ...)
671
+ return
672
+ for row in X:
673
+ yield from _bounded_tensor_chunks(row, max_elements)
674
+
675
+
676
+ def _model_state_sha256(model: Any) -> str:
677
+ """Hash named parameters and persistent buffers using bounded CPU copies."""
678
+
679
+ # Never cache this digest from tensor identity or ``Tensor._version``.
680
+ # ``Parameter.data`` and independent tensor aliases can mutate shared storage
681
+ # without changing either signal, while persisted resume identity must bind
682
+ # the authoritative bytes visible at the start of this run.
683
+ state = model.state_dict(keep_vars=True)
684
+ digest = hashlib.sha256()
685
+ for name, value in sorted(state.items()):
686
+ if not isinstance(value, Tensor):
687
+ raise TypeError(f"Model state entry {name!r} is not a tensor.")
688
+ if value.is_meta:
689
+ raise ValueError(
690
+ f"Cannot fingerprint meta-device model state entry {name!r}; pass "
691
+ "model_state_fingerprint with a caller-owned state identity."
692
+ )
693
+ header = json.dumps(
694
+ {
695
+ "name": name,
696
+ "dtype": str(value.dtype).removeprefix("torch."),
697
+ "shape": list(value.shape),
698
+ },
699
+ sort_keys=True,
700
+ separators=(",", ":"),
701
+ ).encode()
702
+ digest.update(len(header).to_bytes(8, "big"))
703
+ digest.update(header)
704
+ max_elements = max(1, _MODEL_STATE_HASH_CHUNK_BYTES // value.element_size())
705
+ for chunk in _bounded_tensor_chunks(value.detach(), max_elements):
706
+ cpu_chunk = chunk.to(device="cpu").contiguous() # chunk.shape
707
+ digest.update(cpu_chunk.reshape(-1).view(torch.uint8).numpy().tobytes())
708
+ return digest.hexdigest()
709
+
710
+
711
+ def _input_sha256(records: Iterable[EmbeddingInput]) -> str:
712
+ """Hash an ordered input stream without constructing a duplicate JSON payload."""
713
+
714
+ precomputed = getattr(records, "input_fingerprint", None)
715
+ if isinstance(precomputed, str):
716
+ return precomputed
717
+ digest = hashlib.sha256()
718
+ count = 0
719
+ for record in records:
720
+ count += 1
721
+ for value in (record.id, record.sequence):
722
+ encoded = value.encode("utf-8")
723
+ digest.update(len(encoded).to_bytes(8, "big"))
724
+ digest.update(encoded)
725
+ digest.update(count.to_bytes(8, "big"))
726
+ return digest.hexdigest()
727
+
728
+
729
+ def _run_fingerprint(
730
+ model: Any,
731
+ records: Sequence[EmbeddingInput],
732
+ *,
733
+ pooling: Sequence[str],
734
+ full_embeddings: bool,
735
+ max_length: int | None,
736
+ truncate: bool,
737
+ dtype: torch.dtype | None,
738
+ model_kwargs: dict[str, Any],
739
+ tokenizer_metadata: dict[str, Any],
740
+ model_state_fingerprint: str | None,
741
+ persist_output: bool,
742
+ embedding_context: Mapping[str, Any],
743
+ batch_size: int,
744
+ batch_window_size: int,
745
+ max_tokens_per_batch: int | None,
746
+ ) -> tuple[str, str, str | None, str]:
747
+ input_fingerprint = _input_sha256(records)
748
+ attention_backend = _attention_backend(model)
749
+ model_identity = _model_identity_metadata(model)
750
+ if model_state_fingerprint is None and persist_output:
751
+ resolved_model_state_fingerprint = _model_state_sha256(model)
752
+ model_state_fingerprint_source = "computed"
753
+ elif model_state_fingerprint is not None:
754
+ resolved_model_state_fingerprint = model_state_fingerprint.strip()
755
+ if not resolved_model_state_fingerprint:
756
+ raise ValueError("model_state_fingerprint must not be empty.")
757
+ model_state_fingerprint_source = "caller"
758
+ else:
759
+ resolved_model_state_fingerprint = None
760
+ model_state_fingerprint_source = "not-computed"
761
+ payload = {
762
+ "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
763
+ "input_fingerprint": input_fingerprint,
764
+ "model_state_fingerprint": resolved_model_state_fingerprint,
765
+ "model_state_fingerprint_source": model_state_fingerprint_source,
766
+ "model_class": f"{model.__class__.__module__}.{model.__class__.__qualname__}",
767
+ **model_identity,
768
+ "attention_backend": attention_backend,
769
+ "attention_kernel": _attention_kernel_metadata(attention_backend),
770
+ "layer": repr(
771
+ getattr(model, "embedding_layer", model_kwargs.get("hidden_state_index", -1))
772
+ ),
773
+ "projection": getattr(model, "embedding_projection", None),
774
+ "esmc_source": getattr(model, "_esmc_source", None),
775
+ "esmc_revision": getattr(model, "_esmc_source_revision", None),
776
+ "esmc_files": getattr(model, "_esmc_source_files", None),
777
+ "token_policy": getattr(model, "embedding_token_policy", None),
778
+ "tokenizer": tokenizer_metadata,
779
+ "adapter": _adapter_identity_metadata(model),
780
+ "execution": _execution_identity_metadata(model),
781
+ "embedding_context": _fingerprint_jsonable(embedding_context),
782
+ "pooling": list(pooling),
783
+ "full_embeddings": full_embeddings,
784
+ "max_length": max_length,
785
+ "truncate": truncate,
786
+ "dtype": str(dtype) if dtype is not None else None,
787
+ "batching": {
788
+ "batch_size": batch_size,
789
+ "batch_window_size": batch_window_size,
790
+ "max_tokens_per_batch": max_tokens_per_batch,
791
+ "input_storage": ("disk-spool" if isinstance(records, _InputSpool) else "memory"),
792
+ },
793
+ "model_kwargs": {
794
+ key: _fingerprint_jsonable(value) for key, value in sorted(model_kwargs.items())
795
+ },
796
+ "residue_mask_policy": "attention-mask-minus-special-tokens",
797
+ }
798
+ run_fingerprint = hashlib.sha256(
799
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
800
+ ).hexdigest()
801
+ return (
802
+ input_fingerprint,
803
+ run_fingerprint,
804
+ resolved_model_state_fingerprint,
805
+ model_state_fingerprint_source,
806
+ )
807
+
808
+
809
+ def _output_exists(path: str | Path, format: str) -> bool:
810
+ path = Path(path)
811
+ if format == "sqlite":
812
+ return path.is_file()
813
+ return safetensors_result_exists(path)
814
+
815
+
816
+ def _output_descriptor(position: int, record: EmbeddingRecord) -> dict[str, Any]:
817
+ tensor = record.tensor
818
+ if isinstance(tensor, LazyTensorReference):
819
+ dtype = tensor.dtype
820
+ shape = tensor.shape
821
+ digest = tensor.sha256
822
+ else:
823
+ dtype = str(tensor.dtype).removeprefix("torch.")
824
+ shape = tuple(tensor.shape)
825
+ digest = tensor_sha256(tensor)
826
+ return {
827
+ "position": position,
828
+ "id": record.id,
829
+ "dtype": dtype,
830
+ "shape": shape,
831
+ "sha256": digest,
832
+ }
833
+
834
+
835
+ def _ordered_string_sha256(values: Sequence[str]) -> str:
836
+ digest = hashlib.sha256()
837
+ for value in values:
838
+ encoded = value.encode("utf-8")
839
+ digest.update(len(encoded).to_bytes(8, "big"))
840
+ digest.update(encoded)
841
+ digest.update(len(values).to_bytes(8, "big"))
842
+ return digest.hexdigest()
843
+
844
+
845
+ def _embedding_context(
846
+ model: Any,
847
+ records: Sequence[EmbeddingInput],
848
+ *,
849
+ hidden_state_source: str,
850
+ decoder_inputs: Sequence[str] | None,
851
+ decoder_input_ids: Tensor | None,
852
+ decoder_attention_mask: Tensor | None,
853
+ model_kwargs: Mapping[str, Any],
854
+ ) -> tuple[dict[str, Any], tuple[str, ...] | None]:
855
+ if hidden_state_source not in {"encoder", "decoder"}:
856
+ raise ValueError("hidden_state_source must be 'encoder' or 'decoder'.")
857
+ hidden_state_index = model_kwargs.get("hidden_state_index", -1)
858
+ if not isinstance(hidden_state_index, int) or isinstance(hidden_state_index, bool):
859
+ raise TypeError("hidden_state_index must be an integer.")
860
+ store_all_hidden_states = model_kwargs.get("store_all_hidden_states", False)
861
+ if not isinstance(store_all_hidden_states, bool):
862
+ raise TypeError("store_all_hidden_states must be a boolean.")
863
+ normalized_decoder_inputs: tuple[str, ...] | None = None
864
+ has_decoder_inputs = decoder_inputs is not None
865
+ has_decoder_ids = decoder_input_ids is not None
866
+ if hidden_state_source == "encoder":
867
+ if has_decoder_inputs or has_decoder_ids or decoder_attention_mask is not None:
868
+ raise ValueError("Decoder inputs are only valid when hidden_state_source='decoder'.")
869
+ else:
870
+ if has_decoder_inputs == has_decoder_ids:
871
+ raise ValueError(
872
+ "Decoder embedding requires exactly one of decoder_inputs or decoder_input_ids."
873
+ )
874
+ decoder_input_fingerprint: str | None = None
875
+ if decoder_inputs is not None:
876
+ if isinstance(decoder_inputs, (str, bytes)) or not isinstance(decoder_inputs, Sequence):
877
+ raise TypeError("decoder_inputs must be an aligned sequence of strings.")
878
+ normalized_decoder_inputs = tuple(decoder_inputs)
879
+ if not all(isinstance(value, str) and value for value in normalized_decoder_inputs):
880
+ raise ValueError("decoder_inputs must contain non-empty strings.")
881
+ if len(normalized_decoder_inputs) != len(records):
882
+ raise ValueError("decoder_inputs must align one-to-one with embedding inputs.")
883
+ decoder_input_fingerprint = _ordered_string_sha256(normalized_decoder_inputs)
884
+ if decoder_attention_mask is not None:
885
+ raise ValueError("decoder_attention_mask requires decoder_input_ids.")
886
+ if decoder_input_ids is not None:
887
+ if not isinstance(decoder_input_ids, Tensor) or decoder_input_ids.ndim != 2:
888
+ raise ValueError("decoder_input_ids must have shape (batch, sequence).")
889
+ if decoder_input_ids.shape[0] != len(records):
890
+ raise ValueError("decoder_input_ids must align one-to-one with embedding inputs.")
891
+ if decoder_input_ids.dtype == torch.bool or decoder_input_ids.is_floating_point():
892
+ raise TypeError("decoder_input_ids must use an integer token dtype.")
893
+ decoder_input_fingerprint = tensor_sha256(decoder_input_ids)
894
+ decoder_mask_fingerprint: str | None = None
895
+ if decoder_attention_mask is not None:
896
+ if not isinstance(decoder_attention_mask, Tensor):
897
+ raise TypeError("decoder_attention_mask must be a tensor.")
898
+ if decoder_input_ids is None or decoder_attention_mask.shape != decoder_input_ids.shape:
899
+ raise ValueError("decoder_attention_mask must match decoder_input_ids shape.")
900
+ decoder_mask_fingerprint = tensor_sha256(decoder_attention_mask)
901
+
902
+ context: dict[str, Any] = {
903
+ "hidden_state_source": hidden_state_source,
904
+ "hidden_state_index": hidden_state_index,
905
+ "store_all_hidden_states": store_all_hidden_states,
906
+ "decoder_input_fingerprint": decoder_input_fingerprint,
907
+ "decoder_attention_mask_fingerprint": decoder_mask_fingerprint,
908
+ "decoder_alignment": "input-position" if hidden_state_source == "decoder" else None,
909
+ }
910
+ metadata_hook = getattr(model, "_embedding_metadata", None)
911
+ model_metadata: Mapping[str, Any] | None = None
912
+ if callable(metadata_hook):
913
+ model_metadata = metadata_hook(**context)
914
+ if not isinstance(model_metadata, Mapping):
915
+ raise TypeError("_embedding_metadata must return a mapping.")
916
+ context["model_embedding"] = _fingerprint_jsonable(model_metadata)
917
+ if hidden_state_source == "decoder":
918
+ has_decoder_batch = callable(getattr(model, "_embedding_batch", None))
919
+ declares_decoder_stack = (
920
+ model_metadata is not None and model_metadata.get("hidden_state_stack") == "decoder"
921
+ )
922
+ if not has_decoder_batch or not declares_decoder_stack:
923
+ raise ValueError(
924
+ f"{model.__class__.__name__} does not declare decoder embedding support."
925
+ )
926
+ return context, normalized_decoder_inputs
927
+
928
+
929
+ def _planned_batches(
930
+ records: Sequence[EmbeddingInput],
931
+ positions: range,
932
+ *,
933
+ batch_size: int,
934
+ max_tokens_per_batch: int | None,
935
+ max_length: int | None,
936
+ truncate: bool,
937
+ ) -> Iterator[list[int]]:
938
+ """Length-bucket one bounded window while retaining stable output positions."""
939
+
940
+ def effective_length(position: int) -> int:
941
+ length = len(records[position].sequence)
942
+ return min(length, max_length) if truncate and max_length is not None else length
943
+
944
+ ordered = sorted(positions, key=lambda position: (-effective_length(position), position))
945
+ batch: list[int] = []
946
+ longest = 0
947
+ for position in ordered:
948
+ length = effective_length(position)
949
+ if max_tokens_per_batch is not None and length > max_tokens_per_batch:
950
+ raise ValueError(
951
+ f"Input at position {position} has {length} residues, exceeding "
952
+ f"max_tokens_per_batch={max_tokens_per_batch}."
953
+ )
954
+ candidate_longest = max(longest, length)
955
+ exceeds_tokens = (
956
+ max_tokens_per_batch is not None
957
+ and candidate_longest * (len(batch) + 1) > max_tokens_per_batch
958
+ )
959
+ if batch and (len(batch) >= batch_size or exceeds_tokens):
960
+ yield batch
961
+ batch = []
962
+ longest = 0
963
+ batch.append(position)
964
+ longest = max(longest, length)
965
+ if batch:
966
+ yield batch
967
+
968
+
969
+ def embed_dataset(
970
+ model: Any,
971
+ inputs: (Iterable[str | EmbeddingInput | tuple[str, str]] | Mapping[str, str] | str | Path),
972
+ *,
973
+ batch_size: int = 2,
974
+ pooling: str | Sequence[str] | None = None,
975
+ full_embeddings: bool = False,
976
+ output: str | Path | None = None,
977
+ format: str = "safetensors",
978
+ resume: bool = True,
979
+ tokenizer: Any | None = None,
980
+ max_length: int | None = None,
981
+ truncate: bool = True,
982
+ dtype: torch.dtype | None = torch.float32,
983
+ shard_size: int = 2 * 1024**3,
984
+ model_state_fingerprint: str | None = None,
985
+ batch_window_size: int | None = None,
986
+ max_tokens_per_batch: int | None = None,
987
+ hidden_state_source: str = "encoder",
988
+ decoder_inputs: Sequence[str] | None = None,
989
+ decoder_input_ids: Tensor | None = None,
990
+ decoder_attention_mask: Tensor | None = None,
991
+ _embedding_batch_fn: Callable[..., EmbeddingBatch] | None = None,
992
+ _embedding_batch_identity: Mapping[str, Any] | None = None,
993
+ _allowed_unsupported_pooling: Sequence[str] = (),
994
+ **model_kwargs: Any,
995
+ ) -> EmbeddingResult:
996
+ """Embed protein sequences with stable ordering and residue-only pooling."""
997
+
998
+ for name, value in (
999
+ ("batch_size", batch_size),
1000
+ ("shard_size", shard_size),
1001
+ ):
1002
+ if not isinstance(value, int) or isinstance(value, bool):
1003
+ raise TypeError(f"{name} must be a positive integer.")
1004
+ if value <= 0:
1005
+ raise ValueError(f"{name} must be a positive integer.")
1006
+ for optional_name, optional_value in (
1007
+ ("max_length", max_length),
1008
+ ("max_tokens_per_batch", max_tokens_per_batch),
1009
+ ("batch_window_size", batch_window_size),
1010
+ ):
1011
+ if optional_value is not None and (
1012
+ not isinstance(optional_value, int) or isinstance(optional_value, bool)
1013
+ ):
1014
+ raise TypeError(f"{optional_name} must be a positive integer when provided.")
1015
+ if optional_value is not None and optional_value <= 0:
1016
+ raise ValueError(f"{optional_name} must be a positive integer when provided.")
1017
+ for name, value in (
1018
+ ("full_embeddings", full_embeddings),
1019
+ ("resume", resume),
1020
+ ("truncate", truncate),
1021
+ ):
1022
+ if not isinstance(value, bool):
1023
+ raise TypeError(f"{name} must be a boolean.")
1024
+ if not isinstance(format, str):
1025
+ raise TypeError("format must be a string.")
1026
+ if output is not None and not isinstance(output, (str, Path)):
1027
+ raise TypeError("output must be a path or None.")
1028
+ if model_state_fingerprint is not None and (
1029
+ not isinstance(model_state_fingerprint, str) or not model_state_fingerprint
1030
+ ):
1031
+ raise ValueError("model_state_fingerprint must be a non-empty string when provided.")
1032
+ if hidden_state_source not in {"encoder", "decoder"}:
1033
+ raise ValueError("hidden_state_source must be 'encoder' or 'decoder'.")
1034
+ hidden_state_index = model_kwargs.get("hidden_state_index", -1)
1035
+ if not isinstance(hidden_state_index, int) or isinstance(hidden_state_index, bool):
1036
+ raise TypeError("hidden_state_index must be an integer.")
1037
+ store_all_hidden_states = model_kwargs.get("store_all_hidden_states", False)
1038
+ if not isinstance(store_all_hidden_states, bool):
1039
+ raise TypeError("store_all_hidden_states must be a boolean.")
1040
+ if decoder_input_ids is not None:
1041
+ if not isinstance(decoder_input_ids, Tensor):
1042
+ raise TypeError("decoder_input_ids must be a tensor.")
1043
+ if decoder_input_ids.is_meta:
1044
+ raise ValueError("decoder_input_ids cannot be a meta tensor.")
1045
+ if decoder_input_ids.ndim != 2 or decoder_input_ids.shape[1] == 0:
1046
+ raise ValueError("decoder_input_ids must have non-empty shape (batch, sequence).")
1047
+ if decoder_input_ids.dtype not in {torch.int32, torch.int64}:
1048
+ raise TypeError("decoder_input_ids must use torch.int32 or torch.int64.")
1049
+ if decoder_attention_mask is not None:
1050
+ if not isinstance(decoder_attention_mask, Tensor):
1051
+ raise TypeError("decoder_attention_mask must be a tensor.")
1052
+ if decoder_attention_mask.is_meta:
1053
+ raise ValueError("decoder_attention_mask cannot be a meta tensor.")
1054
+ if decoder_attention_mask.is_complex() or not bool(
1055
+ torch.isfinite(decoder_attention_mask).all()
1056
+ ):
1057
+ raise ValueError("decoder_attention_mask must contain finite binary values.")
1058
+ if not bool(((decoder_attention_mask == 0) | (decoder_attention_mask == 1)).all()):
1059
+ raise ValueError("decoder_attention_mask must contain finite binary values.")
1060
+ pooling_names = (
1061
+ (("mean",) if not full_embeddings else ())
1062
+ if pooling is None
1063
+ else ((pooling,) if isinstance(pooling, str) else tuple(pooling))
1064
+ )
1065
+ if full_embeddings and pooling is not None:
1066
+ raise ValueError("full_embeddings=True cannot be combined with pooling.")
1067
+ if not full_embeddings and not pooling_names:
1068
+ raise ValueError("pooling is required unless full_embeddings=True.")
1069
+ pooler = Pooler(pooling_names) if pooling_names else None
1070
+
1071
+ if batch_size <= 0:
1072
+ raise ValueError("batch_size must be positive.")
1073
+ if format == "pth" or (output is not None and Path(output).suffix.lower() == ".pth"):
1074
+ raise ValueError("Writing pickle-based .pth embeddings is not supported.")
1075
+ if format not in _SUPPORTED_STORAGE_FORMATS:
1076
+ raise ValueError("format must be 'safetensors' or 'sqlite'.")
1077
+ if max_length is not None and max_length <= 0:
1078
+ raise ValueError("max_length must be positive when provided.")
1079
+ if max_tokens_per_batch is not None and max_tokens_per_batch <= 0:
1080
+ raise ValueError("max_tokens_per_batch must be positive when provided.")
1081
+ if not isinstance(dtype, (torch.dtype, type(None))):
1082
+ raise TypeError("dtype must be a torch.dtype or None.")
1083
+ if batch_window_size is not None and batch_window_size <= 0:
1084
+ raise ValueError("batch_window_size must be positive when provided.")
1085
+ if _embedding_batch_fn is not None and not callable(_embedding_batch_fn):
1086
+ raise TypeError("_embedding_batch_fn must be callable when provided.")
1087
+ if _embedding_batch_fn is not None and _embedding_batch_identity is None:
1088
+ raise ValueError(
1089
+ "_embedding_batch_identity is required with _embedding_batch_fn so persisted "
1090
+ "runs bind the family-specific embedding behavior."
1091
+ )
1092
+ if _embedding_batch_identity is not None and not isinstance(_embedding_batch_identity, Mapping):
1093
+ raise TypeError("_embedding_batch_identity must be a mapping when provided.")
1094
+ if isinstance(_allowed_unsupported_pooling, (str, bytes)) or not isinstance(
1095
+ _allowed_unsupported_pooling, Sequence
1096
+ ):
1097
+ raise TypeError("_allowed_unsupported_pooling must be a sequence of pooler names.")
1098
+ if not all(isinstance(name, str) for name in _allowed_unsupported_pooling):
1099
+ raise TypeError("_allowed_unsupported_pooling must contain only strings.")
1100
+ allowed_unsupported_pooling = frozenset(_allowed_unsupported_pooling)
1101
+ if allowed_unsupported_pooling and _embedding_batch_fn is None:
1102
+ raise ValueError(
1103
+ "_allowed_unsupported_pooling is only valid with a family-specific _embedding_batch_fn."
1104
+ )
1105
+ resolved_batch_window_size = (
1106
+ batch_size * _DEFAULT_BATCH_WINDOW_MULTIPLIER
1107
+ if batch_window_size is None
1108
+ else batch_window_size
1109
+ )
1110
+ if resolved_batch_window_size < batch_size:
1111
+ raise ValueError("batch_window_size must be at least batch_size.")
1112
+ records = _normalize_inputs(inputs, disk_backed=output is not None)
1113
+ _validate_untruncated_lengths(
1114
+ records,
1115
+ max_length=max_length,
1116
+ truncate=truncate,
1117
+ )
1118
+ pooling_names = (
1119
+ (("mean",) if not full_embeddings else ())
1120
+ if pooling is None
1121
+ else ((pooling,) if isinstance(pooling, str) else tuple(pooling))
1122
+ )
1123
+ if full_embeddings:
1124
+ if pooling is not None:
1125
+ raise ValueError("full_embeddings=True cannot be combined with pooling.")
1126
+ elif not pooling_names:
1127
+ raise ValueError("pooling is required unless full_embeddings=True.")
1128
+ store_all_hidden_states = bool(model_kwargs.get("store_all_hidden_states", False))
1129
+ if store_all_hidden_states and not full_embeddings:
1130
+ raise ValueError("store_all_hidden_states=True requires full_embeddings=True.")
1131
+
1132
+ unsupported = set(getattr(model, "embedding_unsupported_pooling", ()))
1133
+ unknown_pooling_overrides = allowed_unsupported_pooling.difference(unsupported)
1134
+ if unknown_pooling_overrides:
1135
+ raise ValueError(
1136
+ "_allowed_unsupported_pooling may only override poolers declared unsupported "
1137
+ f"by the model; unknown overrides: {sorted(unknown_pooling_overrides)}."
1138
+ )
1139
+ unsupported.difference_update(allowed_unsupported_pooling)
1140
+ requested_unsupported = unsupported.intersection(pooling_names)
1141
+ if requested_unsupported:
1142
+ raise ValueError(
1143
+ f"{model.__class__.__name__} does not support pooling operations "
1144
+ f"{sorted(requested_unsupported)}."
1145
+ )
1146
+
1147
+ # Constructing the pooler validates names and duplicate operations before
1148
+ # any checkpoint hashing, tokenization, or inference occurs.
1149
+ pooler = Pooler(pooling_names) if pooling_names else None
1150
+ embedding_context, normalized_decoder_inputs = _embedding_context(
1151
+ model,
1152
+ records,
1153
+ hidden_state_source=hidden_state_source,
1154
+ decoder_inputs=decoder_inputs,
1155
+ decoder_input_ids=decoder_input_ids,
1156
+ decoder_attention_mask=decoder_attention_mask,
1157
+ model_kwargs=model_kwargs,
1158
+ )
1159
+ if _embedding_batch_identity is not None:
1160
+ embedding_context["family_adapter"] = _fingerprint_jsonable(_embedding_batch_identity)
1161
+ if allowed_unsupported_pooling:
1162
+ embedding_context["family_adapter_pooling_override"] = sorted(
1163
+ allowed_unsupported_pooling
1164
+ )
1165
+
1166
+ tokenizer_metadata = _tokenizer_metadata(model, tokenizer)
1167
+ (
1168
+ input_fingerprint,
1169
+ run_fingerprint,
1170
+ resolved_model_state_fingerprint,
1171
+ model_state_fingerprint_source,
1172
+ ) = _run_fingerprint(
1173
+ model,
1174
+ records,
1175
+ pooling=pooling_names,
1176
+ full_embeddings=full_embeddings,
1177
+ max_length=max_length,
1178
+ truncate=truncate,
1179
+ dtype=dtype,
1180
+ model_kwargs=model_kwargs,
1181
+ tokenizer_metadata=tokenizer_metadata,
1182
+ model_state_fingerprint=model_state_fingerprint,
1183
+ persist_output=output is not None,
1184
+ embedding_context=embedding_context,
1185
+ batch_size=batch_size,
1186
+ batch_window_size=resolved_batch_window_size,
1187
+ max_tokens_per_batch=max_tokens_per_batch,
1188
+ )
1189
+ output_already_exists = output is not None and _output_exists(output, format)
1190
+ existing: EmbeddingResult | None = None
1191
+ start_position = 0
1192
+ if output is not None and resume and output_already_exists:
1193
+ if format == "sqlite":
1194
+ try:
1195
+ existing = load_sqlite_result(output, run_id=run_fingerprint)
1196
+ except KeyError:
1197
+ existing = load_result(output, format=format)
1198
+ else:
1199
+ existing = load_result(output, format=format)
1200
+ if existing.metadata.get("fingerprint_schema_version") != (_RUN_FINGERPRINT_SCHEMA_VERSION):
1201
+ raise ValueError(
1202
+ "Existing embeddings use an incompatible run fingerprint schema; "
1203
+ "choose another output or set resume=False."
1204
+ )
1205
+ if existing.metadata.get("run_fingerprint") != run_fingerprint:
1206
+ raise ValueError(
1207
+ "Existing embeddings were produced by a different run fingerprint; "
1208
+ "choose another output or set resume=False."
1209
+ )
1210
+ if len(existing) > len(records):
1211
+ raise ValueError(
1212
+ "Existing embeddings are not an ordered prefix of the requested inputs."
1213
+ )
1214
+ prefix_matches = all(
1215
+ (observed.id, observed.sequence) == (expected.id, expected.sequence)
1216
+ for expected, observed in zip(records, existing, strict=False)
1217
+ )
1218
+ if not prefix_matches:
1219
+ raise ValueError(
1220
+ "Existing embeddings are not an ordered prefix of the requested inputs."
1221
+ )
1222
+ if len(existing) == len(records) and existing.metadata.get("complete", True):
1223
+ return existing
1224
+ start_position = len(existing)
1225
+
1226
+ sqlite_run_id: str | None = None
1227
+ sqlite_replace_on_first_commit = False
1228
+ sqlite_initial_metadata: dict[str, Any] | None = None
1229
+ if output is not None and format == "sqlite":
1230
+ sqlite_initial_metadata = {
1231
+ "format_version": 1,
1232
+ "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
1233
+ "run_fingerprint": run_fingerprint,
1234
+ "input_fingerprint": input_fingerprint,
1235
+ "model_state_fingerprint": resolved_model_state_fingerprint,
1236
+ "model_state_fingerprint_source": model_state_fingerprint_source,
1237
+ "complete": False,
1238
+ }
1239
+ sqlite_run_id = run_fingerprint
1240
+ if not resume and output_already_exists:
1241
+ try:
1242
+ load_sqlite_result(output, run_id=run_fingerprint)
1243
+ except KeyError:
1244
+ pass
1245
+ else:
1246
+ # Keep an exact prior run readable until replacement inference
1247
+ # has produced the first complete commit window.
1248
+ sqlite_replace_on_first_commit = True
1249
+ if not sqlite_replace_on_first_commit:
1250
+ initialize_sqlite_run(
1251
+ output,
1252
+ sqlite_initial_metadata,
1253
+ resume=resume,
1254
+ )
1255
+
1256
+ stream_safetensors = output is not None and format == "safetensors"
1257
+ attention_backend = _attention_backend(model)
1258
+ output_records: list[EmbeddingRecord] = (
1259
+ [] if sqlite_run_id is not None or stream_safetensors else list(existing or ())
1260
+ )
1261
+ output_descriptors: list[dict[str, Any]] | None = [] if output is None else None
1262
+ pool_slices: dict[str, tuple[int, int]] = {}
1263
+ if existing and pooler is not None:
1264
+ pooled_width = existing[0].load_tensor().shape[-1]
1265
+ if pooled_width % len(pooling_names) != 0:
1266
+ raise ValueError("Stored pooled width is inconsistent with pooling metadata.")
1267
+ pool_slices = pooler.output_slices(pooled_width // len(pooling_names))
1268
+
1269
+ safetensors_writer: SafetensorsStreamWriter | None = None
1270
+ if stream_safetensors:
1271
+ if output is None:
1272
+ raise RuntimeError("Safetensors streaming was enabled without an output destination.")
1273
+ transactional_overwrite = output_already_exists and not resume
1274
+ safetensors_writer = SafetensorsStreamWriter(
1275
+ output,
1276
+ {
1277
+ "format_version": 1,
1278
+ "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
1279
+ "run_fingerprint": run_fingerprint,
1280
+ "input_fingerprint": input_fingerprint,
1281
+ "model_state_fingerprint": resolved_model_state_fingerprint,
1282
+ "model_state_fingerprint_source": model_state_fingerprint_source,
1283
+ "complete": False,
1284
+ },
1285
+ shard_size=shard_size,
1286
+ existing=existing or (),
1287
+ reuse_existing=bool(resume and existing is not None),
1288
+ publish_initial=not transactional_overwrite,
1289
+ publish_incremental=not transactional_overwrite,
1290
+ )
1291
+ need_attentions = "parti" in pooling_names
1292
+
1293
+ config = getattr(model, "config", None)
1294
+ model_type = str(getattr(config, "model_type", "")).lower()
1295
+ resolved_tokenizer = tokenizer if tokenizer is not None else getattr(model, "tokenizer", None)
1296
+ with _temporary_eval(model), torch.inference_mode():
1297
+ for window_start in range(start_position, len(records), resolved_batch_window_size):
1298
+ window_stop = min(window_start + resolved_batch_window_size, len(records))
1299
+ window_records = records[window_start:window_stop]
1300
+ if not isinstance(window_records, Sequence):
1301
+ raise RuntimeError("The immutable embedding spool returned a non-sequence window.")
1302
+ window_results: dict[int, EmbeddingRecord] = {}
1303
+ for local_positions in _planned_batches(
1304
+ window_records,
1305
+ range(len(window_records)),
1306
+ batch_size=batch_size,
1307
+ max_tokens_per_batch=max_tokens_per_batch,
1308
+ max_length=max_length,
1309
+ truncate=truncate,
1310
+ ):
1311
+ batch_positions = [window_start + position for position in local_positions]
1312
+ batch_records = [window_records[position] for position in local_positions]
1313
+ sequences = [
1314
+ record.sequence[:max_length]
1315
+ if truncate and max_length is not None
1316
+ else record.sequence
1317
+ for record in batch_records
1318
+ ]
1319
+ batch_model_kwargs = dict(model_kwargs)
1320
+ if model_type == "fast_ankh" or hidden_state_source == "decoder":
1321
+ batch_model_kwargs["hidden_state_source"] = hidden_state_source
1322
+ if normalized_decoder_inputs is not None:
1323
+ batch_model_kwargs["decoder_inputs"] = [
1324
+ normalized_decoder_inputs[position] for position in batch_positions
1325
+ ]
1326
+ if decoder_input_ids is not None:
1327
+ # decoder_input_ids: (n_records, l_decoder)
1328
+ indices = torch.tensor( # (b,)
1329
+ batch_positions,
1330
+ device=decoder_input_ids.device,
1331
+ dtype=torch.long,
1332
+ )
1333
+ batch_model_kwargs["decoder_input_ids"] = ( # (b, l_decoder)
1334
+ decoder_input_ids.index_select(0, indices)
1335
+ )
1336
+ if decoder_attention_mask is not None:
1337
+ # decoder_attention_mask: (n_records, l_decoder)
1338
+ indices = torch.tensor( # (b,)
1339
+ batch_positions,
1340
+ device=decoder_attention_mask.device,
1341
+ dtype=torch.long,
1342
+ )
1343
+ batch_model_kwargs["decoder_attention_mask"] = (
1344
+ decoder_attention_mask.index_select(0, indices) # (b, l_decoder)
1345
+ )
1346
+ custom_batch = _embedding_batch_fn or getattr(model, "_embedding_batch", None)
1347
+ if custom_batch is not None:
1348
+ if model_type == "fast_ankh":
1349
+ batch = custom_batch(
1350
+ sequences,
1351
+ tokenizer=resolved_tokenizer,
1352
+ max_length=max_length,
1353
+ truncate=truncate,
1354
+ need_attentions=need_attentions,
1355
+ **batch_model_kwargs,
1356
+ )
1357
+ else:
1358
+ batch = custom_batch(sequences, **batch_model_kwargs)
1359
+ if not isinstance(batch, EmbeddingBatch):
1360
+ raise TypeError("_embedding_batch must return EmbeddingBatch.")
1361
+ else:
1362
+ batch = _generic_embedding_batch(
1363
+ model,
1364
+ sequences,
1365
+ tokenizer=tokenizer,
1366
+ max_length=max_length,
1367
+ truncate=truncate,
1368
+ need_attentions=need_attentions,
1369
+ model_kwargs=batch_model_kwargs,
1370
+ )
1371
+ X = batch.X # (b, l, d) or (b, n_states, l, d)
1372
+ raw_mask = batch.residue_mask # (b, l)
1373
+ if not isinstance(X, Tensor) or not isinstance(raw_mask, Tensor):
1374
+ raise TypeError("Embedding batches must provide Tensor X and residue_mask.")
1375
+ if X.is_meta or raw_mask.is_meta:
1376
+ raise ValueError("Embedding batches cannot contain meta tensors.")
1377
+ if not X.is_floating_point():
1378
+ raise TypeError("Embedding batches must use a floating-point X dtype.")
1379
+ if raw_mask.is_complex() or not bool(torch.isfinite(raw_mask).all()):
1380
+ raise ValueError("Embedding residue_mask must contain finite binary values.")
1381
+ if not bool(((raw_mask == 0) | (raw_mask == 1)).all()):
1382
+ raise ValueError("Embedding residue_mask must contain finite binary values.")
1383
+ M = raw_mask.to(device=X.device, dtype=torch.bool) # (b, l)
1384
+ valid_X_shape = (
1385
+ X.ndim == 3
1386
+ and X.shape[0] == len(batch_records)
1387
+ and X.shape[-1] > 0
1388
+ and M.shape == X.shape[:2]
1389
+ )
1390
+ valid_all_states_shape = (
1391
+ X.ndim == 4
1392
+ and store_all_hidden_states
1393
+ and full_embeddings
1394
+ and X.shape[0] == len(batch_records)
1395
+ and X.shape[1] > 0
1396
+ and X.shape[-1] > 0
1397
+ and M.shape == (X.shape[0], X.shape[2])
1398
+ )
1399
+ if not (valid_X_shape or valid_all_states_shape):
1400
+ raise ValueError(
1401
+ "Embedding batches must provide X with shape (b, l, d), or "
1402
+ "(b, states, l, d) when storing all hidden states, and "
1403
+ "residue_mask with shape (b, l)."
1404
+ )
1405
+ if not bool(M.any(dim=1).all()):
1406
+ raise ValueError("Every embedding sample must contain a biological residue.")
1407
+ finite_selected = ( # X.shape
1408
+ torch.isfinite(X) | ~M.unsqueeze(-1)
1409
+ if X.ndim == 3
1410
+ else torch.isfinite(X) | ~M[:, None, :, None]
1411
+ )
1412
+ if not bool(finite_selected.all()):
1413
+ raise ValueError("Biological residue embeddings produced non-finite output.")
1414
+ if need_attentions:
1415
+ # Validate the biological graph only after mask integrity is established.
1416
+ _validate_parti_length(M)
1417
+ if dtype is not None:
1418
+ X = X.to(dtype=dtype) # unchanged shape
1419
+
1420
+ if full_embeddings:
1421
+ if X.ndim == 4:
1422
+ values = [
1423
+ X_i[:, M_i, :].detach().cpu() # (n_states, r_i, d)
1424
+ for X_i, M_i in zip(X, M, strict=True)
1425
+ ]
1426
+ else:
1427
+ values = [
1428
+ X_i[M_i].detach().cpu() # (r_i, d)
1429
+ for X_i, M_i in zip(X, M, strict=True)
1430
+ ]
1431
+ else:
1432
+ if pooler is None:
1433
+ raise RuntimeError(
1434
+ "Pooled embedding output was requested without an initialized pooler."
1435
+ )
1436
+ Y = pooler( # (b, n_poolers * d)
1437
+ X,
1438
+ M,
1439
+ attentions=batch.attentions,
1440
+ attention_backend=attention_backend,
1441
+ )
1442
+ pool_slices = pooler.output_slices(X.shape[-1])
1443
+ values = list(Y.detach().cpu().unbind(0)) # each: (n_poolers * d,)
1444
+ for position, record, value in zip(
1445
+ batch_positions, batch_records, values, strict=True
1446
+ ):
1447
+ window_results[position] = EmbeddingRecord(record.id, record.sequence, value)
1448
+
1449
+ new_records = [
1450
+ window_results[position] for position in range(window_start, window_stop)
1451
+ ]
1452
+ if output_descriptors is not None:
1453
+ output_descriptors.extend(
1454
+ _output_descriptor(window_start + offset, record)
1455
+ for offset, record in enumerate(new_records)
1456
+ )
1457
+ if output is not None and sqlite_run_id is not None:
1458
+ append_sqlite_records(
1459
+ output,
1460
+ sqlite_run_id,
1461
+ window_start,
1462
+ new_records,
1463
+ replace_metadata=(
1464
+ sqlite_initial_metadata if sqlite_replace_on_first_commit else None
1465
+ ),
1466
+ )
1467
+ sqlite_replace_on_first_commit = False
1468
+ elif safetensors_writer is not None:
1469
+ safetensors_writer.append(new_records)
1470
+ else:
1471
+ output_records.extend(new_records)
1472
+
1473
+ software_versions = _software_versions()
1474
+ projection = getattr(model, "embedding_projection", None)
1475
+ resolved_layer = getattr(
1476
+ model,
1477
+ "embedding_layer",
1478
+ model_kwargs.get("hidden_state_index", -1),
1479
+ )
1480
+ token_policy = getattr(
1481
+ model,
1482
+ "embedding_token_policy",
1483
+ {
1484
+ "unit": "residue",
1485
+ "include": ["biological residues"],
1486
+ "exclude": [
1487
+ "BOS",
1488
+ "EOS",
1489
+ "padding",
1490
+ "chain delimiters",
1491
+ "non-protein tokens",
1492
+ ],
1493
+ },
1494
+ )
1495
+ model_identity = _model_identity_metadata(model)
1496
+ metadata: dict[str, Any] = {
1497
+ "format_version": 1,
1498
+ "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
1499
+ "run_fingerprint": run_fingerprint,
1500
+ "input_fingerprint": input_fingerprint,
1501
+ "model_state_fingerprint": resolved_model_state_fingerprint,
1502
+ "model_state_fingerprint_source": model_state_fingerprint_source,
1503
+ "model_class": f"{model.__class__.__module__}.{model.__class__.__qualname__}",
1504
+ **model_identity,
1505
+ "dtype": str(dtype).removeprefix("torch.") if dtype is not None else "model",
1506
+ "attention_backend": attention_backend,
1507
+ "attention_kernel": _attention_kernel_metadata(attention_backend),
1508
+ "layer": resolved_layer,
1509
+ "projection": projection,
1510
+ "esmc_source": getattr(model, "_esmc_source", None),
1511
+ "esmc_revision": getattr(model, "_esmc_source_revision", None),
1512
+ "esmc_files": getattr(model, "_esmc_source_files", None),
1513
+ "token_policy": token_policy,
1514
+ "tokenizer": tokenizer_metadata,
1515
+ **embedding_context,
1516
+ "pooling": list(pooling_names),
1517
+ "pool_slices": pool_slices,
1518
+ "full_embeddings": full_embeddings,
1519
+ "max_length": max_length,
1520
+ "truncate": truncate,
1521
+ "truncation": {"enabled": truncate, "max_length": max_length},
1522
+ "batching": {
1523
+ "batch_size": batch_size,
1524
+ "batch_window_size": resolved_batch_window_size,
1525
+ "max_tokens_per_batch": max_tokens_per_batch,
1526
+ "input_storage": ("disk-spool" if isinstance(records, _InputSpool) else "memory"),
1527
+ "ordering": "bounded-length-bucketed-stable-output",
1528
+ "resume_commit_granularity": (
1529
+ "not-applicable"
1530
+ if output is None
1531
+ else "batch-window"
1532
+ if format == "sqlite"
1533
+ else "shard-flush"
1534
+ ),
1535
+ },
1536
+ "residue_mask_policy": "biological-residues-only",
1537
+ "record_count": len(records),
1538
+ "descriptor_index": (
1539
+ "memory-metadata"
1540
+ if output is None
1541
+ else "sqlite-records"
1542
+ if format == "sqlite"
1543
+ else "safetensors-generation-index"
1544
+ ),
1545
+ "storage_format": format if output is not None else "memory",
1546
+ "software": software_versions,
1547
+ "execution": _execution_identity_metadata(model),
1548
+ "adapter": _adapter_identity_metadata(model),
1549
+ "torch_version": software_versions["torch"],
1550
+ "transformers_version": software_versions["transformers"],
1551
+ "complete": True,
1552
+ }
1553
+ if output_descriptors is not None:
1554
+ metadata["outputs"] = output_descriptors
1555
+ metadata["tensor_hashes"] = [item["sha256"] for item in output_descriptors]
1556
+ status = getattr(model, "esmc_precision_status", None)
1557
+ if status is not None:
1558
+ metadata["esmc_precision"] = status.as_dict() if hasattr(status, "as_dict") else status
1559
+ if output is not None and sqlite_run_id is not None:
1560
+ update_sqlite_run_metadata(output, sqlite_run_id, metadata)
1561
+ return load_sqlite_result(output, run_id=sqlite_run_id)
1562
+ if safetensors_writer is not None:
1563
+ return safetensors_writer.publish(complete=True, metadata=metadata)
1564
+ result = EmbeddingResult(output_records, metadata)
1565
+ if output is not None:
1566
+ return save_result(result, output, format=format, shard_size=shard_size)
1567
+ return result
1568
+
1569
+
1570
+ class EmbeddingMixin:
1571
+ """Small delegation mixin shared by FastPLMs model classes."""
1572
+
1573
+ def embed_dataset(self, inputs: Any, **kwargs: Any) -> EmbeddingResult:
1574
+ return embed_dataset(self, inputs, **kwargs)
1575
+
1576
+
1577
+ __all__ = [
1578
+ "EmbeddingMixin",
1579
+ "embed_dataset",
1580
+ "iter_fasta",
1581
+ "parse_fasta",
1582
+ "select_hidden_state_embeddings",
1583
+ ]
fastplms/embeddings/storage.py ADDED
@@ -0,0 +1,1601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import numpy as np
11
+ import torch
12
+ from bisect import bisect_right
13
+ from collections.abc import Iterable, Iterator, Sequence
14
+ from pathlib import Path
15
+ from typing import Any, cast, overload
16
+ from uuid import uuid4
17
+ from torch import Tensor
18
+
19
+ from .types import (
20
+ EmbeddingRecord,
21
+ EmbeddingResult,
22
+ LazyTensorReference,
23
+ )
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: (...)
84
+ X = X.detach().cpu().contiguous() # (...)
85
+ return X.view(torch.uint8).numpy().tobytes()
86
+
87
+
88
+ def _bounded_tensor_chunks(X: Tensor, max_bytes: int) -> Iterator[Tensor]:
89
+ """Yield row-major CPU chunks without materializing one full byte string."""
90
+
91
+ # X: (...)
92
+ flattened = X.detach().to(device="cpu").reshape(-1) # (n,)
93
+ if flattened.numel() == 0:
94
+ return
95
+ chunk_elements = max(1, max_bytes // flattened.element_size())
96
+ for start in range(0, flattened.numel(), chunk_elements):
97
+ chunk = flattened[start : start + chunk_elements] # (n_chunk,)
98
+ if chunk.stride(0) != 1:
99
+ chunk = chunk.clone(memory_format=torch.contiguous_format) # (n_chunk,)
100
+ yield chunk # (n_chunk,)
101
+
102
+
103
+ def _tensor_hash_chunks(X: Tensor) -> Iterator[bytes]:
104
+ for chunk in _bounded_tensor_chunks(X, _TENSOR_HASH_CHUNK_BYTES):
105
+ yield chunk.view(torch.uint8).numpy().tobytes()
106
+
107
+
108
+ def tensor_sha256(X: Tensor) -> str:
109
+ """Hash dtype, shape, and exact tensor bytes."""
110
+
111
+ if not isinstance(X, Tensor):
112
+ raise TypeError("X must be a tensor.")
113
+ if X.dtype not in _DTYPE_NAMES:
114
+ raise TypeError(f"Unsupported tensor dtype {X.dtype}.")
115
+ if X.is_meta:
116
+ raise ValueError("Cannot hash a meta tensor without storage.")
117
+ if X.layout != torch.strided:
118
+ raise TypeError("Only strided tensors can be hashed.")
119
+ digest = hashlib.sha256()
120
+ digest.update(_DTYPE_NAMES[X.dtype].encode())
121
+ digest.update(json.dumps(tuple(X.shape)).encode())
122
+ for chunk in _tensor_hash_chunks(X):
123
+ digest.update(chunk)
124
+ return digest.hexdigest()
125
+
126
+
127
+ def _encode_tensor(X: Tensor) -> tuple[str, str, bytes]:
128
+ if X.dtype not in _DTYPE_NAMES:
129
+ raise TypeError(f"Unsupported tensor dtype {X.dtype}.")
130
+ shape = json.dumps(tuple(X.shape), separators=(",", ":"))
131
+ return _DTYPE_NAMES[X.dtype], shape, _tensor_bytes(X)
132
+
133
+
134
+ def _decode_tensor(dtype_name: str, shape_json: str, data: bytes) -> Tensor:
135
+ try:
136
+ dtype = _NAME_DTYPES[dtype_name]
137
+ except KeyError as error:
138
+ raise ValueError(f"Unsupported stored dtype {dtype_name!r}.") from error
139
+ shape = tuple(json.loads(shape_json))
140
+ # uint8 is used only as a byte-level carrier, preserving BF16 bits exactly.
141
+ byte_array = np.frombuffer(data, dtype=np.uint8).copy() # (n_bytes,)
142
+ X = torch.from_numpy(byte_array).view(dtype) # (n_elements,)
143
+ return X.reshape(shape).clone() # shape
144
+
145
+
146
+ def _index_path(path: str | Path) -> Path:
147
+ path = Path(path)
148
+ if path.suffix == ".json":
149
+ return path
150
+ if path.suffix == ".safetensors":
151
+ return path.with_suffix(".json")
152
+ return path / "index.json"
153
+
154
+
155
+ def _run_manifest_path(path: str | Path) -> Path:
156
+ path = Path(path)
157
+ if path.name == "index.json":
158
+ return path.with_name("run.json")
159
+ if path.suffix == ".json":
160
+ return path.with_name(f"{path.stem}.run.json")
161
+ if path.suffix == ".safetensors":
162
+ return path.with_suffix(".run.json")
163
+ return path / "run.json"
164
+
165
+
166
+ def _resolve_index_child(root: Path, relative: str, *, label: str) -> Path:
167
+ relative_path = Path(relative)
168
+ candidate = (root / relative_path).resolve()
169
+ if relative_path.is_absolute() or candidate.parent != root.resolve():
170
+ raise ValueError(f"Safetensors {label} references a file outside its output directory.")
171
+ return candidate
172
+
173
+
174
+ def _canonical_json_bytes(payload: dict[str, Any]) -> bytes:
175
+ return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")
176
+
177
+
178
+ def _load_authoritative_index(
179
+ path: str | Path,
180
+ ) -> tuple[dict[str, Any], Path, dict[str, Any]]:
181
+ """Load the index selected by the atomic run-manifest commit record."""
182
+
183
+ stable_index_path = _index_path(path)
184
+ run_manifest_path = _run_manifest_path(path)
185
+ if not run_manifest_path.is_file():
186
+ raise ValueError(f"Missing safetensors run manifest: {run_manifest_path}.")
187
+ run_manifest = json.loads(run_manifest_path.read_text(encoding="utf-8"))
188
+ if not isinstance(run_manifest, dict):
189
+ raise ValueError("Safetensors run manifest must contain a JSON object.")
190
+ if run_manifest.get("format") != "fastplms-embedding-run":
191
+ raise ValueError(f"Not a FastPLMs embedding run manifest: {run_manifest_path}.")
192
+ version = run_manifest.get("version")
193
+ index_reference = run_manifest.get("index")
194
+ if not isinstance(index_reference, dict):
195
+ raise ValueError("Safetensors run manifest contains an invalid index reference.")
196
+ if version == 1:
197
+ snapshot = run_manifest.get("index_payload")
198
+ if isinstance(snapshot, dict):
199
+ payload = snapshot
200
+ index_bytes = _canonical_json_bytes(payload)
201
+ elif snapshot is None:
202
+ index_bytes = stable_index_path.read_bytes()
203
+ payload = json.loads(index_bytes.decode("utf-8"))
204
+ if not isinstance(payload, dict):
205
+ raise ValueError("Safetensors index must contain a JSON object.")
206
+ else:
207
+ raise ValueError("Safetensors run manifest contains an invalid index snapshot.")
208
+ expected = {
209
+ "file": stable_index_path.name,
210
+ "sha256": hashlib.sha256(index_bytes).hexdigest(),
211
+ }
212
+ index_path = stable_index_path
213
+ elif version == 2:
214
+ relative = index_reference.get("file")
215
+ if not isinstance(relative, str):
216
+ raise ValueError("Safetensors run manifest index file is invalid.")
217
+ index_path = _resolve_index_child(stable_index_path.parent, relative, label="run manifest")
218
+ index_bytes = index_path.read_bytes()
219
+ payload = json.loads(index_bytes.decode("utf-8"))
220
+ if not isinstance(payload, dict):
221
+ raise ValueError("Safetensors generation index must contain a JSON object.")
222
+ if payload.get("version") != 2:
223
+ raise ValueError("Safetensors v2 run manifest must reference a v2 generation index.")
224
+ expected = {
225
+ "file": relative,
226
+ "sha256": hashlib.sha256(index_bytes).hexdigest(),
227
+ }
228
+ else:
229
+ raise ValueError(f"Unsupported safetensors run manifest version {version!r}.")
230
+ if index_reference != expected:
231
+ raise ValueError("Safetensors run manifest does not match its index.")
232
+ if payload.get("format") != "fastplms-embedding-safetensors":
233
+ raise ValueError(f"Not a FastPLMs embedding index: {index_path}.")
234
+ record_count = payload.get("record_count")
235
+ if record_count is None:
236
+ legacy_records = payload.get("records", ())
237
+ if not isinstance(legacy_records, list):
238
+ raise ValueError("Safetensors index contains invalid records.")
239
+ record_count = len(legacy_records)
240
+ if not isinstance(record_count, int) or isinstance(record_count, bool) or record_count < 0:
241
+ raise ValueError("Safetensors record count must be a non-negative integer.")
242
+ if run_manifest.get("record_count") != record_count:
243
+ raise ValueError("Safetensors run manifest record count does not match its index.")
244
+ metadata = payload.get("metadata", {})
245
+ if not isinstance(metadata, dict):
246
+ raise ValueError("Safetensors index metadata must contain a JSON object.")
247
+ if metadata.get("record_count", record_count) != record_count:
248
+ raise ValueError("Safetensors metadata record count does not match its index.")
249
+ if version == 1 and run_manifest.get("metadata") != payload.get("metadata"):
250
+ raise ValueError("Safetensors run manifest metadata does not match its index.")
251
+ return payload, index_path, run_manifest
252
+
253
+
254
+ def safetensors_result_exists(path: str | Path) -> bool:
255
+ """Return whether an authoritative committed safetensors run exists."""
256
+
257
+ try:
258
+ _load_authoritative_index(path)
259
+ except (OSError, ValueError, json.JSONDecodeError):
260
+ return False
261
+ return True
262
+
263
+
264
+ def _load_safetensor(path: Path, key: str) -> Tensor:
265
+ try:
266
+ from safetensors import safe_open
267
+ except ImportError as error:
268
+ raise ImportError("Loading embeddings requires the 'safetensors' package.") from error
269
+ with safe_open(path, framework="pt", device="cpu") as handle:
270
+ return cast(Tensor, handle.get_tensor(key))
271
+
272
+
273
+ def _safetensors_shard_prefix(path: str | Path) -> str:
274
+ requested_path = Path(path)
275
+ if requested_path.suffix in {".json", ".safetensors"}:
276
+ return f"{requested_path.stem}-embeddings"
277
+ return "embeddings"
278
+
279
+
280
+ def _authoritative_index_payload(path: str | Path) -> dict[str, Any] | None:
281
+ """Return the last atomically committed generation index when available."""
282
+
283
+ try:
284
+ payload, _, _ = _load_authoritative_index(path)
285
+ except (OSError, ValueError, json.JSONDecodeError):
286
+ return None
287
+ return payload
288
+
289
+
290
+ def _referenced_shards(
291
+ index_path: Path,
292
+ payload: dict[str, Any] | None = None,
293
+ ) -> set[Path]:
294
+ if payload is None:
295
+ payload = _authoritative_index_payload(index_path)
296
+ if payload is None:
297
+ return set()
298
+ shards: set[Path] = set()
299
+ for descriptor_shard in payload.get("descriptor_shards", ()):
300
+ tensor_file = descriptor_shard.get("tensor_file")
301
+ if isinstance(tensor_file, str):
302
+ candidate = _resolve_index_child(
303
+ index_path.parent, tensor_file, label="descriptor index"
304
+ )
305
+ shards.add(candidate)
306
+ for item in payload.get("records", ()):
307
+ relative = item.get("tensor", {}).get("file")
308
+ if not isinstance(relative, str):
309
+ continue
310
+ candidate = (index_path.parent / relative).resolve()
311
+ if candidate.parent == index_path.parent.resolve():
312
+ shards.add(candidate)
313
+ return shards
314
+
315
+
316
+ def _validate_tensor_descriptor(
317
+ tensor: dict[str, Any],
318
+ ) -> tuple[str, str, tuple[int, ...], str]:
319
+ key = tensor.get("key")
320
+ if not isinstance(key, str) or not key:
321
+ raise ValueError("Safetensors descriptor tensor key is invalid.")
322
+ dtype = tensor.get("dtype")
323
+ if not isinstance(dtype, str) or dtype not in _NAME_DTYPES:
324
+ raise ValueError("Safetensors descriptor tensor dtype is invalid.")
325
+ raw_shape = tensor.get("shape")
326
+ if not isinstance(raw_shape, (list, tuple)) or not all(
327
+ isinstance(dimension, int) and not isinstance(dimension, bool) and dimension >= 0
328
+ for dimension in raw_shape
329
+ ):
330
+ raise ValueError("Safetensors descriptor tensor shape is invalid.")
331
+ sha256 = tensor.get("sha256")
332
+ if (
333
+ not isinstance(sha256, str)
334
+ or len(sha256) != 64
335
+ or sha256 != sha256.lower()
336
+ or any(character not in "0123456789abcdef" for character in sha256)
337
+ ):
338
+ raise ValueError("Safetensors descriptor tensor SHA-256 is invalid.")
339
+ return key, dtype, tuple(raw_shape), sha256
340
+
341
+
342
+ def _record_from_safetensors_descriptor(root: Path, item: dict[str, Any]) -> EmbeddingRecord:
343
+ if not isinstance(item, dict):
344
+ raise ValueError("Safetensors record descriptor must contain a JSON object.")
345
+ record_id = item.get("id")
346
+ sequence = item.get("sequence")
347
+ if not isinstance(record_id, str) or not record_id:
348
+ raise ValueError("Safetensors descriptor record ID is invalid.")
349
+ if not isinstance(sequence, str) or not sequence:
350
+ raise ValueError("Safetensors descriptor sequence is invalid.")
351
+ tensor = item.get("tensor")
352
+ if not isinstance(tensor, dict):
353
+ raise ValueError("Safetensors descriptor is missing tensor metadata.")
354
+ relative = tensor.get("file")
355
+ if not isinstance(relative, str) or not relative:
356
+ raise ValueError("Safetensors descriptor tensor file is invalid.")
357
+ key, dtype, shape, sha256 = _validate_tensor_descriptor(tensor)
358
+ tensor_path = _resolve_index_child(root, relative, label="descriptor")
359
+ if not tensor_path.is_file():
360
+ raise ValueError(f"Safetensors tensor shard is missing: {relative}.")
361
+
362
+ def load_tensor() -> Tensor:
363
+ return _load_safetensor(tensor_path, key)
364
+
365
+ reference = LazyTensorReference(
366
+ source=str(tensor_path),
367
+ key=key,
368
+ dtype=dtype,
369
+ shape=shape,
370
+ sha256=sha256,
371
+ _loader=load_tensor,
372
+ )
373
+ return EmbeddingRecord(record_id, sequence, reference)
374
+
375
+
376
+ class _SafetensorsRecordSequence(Sequence[EmbeddingRecord]):
377
+ """Lazy immutable view over bounded descriptor JSONL shards."""
378
+
379
+ _fastplms_immutable_sequence = True
380
+
381
+ def __init__(self, root: Path, descriptor_shards: Sequence[dict[str, Any]]) -> None:
382
+ if not isinstance(descriptor_shards, (list, tuple)):
383
+ raise ValueError("Safetensors generation index has invalid descriptor shards.")
384
+ self.root = root
385
+ self.shards = tuple(descriptor_shards)
386
+ cumulative: list[int] = []
387
+ total = 0
388
+ for shard in self.shards:
389
+ if not isinstance(shard, dict):
390
+ raise ValueError("Safetensors descriptor shard entry is invalid.")
391
+ relative = shard.get("file")
392
+ declared_count = shard.get("count")
393
+ if (
394
+ not isinstance(declared_count, int)
395
+ or isinstance(declared_count, bool)
396
+ or declared_count < 0
397
+ ):
398
+ raise ValueError("Safetensors descriptor shard count is invalid.")
399
+ declared_sha256 = shard.get("sha256")
400
+ if not isinstance(declared_sha256, str) or len(declared_sha256) != 64:
401
+ raise ValueError("Safetensors descriptor shard SHA-256 is invalid.")
402
+ if not isinstance(relative, str):
403
+ raise ValueError("Safetensors descriptor index file is invalid.")
404
+ descriptor_path = _resolve_index_child(root, relative, label="index")
405
+ tensor_file = shard.get("tensor_file")
406
+ if not isinstance(tensor_file, str):
407
+ raise ValueError("Safetensors descriptor tensor file is invalid.")
408
+ tensor_path = _resolve_index_child(root, tensor_file, label="index")
409
+ if not tensor_path.is_file():
410
+ raise ValueError(f"Safetensors tensor shard is missing: {tensor_file}.")
411
+ digest = hashlib.sha256()
412
+ count = 0
413
+ with descriptor_path.open("rb") as handle:
414
+ for line in handle:
415
+ digest.update(line)
416
+ if line.strip():
417
+ item = json.loads(line)
418
+ if not isinstance(item, dict):
419
+ raise ValueError("Safetensors record descriptor must be a JSON object.")
420
+ item_tensor = item.get("tensor")
421
+ if not isinstance(item_tensor, dict):
422
+ raise ValueError("Safetensors descriptor is missing tensor metadata.")
423
+ item_tensor_file = item_tensor.get("file")
424
+ if not isinstance(item_tensor_file, str):
425
+ raise ValueError("Safetensors descriptor tensor file is invalid.")
426
+ _resolve_index_child(root, item_tensor_file, label="descriptor")
427
+ if item_tensor_file != tensor_file:
428
+ raise ValueError(
429
+ "Safetensors descriptor tensor file does not match its shard."
430
+ )
431
+ count += 1
432
+ _validate_tensor_descriptor(item_tensor)
433
+ if digest.hexdigest() != declared_sha256 or count != declared_count:
434
+ raise ValueError(
435
+ f"Safetensors descriptor shard failed integrity validation: {relative}."
436
+ )
437
+ total += count
438
+ cumulative.append(total)
439
+ self._cumulative = tuple(cumulative)
440
+ self._count = total
441
+
442
+ def __len__(self) -> int:
443
+ return self._count
444
+
445
+ def _iter_shard(self, shard_index: int) -> Iterator[EmbeddingRecord]:
446
+ descriptor_path = _resolve_index_child(
447
+ self.root, str(self.shards[shard_index]["file"]), label="index"
448
+ )
449
+ with descriptor_path.open("r", encoding="utf-8") as handle:
450
+ for line in handle:
451
+ if line.strip():
452
+ yield _record_from_safetensors_descriptor(self.root, json.loads(line))
453
+
454
+ def __iter__(self) -> Iterator[EmbeddingRecord]:
455
+ for shard_index in range(len(self.shards)):
456
+ yield from self._iter_shard(shard_index)
457
+
458
+ @overload
459
+ def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
460
+
461
+ @overload
462
+ def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
463
+
464
+ def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
465
+ if isinstance(index, slice):
466
+ start, stop, step = index.indices(self._count)
467
+ return [self[position] for position in range(start, stop, step)]
468
+ position = index + self._count if index < 0 else index
469
+ if position < 0 or position >= self._count:
470
+ raise IndexError(index)
471
+ shard_index = bisect_right(self._cumulative, position)
472
+ previous = self._cumulative[shard_index - 1] if shard_index else 0
473
+ local_position = position - previous
474
+ for offset, record in enumerate(self._iter_shard(shard_index)):
475
+ if offset == local_position:
476
+ return record
477
+ raise IndexError(index)
478
+
479
+
480
+ class SafetensorsStreamWriter:
481
+ """Bounded-memory, resumable publisher with immutable retained generations."""
482
+
483
+ def __init__(
484
+ self,
485
+ path: str | Path,
486
+ metadata: dict[str, Any],
487
+ *,
488
+ shard_size: int = DEFAULT_SHARD_SIZE,
489
+ existing: Iterable[EmbeddingRecord] = (),
490
+ reuse_existing: bool = False,
491
+ publish_initial: bool = True,
492
+ publish_incremental: bool = True,
493
+ ) -> None:
494
+ try:
495
+ from safetensors.torch import save_file
496
+ except ImportError as error:
497
+ raise ImportError("Saving embeddings requires the 'safetensors' package.") from error
498
+ if shard_size <= 0:
499
+ raise ValueError("shard_size must be positive.")
500
+
501
+ self.path = Path(path)
502
+ self.index_path = _index_path(path)
503
+ self.run_manifest_path = _run_manifest_path(path)
504
+ self.index_path.parent.mkdir(parents=True, exist_ok=True)
505
+ self.metadata = _persistent_metadata(
506
+ metadata,
507
+ descriptor_index="safetensors-generation-index",
508
+ record_count=0,
509
+ )
510
+ self.shard_size = shard_size
511
+ self.publish_incremental = publish_incremental
512
+ self._save_file = save_file
513
+ authoritative_payload = _authoritative_index_payload(path)
514
+ prefix = _safetensors_shard_prefix(path)
515
+ # A random generation identity prevents a new writer from reusing a
516
+ # previously published or interrupted generation name. Published files
517
+ # are immutable and remain available to lazy readers until explicit GC.
518
+ self._generation = uuid4().hex
519
+ self._prefix = prefix
520
+ self._shard_index = 0
521
+ self._seed_index = 0
522
+ self._commit_index = 0
523
+ self._descriptor_shards: list[dict[str, Any]] = []
524
+ self._record_count = 0
525
+ self._current: dict[str, Tensor] = {}
526
+ self._pending: list[tuple[EmbeddingRecord, str, str, tuple[int, ...], str]] = []
527
+ self._current_size = 0
528
+ if reuse_existing:
529
+ if authoritative_payload is None:
530
+ raise ValueError("Cannot resume without an authoritative safetensors index.")
531
+ authoritative_metadata = authoritative_payload.get("metadata")
532
+ if not isinstance(authoritative_metadata, dict) or authoritative_metadata.get(
533
+ "run_fingerprint"
534
+ ) != self.metadata.get("run_fingerprint"):
535
+ raise ValueError("Cannot resume a safetensors run with a different fingerprint.")
536
+ expected_prefix_length = (
537
+ len(existing) if isinstance(existing, Sequence) else sum(1 for _ in existing)
538
+ )
539
+ if authoritative_payload.get("version") == 2:
540
+ self._descriptor_shards = list(authoritative_payload.get("descriptor_shards", ()))
541
+ self._record_count = int(authoritative_payload.get("record_count", 0))
542
+ else:
543
+ legacy_records = list(authoritative_payload.get("records", ()))
544
+ self._record_count = len(legacy_records)
545
+ if legacy_records:
546
+ self._descriptor_shards.extend(self._write_descriptor_seed(legacy_records))
547
+ if expected_prefix_length != self._record_count:
548
+ raise ValueError(
549
+ "The resumable safetensors prefix does not match the validated "
550
+ "embedding records."
551
+ )
552
+
553
+ if publish_initial:
554
+ self._publish_metadata(complete=False)
555
+
556
+ def _write_descriptor_file(
557
+ self,
558
+ name: str,
559
+ descriptors: Sequence[dict[str, Any]],
560
+ *,
561
+ tensor_file: str,
562
+ ) -> dict[str, Any]:
563
+ temporary = self.index_path.parent / f".{name}.tmp"
564
+ destination = self.index_path.parent / name
565
+ if temporary.exists() or destination.exists():
566
+ raise FileExistsError(
567
+ f"Refusing to reuse immutable safetensors generation path {destination}."
568
+ )
569
+ digest = hashlib.sha256()
570
+ with temporary.open("wb") as handle:
571
+ for item in descriptors:
572
+ encoded = (
573
+ json.dumps(item, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
574
+ )
575
+ handle.write(encoded)
576
+ digest.update(encoded)
577
+ temporary.replace(destination)
578
+ return {
579
+ "file": name,
580
+ "sha256": digest.hexdigest(),
581
+ "count": len(descriptors),
582
+ "tensor_file": tensor_file,
583
+ }
584
+
585
+ def _write_descriptor_seed(self, records: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
586
+ groups: list[tuple[str, list[dict[str, Any]]]] = []
587
+ for record in records:
588
+ tensor_file = str(record["tensor"]["file"])
589
+ if (
590
+ not groups
591
+ or groups[-1][0] != tensor_file
592
+ or len(groups[-1][1]) == _MAX_RECORDS_PER_DESCRIPTOR_SHARD
593
+ ):
594
+ groups.append((tensor_file, []))
595
+ groups[-1][1].append(record)
596
+ descriptor_shards: list[dict[str, Any]] = []
597
+ for tensor_file, descriptors in groups:
598
+ self._seed_index += 1
599
+ name = (
600
+ f"{self._prefix}-records-run-{self._generation}-seed-{self._seed_index:05d}.jsonl"
601
+ )
602
+ descriptor_shards.append(
603
+ self._write_descriptor_file(name, descriptors, tensor_file=tensor_file)
604
+ )
605
+ return descriptor_shards
606
+
607
+ def _write_shard(self) -> None:
608
+ if not self._current:
609
+ return
610
+ self._shard_index += 1
611
+ name = f"{self._prefix}-run-{self._generation}-{self._shard_index:05d}.safetensors"
612
+ temporary = self.index_path.parent / f".{name}.tmp"
613
+ destination = self.index_path.parent / name
614
+ if temporary.exists() or destination.exists():
615
+ raise FileExistsError(
616
+ f"Refusing to reuse immutable safetensors generation path {destination}."
617
+ )
618
+ self._save_file(self._current, temporary)
619
+ temporary.replace(destination)
620
+ descriptors: list[dict[str, Any]] = []
621
+ for record, key, dtype_name, shape, digest in self._pending:
622
+ descriptors.append(
623
+ {
624
+ "id": record.id,
625
+ "sequence": record.sequence,
626
+ "tensor": {
627
+ "file": name,
628
+ "key": key,
629
+ "dtype": dtype_name,
630
+ "shape": list(shape),
631
+ "sha256": digest,
632
+ },
633
+ }
634
+ )
635
+ descriptor_name = (
636
+ f"{self._prefix}-records-run-{self._generation}-{self._shard_index:05d}.jsonl"
637
+ )
638
+ self._descriptor_shards.append(
639
+ self._write_descriptor_file(descriptor_name, descriptors, tensor_file=name)
640
+ )
641
+ self._record_count += len(descriptors)
642
+ self._current = {}
643
+ self._pending = []
644
+ self._current_size = 0
645
+
646
+ def append(
647
+ self,
648
+ records: Iterable[EmbeddingRecord],
649
+ *,
650
+ publish: bool | None = None,
651
+ ) -> None:
652
+ """Persist records while retaining at most one shard of tensors."""
653
+
654
+ for record in records:
655
+ position = self._record_count + len(self._pending)
656
+ tensor = record.load_tensor().detach().cpu().contiguous() # (...)
657
+ if tensor.dtype not in _DTYPE_NAMES:
658
+ raise TypeError(f"Unsupported tensor dtype {tensor.dtype}.")
659
+ nbytes = tensor.numel() * tensor.element_size()
660
+ if nbytes > self.shard_size:
661
+ raise ValueError(
662
+ f"Embedding {position} requires {nbytes} bytes and cannot fit in a "
663
+ f"{self.shard_size}-byte safetensors shard."
664
+ )
665
+ if self._current and (
666
+ self._current_size + nbytes > self.shard_size
667
+ or len(self._pending) == _MAX_RECORDS_PER_DESCRIPTOR_SHARD
668
+ ):
669
+ self._write_shard()
670
+ if self.publish_incremental:
671
+ self._publish_metadata(complete=False)
672
+ position = self._record_count
673
+ key = f"embedding_{position:08d}"
674
+ self._current[key] = tensor
675
+ self._current_size += nbytes
676
+ self._pending.append(
677
+ (
678
+ record,
679
+ key,
680
+ _DTYPE_NAMES[tensor.dtype],
681
+ tuple(tensor.shape),
682
+ tensor_sha256(tensor),
683
+ )
684
+ )
685
+ if publish:
686
+ self.publish(complete=False)
687
+
688
+ def _publish_metadata(
689
+ self,
690
+ *,
691
+ complete: bool,
692
+ metadata: dict[str, Any] | None = None,
693
+ ) -> EmbeddingResult:
694
+ """Atomically expose one self-consistent metadata generation."""
695
+
696
+ if metadata is not None:
697
+ self.metadata = _persistent_metadata(
698
+ metadata,
699
+ descriptor_index="safetensors-generation-index",
700
+ )
701
+ self.metadata["complete"] = complete
702
+ self.metadata["record_count"] = self._record_count
703
+ self._commit_index += 1
704
+ payload = {
705
+ "version": 2,
706
+ "format": "fastplms-embedding-safetensors",
707
+ "metadata": self.metadata,
708
+ "record_count": self._record_count,
709
+ "descriptor_shards": self._descriptor_shards,
710
+ }
711
+ generation_index_name = (
712
+ f"{self._prefix}-index-run-{self._generation}-{self._commit_index:05d}.json"
713
+ )
714
+ generation_index_path = self.index_path.parent / generation_index_name
715
+ temporary_generation_index = generation_index_path.with_name(
716
+ f".{generation_index_path.name}.tmp"
717
+ )
718
+ if temporary_generation_index.exists() or generation_index_path.exists():
719
+ raise FileExistsError(
720
+ f"Refusing to reuse immutable safetensors generation index {generation_index_path}."
721
+ )
722
+ encoded_index = _canonical_json_bytes(payload)
723
+ temporary_generation_index.write_bytes(encoded_index)
724
+ temporary_generation_index.replace(generation_index_path)
725
+
726
+ index_sha256 = hashlib.sha256(encoded_index).hexdigest()
727
+ index_reference = {
728
+ "file": generation_index_name,
729
+ "sha256": index_sha256,
730
+ }
731
+ run_manifest = {
732
+ "version": 2,
733
+ "format": "fastplms-embedding-run",
734
+ "index": index_reference,
735
+ "record_count": self._record_count,
736
+ }
737
+ pointer_identity = f"{self._generation}-{self._commit_index:05d}"
738
+ temporary_manifest = self.run_manifest_path.with_name(
739
+ f".{self.run_manifest_path.name}.{pointer_identity}.tmp"
740
+ )
741
+ temporary_manifest.write_bytes(_canonical_json_bytes(run_manifest))
742
+ temporary_manifest.replace(self.run_manifest_path)
743
+
744
+ # ``index.json`` is a non-authoritative convenience pointer. The run
745
+ # manifest is committed first, so interruption here cannot invalidate
746
+ # the newly committed generation.
747
+ stable_pointer = {
748
+ "version": 2,
749
+ "format": "fastplms-embedding-index-pointer",
750
+ "index": index_reference,
751
+ }
752
+ temporary_index = self.index_path.with_name(
753
+ f".{self.index_path.name}.{pointer_identity}.tmp"
754
+ )
755
+ temporary_index.write_bytes(_canonical_json_bytes(stable_pointer))
756
+ temporary_index.replace(self.index_path)
757
+
758
+ return load_safetensors_result(self.index_path)
759
+
760
+ def publish(
761
+ self,
762
+ *,
763
+ complete: bool,
764
+ metadata: dict[str, Any] | None = None,
765
+ ) -> EmbeddingResult:
766
+ """Flush the current shard and atomically expose a consistent generation."""
767
+
768
+ self._write_shard()
769
+ return self._publish_metadata(complete=complete, metadata=metadata)
770
+
771
+
772
+ def save_safetensors_result(
773
+ result: EmbeddingResult,
774
+ path: str | Path,
775
+ *,
776
+ shard_size: int = DEFAULT_SHARD_SIZE,
777
+ ) -> EmbeddingResult:
778
+ """Write sharded safetensors without materializing the full result."""
779
+
780
+ writer = SafetensorsStreamWriter(
781
+ path,
782
+ result.metadata,
783
+ shard_size=shard_size,
784
+ publish_initial=False,
785
+ publish_incremental=False,
786
+ )
787
+ writer.append(result, publish=False)
788
+ return writer.publish(complete=bool(result.metadata.get("complete", True)))
789
+
790
+
791
+ def load_safetensors_result(path: str | Path) -> EmbeddingResult:
792
+ """Load an indexed safetensors result without loading tensor payloads."""
793
+
794
+ payload, index_path, _ = _load_authoritative_index(path)
795
+ if payload.get("version") == 2:
796
+ lazy_records = _SafetensorsRecordSequence(
797
+ index_path.parent, payload.get("descriptor_shards", ())
798
+ )
799
+ if len(lazy_records) != payload.get("record_count"):
800
+ raise ValueError("Safetensors descriptor count does not match its generation index.")
801
+ return EmbeddingResult(lazy_records, payload.get("metadata", {}))
802
+
803
+ records: list[EmbeddingRecord] = []
804
+ for item in payload["records"]:
805
+ records.append(_record_from_safetensors_descriptor(index_path.parent, item))
806
+ return EmbeddingResult(records, payload.get("metadata", {}))
807
+
808
+
809
+ def garbage_collect_safetensors_generations(
810
+ path: str | Path,
811
+ *,
812
+ dry_run: bool = True,
813
+ confirm_no_active_readers_or_writers: bool = False,
814
+ ) -> tuple[Path, ...]:
815
+ """Remove non-authoritative generations after an explicit exclusivity check.
816
+
817
+ Safetensors results retain immutable historical generations because an
818
+ already-open :class:`EmbeddingResult` resolves tensors through those exact
819
+ descriptor and shard paths. Destructive collection is therefore safe only
820
+ when the caller guarantees that no reader or writer for ``path`` remains
821
+ active. ``dry_run=True`` is the default and returns the paths that would be
822
+ removed without changing the output directory.
823
+ """
824
+
825
+ if not isinstance(dry_run, bool):
826
+ raise TypeError("dry_run must be a bool.")
827
+ if not isinstance(confirm_no_active_readers_or_writers, bool):
828
+ raise TypeError("confirm_no_active_readers_or_writers must be a bool.")
829
+ if not dry_run and not confirm_no_active_readers_or_writers:
830
+ raise ValueError(
831
+ "Destructive safetensors generation collection requires "
832
+ "confirm_no_active_readers_or_writers=True."
833
+ )
834
+
835
+ # Validate the full descriptor graph before identifying anything as stale.
836
+ load_safetensors_result(path)
837
+ payload, authoritative_index_path, _ = _load_authoritative_index(path)
838
+ stable_index_path = _index_path(path)
839
+ run_manifest_path = _run_manifest_path(path)
840
+ root = stable_index_path.parent
841
+ prefix = _safetensors_shard_prefix(path)
842
+ protected = {
843
+ stable_index_path.resolve(),
844
+ run_manifest_path.resolve(),
845
+ authoritative_index_path.resolve(),
846
+ *_referenced_shards(stable_index_path, payload),
847
+ }
848
+ for descriptor_shard in payload.get("descriptor_shards", ()):
849
+ relative = descriptor_shard.get("file")
850
+ if isinstance(relative, str):
851
+ protected.add(_resolve_index_child(root, relative, label="index").resolve())
852
+
853
+ candidates: set[Path] = set()
854
+ for pattern in (
855
+ f"{prefix}-run-*-*.safetensors",
856
+ f"{prefix}-records-run-*.jsonl",
857
+ f"{prefix}-index-run-*.json",
858
+ f".{prefix}-*.tmp",
859
+ ):
860
+ candidates.update(root.glob(pattern))
861
+ candidates.update(root.glob(f".{stable_index_path.name}.*.tmp"))
862
+ candidates.update(root.glob(f".{run_manifest_path.name}.*.tmp"))
863
+
864
+ stale = tuple(
865
+ sorted(
866
+ (candidate for candidate in candidates if candidate.resolve() not in protected),
867
+ key=lambda candidate: candidate.name,
868
+ )
869
+ )
870
+ if not dry_run:
871
+ for candidate in stale:
872
+ candidate.unlink(missing_ok=True)
873
+ return stale
874
+
875
+
876
+ def _ensure_sqlite_schema(connection: sqlite3.Connection) -> None:
877
+ connection.executescript(
878
+ """
879
+ PRAGMA foreign_keys = ON;
880
+ CREATE TABLE IF NOT EXISTS runs (
881
+ run_id TEXT PRIMARY KEY,
882
+ metadata_json TEXT NOT NULL,
883
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
884
+ published_order INTEGER
885
+ );
886
+ CREATE TABLE IF NOT EXISTS tensors (
887
+ run_id TEXT NOT NULL,
888
+ position INTEGER NOT NULL,
889
+ dtype TEXT NOT NULL,
890
+ shape_json TEXT NOT NULL,
891
+ data BLOB NOT NULL,
892
+ sha256 TEXT NOT NULL,
893
+ PRIMARY KEY (run_id, position),
894
+ FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE
895
+ );
896
+ CREATE TABLE IF NOT EXISTS records (
897
+ run_id TEXT NOT NULL,
898
+ position INTEGER NOT NULL,
899
+ record_id TEXT NOT NULL,
900
+ sequence TEXT NOT NULL,
901
+ PRIMARY KEY (run_id, position),
902
+ FOREIGN KEY (run_id, position) REFERENCES tensors(run_id, position)
903
+ ON DELETE CASCADE
904
+ );
905
+ """
906
+ )
907
+ run_columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(runs)").fetchall()}
908
+ if "published_order" not in run_columns:
909
+ connection.execute("ALTER TABLE runs ADD COLUMN published_order INTEGER")
910
+ # Databases created before staged publication exposed every stored run.
911
+ # Preserve that view for historical runs containing committed records.
912
+ connection.execute(
913
+ "UPDATE runs SET published_order = rowid "
914
+ "WHERE published_order IS NULL AND EXISTS ("
915
+ "SELECT 1 FROM records WHERE records.run_id = runs.run_id)"
916
+ )
917
+ connection.execute(
918
+ "CREATE INDEX IF NOT EXISTS runs_published_order_idx ON runs(published_order)"
919
+ )
920
+ if "published_order" not in run_columns:
921
+ # Schema upgrades run before callers open their data transaction.
922
+ # End the migration transaction explicitly so BEGIN IMMEDIATE below
923
+ # remains valid on existing databases.
924
+ connection.commit()
925
+
926
+
927
+ def save_sqlite_result(result: EmbeddingResult, path: str | Path) -> EmbeddingResult:
928
+ """Transactionally store an ordered result in normalized SQLite tables."""
929
+
930
+ path = Path(path)
931
+ path.parent.mkdir(parents=True, exist_ok=True)
932
+ run_id = str(result.metadata.get("run_fingerprint", ""))
933
+ if not run_id:
934
+ raise ValueError("SQLite results require metadata['run_fingerprint'].")
935
+ metadata_json = json.dumps(
936
+ _persistent_metadata(
937
+ result.metadata,
938
+ descriptor_index="sqlite-records",
939
+ record_count=len(result),
940
+ ),
941
+ sort_keys=True,
942
+ )
943
+ with sqlite3.connect(path, timeout=30) as connection:
944
+ _ensure_sqlite_schema(connection)
945
+ connection.execute("PRAGMA journal_mode = WAL")
946
+ connection.execute("BEGIN IMMEDIATE")
947
+ connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
948
+ connection.execute(
949
+ "INSERT INTO runs(run_id, metadata_json, published_order) "
950
+ "SELECT ?, ?, COALESCE(MAX(published_order), 0) + 1 FROM runs",
951
+ (run_id, metadata_json),
952
+ )
953
+ for position, record in enumerate(result):
954
+ X = record.load_tensor().detach().cpu().contiguous() # (...)
955
+ dtype_name, shape_json, data = _encode_tensor(X)
956
+ digest = tensor_sha256(X)
957
+ connection.execute(
958
+ "INSERT INTO tensors VALUES (?, ?, ?, ?, ?, ?)",
959
+ (run_id, position, dtype_name, shape_json, data, digest),
960
+ )
961
+ connection.execute(
962
+ "INSERT INTO records VALUES (?, ?, ?, ?)",
963
+ (run_id, position, record.id, record.sequence),
964
+ )
965
+ connection.commit()
966
+ return load_sqlite_result(path, run_id=run_id)
967
+
968
+
969
+ def initialize_sqlite_run(
970
+ path: str | Path,
971
+ metadata: dict[str, Any],
972
+ *,
973
+ resume: bool,
974
+ ) -> str:
975
+ """Create a resumable SQLite run without buffering tensor results."""
976
+
977
+ path = Path(path)
978
+ path.parent.mkdir(parents=True, exist_ok=True)
979
+ run_id = str(metadata.get("run_fingerprint", ""))
980
+ if not run_id:
981
+ raise ValueError("SQLite runs require metadata['run_fingerprint'].")
982
+ with sqlite3.connect(path, timeout=30) as connection:
983
+ _ensure_sqlite_schema(connection)
984
+ connection.execute("PRAGMA journal_mode = WAL")
985
+ connection.execute("BEGIN IMMEDIATE")
986
+ exists = connection.execute("SELECT 1 FROM runs WHERE run_id = ?", (run_id,)).fetchone()
987
+ if exists and not resume:
988
+ connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
989
+ exists = None
990
+ if exists is None:
991
+ initial_metadata = _persistent_metadata(
992
+ metadata,
993
+ descriptor_index="sqlite-records",
994
+ record_count=0,
995
+ )
996
+ connection.execute(
997
+ "INSERT INTO runs(run_id, metadata_json) VALUES (?, ?)",
998
+ (run_id, json.dumps(initial_metadata, sort_keys=True)),
999
+ )
1000
+ connection.commit()
1001
+ return run_id
1002
+
1003
+
1004
+ def append_sqlite_records(
1005
+ path: str | Path,
1006
+ run_id: str,
1007
+ start_position: int,
1008
+ records: list[EmbeddingRecord],
1009
+ *,
1010
+ replace_metadata: dict[str, Any] | None = None,
1011
+ ) -> None:
1012
+ """Commit one ordered embedding batch so an interrupted run can resume."""
1013
+
1014
+ if not isinstance(run_id, str) or not run_id:
1015
+ raise ValueError("run_id must be a non-empty string.")
1016
+ if not isinstance(start_position, int) or isinstance(start_position, bool):
1017
+ raise TypeError("start_position must be a non-negative integer.")
1018
+ if start_position < 0:
1019
+ raise ValueError("start_position must be a non-negative integer.")
1020
+ if not isinstance(records, list) or not all(
1021
+ isinstance(record, EmbeddingRecord) for record in records
1022
+ ):
1023
+ raise TypeError("records must be a list of EmbeddingRecord values.")
1024
+
1025
+ with sqlite3.connect(Path(path), timeout=30) as connection:
1026
+ _ensure_sqlite_schema(connection)
1027
+ connection.execute("PRAGMA journal_mode = WAL")
1028
+ connection.execute("BEGIN IMMEDIATE")
1029
+ if replace_metadata is not None:
1030
+ replacement_run_id = str(replace_metadata.get("run_fingerprint", ""))
1031
+ if replacement_run_id != run_id:
1032
+ raise ValueError("Replacement metadata must match the SQLite run ID.")
1033
+ initial_metadata = _persistent_metadata(
1034
+ replace_metadata,
1035
+ descriptor_index="sqlite-records",
1036
+ record_count=0,
1037
+ )
1038
+ connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
1039
+ connection.execute(
1040
+ "INSERT INTO runs(run_id, metadata_json) VALUES (?, ?)",
1041
+ (run_id, json.dumps(initial_metadata, sort_keys=True)),
1042
+ )
1043
+ if connection.execute("SELECT 1 FROM runs WHERE run_id = ?", (run_id,)).fetchone() is None:
1044
+ raise KeyError(f"Missing SQLite embedding run {run_id}.")
1045
+ current_count, minimum_position, maximum_position = connection.execute(
1046
+ "SELECT COUNT(*), MIN(position), MAX(position) FROM records WHERE run_id = ?",
1047
+ (run_id,),
1048
+ ).fetchone()
1049
+ if current_count and (minimum_position != 0 or maximum_position != current_count - 1):
1050
+ raise ValueError("SQLite embedding run has a non-contiguous record prefix.")
1051
+ if start_position != current_count:
1052
+ raise ValueError(
1053
+ f"start_position={start_position} does not match the contiguous "
1054
+ f"SQLite prefix length {current_count}."
1055
+ )
1056
+ for offset, record in enumerate(records):
1057
+ position = start_position + offset
1058
+ X = record.load_tensor().detach().cpu().contiguous() # (...)
1059
+ dtype_name, shape_json, data = _encode_tensor(X)
1060
+ digest = tensor_sha256(X)
1061
+ connection.execute(
1062
+ "INSERT INTO tensors VALUES (?, ?, ?, ?, ?, ?)",
1063
+ (run_id, position, dtype_name, shape_json, data, digest),
1064
+ )
1065
+ connection.execute(
1066
+ "INSERT INTO records VALUES (?, ?, ?, ?)",
1067
+ (run_id, position, record.id, record.sequence),
1068
+ )
1069
+ row = connection.execute(
1070
+ "SELECT metadata_json FROM runs WHERE run_id = ?", (run_id,)
1071
+ ).fetchone()
1072
+ if row is None:
1073
+ raise KeyError(f"Missing SQLite embedding run {run_id}.")
1074
+ metadata = json.loads(row[0])
1075
+ if not isinstance(metadata, dict):
1076
+ raise ValueError("SQLite run metadata must contain a JSON object.")
1077
+ metadata["record_count"] = start_position + len(records)
1078
+ metadata["descriptor_index"] = "sqlite-records"
1079
+ connection.execute(
1080
+ "UPDATE runs SET metadata_json = ? WHERE run_id = ?",
1081
+ (json.dumps(metadata, sort_keys=True), run_id),
1082
+ )
1083
+ if records:
1084
+ connection.execute(
1085
+ "UPDATE runs SET published_order = ("
1086
+ "SELECT COALESCE(MAX(published_order), 0) + 1 FROM runs"
1087
+ ") WHERE run_id = ? AND published_order IS NULL",
1088
+ (run_id,),
1089
+ )
1090
+ connection.commit()
1091
+
1092
+
1093
+ def update_sqlite_run_metadata(path: str | Path, run_id: str, metadata: dict[str, Any]) -> None:
1094
+ """Finalize reproducibility metadata after the last streamed batch."""
1095
+
1096
+ with sqlite3.connect(Path(path), timeout=30) as connection:
1097
+ row = connection.execute(
1098
+ "SELECT COUNT(*) FROM records WHERE run_id = ?", (run_id,)
1099
+ ).fetchone()
1100
+ record_count = int(row[0]) if row is not None else 0
1101
+ cleaned_metadata = _persistent_metadata(
1102
+ metadata,
1103
+ descriptor_index="sqlite-records",
1104
+ record_count=record_count,
1105
+ )
1106
+ updated = connection.execute(
1107
+ "UPDATE runs SET metadata_json = ? WHERE run_id = ?",
1108
+ (json.dumps(cleaned_metadata, sort_keys=True), run_id),
1109
+ ).rowcount
1110
+ if updated != 1:
1111
+ raise KeyError(f"Missing SQLite embedding run {run_id}.")
1112
+ connection.commit()
1113
+
1114
+
1115
+ def _connect_sqlite_read_only(path: Path) -> sqlite3.Connection:
1116
+ if not path.is_file():
1117
+ raise FileNotFoundError(path)
1118
+ return sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=30)
1119
+
1120
+
1121
+ def _validate_sqlite_result_schema(connection: sqlite3.Connection, path: Path) -> None:
1122
+ tables = {
1123
+ str(row[0])
1124
+ for row in connection.execute(
1125
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
1126
+ ).fetchall()
1127
+ }
1128
+ required = {"runs", "records", "tensors"}
1129
+ if not required.issubset(tables):
1130
+ raise ValueError(
1131
+ f"Not a FastPLMs embedding SQLite database: {path}. "
1132
+ "Use convert_legacy_sqlite() for a legacy embeddings table."
1133
+ )
1134
+
1135
+
1136
+ def _load_sqlite_tensor(path: Path, run_id: str, position: int) -> Tensor:
1137
+ with _connect_sqlite_read_only(path) as connection:
1138
+ row = connection.execute(
1139
+ "SELECT dtype, shape_json, data FROM tensors WHERE run_id = ? AND position = ?",
1140
+ (run_id, position),
1141
+ ).fetchone()
1142
+ if row is None:
1143
+ raise KeyError(f"Missing SQLite tensor {run_id}:{position}.")
1144
+ return _decode_tensor(*row)
1145
+
1146
+
1147
+ def _validate_sqlite_descriptor_row(
1148
+ row: Sequence[Any],
1149
+ ) -> tuple[int, str, str, str, str, str]:
1150
+ if len(row) != 6:
1151
+ raise ValueError("SQLite embedding descriptor has an invalid column count.")
1152
+ position, record_id, sequence, dtype_name, shape_json, digest = row
1153
+ if not isinstance(position, int) or isinstance(position, bool) or position < 0:
1154
+ raise ValueError("SQLite embedding position is invalid.")
1155
+ if not isinstance(record_id, str) or not record_id:
1156
+ raise ValueError("SQLite embedding record ID is invalid.")
1157
+ if not isinstance(sequence, str) or not sequence:
1158
+ raise ValueError("SQLite embedding sequence is invalid.")
1159
+ if not isinstance(shape_json, str):
1160
+ raise ValueError("SQLite embedding tensor shape is invalid.")
1161
+ try:
1162
+ shape = json.loads(shape_json)
1163
+ except json.JSONDecodeError as error:
1164
+ raise ValueError("SQLite embedding tensor shape is invalid.") from error
1165
+ _validate_tensor_descriptor(
1166
+ {
1167
+ "key": f"embedding_{position}",
1168
+ "dtype": dtype_name,
1169
+ "shape": shape,
1170
+ "sha256": digest,
1171
+ }
1172
+ )
1173
+ return position, record_id, sequence, dtype_name, shape_json, digest
1174
+
1175
+
1176
+ def _sqlite_record_from_row(path: Path, run_id: str, row: Sequence[Any]) -> EmbeddingRecord:
1177
+ position, record_id, sequence, dtype_name, shape_json, digest = _validate_sqlite_descriptor_row(
1178
+ row
1179
+ )
1180
+
1181
+ def load_tensor() -> Tensor:
1182
+ return _load_sqlite_tensor(path, run_id, position)
1183
+
1184
+ reference = LazyTensorReference(
1185
+ source=str(path),
1186
+ key=f"{run_id}:{position}",
1187
+ dtype=dtype_name,
1188
+ shape=tuple(json.loads(shape_json)),
1189
+ sha256=digest,
1190
+ _loader=load_tensor,
1191
+ )
1192
+ return EmbeddingRecord(record_id, sequence, reference)
1193
+
1194
+
1195
+ class _SQLiteRecordSequence(Sequence[EmbeddingRecord]):
1196
+ """Lazy immutable descriptor view over one SQLite embedding run."""
1197
+
1198
+ _fastplms_immutable_sequence = True
1199
+
1200
+ def __init__(self, path: Path, run_id: str, count: int) -> None:
1201
+ self.path = path
1202
+ self.run_id = run_id
1203
+ self._count = count
1204
+
1205
+ @staticmethod
1206
+ def _row_query() -> str:
1207
+ return (
1208
+ "SELECT r.position, r.record_id, r.sequence, t.dtype, t.shape_json, t.sha256 "
1209
+ "FROM records r JOIN tensors t USING (run_id, position) "
1210
+ "WHERE r.run_id = ?"
1211
+ )
1212
+
1213
+ def __len__(self) -> int:
1214
+ return self._count
1215
+
1216
+ def __iter__(self) -> Iterator[EmbeddingRecord]:
1217
+ with _connect_sqlite_read_only(self.path) as connection:
1218
+ cursor = connection.execute(f"{self._row_query()} ORDER BY r.position", (self.run_id,))
1219
+ while rows := cursor.fetchmany(1_024):
1220
+ for row in rows:
1221
+ yield _sqlite_record_from_row(self.path, self.run_id, row)
1222
+
1223
+ @overload
1224
+ def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
1225
+
1226
+ @overload
1227
+ def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
1228
+
1229
+ def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
1230
+ if isinstance(index, slice):
1231
+ start, stop, step = index.indices(self._count)
1232
+ return [self[position] for position in range(start, stop, step)]
1233
+ position = index + self._count if index < 0 else index
1234
+ if position < 0 or position >= self._count:
1235
+ raise IndexError(index)
1236
+ with _connect_sqlite_read_only(self.path) as connection:
1237
+ row = connection.execute(
1238
+ f"{self._row_query()} AND r.position = ?",
1239
+ (self.run_id, position),
1240
+ ).fetchone()
1241
+ if row is None:
1242
+ raise IndexError(index)
1243
+ return _sqlite_record_from_row(self.path, self.run_id, row)
1244
+
1245
+
1246
+ def load_sqlite_result(
1247
+ path: str | Path,
1248
+ *,
1249
+ run_id: str | None = None,
1250
+ positions: Iterable[int] | None = None,
1251
+ record_ids: Iterable[str] | None = None,
1252
+ sequences: Iterable[str] | None = None,
1253
+ ) -> EmbeddingResult:
1254
+ """Load one SQLite run read-only, optionally in explicit selector order.
1255
+
1256
+ Exactly one selector may be supplied. Repeated selectors are retained. An
1257
+ ID or sequence selector that matches multiple stored rows returns those
1258
+ rows in their original order for every occurrence of that selector.
1259
+ """
1260
+
1261
+ path = Path(path).resolve()
1262
+ supplied_selectors = sum(
1263
+ selector is not None for selector in (positions, record_ids, sequences)
1264
+ )
1265
+ if supplied_selectors > 1:
1266
+ raise ValueError("Choose at most one of positions, record_ids, or sequences.")
1267
+ normalized_positions = tuple(positions) if positions is not None else None
1268
+ normalized_ids = tuple(record_ids) if record_ids is not None else None
1269
+ normalized_sequences = tuple(sequences) if sequences is not None else None
1270
+ if normalized_positions is not None and not all(
1271
+ isinstance(position, int) and not isinstance(position, bool) and position >= 0
1272
+ for position in normalized_positions
1273
+ ):
1274
+ raise ValueError("positions must contain non-negative integers.")
1275
+ for name, values in (
1276
+ ("record_ids", normalized_ids),
1277
+ ("sequences", normalized_sequences),
1278
+ ):
1279
+ if values is not None and not all(isinstance(value, str) for value in values):
1280
+ raise TypeError(f"{name} must contain strings.")
1281
+
1282
+ with _connect_sqlite_read_only(path) as connection:
1283
+ _validate_sqlite_result_schema(connection, path)
1284
+ if run_id is None:
1285
+ run_columns = {
1286
+ str(info[1]) for info in connection.execute("PRAGMA table_info(runs)").fetchall()
1287
+ }
1288
+ if "published_order" in run_columns:
1289
+ row = connection.execute(
1290
+ "SELECT run_id, metadata_json FROM runs "
1291
+ "WHERE published_order IS NOT NULL "
1292
+ "ORDER BY published_order DESC, rowid DESC LIMIT 1"
1293
+ ).fetchone()
1294
+ else:
1295
+ row = connection.execute(
1296
+ "SELECT run_id, metadata_json FROM runs "
1297
+ "ORDER BY created_at DESC, rowid DESC LIMIT 1"
1298
+ ).fetchone()
1299
+ else:
1300
+ row = connection.execute(
1301
+ "SELECT run_id, metadata_json FROM runs WHERE run_id = ?", (run_id,)
1302
+ ).fetchone()
1303
+ if row is None:
1304
+ raise KeyError(f"No embedding run found in {path}.")
1305
+ selected_run, metadata_json = row
1306
+ metadata = json.loads(metadata_json)
1307
+ if not isinstance(metadata, dict):
1308
+ raise ValueError("SQLite run metadata must contain a JSON object.")
1309
+ row_prefix = (
1310
+ "SELECT r.position, r.record_id, r.sequence, t.dtype, t.shape_json, t.sha256 "
1311
+ "FROM records r JOIN tensors t USING (run_id, position) "
1312
+ "WHERE r.run_id = ?"
1313
+ )
1314
+ record_count, minimum_position, maximum_position = connection.execute(
1315
+ "SELECT COUNT(*), MIN(position), MAX(position) FROM records WHERE run_id = ?",
1316
+ (selected_run,),
1317
+ ).fetchone()
1318
+ (tensor_count,) = connection.execute(
1319
+ "SELECT COUNT(*) FROM tensors WHERE run_id = ?", (selected_run,)
1320
+ ).fetchone()
1321
+ (joined_count,) = connection.execute(
1322
+ "SELECT COUNT(*) FROM records r JOIN tensors t USING (run_id, position) "
1323
+ "WHERE r.run_id = ?",
1324
+ (selected_run,),
1325
+ ).fetchone()
1326
+ if (
1327
+ tensor_count != record_count
1328
+ or joined_count != record_count
1329
+ or (record_count and (minimum_position != 0 or maximum_position != record_count - 1))
1330
+ ):
1331
+ raise ValueError("SQLite embedding run has inconsistent or non-contiguous records.")
1332
+ metadata_count = metadata.get("record_count")
1333
+ if (
1334
+ not isinstance(metadata_count, int)
1335
+ or isinstance(metadata_count, bool)
1336
+ or metadata_count != record_count
1337
+ ):
1338
+ raise ValueError("SQLite metadata record count does not match stored records.")
1339
+ descriptor_cursor = connection.execute(f"{row_prefix} ORDER BY r.position", (selected_run,))
1340
+ while descriptor_rows := descriptor_cursor.fetchmany(1_024):
1341
+ for descriptor_row in descriptor_rows:
1342
+ _validate_sqlite_descriptor_row(descriptor_row)
1343
+ if supplied_selectors == 0:
1344
+ rows: list[tuple[Any, ...]] | None = None
1345
+ else:
1346
+ selector_values: tuple[Any, ...]
1347
+ selector_column: str
1348
+ if normalized_positions is not None:
1349
+ selector_values = normalized_positions
1350
+ selector_column = "r.position"
1351
+ elif normalized_ids is not None:
1352
+ selector_values = normalized_ids
1353
+ selector_column = "r.record_id"
1354
+ else:
1355
+ if normalized_sequences is None:
1356
+ raise RuntimeError("Filtered SQLite retrieval resolved no selector values.")
1357
+ selector_values = normalized_sequences
1358
+ selector_column = "r.sequence"
1359
+ fetched: list[tuple[Any, ...]] = []
1360
+ unique_values = tuple(dict.fromkeys(selector_values))
1361
+ for start in range(0, len(unique_values), 900):
1362
+ chunk = unique_values[start : start + 900]
1363
+ placeholders = ",".join("?" for _ in chunk)
1364
+ fetched.extend(
1365
+ connection.execute(
1366
+ f"{row_prefix} AND {selector_column} IN ({placeholders}) "
1367
+ "ORDER BY r.position",
1368
+ (selected_run, *chunk),
1369
+ ).fetchall()
1370
+ )
1371
+ value_index = (
1372
+ 0 if normalized_positions is not None else (1 if normalized_ids is not None else 2)
1373
+ )
1374
+ matched: dict[Any, list[tuple[Any, ...]]] = {}
1375
+ for fetched_row in sorted(fetched, key=lambda item: int(item[0])):
1376
+ matched.setdefault(fetched_row[value_index], []).append(fetched_row)
1377
+ missing = [value for value in selector_values if value not in matched]
1378
+ if missing:
1379
+ raise KeyError(f"SQLite embedding selectors were not found: {missing!r}.")
1380
+ rows = [
1381
+ fetched_row for value in selector_values for fetched_row in matched.get(value, ())
1382
+ ]
1383
+
1384
+ if rows is None:
1385
+ return EmbeddingResult(
1386
+ _SQLiteRecordSequence(path, selected_run, int(record_count)),
1387
+ metadata,
1388
+ )
1389
+ records = [_sqlite_record_from_row(path, selected_run, selected_row) for selected_row in rows]
1390
+ if supplied_selectors:
1391
+ metadata = dict(metadata)
1392
+ metadata["selection"] = {
1393
+ "kind": (
1394
+ "positions"
1395
+ if normalized_positions is not None
1396
+ else "record_ids"
1397
+ if normalized_ids is not None
1398
+ else "sequences"
1399
+ ),
1400
+ "count": len(rows),
1401
+ "duplicate_policy": "preserve-request-order",
1402
+ }
1403
+ return EmbeddingResult(records, metadata)
1404
+
1405
+
1406
+ def load_legacy_pth(path: str | Path, *, allow_unsafe_pickle: bool = False) -> EmbeddingResult:
1407
+ """Import a legacy mapping-only ``.pth`` file after explicit opt-in."""
1408
+
1409
+ if not allow_unsafe_pickle:
1410
+ raise ValueError(
1411
+ "Legacy .pth loading can execute pickle payloads. Pass "
1412
+ "allow_unsafe_pickle=True only for a trusted file."
1413
+ )
1414
+ payload = torch.load(Path(path), map_location="cpu", weights_only=False)
1415
+ if not isinstance(payload, dict):
1416
+ raise ValueError("A legacy .pth embedding file must contain a mapping.")
1417
+ records: list[EmbeddingRecord] = []
1418
+ for position, (sequence, X) in enumerate(payload.items()):
1419
+ # X: (...)
1420
+ if not isinstance(sequence, str) or not isinstance(X, Tensor):
1421
+ raise ValueError("Legacy embedding mappings must use str keys and Tensor values.")
1422
+ records.append(EmbeddingRecord(str(position), sequence, X.detach().cpu()))
1423
+ return EmbeddingResult(records, {"format": "legacy-pth", "unsafe_pickle": True})
1424
+
1425
+
1426
+ _LEGACY_COMPACT_VERSION = 0x01
1427
+ _LEGACY_CODE_DTYPES: dict[int, tuple[np.dtype[Any], torch.dtype]] = {
1428
+ 0: (np.dtype(np.float16), torch.float16),
1429
+ # Legacy BF16 blobs stored FP16 payload bytes and converted back to BF16.
1430
+ 1: (np.dtype(np.float16), torch.bfloat16),
1431
+ 2: (np.dtype(np.float32), torch.float32),
1432
+ }
1433
+
1434
+
1435
+ def _decode_legacy_sqlite_blob(
1436
+ data: bytes,
1437
+ *,
1438
+ fallback_shape: tuple[int, ...] | None,
1439
+ allow_unsafe_pickle: bool,
1440
+ ) -> Tensor:
1441
+ if len(data) >= 6 and data[0] == _LEGACY_COMPACT_VERSION:
1442
+ dtype_code = int(data[1])
1443
+ if dtype_code not in _LEGACY_CODE_DTYPES:
1444
+ raise ValueError(f"Unsupported legacy compact dtype code {dtype_code}.")
1445
+ (ndim,) = struct.unpack_from("<i", data, 2)
1446
+ if ndim < 0 or ndim > 16 or len(data) < 6 + 4 * ndim:
1447
+ raise ValueError("Malformed legacy compact embedding header.")
1448
+ shape = tuple(int(value) for value in struct.unpack_from(f"<{ndim}i", data, 6))
1449
+ if any(size < 0 for size in shape):
1450
+ raise ValueError("Malformed negative legacy embedding dimension.")
1451
+ numpy_dtype, target_dtype = _LEGACY_CODE_DTYPES[dtype_code]
1452
+ offset = 6 + 4 * ndim
1453
+ expected = int(np.prod(shape, dtype=np.int64)) * numpy_dtype.itemsize
1454
+ if len(data) - offset != expected:
1455
+ raise ValueError("Legacy compact embedding payload length does not match shape.")
1456
+ array = ( # shape
1457
+ np.frombuffer(data, dtype=numpy_dtype, offset=offset).copy().reshape(shape)
1458
+ )
1459
+ return torch.from_numpy(array).to(dtype=target_dtype) # shape
1460
+
1461
+ try:
1462
+ loaded = torch.load(io.BytesIO(data), map_location="cpu", weights_only=True)
1463
+ except Exception as safe_error:
1464
+ if allow_unsafe_pickle:
1465
+ loaded = torch.load(io.BytesIO(data), map_location="cpu", weights_only=False)
1466
+ elif fallback_shape is None:
1467
+ raise ValueError(
1468
+ "Legacy embedding blob is neither compact nor safely loadable. "
1469
+ "Provide fallback_shape for raw FP32 bytes, or set "
1470
+ "allow_unsafe_pickle=True only for a trusted database."
1471
+ ) from safe_error
1472
+ else:
1473
+ expected = int(np.prod(fallback_shape, dtype=np.int64)) * 4
1474
+ if len(data) != expected:
1475
+ raise ValueError(
1476
+ "Legacy raw FP32 payload length does not match fallback_shape."
1477
+ ) from safe_error
1478
+ array = np.frombuffer(data, dtype=np.float32).copy().reshape( # fallback_shape
1479
+ fallback_shape
1480
+ )
1481
+ return torch.from_numpy(array) # fallback_shape
1482
+ if not isinstance(loaded, Tensor):
1483
+ raise ValueError("Legacy serialized embedding payload must contain one tensor.")
1484
+ return loaded.detach().cpu() # (...)
1485
+
1486
+
1487
+ def convert_legacy_sqlite(
1488
+ source: str | Path,
1489
+ output: str | Path,
1490
+ *,
1491
+ fallback_shape: tuple[int, ...] | None = None,
1492
+ allow_unsafe_pickle: bool = False,
1493
+ metadata: dict[str, Any] | None = None,
1494
+ ) -> EmbeddingResult:
1495
+ """Convert the v0 ``embeddings(sequence, embedding)`` database safely.
1496
+
1497
+ The source is opened read-only. Compact blobs and ``weights_only`` Torch
1498
+ tensors are accepted by default. Unsafe general pickle deserialization
1499
+ remains an explicit opt-in.
1500
+ """
1501
+
1502
+ source_path = Path(source)
1503
+ output_path = Path(output)
1504
+ if source_path.resolve() == output_path.resolve():
1505
+ raise ValueError("Legacy SQLite conversion requires a different output path.")
1506
+ if fallback_shape is not None and (
1507
+ not fallback_shape or any(not isinstance(size, int) or size < 0 for size in fallback_shape)
1508
+ ):
1509
+ raise ValueError("fallback_shape must contain non-negative integer dimensions.")
1510
+ with _connect_sqlite_read_only(source_path) as connection:
1511
+ columns = {
1512
+ str(row[1]) for row in connection.execute("PRAGMA table_info(embeddings)").fetchall()
1513
+ }
1514
+ if not {"sequence", "embedding"}.issubset(columns):
1515
+ raise ValueError("Legacy SQLite database must contain embeddings(sequence, embedding).")
1516
+ rows = connection.execute(
1517
+ "SELECT sequence, embedding FROM embeddings ORDER BY rowid"
1518
+ ).fetchall()
1519
+ if not rows:
1520
+ raise ValueError("Legacy SQLite database contains no embeddings.")
1521
+
1522
+ records: list[EmbeddingRecord] = []
1523
+ content_digest = hashlib.sha256()
1524
+ for position, (sequence, data) in enumerate(rows):
1525
+ if not isinstance(sequence, str) or not sequence:
1526
+ raise ValueError("Legacy embedding sequences must be non-empty strings.")
1527
+ if not isinstance(data, bytes):
1528
+ data = bytes(data)
1529
+ tensor = _decode_legacy_sqlite_blob(
1530
+ data,
1531
+ fallback_shape=fallback_shape,
1532
+ allow_unsafe_pickle=allow_unsafe_pickle,
1533
+ )
1534
+ tensor_digest = tensor_sha256(tensor)
1535
+ for value in (sequence.encode("utf-8"), tensor_digest.encode("ascii")):
1536
+ content_digest.update(len(value).to_bytes(8, "big"))
1537
+ content_digest.update(value)
1538
+ records.append(EmbeddingRecord(str(position), sequence, tensor))
1539
+
1540
+ content_sha256 = content_digest.hexdigest()
1541
+ run_fingerprint = hashlib.sha256(
1542
+ f"fastplms-legacy-sqlite-v1:{content_sha256}".encode("ascii")
1543
+ ).hexdigest()
1544
+ converted_metadata: dict[str, Any] = {
1545
+ "format_version": 1,
1546
+ "run_fingerprint": run_fingerprint,
1547
+ "source_format": "legacy-fastplms-sqlite-v0",
1548
+ "source_content_sha256": content_sha256,
1549
+ "unsafe_pickle": allow_unsafe_pickle,
1550
+ "complete": True,
1551
+ }
1552
+ if metadata:
1553
+ converted_metadata["conversion_metadata"] = _jsonable(metadata)
1554
+ return save_sqlite_result(
1555
+ EmbeddingResult(records, converted_metadata),
1556
+ output_path,
1557
+ )
1558
+
1559
+
1560
+ def save_result(
1561
+ result: EmbeddingResult,
1562
+ path: str | Path,
1563
+ *,
1564
+ format: str = "safetensors",
1565
+ shard_size: int = DEFAULT_SHARD_SIZE,
1566
+ ) -> EmbeddingResult:
1567
+ if format == "safetensors":
1568
+ return save_safetensors_result(result, path, shard_size=shard_size)
1569
+ if format == "sqlite":
1570
+ return save_sqlite_result(result, path)
1571
+ if format == "pth":
1572
+ raise ValueError("Writing pickle-based .pth embeddings is not supported.")
1573
+ raise ValueError("format must be 'safetensors' or 'sqlite'.")
1574
+
1575
+
1576
+ def load_result(path: str | Path, *, format: str = "safetensors") -> EmbeddingResult:
1577
+ if format == "safetensors":
1578
+ return load_safetensors_result(path)
1579
+ if format == "sqlite":
1580
+ return load_sqlite_result(path)
1581
+ raise ValueError("format must be 'safetensors' or 'sqlite'.")
1582
+
1583
+
1584
+ __all__ = [
1585
+ "DEFAULT_SHARD_SIZE",
1586
+ "SafetensorsStreamWriter",
1587
+ "append_sqlite_records",
1588
+ "convert_legacy_sqlite",
1589
+ "garbage_collect_safetensors_generations",
1590
+ "initialize_sqlite_run",
1591
+ "load_legacy_pth",
1592
+ "load_result",
1593
+ "load_safetensors_result",
1594
+ "load_sqlite_result",
1595
+ "safetensors_result_exists",
1596
+ "save_result",
1597
+ "save_safetensors_result",
1598
+ "save_sqlite_result",
1599
+ "tensor_sha256",
1600
+ "update_sqlite_run_metadata",
1601
+ ]
fastplms/embeddings/types.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from torch import Tensor
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class EmbeddingInput:
13
+ """One named protein sequence supplied to :func:`embed_dataset`."""
14
+
15
+ id: str
16
+ sequence: str
17
+
18
+ def __post_init__(self) -> None:
19
+ if not isinstance(self.id, str) or not self.id:
20
+ raise ValueError("EmbeddingInput.id must be a non-empty string.")
21
+ if not isinstance(self.sequence, str) or not self.sequence:
22
+ raise ValueError("EmbeddingInput.sequence must be a non-empty string.")
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class LazyTensorReference:
27
+ """A tensor stored outside memory and loaded only when requested."""
28
+
29
+ source: str
30
+ key: str
31
+ dtype: str
32
+ shape: tuple[int, ...]
33
+ sha256: str
34
+ _loader: Callable[[], Tensor] = field(repr=False, compare=False)
35
+
36
+ def load(self, *, verify: bool = True) -> Tensor:
37
+ """Load X and optionally verify its content digest."""
38
+
39
+ if not isinstance(verify, bool):
40
+ raise TypeError("verify must be a boolean.")
41
+ X = self._loader() # self.shape
42
+ if not isinstance(X, Tensor):
43
+ raise TypeError(f"Stored tensor loader for {self.key!r} must return a Tensor.")
44
+ if tuple(X.shape) != self.shape:
45
+ raise ValueError(
46
+ f"Stored tensor {self.key!r} has shape {tuple(X.shape)}, expected {self.shape}."
47
+ )
48
+ dtype = str(X.dtype).removeprefix("torch.")
49
+ if dtype != self.dtype:
50
+ raise ValueError(
51
+ f"Stored tensor {self.key!r} has dtype {dtype!r}, expected {self.dtype!r}."
52
+ )
53
+ if verify:
54
+ from .storage import tensor_sha256
55
+
56
+ digest = tensor_sha256(X)
57
+ if digest != self.sha256:
58
+ raise ValueError(f"Stored tensor {self.key!r} failed SHA-256 verification.")
59
+ return X # self.shape
60
+
61
+
62
+ TensorValue = Tensor | LazyTensorReference
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class EmbeddingRecord:
67
+ """One ordered embedding result."""
68
+
69
+ id: str
70
+ sequence: str
71
+ tensor: TensorValue
72
+
73
+ def __post_init__(self) -> None:
74
+ if not isinstance(self.id, str) or not self.id:
75
+ raise ValueError("EmbeddingRecord.id must be a non-empty string.")
76
+ if not isinstance(self.sequence, str) or not self.sequence:
77
+ raise ValueError("EmbeddingRecord.sequence must be a non-empty string.")
78
+ if not isinstance(self.tensor, (Tensor, LazyTensorReference)):
79
+ raise TypeError("EmbeddingRecord.tensor must be a Tensor or LazyTensorReference.")
80
+
81
+ def load_tensor(self, *, verify: bool = True) -> Tensor:
82
+ """Return X regardless of whether this record is memory-backed or lazy."""
83
+
84
+ if not isinstance(verify, bool):
85
+ raise TypeError("verify must be a boolean.")
86
+ if isinstance(self.tensor, LazyTensorReference):
87
+ return self.tensor.load(verify=verify) # (...)
88
+ return self.tensor # (...)
89
+
90
+
91
+ class EmbeddingResult(Sequence[EmbeddingRecord]):
92
+ """Ordered embedding records and the metadata needed to reproduce them."""
93
+
94
+ def __init__(
95
+ self,
96
+ records: Sequence[EmbeddingRecord],
97
+ metadata: Mapping[str, Any] | None = None,
98
+ ) -> None:
99
+ self.records: Sequence[EmbeddingRecord] = (
100
+ records if getattr(records, "_fastplms_immutable_sequence", False) else tuple(records)
101
+ )
102
+ self.metadata = dict(metadata or {})
103
+
104
+ def __len__(self) -> int:
105
+ return len(self.records)
106
+
107
+ def __iter__(self) -> Iterator[EmbeddingRecord]:
108
+ return iter(self.records)
109
+
110
+ @overload
111
+ def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
112
+
113
+ @overload
114
+ def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
115
+
116
+ def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
117
+ return self.records[index]
118
+
119
+ def as_dict(
120
+ self,
121
+ *,
122
+ key: Literal["id", "sequence"] = "id",
123
+ duplicates: Literal["error", "first", "last"] = "error",
124
+ materialize: bool = True,
125
+ ) -> dict[str, TensorValue]:
126
+ """Convert records to a mapping under an explicit duplicate policy."""
127
+
128
+ if key not in {"id", "sequence"}:
129
+ raise ValueError("key must be 'id' or 'sequence'.")
130
+ if duplicates not in {"error", "first", "last"}:
131
+ raise ValueError("duplicates must be 'error', 'first', or 'last'.")
132
+ if not isinstance(materialize, bool):
133
+ raise TypeError("materialize must be a boolean.")
134
+ output: dict[str, TensorValue] = {}
135
+ for record in self.records:
136
+ record_key = getattr(record, key)
137
+ if record_key in output:
138
+ if duplicates == "error":
139
+ raise ValueError(
140
+ f"Duplicate {key} {record_key!r}; choose duplicates='first' "
141
+ "or duplicates='last' explicitly."
142
+ )
143
+ if duplicates == "first":
144
+ continue
145
+ output[record_key] = record.load_tensor() if materialize else record.tensor
146
+ return output
147
+
148
+ def materialize(self, *, verify: bool = True) -> EmbeddingResult:
149
+ """Return an equivalent result with every X loaded into CPU memory."""
150
+
151
+ if not isinstance(verify, bool):
152
+ raise TypeError("verify must be a boolean.")
153
+ return EmbeddingResult(
154
+ [
155
+ EmbeddingRecord(
156
+ id=record.id,
157
+ sequence=record.sequence,
158
+ tensor=record.load_tensor(verify=verify),
159
+ )
160
+ for record in self.records
161
+ ],
162
+ self.metadata,
163
+ )
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class EmbeddingBatch:
168
+ """Internal model-to-runner contract.
169
+
170
+ ``X`` has shape ``(b, l, d)`` and ``residue_mask`` has shape ``(b, l)``.
171
+ ``attentions`` may contain layer/head attention matrices for ``parti``.
172
+ """
173
+
174
+ X: Tensor
175
+ residue_mask: Tensor
176
+ attentions: Tensor | tuple[Tensor, ...] | None = None
177
+
178
+
179
+ __all__ = [
180
+ "EmbeddingBatch",
181
+ "EmbeddingInput",
182
+ "EmbeddingRecord",
183
+ "EmbeddingResult",
184
+ "LazyTensorReference",
185
+ "TensorValue",
186
+ ]
fastplms/models.toml ADDED
@@ -0,0 +1,1275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ schema_version = 1
2
+ legal_files = [
3
+ "LICENSE=sha256:2d2b50c7b1414bff1189a1db1f0cfb92e3e064b50f4c2b1019827b683e1b629a",
4
+ "THIRD_PARTY_NOTICES.md=sha256:d35e506b728868b52290672d54a89c6aade09529af99dbc8a4db72d9e9ca3460",
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
+ "SOURCE_RECORD.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
+ "SOURCE_RECORD.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
+ "SOURCE_RECORD.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
+ "SOURCE_RECORD.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", "fp8"]
191
+ experimental_precisions = ["fp8"]
192
+ vram_tier = "sequence"
193
+ checkpoint_license = "MIT"
194
+ hub_license = "mit"
195
+ weights_publication_allowed = true
196
+ state_transform = "esmc_to_fastplms_v1"
197
+ 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."
198
+ representative = "esmc_small"
199
+ documentation = "docs/models.md#esm-and-esmc"
200
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
201
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm_plusplus", "models/ttt.py"]
202
+ 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", AutoModelForSequenceClassification = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForTokenClassification" }
203
+
204
+ [families.esm3]
205
+ architecture = "ESM3"
206
+ upstreams = ["biohub-esm", "biohub-transformers"]
207
+ tokenizer_mode = "tokenizer"
208
+ public_input = "Sequence, structure, and function tracks prepared through the multimodal helpers"
209
+ extra = "core"
210
+ reference_container = "reference-biohub-esm"
211
+ reference_adapter = "tests.parity.support.reference_adapters.esm3"
212
+ attention = ["eager", "sdpa", "flex_attention"]
213
+ dtypes = ["float32", "bfloat16"]
214
+ bf16_execution = "fp32_parameters_autocast"
215
+ precisions = ["default"]
216
+ vram_tier = "large-sequence"
217
+ checkpoint_license = "MIT"
218
+ hub_license = "mit"
219
+ weights_publication_allowed = true
220
+ state_transform = "esm3_to_fastplms_v1"
221
+ 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."
222
+ representative = "esm3_small"
223
+ documentation = "docs/models.md#esm3"
224
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
225
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm3", "models/ttt.py"]
226
+ auto_map = { AutoConfig = "fastplms.models.esm3.modeling_esm3.FastESM3Config", AutoModel = "fastplms.models.esm3.modeling_esm3.FastESM3Model", AutoModelForSequenceClassification = "fastplms.models.esm3.modeling_esm3.FastESM3ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm3.modeling_esm3.FastESM3ForTokenClassification" }
227
+
228
+ [families.e1]
229
+ architecture = "E1"
230
+ upstreams = ["e1"]
231
+ tokenizer_mode = "sequence"
232
+ public_input = "Raw amino-acid sequences prepared by the native E1 adapter"
233
+ extra = "core"
234
+ reference_container = "reference-e1"
235
+ reference_adapter = "tests.parity.support.reference_adapters.e1"
236
+ attention = ["sdpa", "flex_attention"]
237
+ dtypes = ["float32", "bfloat16"]
238
+ bf16_execution = "static_parameters"
239
+ precisions = ["default"]
240
+ vram_tier = "sequence"
241
+ checkpoint_license = "Profluent-E1-Agreement"
242
+ hub_license = "other"
243
+ hub_license_name = "Profluent-E1 Clickthrough License Agreement"
244
+ hub_license_link = "https://github.com/Profluent-AI/E1/blob/main/LICENSE"
245
+ weights_publication_allowed = true
246
+ state_transform = "e1_to_fastplms_v1"
247
+ 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."
248
+ representative = "e1_150m"
249
+ documentation = "docs/models.md#e1"
250
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
251
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/e1", "models/ttt.py"]
252
+ 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" }
253
+
254
+ [families.dplm]
255
+ architecture = "DPLM"
256
+ upstreams = ["dplm"]
257
+ tokenizer_mode = "tokenizer"
258
+ public_input = "Amino-acid sequences tokenized to masked or partially masked residue IDs"
259
+ extra = "core"
260
+ reference_container = "reference-dplm"
261
+ reference_adapter = "tests.parity.support.reference_adapters.dplm"
262
+ attention = ["eager", "sdpa", "flex_attention", "flash_attention_3"]
263
+ dtypes = ["float32", "bfloat16"]
264
+ bf16_execution = "fp32_parameters_autocast"
265
+ precisions = ["default"]
266
+ vram_tier = "sequence"
267
+ checkpoint_license = "Apache-2.0"
268
+ hub_license = "apache-2.0"
269
+ weights_publication_allowed = true
270
+ state_transform = "dplm_to_fastplms_v1"
271
+ 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/SOURCE_RECORD.md. Limitation: redistribution remains subject to Apache-2.0 and the pinned source record; no broader rights are inferred."
272
+ representative = "dplm_150m"
273
+ documentation = "docs/models.md#dplm"
274
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
275
+ 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"]
276
+ 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" }
277
+
278
+ [families.dplm2]
279
+ architecture = "DPLM2"
280
+ upstreams = ["dplm"]
281
+ tokenizer_mode = "tokenizer"
282
+ public_input = "Tokenized amino-acid and structure tracks with explicit modality boundaries"
283
+ extra = "core"
284
+ reference_container = "reference-dplm"
285
+ reference_adapter = "tests.parity.support.reference_adapters.dplm2"
286
+ attention = ["sdpa"]
287
+ dtypes = ["float32", "bfloat16"]
288
+ bf16_execution = "fp32_parameters_autocast"
289
+ precisions = ["default"]
290
+ vram_tier = "sequence"
291
+ checkpoint_license = "Apache-2.0"
292
+ hub_license = "apache-2.0"
293
+ weights_publication_allowed = true
294
+ state_transform = "dplm2_to_fastplms_v1"
295
+ 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/SOURCE_RECORD.md. Limitation: no head exception is permitted by this source record, and redistribution remains subject to Apache-2.0."
296
+ representative = "dplm2_150m"
297
+ documentation = "docs/models.md#dplm2"
298
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
299
+ 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"]
300
+ 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" }
301
+ tokenizer_class = "fastplms.models.dplm2.tokenization_dplm2.DPLM2Tokenizer"
302
+
303
+ [families.ankh]
304
+ architecture = "ANKH"
305
+ upstreams = ["ankh"]
306
+ tokenizer_mode = "tokenizer"
307
+ public_input = "Amino-acid sequences tokenized for encoder or sequence-to-sequence use"
308
+ extra = "core"
309
+ reference_container = "reference-ankh"
310
+ reference_adapter = "tests.parity.support.reference_adapters.ankh"
311
+ attention = ["eager", "sdpa"]
312
+ dtypes = ["float32", "bfloat16"]
313
+ bf16_execution = "static_parameters"
314
+ precisions = ["default"]
315
+ vram_tier = "large-sequence"
316
+ checkpoint_license = "CC-BY-NC-SA-4.0"
317
+ hub_license = "cc-by-nc-sa-4.0"
318
+ weights_publication_allowed = true
319
+ state_transform = "ankh_t5_to_fastplms_v1"
320
+ 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."
321
+ representative = "ankh_base"
322
+ documentation = "docs/models.md#ankh"
323
+ test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
324
+ requires_complete_weight_publication = false
325
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/ankh", "models/ttt.py"]
326
+ 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" }
327
+
328
+ [families.boltz2]
329
+ architecture = "Boltz2"
330
+ upstreams = ["boltz"]
331
+ tokenizer_mode = "structure"
332
+ public_input = "Raw amino-acid sequences through the convenience API, or prepared model features"
333
+ extra = "structure"
334
+ reference_container = "reference-boltz2"
335
+ reference_adapter = "tests.parity.support.reference_adapters.boltz"
336
+ attention = ["eager"]
337
+ dtypes = ["float32", "bfloat16"]
338
+ bf16_execution = "fp32_parameters_autocast"
339
+ precisions = ["default"]
340
+ vram_tier = "structure"
341
+ checkpoint_license = "MIT"
342
+ hub_license = "mit"
343
+ weights_publication_allowed = true
344
+ state_transform = "boltz2_inference_core_v1"
345
+ 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."
346
+ representative = "boltz2"
347
+ documentation = "docs/models.md#boltz2"
348
+ test_tiers = ["structure", "artifact", "benchmark"]
349
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "models/boltz"]
350
+ auto_map = { AutoConfig = "fastplms.models.boltz.modeling_boltz2.Boltz2Config", AutoModel = "fastplms.models.boltz.modeling_boltz2.Boltz2Model" }
351
+
352
+ [families.esmfold]
353
+ architecture = "ESMFold"
354
+ upstreams = ["fair-esm", "openfold"]
355
+ tokenizer_mode = "structure"
356
+ public_input = "Raw amino-acid sequences through folding helpers, or prepared residue tensors"
357
+ extra = "structure"
358
+ reference_container = "reference-esmfold"
359
+ reference_adapter = "tests.parity.support.reference_adapters.esmfold"
360
+ attention = ["eager", "sdpa", "flex_attention"]
361
+ dtypes = ["float32", "bfloat16"]
362
+ bf16_execution = "fp32_parameters_autocast"
363
+ precisions = ["default"]
364
+ vram_tier = "structure"
365
+ checkpoint_license = "MIT"
366
+ hub_license = "mit"
367
+ weights_publication_allowed = true
368
+ state_transform = "esmfold_meta_to_fastplms_v1"
369
+ 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."
370
+ representative = "esmfold"
371
+ documentation = "docs/models.md#esmfold"
372
+ test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
373
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/classification_probe.py", "models/esmfold"]
374
+ auto_map = { AutoConfig = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmFoldConfig", AutoModel = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForProteinFolding", AutoModelForSequenceClassification = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForTokenClassification" }
375
+
376
+ [families.esmfold2]
377
+ architecture = "ESMFold2"
378
+ upstreams = ["biohub-esm", "biohub-transformers", "protein-ttt"]
379
+ backbone_model = "esmc_6b"
380
+ tokenizer_mode = "structure"
381
+ public_input = "Raw amino-acid sequences or typed molecular-complex specifications; low-level forward accepts prepared feature tensors"
382
+ extra = "structure"
383
+ reference_container = "reference-esmfold2"
384
+ reference_adapter = "tests.parity.support.reference_adapters.esmfold2"
385
+ attention = ["eager", "sdpa", "flex_attention"]
386
+ dtypes = ["float32", "bfloat16"]
387
+ bf16_execution = "fp32_parameters_autocast"
388
+ precisions = ["auto", "fp32", "bf16", "fp8"]
389
+ experimental_precisions = ["fp8"]
390
+ vram_tier = "structure-6b"
391
+ checkpoint_license = "MIT"
392
+ hub_license = "mit"
393
+ weights_publication_allowed = true
394
+ state_transform = "identity"
395
+ 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, the four 6B-backbone variants, and three BF16-to-FP8 reload cycles on the standard variant. Limitation: only the six 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."
396
+ representative = "esmfold2"
397
+ documentation = "docs/esmfold2.md"
398
+ test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
399
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/classification_probe.py", "models/_esm_rotary.py", "models/esmfold2", "models/esm_plusplus", "models/ttt.py"]
400
+ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2.ESMFold2Model", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ForTokenClassification" }
401
+
402
+ [[models]]
403
+ id = "esm2_8m"
404
+ family = "esm2"
405
+ size_category = "small"
406
+ generation_contract = "not_applicable"
407
+ official_golden = { metadata = "tests/goldens/esm2_8m.json=sha256:6975e86d1d8f27488bf2a676551feaa48cc19254c9d24b6acb09198122745609", tensors = "tests/goldens/esm2_8m.safetensors=sha256:b40217566c33c71988d28869de353be54a3b3ebfc21fdfd29056e88cf7e99f4c" }
408
+ fast_repo = "Synthyra/ESM2-8M"
409
+ fast_revision = "185ecbd45665d050a8dae326d91886d330c5f9d0"
410
+ fast_files = [
411
+ "config.json=git-sha1:46d0a7b517f59123c6ebc6d1011585731cbab259",
412
+ "model.safetensors=sha256:c824e6ded5fb71c72bc5ac05300699947819023cb26cdaf6897665e6b2645e1b",
413
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
414
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
415
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
416
+ ]
417
+ official_repo = "facebook/esm2_t6_8M_UR50D"
418
+ official_revision = "c731040fcd8d73dceaa04b0a8e6329b345b0f5df"
419
+ official_files = [
420
+ "config.json=git-sha1:c2c6e65a87d9d20d47699ae236d605b80c741dd3",
421
+ "model.safetensors=sha256:24c5fa474c48f3b754b86efe752d5f189d2bcd88190fa2270fc92b2ef3034189",
422
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
423
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
424
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
425
+ ]
426
+
427
+ [[models.oracle_assets]]
428
+ role = "weights"
429
+ path = "models/esm2_t6_8M_UR50D.pt"
430
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t6_8M_UR50D.pt"
431
+ sha256 = "46f002a9870c9bdecd0ea887acb1f9a38a6b561e8f8bf8a6990b679b9d31b928"
432
+ size = 30099493
433
+
434
+ [[models.oracle_assets]]
435
+ role = "contact_regression"
436
+ path = "regression/esm2_t6_8M_UR50D-contact-regression.pt"
437
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t6_8M_UR50D-contact-regression.pt"
438
+ sha256 = "8f7a4557d57713b97ba0e484303007efb7230d25299c0ac47a0a1b12a87bbb9d"
439
+ size = 1511
440
+
441
+ [[models]]
442
+ id = "esm2_35m"
443
+ family = "esm2"
444
+ size_category = "small"
445
+ generation_contract = "not_applicable"
446
+ official_golden = { metadata = "tests/goldens/esm2_35m.json=sha256:e919d3ce6d20b6a942d27d92323814ae7594a0129dc9c4de27c5053e96675bcd", tensors = "tests/goldens/esm2_35m.safetensors=sha256:c9b8bb616cf884fb7744521a2fcc6eed23586342d11241e6c9ef16454ec31e17" }
447
+ fast_repo = "Synthyra/ESM2-35M"
448
+ fast_revision = "37ab9f56b41e365b3bd9e25d6fefe9150fd910f0"
449
+ fast_files = [
450
+ "config.json=git-sha1:4d428c9934572f39e2a00db162249971f37c88e4",
451
+ "model.safetensors=sha256:21d95ab6bb9aa91bfec87eff11da61a657b732f2df279cbddbae6a7f1f0bba9c",
452
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
453
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
454
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
455
+ ]
456
+ official_repo = "facebook/esm2_t12_35M_UR50D"
457
+ official_revision = "6fbf070e65b0b7291e7bbcd451118c216cff79d8"
458
+ official_files = [
459
+ "config.json=git-sha1:3f64131bb610ed1ce482c4b5421fc358c785278f",
460
+ "model.safetensors=sha256:e35647818e0e064351d4531ed480d225a002567b4b2b93ad3a9246d753150fc0",
461
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
462
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
463
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
464
+ ]
465
+
466
+ [[models.oracle_assets]]
467
+ role = "weights"
468
+ path = "models/esm2_t12_35M_UR50D.pt"
469
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t12_35M_UR50D.pt"
470
+ sha256 = "7f21e80e61d16a71735163ef555d3009afb0c98da74c48e29df08606973cc55e"
471
+ size = 134095705
472
+
473
+ [[models.oracle_assets]]
474
+ role = "contact_regression"
475
+ path = "regression/esm2_t12_35M_UR50D-contact-regression.pt"
476
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t12_35M_UR50D-contact-regression.pt"
477
+ sha256 = "16641e05d830d0ce863dd152dbb8c2f3ddfa3c3ec2a66080152c8abad01d8585"
478
+ size = 1959
479
+
480
+ [[models]]
481
+ id = "esm2_150m"
482
+ family = "esm2"
483
+ size_category = "medium"
484
+ generation_contract = "not_applicable"
485
+ official_golden = { metadata = "tests/goldens/esm2_150m.json=sha256:c04c93486024ba0fa1c81fbfbe92ee79d1d4c7f1cfcc2c9886728522f752feab", tensors = "tests/goldens/esm2_150m.safetensors=sha256:c03fe9916dba137b452a6bbe944c7dc414db4019a6f0921e87b92d4bb6a8a42f" }
486
+ fast_repo = "Synthyra/ESM2-150M"
487
+ fast_revision = "979e0880dfc9e0c0080839b83d9d2dc05b92786a"
488
+ fast_files = [
489
+ "config.json=git-sha1:efeae2af182b7d34dc35740a45f157661e7acdf4",
490
+ "model.safetensors=sha256:d1f7c60f98c31af328381519a750972b6a31b13b97aa7cca2e71b5ae1b3f8f53",
491
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
492
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
493
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
494
+ ]
495
+ official_repo = "facebook/esm2_t30_150M_UR50D"
496
+ official_revision = "a695f6045e2e32885fa60af20c13cb35398ce30c"
497
+ official_files = [
498
+ "config.json=git-sha1:52e04179e6fbad6663a94ea5cc44f09d764c5cd4",
499
+ "model.safetensors=sha256:c3f1da8aea53bddd32c246c86168c23b9fd72341fb9db9a94436f855f5053566",
500
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
501
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
502
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
503
+ ]
504
+
505
+ [[models.oracle_assets]]
506
+ role = "weights"
507
+ path = "models/esm2_t30_150M_UR50D.pt"
508
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t30_150M_UR50D.pt"
509
+ sha256 = "881c7176cf198ef8dec26a3c375d40eb58d0c33df95c22562ca6cc6d3f812c62"
510
+ size = 592774773
511
+
512
+ [[models.oracle_assets]]
513
+ role = "contact_regression"
514
+ path = "regression/esm2_t30_150M_UR50D-contact-regression.pt"
515
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t30_150M_UR50D-contact-regression.pt"
516
+ sha256 = "6a604b96722ed052eef8a094ad90b275ba2e987d406315dbed0bdc6b3c4238a7"
517
+ size = 3431
518
+
519
+ [[models]]
520
+ id = "esm2_650m"
521
+ family = "esm2"
522
+ size_category = "large"
523
+ generation_contract = "not_applicable"
524
+ official_golden = { metadata = "tests/goldens/esm2_650m.json=sha256:f18332172fcb3abf5dd2485fd55f5b0d193ad3b93a44cc744e0d02817c927477", tensors = "tests/goldens/esm2_650m.safetensors=sha256:c3a66b75add03628e62e238cb63da6a9e4d321f8160e84bdf2a131c096977f86" }
525
+ fast_repo = "Synthyra/ESM2-650M"
526
+ fast_revision = "ca0718a5d52b80d5c60dd76860e55e061a95fb0a"
527
+ fast_files = [
528
+ "config.json=git-sha1:88f6bd240680b29c3244df8292246048401f5caf",
529
+ "model.safetensors=sha256:a15142e94ecf36f0edde9b37796f591e609ebe1694ca411e93640f0ee384994a",
530
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
531
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
532
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
533
+ ]
534
+ official_repo = "facebook/esm2_t33_650M_UR50D"
535
+ official_revision = "08e4846e537177426273712802403f7ba8261b6c"
536
+ official_files = [
537
+ "config.json=git-sha1:a956a25d277f30bd870d3760b9a116f19ead885e",
538
+ "model.safetensors=sha256:a08adabb949fa67ad3c14b509d04fd60368b35007b0095e3358f81200c4f4db0",
539
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
540
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
541
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
542
+ ]
543
+
544
+ [[models.oracle_assets]]
545
+ role = "weights"
546
+ path = "models/esm2_t33_650M_UR50D.pt"
547
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t33_650M_UR50D.pt"
548
+ sha256 = "ea9d0522b335a8778dea6535a65301f10208dece28cd5865482b0b1fc446168c"
549
+ size = 2604537549
550
+
551
+ [[models.oracle_assets]]
552
+ role = "contact_regression"
553
+ path = "regression/esm2_t33_650M_UR50D-contact-regression.pt"
554
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t33_650M_UR50D-contact-regression.pt"
555
+ sha256 = "8ffe6edbd4173dc8d45c2cd5cb27d43aad77ec26b4c768200c58ae1f96693575"
556
+ size = 3687
557
+
558
+ [[models]]
559
+ id = "esm2_3b"
560
+ family = "esm2"
561
+ size_category = "xlarge"
562
+ generation_contract = "not_applicable"
563
+ official_golden = { metadata = "tests/goldens/esm2_3b.json=sha256:5043b2333c57a34d54fac53916722d1acb4b6fd50395b9abafa805435b184a48", tensors = "tests/goldens/esm2_3b.safetensors=sha256:dfd5a8cb05d3e814a080185c4808c8e7ec2277f070f395562fcfbe4376789e4e" }
564
+ 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."
565
+ fast_repo = "Synthyra/ESM2-3B"
566
+ fast_revision = "ff89d0180f414ab9c677219a25da79bf09185456"
567
+ fast_files = [
568
+ "config.json=git-sha1:94944ad6cabaa40a3ce1cbe6699cf464fdc1b2c0",
569
+ "model-00001-of-00003.safetensors=sha256:04b57854545c23779b562ee2ae22f10021ba0f4d586ba0ad482ee6eda187d562",
570
+ "model-00002-of-00003.safetensors=sha256:34954aaa05bc91635776ba6672946da5822626753d80db97b38c0538e9525102",
571
+ "model-00003-of-00003.safetensors=sha256:a6b3a55b9e3b2e1778de34c665c3dd17bdfdf6da9d6d5c97730c57168709ccae",
572
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
573
+ "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
574
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
575
+ ]
576
+ official_repo = "facebook/esm2_t36_3B_UR50D"
577
+ official_revision = "476b639933c8baad5ad09a60ac1a87f987b656fc"
578
+ official_files = [
579
+ "config.json=git-sha1:69e7563923f87d2d7439bfb83e5a19b44b46d71b",
580
+ "pytorch_model-00001-of-00002.bin=sha256:0f971f11c449d21422aa982b791619c10351972992c735f4c3cd43fe09790412",
581
+ "pytorch_model-00002-of-00002.bin=sha256:7560b46fc383c691fb74b915b7d4bcef40d3df181447f16ba4b298845e308d0c",
582
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
583
+ "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
584
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
585
+ ]
586
+
587
+ [[models.oracle_assets]]
588
+ role = "weights"
589
+ path = "models/esm2_t36_3B_UR50D.pt"
590
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t36_3B_UR50D.pt"
591
+ sha256 = "7de8b4082ba15891959ab368b77ce3886697af1efb16d3c9e9e7b0c5d3f07500"
592
+ size = 5678116398
593
+
594
+ [[models.oracle_assets]]
595
+ role = "contact_regression"
596
+ path = "regression/esm2_t36_3B_UR50D-contact-regression.pt"
597
+ url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t36_3B_UR50D-contact-regression.pt"
598
+ sha256 = "4da500eab246481dc9c8c95bc7b1d02f2803d761c380b0e95186d4a07d0fc84e"
599
+ size = 6759
600
+
601
+ [[models]]
602
+ id = "esmc_small"
603
+ family = "esm_plusplus"
604
+ size_category = "medium"
605
+ generation_contract = "not_applicable"
606
+ official_golden = { metadata = "tests/goldens/esmc_small.json=sha256:bb02652cf3cc484756b98ffa4ba55ed4c55870d2cea3342adb1d920ba9dfe10a", tensors = "tests/goldens/esmc_small.safetensors=sha256:03378d0f0fdd8161178ebb2c1f0da1b9776a726c8e8d3a10c009808a24de5654" }
607
+ 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."
608
+ fast_repo = "Synthyra/ESMplusplus_small"
609
+ fast_revision = "46c5f7d562e47d4c14165b424c71ab7db008e6fb"
610
+ fast_files = [
611
+ "config.json=git-sha1:df2f44187157b0cc371c48c887b77b1783679201",
612
+ "model.safetensors=sha256:d099223765bc4f1ae8d6c7e18561ce41df1d54073fdc5327ef0a229235a8f52a",
613
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
614
+ "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
615
+ "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
616
+ ]
617
+ official_repo = "biohub/ESMC-300M"
618
+ official_revision = "a59b831785f907e96e6a246b1d142bfb76df31ee"
619
+ official_files = [
620
+ "config.json=git-sha1:9a49eacf4e65c39f74381f0f0d240e3b89ef43d7",
621
+ "model.safetensors=sha256:0772d8fe64bb25e14fe6f23b80e3c9a7d215d0da3c6cba5bd356d7c0e0bb22cc",
622
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
623
+ "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
624
+ "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
625
+ ]
626
+
627
+ [[models]]
628
+ id = "esmc_large"
629
+ family = "esm_plusplus"
630
+ size_category = "large"
631
+ generation_contract = "not_applicable"
632
+ official_golden = { metadata = "tests/goldens/esmc_large.json=sha256:7a4d614f67b6fde417f3fd89f61e7ec442ae284769734b2b73e14945a816a8fd", tensors = "tests/goldens/esmc_large.safetensors=sha256:e13302df4cf7e8381552f1043a8fd0f31f3e0d50b2ab6009fb86b7940ae8ff79" }
633
+ 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."
634
+ fast_repo = "Synthyra/ESMplusplus_large"
635
+ fast_revision = "f813401638b3fddab09748aec1ad2bf537aa4208"
636
+ fast_files = [
637
+ "config.json=git-sha1:5736371902fe5d04e2859be30ac7dbd31b271b25",
638
+ "model.safetensors=sha256:4aff3f8c5de68c4d3e3824eb2c478e4a47355d3f849f3c745e5c8a5ee6cff851",
639
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
640
+ "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
641
+ "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
642
+ ]
643
+ official_repo = "biohub/ESMC-600M"
644
+ official_revision = "a7e82012c83126b9eedb055fea9fa84b6c02f094"
645
+ official_files = [
646
+ "config.json=git-sha1:71c8241dc28a5fb636248267a0927c0242b264c1",
647
+ "model.safetensors=sha256:e4232c30fd35fe2f57051ec88a703996ac94520580b4b836894207a3d45d9ff8",
648
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
649
+ "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
650
+ "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
651
+ ]
652
+
653
+ [[models]]
654
+ id = "esmc_6b"
655
+ family = "esm_plusplus"
656
+ size_category = "xlarge"
657
+ generation_contract = "not_applicable"
658
+ official_golden = { metadata = "tests/goldens/esmc_6b.json=sha256:e229d938719782f280fab22dfc4c43e86109fdb0cc523631168c5a491afaace3", tensors = "tests/goldens/esmc_6b.safetensors=sha256:a948945e985c7deaca7be8b7eed09c0a9521a2af3f2b10fc2ec7a7d2a0f99ada" }
659
+ 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."
660
+ fast_repo = "Synthyra/ESMplusplus_6B"
661
+ fast_revision = "0d579cce3b0f09efa6b3baddf6cc3fd8c9b616c8"
662
+ fast_files = [
663
+ "config.json=git-sha1:e740cbcf211f2511c70c25a1ff6017a757ba7a69",
664
+ "model-00001-of-00006.safetensors=sha256:d30d18703453019f2d2d050866309888720c28eebc9a10307d1ddf3799e85a65",
665
+ "model-00002-of-00006.safetensors=sha256:b3d85378ab5023f4160a96e9c8cbd4cc6f78a771a83c856e88d48112f555bc13",
666
+ "model-00003-of-00006.safetensors=sha256:52595519b59349c5c6e373e6f5ca4a3d48ea6dde345f7e61e24766df5fab0e5b",
667
+ "model-00004-of-00006.safetensors=sha256:e46c6113c89c6f3e9b072c1bef02d763a625c37bcd8f9da2ed9363891c9a0758",
668
+ "model-00005-of-00006.safetensors=sha256:6d92cb2bf9791de644de2ae86f8523d802ac3b4aaabfff0716ab6c2b97f6fb14",
669
+ "model-00006-of-00006.safetensors=sha256:5fc1a8632490bb34162823c35d0d591337b9e4195b22cc0560741397a6e9d0b3",
670
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
671
+ "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
672
+ "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
673
+ ]
674
+ official_repo = "biohub/ESMC-6B"
675
+ official_revision = "45b0fa5d7fb06faefbd5e3b89bdcef35d564e79a"
676
+ official_files = [
677
+ "config.json=git-sha1:19f5fb09e4f630fb5b748a497183c22a87ec5102",
678
+ "model-00001-of-00006.safetensors=sha256:bd90149ff223e6ac1a0cac6147a5ae0df20d3a21df4f65356a1f19cd14f4aa8a",
679
+ "model-00002-of-00006.safetensors=sha256:f75e2144d8269fe2eb4b3e0823fb089b94f176d8024153e85b8fb573a42294fa",
680
+ "model-00003-of-00006.safetensors=sha256:f699f01ecc9691d9c6470492765fe54b8b5d2e9f277c139e89427433ffdfe0b2",
681
+ "model-00004-of-00006.safetensors=sha256:46add1b7be098bbfdc3073884851ba3057f1b33ea23a158b650a37007dabd13d",
682
+ "model-00005-of-00006.safetensors=sha256:1e1cb62f060a34e18f54a31a76683ef888b8cec59e73315f5b31d25d45a1f88c",
683
+ "model-00006-of-00006.safetensors=sha256:56c73e13ae96e777ce65eee99364056069ef93b646470f352f83c5f1037b1b18",
684
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
685
+ "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
686
+ "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
687
+ ]
688
+
689
+ [[models]]
690
+ id = "esm3_small"
691
+ family = "esm3"
692
+ tokenizer_source = "esmc_small"
693
+ size_category = "large"
694
+ generation_contract = "not_applicable"
695
+ official_golden = { metadata = "tests/goldens/esm3_small.json=sha256:5470e8596cbba0e2882647eccbc53c36d8b48b0f3947d1fe0bcea68da1078c32", tensors = "tests/goldens/esm3_small.safetensors=sha256:d957922f810c9ab4c557d80d5aaaf6a3aab79a5a45e4638012a634a4134803b1" }
696
+ fast_repo = "Synthyra/ESM3_small"
697
+ fast_revision = "7ddb5a740f9e5f93933eb6410c0ee8684bc63ec1"
698
+ fast_files = [
699
+ "config.json=git-sha1:60526e2fdd8af9d4fba17f323775458ef5a1a1f9",
700
+ "model-00001-of-00002.safetensors=sha256:a4c9b736c4c59d51180e966005a164859b47d5cd36e1f8ecdea619fbd34a0e92",
701
+ "model-00002-of-00002.safetensors=sha256:bea60e4e91b03bb00b6cedd29b07606b8543f0869fb74454af7b26e216d80d2b",
702
+ "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
703
+ "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
704
+ "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
705
+ ]
706
+ official_repo = "biohub/esm3-sm-open-v1"
707
+ official_revision = "47f0545b2b6daf26a93439a3cd610f4f7f3d5478"
708
+ official_files = [
709
+ "config.json=git-sha1:0967ef424bce6791893e9a57bb952f80fd536e93",
710
+ "data/weights/esm3_function_decoder_v0.pth=sha256:f76d074efcaccfe21365a4fa96f212dadd66798e1e49d809ab7ffbe025d227c9",
711
+ "data/weights/esm3_sm_open_v1.pth=sha256:5ead5a135c658068db6a4f1b933e72d6110992c4668822e1c0e2dcc53e38acd9",
712
+ "data/weights/esm3_structure_decoder_v0.pth=sha256:3b726258a44274792b40ce7ea307e10c5da09936368a4ffa2970264d909da65b",
713
+ "data/weights/esm3_structure_encoder_v0.pth=sha256:467acbaee703ba3ccde6e75241a912a316952e5ff071355f85c1d33c68704f40",
714
+ ]
715
+
716
+ [[models]]
717
+ id = "e1_150m"
718
+ family = "e1"
719
+ size_category = "small"
720
+ generation_contract = "not_applicable"
721
+ official_golden = { metadata = "tests/goldens/e1_150m.json=sha256:701a64a6ab1a2fec5a427555b6af96232526c15cb3d5b4dc7fb253ac8f20b922", tensors = "tests/goldens/e1_150m.safetensors=sha256:6558bc8f1a7b20629eaaaa6f72601d0c2cdb859a5dc13595549b1773b6e2de41" }
722
+ fast_repo = "Synthyra/Profluent-E1-150M"
723
+ fast_revision = "7c5f3bbf697226a2e0900db7a100f9201774a907"
724
+ fast_files = [
725
+ "config.json=git-sha1:562ef21e722ca708064fc3d54d25b731d4ac8171",
726
+ "model.safetensors=sha256:d779ed3a4e23799aafc932dc09c9963428d10aa7075999b5f8851b39c76b67f6",
727
+ ]
728
+ official_repo = "Profluent-Bio/E1-150m"
729
+ official_revision = "c4dbfe827e4aa6ed7f95eaef50dc1e084f4d77dc"
730
+ official_files = [
731
+ "config.json=git-sha1:485e649199b46fe6ee7456bebf7aae9b3d4baeab",
732
+ "model.safetensors=sha256:ba2656339005e6598642836acfdafde480fecc7e145ce0058eb54adf572c3484",
733
+ ]
734
+
735
+ [[models]]
736
+ id = "e1_300m"
737
+ family = "e1"
738
+ size_category = "medium"
739
+ generation_contract = "not_applicable"
740
+ official_golden = { metadata = "tests/goldens/e1_300m.json=sha256:d3478f3f5957a0e0377864074dde0107de890019f96cb63548ee17ffb8f3ec3a", tensors = "tests/goldens/e1_300m.safetensors=sha256:92778b9ef95a803ddc84b3e3ca764c59e045872a94bcff0eb0cd47647732c188" }
741
+ fast_repo = "Synthyra/Profluent-E1-300M"
742
+ fast_revision = "5ef52c0ad2ae2578f40622696b763523810e8e26"
743
+ fast_files = [
744
+ "config.json=git-sha1:f5c91498b76a3e3282a0d716d87738abb1a1b6c1",
745
+ "model.safetensors=sha256:9271c4176a8a2e0905a0bb769570ba1c2978fb999a87da92db4cf2b041224864",
746
+ ]
747
+ official_repo = "Profluent-Bio/E1-300m"
748
+ official_revision = "5a2871c587eadbcc9237bc686ea45e5b4d28dfb3"
749
+ official_files = [
750
+ "config.json=git-sha1:918cb09e6e96d4719ed85951f38c693360f9cdb8",
751
+ "model.safetensors=sha256:31e09a2542f45b04e6ce4adafb3b657f21e2d56d12bf68fd2266b1576a80bc9b",
752
+ ]
753
+
754
+ [[models]]
755
+ id = "e1_600m"
756
+ family = "e1"
757
+ size_category = "large"
758
+ generation_contract = "not_applicable"
759
+ official_golden = { metadata = "tests/goldens/e1_600m.json=sha256:914be191c28141c1f84535cdb69ead0588a2057bb19d46c5bc7f3891a3d6739e", tensors = "tests/goldens/e1_600m.safetensors=sha256:22ed8417a4651ded255099f6d15c63c2c40552e700d2b0470d1adfde3a39c513" }
760
+ fast_repo = "Synthyra/Profluent-E1-600M"
761
+ fast_revision = "6c8bf0ec83b0e0178677c528b101efffd0677742"
762
+ fast_files = [
763
+ "config.json=git-sha1:1d35c0b35b473259875fd29ee80167487a0d6afe",
764
+ "model.safetensors=sha256:793483b1b3411eab73fe5214b94d1424ca0545992dfac6889cfc0186af472363",
765
+ ]
766
+ official_repo = "Profluent-Bio/E1-600m"
767
+ official_revision = "52d959fb87a609d15cf223a485127b29ed5c382a"
768
+ official_files = [
769
+ "config.json=git-sha1:8a0a439ed4201462bc01189c9f8b43523b257b5c",
770
+ "model.safetensors=sha256:cfc108d4b98baaa62932331b40be265eae39dc382595bc3cde4a5ab55db1bf7a",
771
+ ]
772
+
773
+ [[models]]
774
+ id = "dplm_150m"
775
+ family = "dplm"
776
+ size_category = "small"
777
+ generation_contract = "required"
778
+ official_golden = { metadata = "tests/goldens/dplm_150m.json=sha256:3228551fe3bed951db9ec97347143ec4462ce7c221ac240b7ce7730948c1dc1f", tensors = "tests/goldens/dplm_150m.safetensors=sha256:392992235195beed97ab8359b90a2e11e52f4326606f99a471447bed81d146bd" }
779
+ fast_repo = "Synthyra/DPLM-150M"
780
+ fast_revision = "90ba742754151a774f3b7ed580170d0a76b3e69d"
781
+ fast_files = [
782
+ "config.json=git-sha1:117ac2c1222152ef378abaad1f605e18c4a18ab0",
783
+ "model.safetensors=sha256:8bac5ac767ceb8deb511b272d32883f811768d56cb25e920cea94ba9b979ca14",
784
+ "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
785
+ "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
786
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
787
+ ]
788
+ official_repo = "airkingbd/dplm_150m"
789
+ official_revision = "49b7125a5d28c6418fcc2f3c4fe799352ac1488b"
790
+ official_files = [
791
+ "config.json=git-sha1:4910cb02f1840e9ac577026f601829604af58c74",
792
+ "pytorch_model.bin=sha256:ea4eaa99536b60ed76f945f71a1a5e604f08447ec3def5104a93ca6001a59961",
793
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
794
+ "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
795
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
796
+ ]
797
+
798
+ [[models]]
799
+ id = "dplm_650m"
800
+ family = "dplm"
801
+ size_category = "large"
802
+ generation_contract = "required"
803
+ official_golden = { metadata = "tests/goldens/dplm_650m.json=sha256:bf58d0ce73aaac7e6fb1923ef3d9adad67122df2a3dd414c3229488ef9587a6d", tensors = "tests/goldens/dplm_650m.safetensors=sha256:073f0a6abea7e48f28c2d921ff8329a28e22627f01979277cb324908a01b3378" }
804
+ fast_repo = "Synthyra/DPLM-650M"
805
+ fast_revision = "05dc16d97c5c028aed924c9ed681cee4ab609760"
806
+ fast_files = [
807
+ "config.json=git-sha1:3537150eb87b213a676d5840548625e220b60e8b",
808
+ "model.safetensors=sha256:e27a47b8ec1c078b3fccb36542210e20f0380c88828db2ca9acf3d8a25048bd8",
809
+ "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
810
+ "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
811
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
812
+ ]
813
+ official_repo = "airkingbd/dplm_650m"
814
+ official_revision = "7a7e651baa667d094aba05e9dc1cf52a3332110a"
815
+ official_files = [
816
+ "config.json=git-sha1:625574d625a4178ca6966e9545fee56026c0b634",
817
+ "pytorch_model.bin=sha256:db4e54343a89e7600f41c3aacbc593db1b0caee82ec28cab25ff2ae090eba39c",
818
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
819
+ "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
820
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
821
+ ]
822
+
823
+ [[models]]
824
+ id = "dplm_3b"
825
+ family = "dplm"
826
+ size_category = "xlarge"
827
+ generation_contract = "required"
828
+ official_golden = { metadata = "tests/goldens/dplm_3b.json=sha256:a5b6df8b9c7b371976892ec1d6c45581a32ad3a6325c6c0a0b3267012848c8ed", tensors = "tests/goldens/dplm_3b.safetensors=sha256:75b0a0854fc391133920b0feaaeb8f69ab7568a88b3759627aca1556c4338c1e" }
829
+ fast_repo = "Synthyra/DPLM-3B"
830
+ fast_revision = "7d764dd3d70ecf1ac0e64693de64a0064aacac65"
831
+ fast_files = [
832
+ "config.json=git-sha1:7f5baf9426be06760c86882948b0f4af2e681e22",
833
+ "model-00001-of-00003.safetensors=sha256:37b54855d087ef3e7d883464ae9d5ea3127ec15a16c6323d91ad16a6b98305c9",
834
+ "model-00002-of-00003.safetensors=sha256:042604fefb05ea8c360a48416ce7ba662a4f90b176b4baf646c5c1814c35e6e8",
835
+ "model-00003-of-00003.safetensors=sha256:b9ae04012665163c3fc9781dd04fcd69738ac20c07e615e98fc4483fd2c4de45",
836
+ "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
837
+ "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
838
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
839
+ ]
840
+ official_repo = "airkingbd/dplm_3b"
841
+ official_revision = "53849d4a7fe944ae0b9cf2bbc0d2cc0054795b51"
842
+ official_files = [
843
+ "config.json=git-sha1:f6206456e8c2f22ebe1d37fce3b5d50fd8073e68",
844
+ "pytorch_model-00001-of-00004.bin=sha256:0bcb86a115fe744ed686756db143f78851304e855e2f83cec58681c6080ced5f",
845
+ "pytorch_model-00002-of-00004.bin=sha256:daf3324f3be949e7dd1c3c84b28da7fec5151b1890cb0904e73427266856a06f",
846
+ "pytorch_model-00003-of-00004.bin=sha256:dbbeb7924a21059854f994931e23590b054aa000b10370a71c052c4aa36e9246",
847
+ "pytorch_model-00004-of-00004.bin=sha256:21c01740d091487db43446489d8a893dea1fcc6f2e1c1991ece13945f7ab4e07",
848
+ "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
849
+ "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
850
+ "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
851
+ ]
852
+
853
+ [[models]]
854
+ id = "dplm2_150m"
855
+ family = "dplm2"
856
+ size_category = "small"
857
+ generation_contract = "required"
858
+ official_golden = { metadata = "tests/goldens/dplm2_150m.json=sha256:d269de779ea1503de72c77e7b2e6224afc9797bd945b40c571ff6faec782e4aa", tensors = "tests/goldens/dplm2_150m.safetensors=sha256:17fc26600938ba5364b8ecb96750786d33e9f92bcd4ea4df3e12a389340748eb" }
859
+ artifact_source = "official"
860
+ canonical_state_sha256 = "82e1751f59052b8de72b082517557db47947e8d9b4ac2f11278369e6c0cbf001"
861
+ fast_repo = "Synthyra/DPLM2-150M"
862
+ fast_revision = "182745b8dc5661f898481a4fa60a7af9d53385c4"
863
+ fast_files = [
864
+ "config.json=git-sha1:07905a2e4327d27d073cd0390f140aec2976125a",
865
+ "model.safetensors=sha256:0a7751b3113027b1d9c966a5bda2d6ab831855de7aaa047b911731665a7c3cc6",
866
+ "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
867
+ "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
868
+ "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
869
+ ]
870
+ official_repo = "airkingbd/dplm2_150m"
871
+ official_revision = "3451d984d06497f835ed49634bd68c9dfb54d730"
872
+ official_files = [
873
+ "config.json=git-sha1:20f1e55c64fdc4d1d30f7b1df64b6167fa23dc7c",
874
+ "pytorch_model.bin=sha256:be7f5cf9e421f59fcc437e63ce1c7391099a314a4e9a4f10b8688785fa581238",
875
+ "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
876
+ "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
877
+ "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
878
+ ]
879
+
880
+ [[models]]
881
+ id = "dplm2_650m"
882
+ family = "dplm2"
883
+ size_category = "large"
884
+ generation_contract = "required"
885
+ official_golden = { metadata = "tests/goldens/dplm2_650m.json=sha256:d9a7548f9af657a72d441ca70f27379863724fcce8ddd3da4f672104b7bfb772", tensors = "tests/goldens/dplm2_650m.safetensors=sha256:c4e0e467c252c3ac813363d2d4b17a5e3bd99e75fad315e76d97689b4655ddac" }
886
+ artifact_source = "official"
887
+ canonical_state_sha256 = "cba76b6602d2258de9fffff953b608d93cb8ef4a9e89b0bbd27e160c81e78bb4"
888
+ fast_repo = "Synthyra/DPLM2-650M"
889
+ fast_revision = "b9d8527a9473a54954fa2764f590b9ea1b435bb2"
890
+ fast_files = [
891
+ "config.json=git-sha1:3e079579b214d48a09db57f2c60be6a1acea5baf",
892
+ "model.safetensors=sha256:92db08c7dbfd6c5e03fbfeaea3f36b09640ee794dcf5ea8d550527869a9f1d63",
893
+ "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
894
+ "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
895
+ "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
896
+ ]
897
+ official_repo = "airkingbd/dplm2_650m"
898
+ official_revision = "0bc69b644976c6680ab7e26669854d1979e8876e"
899
+ official_files = [
900
+ "config.json=git-sha1:4cce8d9dc212cdace0e20e89169790bcf199c158",
901
+ "pytorch_model.bin=sha256:8d6e08cc05e4858064a714013c74cc88c9caa2cc8b12c34605a3c24bcd877cfb",
902
+ "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
903
+ "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
904
+ "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
905
+ ]
906
+
907
+ [[models]]
908
+ id = "dplm2_3b"
909
+ family = "dplm2"
910
+ size_category = "xlarge"
911
+ # The pinned public sampler fails before generation because cls_token_id is None.
912
+ # State, tokenizer, and inference parity remain required for this checkpoint.
913
+ generation_contract = "official_unavailable"
914
+ official_golden = { metadata = "tests/goldens/dplm2_3b.json=sha256:d6e0e02af53b13cb129192f06e264758aa21c9ebf4ee82411cf67037082d2329", tensors = "tests/goldens/dplm2_3b.safetensors=sha256:838b11824d08f83bcb0c0b3268e579f3a87dbfb965370cfe5c3f8793b96b1964" }
915
+ 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."
916
+ artifact_source = "official"
917
+ canonical_state_sha256 = "8c46ec09115dbe6cbfb91d94ab5e906369d57e27fe620a7741c6f8cb1b6ca890"
918
+ fast_repo = "Synthyra/DPLM2-3B"
919
+ fast_revision = "2a63babe8848abf5233d31bd55891dff8285fc50"
920
+ fast_files = [
921
+ "config.json=git-sha1:5932b1d501fed28b84614e0d2c1ecc4e89f10d6e",
922
+ "model-00001-of-00003.safetensors=sha256:2ff393f6e8df1568ce075d50de69ff4e5e9d9886e5ec47e43d6c24df23459be3",
923
+ "model-00002-of-00003.safetensors=sha256:feb3cea852c2aa849cc30783a984a97f0d076990ade6606cda5e38bf2a5a9621",
924
+ "model-00003-of-00003.safetensors=sha256:9be363ddb98436af20901981ffbed2f1097377424987f6c1baad27d512b62e71",
925
+ "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
926
+ "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
927
+ "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
928
+ ]
929
+ official_repo = "airkingbd/dplm2_3b"
930
+ official_revision = "9e77567926f98d1b997ea9131a8eeb035b9bf827"
931
+ official_files = [
932
+ "config.json=git-sha1:22d51ce44cd6da8d819e0d00566987bb51d74753",
933
+ "pytorch_model-00001-of-00004.bin=sha256:d8c641eae6bf891581ec64d543169891b093e296f5679ac75c695bcf596b4211",
934
+ "pytorch_model-00002-of-00004.bin=sha256:6478ad86ec5fef3d1d26580493af2d8666009d3ff884f3f88548080c8bbf94b5",
935
+ "pytorch_model-00003-of-00004.bin=sha256:dde8f88dac4a6355488c2fb433ee12cd69f1169950566624fba43684d4d99dc6",
936
+ "pytorch_model-00004-of-00004.bin=sha256:17ec0145152bc10e4dd3b4c2edff337979f6b99ee7c7bfd6cf4e6dbd7262d079",
937
+ "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
938
+ "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
939
+ "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
940
+ ]
941
+
942
+ [[models]]
943
+ id = "ankh_base"
944
+ family = "ankh"
945
+ size_category = "medium"
946
+ generation_contract = "required"
947
+ official_golden = { metadata = "tests/goldens/ankh_base.json=sha256:ebce8d7de821827ee995789c9b38d79252d3b2f76888130b0a8a7eedafaefe2b", tensors = "tests/goldens/ankh_base.safetensors=sha256:f0e78aa15d11749e0c64ff57f9e88c51cec6538a0adf8951f839df70cc708b65" }
948
+ 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."
949
+ artifact_source = "official"
950
+ canonical_state_sha256 = "cdd8d30d88e5bf41f44e1eef4470d8e46607aba5f7c7c805b06c035b89c8c16f"
951
+ fast_repo = "Synthyra/ANKH_base"
952
+ fast_revision = "a3afa1db21c876dff57b3540fa7241e138fb1ed6"
953
+ fast_files = [
954
+ "config.json=git-sha1:d1b81bb97129bc75dea04daef1ea2af373018e6b",
955
+ "model-00001-of-00001.safetensors=sha256:c943d25cacdafd2c8e3518a74450b5f90f715becf30ceb24c327f1c5a0bc8b5d",
956
+ "model.safetensors.index.json=git-sha1:ca251ab9277c06081b33e027f68f5bdc0808b443",
957
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
958
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
959
+ "tokenizer_config.json=git-sha1:a8a872ae3441e7cc85ce19210dff1e4c5d2d7bd0",
960
+ ]
961
+ official_repo = "ElnaggarLab/ankh-base"
962
+ official_revision = "d99cb6b966530dfc2ae96bc69d9255c2a07308b0"
963
+ official_files = [
964
+ "config.json=git-sha1:abd44a36b5469e9a7cb019e4059b5ac1392d8422",
965
+ "pytorch_model.bin=sha256:9b2a886374f0ff4a893f4e7a989deed76bb2458c8998bd5202ea8e97d92ddcc3",
966
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
967
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
968
+ "tokenizer_config.json=git-sha1:a8a872ae3441e7cc85ce19210dff1e4c5d2d7bd0",
969
+ ]
970
+
971
+ [[models]]
972
+ id = "ankh_large"
973
+ family = "ankh"
974
+ size_category = "large"
975
+ generation_contract = "required"
976
+ official_golden = { metadata = "tests/goldens/ankh_large.json=sha256:59492518b021de5cfaea87d672c9448c8558e99a3443ba2cc7ab544963196ecb", tensors = "tests/goldens/ankh_large.safetensors=sha256:3fb8d3ac27716d15a9ea92aeef6acf2b977bcc887d9b535000539e523673459b" }
977
+ 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."
978
+ artifact_source = "official"
979
+ canonical_state_sha256 = "e498a2e9aea76ef784cbe3e596c6b3f5e9a40e209ad837f7e3207099e4d74483"
980
+ fast_repo = "Synthyra/ANKH_large"
981
+ fast_revision = "92d2403bbe3c32acaa944fbb8dc2beb5f571f008"
982
+ fast_files = [
983
+ "config.json=git-sha1:46eef0fff286107820f8ffc523127fa981435aeb",
984
+ "model-00001-of-00002.safetensors=sha256:79301f0b6a4fcbfd3b8bd10ca892846d79b1aad6ad06976da7380249e36f5158",
985
+ "model-00002-of-00002.safetensors=sha256:20062a5049fcde509030024527665a75062a95d64966558dfcaa9245b441cbec",
986
+ "model.safetensors.index.json=git-sha1:6b707ca3ce7255d241a52feeca68c0cbbe2a383f",
987
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
988
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
989
+ "tokenizer_config.json=git-sha1:d7fe02ba6f2b18d9ccfa19ac129c9fdc9ec24d09",
990
+ ]
991
+ official_repo = "ElnaggarLab/ankh-large"
992
+ official_revision = "74b371dbfa3ee0a05d32ae74df0c2e0b82d6b9a6"
993
+ official_files = [
994
+ "config.json=git-sha1:1abf33e52ee3d6be67d780ec57d32ac2b27b5306",
995
+ "pytorch_model.bin=sha256:517b6e8b279dedcb477af240b35c46bd6eb3307723eb281e60d4b2c8a87b889b",
996
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
997
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
998
+ "tokenizer_config.json=git-sha1:d7fe02ba6f2b18d9ccfa19ac129c9fdc9ec24d09",
999
+ ]
1000
+
1001
+ [[models]]
1002
+ id = "ankh2_large"
1003
+ family = "ankh"
1004
+ size_category = "large"
1005
+ generation_contract = "required"
1006
+ official_golden = { metadata = "tests/goldens/ankh2_large.json=sha256:e8df38994ca1a1e0c598ace34a0b257b264937e4fdbb01bc41544985116b02a4", tensors = "tests/goldens/ankh2_large.safetensors=sha256:25fe1569f55c635fab8fa49c1d62a889a35a2a738bad921f5764a85b58fd4b5d" }
1007
+ 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."
1008
+ artifact_source = "official"
1009
+ canonical_state_sha256 = "597c4fe2fa8711f11a25317905f1d62fa92905e55fdd5c0a79614cd9c9d2bca3"
1010
+ fast_repo = "Synthyra/ANKH2_large"
1011
+ fast_revision = "729167c1980316ae61691338838447491926033f"
1012
+ fast_files = [
1013
+ "config.json=git-sha1:dd5d59e6b74bc8afa9fd4a5bda13526c235dabb8",
1014
+ "generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a",
1015
+ "model-00001-of-00002.safetensors=sha256:7c0c297f60bcf81c732cdfeae6e99e140272807eb52afd70356fc6fdfa94e5a8",
1016
+ "model-00002-of-00002.safetensors=sha256:f3d425d3e8741ccbdd925446559a9bf317c2c91e328f2eee44924423b56e3a3d",
1017
+ "model.safetensors.index.json=git-sha1:6b707ca3ce7255d241a52feeca68c0cbbe2a383f",
1018
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
1019
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
1020
+ "tokenizer_config.json=git-sha1:854e5db75dae8b1e9dd39c5bae80dae5508b3e25",
1021
+ ]
1022
+ official_repo = "ElnaggarLab/ankh2-ext2"
1023
+ official_revision = "aa9b9fa72288c47d9f618ce80c011e24b54e17a8"
1024
+ official_files = [
1025
+ "config.json=git-sha1:9286bed4ecbc4f7113024919d16ec9719b0c0748",
1026
+ "generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a",
1027
+ "pytorch_model.bin=sha256:2df583f28f111276ee22a7b76007f4297e9a69766d60bccd9c8d7169c06ac606",
1028
+ "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
1029
+ "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
1030
+ "tokenizer_config.json=git-sha1:854e5db75dae8b1e9dd39c5bae80dae5508b3e25",
1031
+ ]
1032
+
1033
+ [[models]]
1034
+ id = "ankh3_large"
1035
+ family = "ankh"
1036
+ size_category = "large"
1037
+ generation_contract = "required"
1038
+ official_golden = { metadata = "tests/goldens/ankh3_large.json=sha256:2e5bb05b3baa5baa78f61fef7d2a2c669b0da5dbfaf6b50b12abd3e17253a961", tensors = "tests/goldens/ankh3_large.safetensors=sha256:e5c494ac418e0a2fe7bdad1376676d48960d58ec9e044d19bfffccb8c3288513" }
1039
+ 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."
1040
+ artifact_source = "official"
1041
+ canonical_state_sha256 = "60acb7ef86e85dc0c51fc1edf4c8e69a0480049723b6b2c95e6e9faa720c112a"
1042
+ fast_repo = "Synthyra/ANKH3_large"
1043
+ fast_revision = "c6d16ca2a1b3b27a27bcf3875e816a059029d264"
1044
+ fast_files = [
1045
+ "config.json=git-sha1:813ffc6c319549a2c1f3503e1309c36110202d65",
1046
+ "generation_config.json=git-sha1:5767cc0cacebfd06884eb27ae1c796d3ca829fd2",
1047
+ "model-00001-of-00002.safetensors=sha256:7f1f5c5dcff4b6bc6b8464fe9a7eebdd99b0789ee8da895f42a41bdb04191654",
1048
+ "model-00002-of-00002.safetensors=sha256:c1a67cef9b76202362ff00c9d2b2dc4b5fc7acd1f22d30c8b3f2e3d2597d0f22",
1049
+ "model.safetensors.index.json=git-sha1:a20cfc1f8517ef47d12d08604dc93c064f1e6736",
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
+ official_repo = "ElnaggarLab/ankh3-large"
1056
+ official_revision = "2be091622e8a393f0ef21735070084123c874b6e"
1057
+ official_files = [
1058
+ "config.json=git-sha1:f5278f77d158cdd8a173df888e3ed365e84a80a3",
1059
+ "generation_config.json=git-sha1:5767cc0cacebfd06884eb27ae1c796d3ca829fd2",
1060
+ "pytorch_model.bin=sha256:26321a345e07a25b21c6c41b651c4db91b420892e52c0dcbc55bd7a8f510f95b",
1061
+ "special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4",
1062
+ "spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0",
1063
+ "tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca",
1064
+ "tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21",
1065
+ ]
1066
+
1067
+ [[models]]
1068
+ id = "ankh3_xl"
1069
+ family = "ankh"
1070
+ size_category = "xlarge"
1071
+ generation_contract = "required"
1072
+ official_golden = { metadata = "tests/goldens/ankh3_xl.json=sha256:66bb12e033e4163be225d636108a479393228a4f5061015c8af114e766c3c486", tensors = "tests/goldens/ankh3_xl.safetensors=sha256:72d34567d0228cb6f1ee701c578ed4039fead4346e3f161a52e0e74df28dc8ae" }
1073
+ 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."
1074
+ artifact_source = "official"
1075
+ canonical_state_sha256 = "dd2188e0d2ca65232135714eef6de394239734d843ddae4928c7398685d858e7"
1076
+ fast_repo = "Synthyra/ANKH3_xl"
1077
+ fast_revision = "d2856892e7535af2f55c2c4de043b1b272a29ed8"
1078
+ fast_files = [
1079
+ "config.json=git-sha1:791460a5c0d6c03bebbac1d7eec7e35805eaf7b7",
1080
+ "generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a",
1081
+ "model-00001-of-00005.safetensors=sha256:f6b841f6b800e436b08e362d04f8442fd044839b1e32ba6ac01ecb30a9d2bae5",
1082
+ "model-00002-of-00005.safetensors=sha256:ea556d511d4747ada49b9d0c24ef503774410093e53c0d003ffb9407efc2be31",
1083
+ "model-00003-of-00005.safetensors=sha256:125884f5dcb5b44435e3b76330582f31f74547b67c9b6cadf9a4d7cf38748eb7",
1084
+ "model-00004-of-00005.safetensors=sha256:e776ef6c5d6a50d4b3fcf0bbb2431de7ce813eddd2903290e54809c801ddb241",
1085
+ "model-00005-of-00005.safetensors=sha256:58ee3b065cfcccd179fdbecef9827dfb91feb33e0d8385b692e6309d56ec530e",
1086
+ "model.safetensors.index.json=git-sha1:74d149f64234c3f43eb85971f40b4a1c6d05a407",
1087
+ "special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4",
1088
+ "spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0",
1089
+ "tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca",
1090
+ "tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21",
1091
+ ]
1092
+ official_repo = "ElnaggarLab/ankh3-xl"
1093
+ official_revision = "e00113df5c95ef71df7ea3f5a73d56bd00e473a4"
1094
+ official_files = [
1095
+ "config.json=git-sha1:f8997040e8913df75fd2eebe71a2a8eb750ed0d0",
1096
+ "generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a",
1097
+ "pytorch_model-00001-of-00003.bin=sha256:2c9793cbee16697cd4149debe07d3a27143e280f6e970fa46042aae820fea981",
1098
+ "pytorch_model-00002-of-00003.bin=sha256:31c5a860e414513c829ae52affb0970d7cef2c0545df2d6e1338b6806ab7174b",
1099
+ "pytorch_model-00003-of-00003.bin=sha256:055a853bdd3623db95a637935aa299427e837cd8ea69fc04708b0262508bec75",
1100
+ "special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4",
1101
+ "spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0",
1102
+ "tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca",
1103
+ "tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21",
1104
+ ]
1105
+
1106
+ [[models]]
1107
+ id = "boltz2"
1108
+ family = "boltz2"
1109
+ size_category = "structure"
1110
+ generation_contract = "not_applicable"
1111
+ 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."
1112
+ fast_repo = "Synthyra/Boltz2"
1113
+ fast_revision = "3b148fc5efea109c065ec82ba8683d024de7134e"
1114
+ fast_files = [
1115
+ "config.json=git-sha1:8682ccb12e177e73bc7a351ff7e3af484bfb6fac",
1116
+ "model.safetensors=sha256:5c863fd200a1613a0e311071e2ad73ab350635e3fd336e6822cf45c52cb960e5",
1117
+ ]
1118
+ official_repo = "boltz-community/boltz-2"
1119
+ official_revision = "6fdef46d763fee7fbb83ca5501ccceff43b85607"
1120
+ official_files = [
1121
+ "boltz2_conf.ckpt=sha256:090e82ac8c92f5e943fa1b39e7410a44027bea7243c0bbb3caa67a77fc1428e1",
1122
+ "mols.tar=sha256:39e076d96dbec6b4e86982bbda16f3a53a2a60c9bdc17828d88f6f9a0c7d1fd7",
1123
+ ]
1124
+
1125
+ [[models]]
1126
+ id = "esmfold"
1127
+ family = "esmfold"
1128
+ size_category = "structure"
1129
+ generation_contract = "not_applicable"
1130
+ official_golden = { metadata = "tests/goldens/esmfold.json=sha256:380b9a96168410717d1f698feaabb826b1606444cbdeec86c2ea06d9ffe8f186", tensors = "tests/goldens/esmfold.safetensors=sha256:873b1b325a43d8e0f35f355c8914a2a9fe611cc48763875e9e6a22e09ec9ebcb" }
1131
+ fast_repo = "Synthyra/FastESMFold"
1132
+ fast_revision = "b88c8cb50d19b2cf7ab4fee4b0a61f5e02da7823"
1133
+ fast_files = [
1134
+ "config.json=git-sha1:18e0091dcbf6140bf68924d53c4c8917b9cd90b1",
1135
+ "model-00001-of-00003.safetensors=sha256:36fab9e5c96d409b2a34a8b4f1273acac8c07f119c32c4fcfa7d47bbcd55b83c",
1136
+ "model-00002-of-00003.safetensors=sha256:34954aaa05bc91635776ba6672946da5822626753d80db97b38c0538e9525102",
1137
+ "model-00003-of-00003.safetensors=sha256:2f1178cda0e6cff3b1e158e1acc59c83e3f4fc46e246388a5127bc56b8d9c4f2",
1138
+ "special_tokens_map.json=git-sha1:53cd95604a28eb7e23da763c8da23f5006ab2179",
1139
+ "tokenizer_config.json=git-sha1:10213f69b51b4b38876a29271b8f908e853a5800",
1140
+ "vocab.txt=git-sha1:eee0a1fc93c82568f78f086550fbd7c591cf423a",
1141
+ ]
1142
+ official_repo = "facebook/esmfold_v1"
1143
+ official_revision = "75a3841ee059df2bf4d56688166c8fb459ddd97a"
1144
+ official_files = [
1145
+ "config.json=git-sha1:1232d0aee4be551021d8e70e66ed2b062df917bf",
1146
+ "pytorch_model.bin=sha256:2ee07356b125d1e3e57503c204111fd7323347fc4735d41d3caac57c2a78e116",
1147
+ "special_tokens_map.json=git-sha1:121c8d54f8ea66cdf678f48b3cb37c05b4de5c0d",
1148
+ "tokenizer_config.json=git-sha1:aad24fba9f1bad2d74ed79d414ddcd60e6b0f812",
1149
+ "vocab.txt=git-sha1:9abfdf5472c0ed970648b683b86ab131256b3e42",
1150
+ ]
1151
+
1152
+ [[models.oracle_assets]]
1153
+ role = "weights"
1154
+ path = "models/esmfold_3B_v1.pt"
1155
+ url = "https://dl.fbaipublicfiles.com/fair-esm/models/esmfold_3B_v1.pt"
1156
+ sha256 = "e9a52579027e77d2d2e0a18218e755821f395730e86624cab9413dc117f5ca62"
1157
+ size = 2771653574
1158
+
1159
+ [[models]]
1160
+ id = "esmfold2"
1161
+ family = "esmfold2"
1162
+ size_category = "structure"
1163
+ generation_contract = "not_applicable"
1164
+ msa_conditioning = true
1165
+ official_golden = { metadata = "tests/goldens/esmfold2.json=sha256:f6e0ed1ec400b9a0fcc817db51774be968dc454b7a32645a07c479e42423ab20", tensors = "tests/goldens/esmfold2.safetensors=sha256:e4d6be4344c528e26b13f79a9303549e3de7e582da195c0078db3ce957fad420" }
1166
+ fast_repo = "Synthyra/ESMFold2"
1167
+ fast_revision = "cd5a0927cec585a778d983b99a8db23d2e9b281e"
1168
+ fast_files = [
1169
+ "config.json=git-sha1:67e81ff571f393f0b630cd5a22398bd84979c030",
1170
+ "model.safetensors=sha256:138fd4350d6892b81ce6be7ff9bf5a93ae9d4d3751f46a27438a3f9f0dcefa0e",
1171
+ ]
1172
+ official_repo = "biohub/ESMFold2"
1173
+ official_revision = "1ebf0e3481a5184eb6171d40615c79e384b48796"
1174
+ official_files = [
1175
+ "config.json=git-sha1:0300c084b990b2bd600efd9f538aa5de27109fea",
1176
+ "model.safetensors=sha256:138fd4350d6892b81ce6be7ff9bf5a93ae9d4d3751f46a27438a3f9f0dcefa0e",
1177
+ ]
1178
+
1179
+ [[models]]
1180
+ id = "esmfold2_fast"
1181
+ family = "esmfold2"
1182
+ size_category = "structure"
1183
+ generation_contract = "not_applicable"
1184
+ msa_conditioning = false
1185
+ official_golden = { metadata = "tests/goldens/esmfold2_fast.json=sha256:091b004c0b330217b59c12acd6da3d6edaf91e48d95f6d5f40fc20399cef9478", tensors = "tests/goldens/esmfold2_fast.safetensors=sha256:6e2e1cd07401538b4d9df994f82abe7a5b38a01e8d1ee26681e1216d44a81990" }
1186
+ fast_repo = "Synthyra/ESMFold2-Fast"
1187
+ fast_revision = "407875bfcaa42552bfcb25acd67ee1888b790170"
1188
+ fast_files = [
1189
+ "config.json=git-sha1:62ccca15a416a5dcbd02cd6ce161f432c7b4de58",
1190
+ "model.safetensors=sha256:60ca19f2898188beba92944365f7b909efd9c99212f5018af75cc47cd9a6184a",
1191
+ ]
1192
+ official_repo = "biohub/ESMFold2-Fast"
1193
+ official_revision = "b28d8ace5e05e61e5bec1e6820cfd3e221819d12"
1194
+ official_files = [
1195
+ "config.json=git-sha1:c0ca526090fa7f8342ee4666d56e7fe3a4b8cbb2",
1196
+ "model.safetensors=sha256:60ca19f2898188beba92944365f7b909efd9c99212f5018af75cc47cd9a6184a",
1197
+ ]
1198
+
1199
+ [[models]]
1200
+ id = "esmfold2_experimental_cutoff2025"
1201
+ family = "esmfold2"
1202
+ size_category = "structure"
1203
+ generation_contract = "not_applicable"
1204
+ msa_conditioning = true
1205
+ official_golden = { metadata = "tests/goldens/esmfold2_experimental_cutoff2025.json=sha256:cfd0e35b2bc468a0dc4f614d3acfa2fce004f96e9ae2433256ed095b829d55cc", tensors = "tests/goldens/esmfold2_experimental_cutoff2025.safetensors=sha256:9347466bbe803b6f5dc82e3356ca6cbbf2c2edd8765f9fd273385bda255019f6" }
1206
+ fast_repo = "Synthyra/ESMFold2-Experimental-Cutoff2025"
1207
+ fast_revision = "632ff4a9e68f1de78ee956a613267bdcdb5b354d"
1208
+ fast_files = [
1209
+ "config.json=git-sha1:41119745d38bc5503a0212ad923e75211dec565f",
1210
+ "model.safetensors=sha256:01358c317428d38535e3db513cab177336fc0f7fab0d84002e64b7741d5181b3",
1211
+ ]
1212
+ official_repo = "biohub/ESMFold2-Experimental-Cutoff2025"
1213
+ official_revision = "56f94f5c1069ecde17512c96928850518340d287"
1214
+ official_files = [
1215
+ "config.json=git-sha1:79ed0dc0f867b8f09bfa004d6f77397c2ab9b38d",
1216
+ "model.safetensors=sha256:01358c317428d38535e3db513cab177336fc0f7fab0d84002e64b7741d5181b3",
1217
+ ]
1218
+ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForTokenClassification" }
1219
+
1220
+ [[models]]
1221
+ id = "esmfold2_experimental_fast_cutoff2025"
1222
+ family = "esmfold2"
1223
+ size_category = "structure"
1224
+ generation_contract = "not_applicable"
1225
+ msa_conditioning = false
1226
+ official_golden = { metadata = "tests/goldens/esmfold2_experimental_fast_cutoff2025.json=sha256:1d0b2da4f1579243f37ae04bd4b834b747005cd8e8e7665e00d088123c43afd9", tensors = "tests/goldens/esmfold2_experimental_fast_cutoff2025.safetensors=sha256:516e216d05d7e6bee59e77126d3e595e2bb7821929433f00c259c5d5241964bb" }
1227
+ fast_repo = "Synthyra/ESMFold2-Experimental-Fast-Cutoff2025"
1228
+ fast_revision = "8f022c2514a6c32692aaca078a8391d6bc6c4bac"
1229
+ fast_files = [
1230
+ "config.json=git-sha1:b9d39e941050179ca51faaed58cbbd77778c1143",
1231
+ "model.safetensors=sha256:4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f",
1232
+ ]
1233
+ official_repo = "biohub/ESMFold2-Experimental-Fast-Cutoff2025"
1234
+ official_revision = "74b88548bf19688b8727432db0d698cb2e1d8783"
1235
+ official_files = [
1236
+ "config.json=git-sha1:0333d68ddb12ed2f066741dcb801142f466c0a2c",
1237
+ "model.safetensors=sha256:4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f",
1238
+ ]
1239
+ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForTokenClassification" }
1240
+
1241
+ [[models]]
1242
+ id = "esmfold2_300"
1243
+ family = "esmfold2"
1244
+ size_category = "structure"
1245
+ generation_contract = "not_applicable"
1246
+ msa_conditioning = false
1247
+ publication_status = "pending"
1248
+ fast_repo = "Synthyra/ESMFold2-300"
1249
+ fast_revision = "unpublished"
1250
+ fast_files = ["config.json=git-sha1:8c9a04fe22b0e5fca77bc4e2861a12c9494ef4d4", "model.safetensors=sha256:44d6797c5efebf24753d502b40950e0874871c96ceea14f2d7f7e39cebac67fd"]
1251
+ official_repo = "biohub/ESMFold2-Experimental-Fast-base300M-step1500k"
1252
+ official_revision = "21531e59002c9205284715e28ee802dafb430637"
1253
+ official_files = ["config.json=git-sha1:8c9a04fe22b0e5fca77bc4e2861a12c9494ef4d4", "model.safetensors=sha256:44d6797c5efebf24753d502b40950e0874871c96ceea14f2d7f7e39cebac67fd"]
1254
+ notes = "Experimental Fast checkpoint with a frozen 300M ESM++ backbone, tensor-exact in BF16 with the pinned step-1500000 source, 24 folding blocks, no MSA conditioning, and no confidence head. BF16 execution uses FP32 folding parameters with CUDA autocast. FP8 is unsupported. Docker BF16 inference validation passed on the compact Protein G case; publication is pending."
1255
+ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForTokenClassification" }
1256
+ backbone_model = "esmc_small"
1257
+ backbone = { repo = "biohub/ESMC-300M-1500000", revision = "56803b6378b82e16c3b24aac49d1fce4445540b7", files = ["config.json=git-sha1:7fe728a0eb3fb81b24491d6cc1de816bf7797c27", "model.safetensors=sha256:8bd6cacf9b5a92d51954b64b20407f1f9f564a7e4849b8663470784d2a8b7ed2", "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c", "tokenizer_config.json=git-sha1:f49f57b24a1c93bd544974811e8ecbd61b7fae89", "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b"] }
1258
+
1259
+ [[models]]
1260
+ id = "esmfold2_600"
1261
+ family = "esmfold2"
1262
+ size_category = "structure"
1263
+ generation_contract = "not_applicable"
1264
+ msa_conditioning = false
1265
+ publication_status = "pending"
1266
+ fast_repo = "Synthyra/ESMFold2-600"
1267
+ fast_revision = "unpublished"
1268
+ fast_files = ["config.json=git-sha1:95517e555f17a1eca4b68866c89033a1f6916a5d", "model.safetensors=sha256:11a53c1b4700b6c62a5a464fc3ec7076160c19e8584157f88e13275116cfd602"]
1269
+ official_repo = "biohub/ESMFold2-Experimental-Fast-base600M-step1500k"
1270
+ official_revision = "15cf2d6648692f6c17cee1297d8a285476fffa9b"
1271
+ official_files = ["config.json=git-sha1:95517e555f17a1eca4b68866c89033a1f6916a5d", "model.safetensors=sha256:11a53c1b4700b6c62a5a464fc3ec7076160c19e8584157f88e13275116cfd602"]
1272
+ notes = "Experimental Fast checkpoint with a frozen 600M ESM++ backbone, tensor-exact in BF16 with the pinned step-1500000 source, 24 folding blocks, no MSA conditioning, and no confidence head. BF16 execution uses FP32 folding parameters with CUDA autocast. FP8 is unsupported. Configuration, weight identities, and artifact reload are verified; this model is not inference-validated. Publication is pending."
1273
+ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForTokenClassification" }
1274
+ backbone_model = "esmc_large"
1275
+ backbone = { repo = "biohub/ESMC-600M-1500000", revision = "21af9cc429af76ebda6c48074fb624db4735aaaf", files = ["config.json=git-sha1:ec29f6009b21d710f64bf1c058f3a9710833d692", "model.safetensors=sha256:d6869f5ae0f11e5dc829b195e062e87cfcc2f851a08a5edbaf5d1083ae7f76cc", "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c", "tokenizer_config.json=git-sha1:f49f57b24a1c93bd544974811e8ecbd61b7fae89", "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b"] }
fastplms/models/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
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
+
11
+ __all__: tuple[str, ...] = ()
fastplms/models/_esm_rotary.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stable ESM rotary embeddings independent of Transformers internals.
2
+
3
+ Transformers 5 changed both the name and call contract of its private ESM
4
+ rotary helper. FastPLMs checkpoints use the earlier two-tensor contract, so
5
+ the small mathematical primitive lives here instead of importing a private
6
+ Transformers implementation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+ from torch import nn
13
+
14
+
15
+ def _rotate_half(tensor: torch.Tensor) -> torch.Tensor:
16
+ """Rotate the final dimension of X by 90 degrees in paired subspaces."""
17
+
18
+ # tensor: (..., d)
19
+ first, second = tensor.chunk(2, dim=-1) # (..., d / 2), (..., d / 2)
20
+ return torch.cat((-second, first), dim=-1) # (..., d)
21
+
22
+
23
+ def apply_rotary_pos_emb(
24
+ tensor: torch.Tensor,
25
+ cos: torch.Tensor,
26
+ sin: torch.Tensor,
27
+ ) -> torch.Tensor:
28
+ """Apply cached rotary factors to X with shape ``(b, h, l, d)``."""
29
+
30
+ # tensor: (b, h, l, d); cos, sin: (1, 1, l_cache, d)
31
+ cos = cos[:, :, : tensor.shape[-2], :] # (1, 1, l, d)
32
+ sin = sin[:, :, : tensor.shape[-2], :] # (1, 1, l, d)
33
+ return tensor * cos + _rotate_half(tensor) * sin # (b, h, l, d)
34
+
35
+
36
+ class RotaryEmbedding(nn.Module):
37
+ """Apply rotary position embeddings to query and key tensors."""
38
+
39
+ inv_freq: torch.Tensor
40
+
41
+ def __init__(self, dim: int) -> None:
42
+ super().__init__()
43
+ frequencies = 1.0 / ( # (d / 2,)
44
+ 10_000 ** (torch.arange(0, dim, 2, dtype=torch.int64).float() / dim)
45
+ )
46
+ # Keep this persistent to preserve the historical checkpoint schema.
47
+ self.register_buffer("inv_freq", frequencies)
48
+ self._seq_len_cached: int | None = None
49
+ self._cos_cached: torch.Tensor | None = None
50
+ self._sin_cached: torch.Tensor | None = None
51
+
52
+ def _update_cos_sin_tables(
53
+ self,
54
+ tensor: torch.Tensor,
55
+ seq_dimension: int = 2,
56
+ ) -> tuple[torch.Tensor, torch.Tensor]:
57
+ # tensor: (..., l, d)
58
+ seq_len = tensor.shape[seq_dimension]
59
+ cache_stale = (
60
+ self._cos_cached is None
61
+ or self._sin_cached is None
62
+ or self._seq_len_cached != seq_len
63
+ or self._cos_cached.device != tensor.device
64
+ )
65
+ if cache_stale:
66
+ self._seq_len_cached = seq_len
67
+ positions = torch.arange(seq_len, device=tensor.device).type_as( # (l,)
68
+ self.inv_freq
69
+ )
70
+ angles = torch.outer(positions, self.inv_freq) # (l, d / 2)
71
+ angles = torch.cat((angles, angles), dim=-1).to(tensor.device) # (l, d)
72
+ self._cos_cached = angles.cos()[None, None, :, :] # (1, 1, l, d)
73
+ self._sin_cached = angles.sin()[None, None, :, :] # (1, 1, l, d)
74
+
75
+ assert self._cos_cached is not None
76
+ assert self._sin_cached is not None
77
+ return self._cos_cached, self._sin_cached # (1, 1, l, d), (1, 1, l, d)
78
+
79
+ def forward(
80
+ self,
81
+ query: torch.Tensor,
82
+ key: torch.Tensor,
83
+ ) -> tuple[torch.Tensor, torch.Tensor]:
84
+ # query, key: (b, h, l, d)
85
+ cos, sin = self._update_cos_sin_tables( # (1, 1, l, d), (1, 1, l, d)
86
+ key,
87
+ seq_dimension=-2,
88
+ )
89
+ return (
90
+ apply_rotary_pos_emb(query, cos, sin).to(dtype=query.dtype), # (b, h, l, d)
91
+ apply_rotary_pos_emb(key, cos, sin).to(dtype=key.dtype), # (b, h, l, d)
92
+ )
fastplms/models/classification_probe.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared transformer probes for residue and sequence prediction tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ import torch
9
+ from torch import nn
10
+ from torch.nn import functional as F
11
+ from transformers.modeling_outputs import (
12
+ BaseModelOutput,
13
+ SequenceClassifierOutput,
14
+ TokenClassifierOutput,
15
+ )
16
+
17
+ try:
18
+ from fastplms.attention import (
19
+ AttentionBackend,
20
+ _get_flex_attention_fn,
21
+ flex_attention,
22
+ get_attention_mask,
23
+ resolve_attention_backend,
24
+ )
25
+ from fastplms.embeddings.pooling import Pooler
26
+ from fastplms.models._esm_rotary import RotaryEmbedding
27
+ except ModuleNotFoundError as error:
28
+ _COMPOSITE_REQUIRED_NAMES = (
29
+ "AttentionBackend",
30
+ "Pooler",
31
+ "RotaryEmbedding",
32
+ "_get_flex_attention_fn",
33
+ "flex_attention",
34
+ "get_attention_mask",
35
+ "resolve_attention_backend",
36
+ )
37
+ if error.name != "fastplms" or any(
38
+ name not in globals() for name in _COMPOSITE_REQUIRED_NAMES
39
+ ):
40
+ raise
41
+ # Flat Hub composites define every shared symbol above this source.
42
+
43
+
44
+ _SUPPORTED_BACKENDS = frozenset(
45
+ {
46
+ AttentionBackend.EAGER,
47
+ AttentionBackend.SDPA,
48
+ AttentionBackend.FLEX_ATTENTION,
49
+ }
50
+ )
51
+ _SUPPORTED_PROBLEM_TYPES = frozenset(
52
+ {
53
+ "regression",
54
+ "single_label_classification",
55
+ "multi_label_classification",
56
+ }
57
+ )
58
+ _UNSUPPORTED_POOLING = frozenset({"cls", "parti"})
59
+
60
+
61
+ def _config_value(config: Any, name: str, default: Any) -> Any:
62
+ value = getattr(config, name, None)
63
+ return default if value is None else value
64
+
65
+
66
+ def _attention_backend(config: Any) -> AttentionBackend:
67
+ requested = getattr(config, "_attn_implementation", None)
68
+ if requested is None:
69
+ requested = getattr(config, "attn_backend", "sdpa")
70
+ backend = resolve_attention_backend(requested)
71
+ if backend not in _SUPPORTED_BACKENDS:
72
+ expected = ", ".join(sorted(item.value for item in _SUPPORTED_BACKENDS))
73
+ raise ValueError(
74
+ f"Classification probes support only {expected}; received {backend.value!r}."
75
+ )
76
+ return backend
77
+
78
+
79
+ def resolve_problem_type(
80
+ config: Any,
81
+ labels: torch.Tensor,
82
+ *,
83
+ num_labels: int,
84
+ ) -> str:
85
+ """Resolve and persist the standard Transformers classification problem type."""
86
+
87
+ problem_type = getattr(config, "problem_type", None)
88
+ if problem_type is None:
89
+ if num_labels == 1:
90
+ problem_type = "regression"
91
+ elif labels.dtype in {torch.long, torch.int}:
92
+ problem_type = "single_label_classification"
93
+ else:
94
+ problem_type = "multi_label_classification"
95
+ config.problem_type = problem_type
96
+ if problem_type not in _SUPPORTED_PROBLEM_TYPES:
97
+ raise ValueError(
98
+ f"Unsupported problem_type {problem_type!r}; expected one of "
99
+ f"{sorted(_SUPPORTED_PROBLEM_TYPES)}."
100
+ )
101
+ return problem_type
102
+
103
+
104
+ def sequence_classification_loss(
105
+ logits: torch.Tensor,
106
+ labels: torch.Tensor,
107
+ *,
108
+ problem_type: str,
109
+ num_labels: int,
110
+ ) -> torch.Tensor:
111
+ """Compute a Hugging Face-compatible sequence task loss."""
112
+
113
+ labels = labels.to(logits.device)
114
+ if problem_type == "regression":
115
+ if num_labels == 1:
116
+ return F.mse_loss(logits.squeeze(-1), labels.squeeze(-1).to(logits.dtype))
117
+ return F.mse_loss(logits, labels.to(logits.dtype))
118
+ if problem_type == "single_label_classification":
119
+ return F.cross_entropy(logits.reshape(-1, num_labels), labels.reshape(-1).long())
120
+ if problem_type == "multi_label_classification":
121
+ return F.binary_cross_entropy_with_logits(logits, labels.to(logits.dtype))
122
+ raise ValueError(f"Unsupported problem_type {problem_type!r}.")
123
+
124
+
125
+ def _masked_elementwise_loss(
126
+ losses: torch.Tensor,
127
+ labels: torch.Tensor,
128
+ ) -> torch.Tensor:
129
+ valid = labels.ne(-100)
130
+ if not bool(valid.any()):
131
+ return losses.sum() * 0
132
+ return losses.masked_select(valid).mean()
133
+
134
+
135
+ def token_classification_loss(
136
+ logits: torch.Tensor,
137
+ labels: torch.Tensor,
138
+ *,
139
+ problem_type: str,
140
+ num_labels: int,
141
+ ) -> torch.Tensor:
142
+ """Compute a token task loss, excluding every label element equal to ``-100``."""
143
+
144
+ labels = labels.to(logits.device)
145
+ if problem_type == "regression":
146
+ targets = labels.to(logits.dtype)
147
+ if num_labels == 1 and targets.ndim == logits.ndim - 1:
148
+ targets = targets.unsqueeze(-1)
149
+ if targets.shape != logits.shape:
150
+ raise ValueError(
151
+ "Token regression labels must match logits, except that the final "
152
+ "singleton dimension may be omitted when num_labels=1."
153
+ )
154
+ return _masked_elementwise_loss(F.mse_loss(logits, targets, reduction="none"), targets)
155
+ if problem_type == "single_label_classification":
156
+ if not bool(labels.ne(-100).any()):
157
+ return logits.sum() * 0
158
+ return F.cross_entropy(
159
+ logits.reshape(-1, num_labels),
160
+ labels.reshape(-1).long(),
161
+ ignore_index=-100,
162
+ )
163
+ if problem_type == "multi_label_classification":
164
+ if labels.shape != logits.shape:
165
+ raise ValueError("Multilabel token labels must have the same shape as logits.")
166
+ losses = F.binary_cross_entropy_with_logits(
167
+ logits,
168
+ labels.to(logits.dtype),
169
+ reduction="none",
170
+ )
171
+ return _masked_elementwise_loss(losses, labels)
172
+ raise ValueError(f"Unsupported problem_type {problem_type!r}.")
173
+
174
+
175
+ class SwiGLU(nn.Module):
176
+ """SwiGLU activation used by the Protify-aligned feed-forward layer."""
177
+
178
+ def forward(self, inputs: torch.Tensor) -> torch.Tensor:
179
+ gate, values = inputs.chunk(2, dim=-1)
180
+ return F.silu(gate) * values
181
+
182
+
183
+ class ProbeSelfAttention(nn.Module):
184
+ """Four-head RoPE self-attention with explicit, fail-closed dispatch."""
185
+
186
+ def __init__(
187
+ self,
188
+ hidden_size: int,
189
+ num_heads: int,
190
+ dropout: float,
191
+ backend: AttentionBackend,
192
+ use_bias: bool,
193
+ ) -> None:
194
+ super().__init__()
195
+ if hidden_size % num_heads:
196
+ raise ValueError("classifier_probe_hidden_size must be divisible by its head count.")
197
+ self.hidden_size = hidden_size
198
+ self.num_heads = num_heads
199
+ self.head_size = hidden_size // num_heads
200
+ self.dropout = dropout
201
+ self.backend = backend
202
+ self.qkv = nn.Linear(hidden_size, 3 * hidden_size, bias=use_bias)
203
+ self.output = nn.Linear(hidden_size, hidden_size, bias=use_bias)
204
+ self.rotary = RotaryEmbedding(self.head_size)
205
+
206
+ def _reshape(self, tensor: torch.Tensor) -> torch.Tensor:
207
+ batch_size, sequence_length, _ = tensor.shape
208
+ return tensor.view(
209
+ batch_size,
210
+ sequence_length,
211
+ self.num_heads,
212
+ self.head_size,
213
+ ).transpose(1, 2)
214
+
215
+ def forward(
216
+ self,
217
+ hidden_states: torch.Tensor,
218
+ *,
219
+ attention_mask: torch.Tensor | None,
220
+ output_attentions: bool,
221
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
222
+ batch_size, sequence_length, _ = hidden_states.shape
223
+ query, key, value = self.qkv(hidden_states).chunk(3, dim=-1)
224
+ query = self._reshape(query)
225
+ key = self._reshape(key)
226
+ value = self._reshape(value)
227
+ query, key = self.rotary(query, key)
228
+ if output_attentions and self.backend != AttentionBackend.EAGER:
229
+ raise ValueError(
230
+ f"output_attentions=True is unavailable for {self.backend.value!r}; "
231
+ "select 'eager' explicitly."
232
+ )
233
+ _, attention_mask_4d, flex_block_mask = get_attention_mask(
234
+ self.backend,
235
+ batch_size,
236
+ sequence_length,
237
+ hidden_states.device,
238
+ attention_mask,
239
+ hidden_states.dtype,
240
+ )
241
+ dropout = self.dropout if self.training else 0.0
242
+ attention_weights = None
243
+ if self.backend == AttentionBackend.EAGER:
244
+ scores = query @ key.transpose(-2, -1) / math.sqrt(self.head_size)
245
+ if attention_mask_4d is not None:
246
+ scores = scores.masked_fill(~attention_mask_4d, float("-inf"))
247
+ attention_weights = scores.softmax(dim=-1)
248
+ context = F.dropout(attention_weights, p=dropout, training=self.training) @ value
249
+ elif self.backend == AttentionBackend.SDPA:
250
+ context = F.scaled_dot_product_attention(
251
+ query,
252
+ key,
253
+ value,
254
+ attn_mask=attention_mask_4d,
255
+ dropout_p=dropout,
256
+ )
257
+ elif self.backend == AttentionBackend.FLEX_ATTENTION:
258
+ if flex_attention is None:
259
+ raise RuntimeError("'flex_attention' was requested but is unavailable.")
260
+ flex_fn = _get_flex_attention_fn(
261
+ device=query.device,
262
+ dtype=query.dtype,
263
+ shape=tuple(query.shape),
264
+ mask_semantics="padding",
265
+ )
266
+ if flex_fn is None:
267
+ raise RuntimeError("'flex_attention' was requested but is unavailable.")
268
+ context = flex_fn(
269
+ query,
270
+ key,
271
+ value,
272
+ block_mask=flex_block_mask,
273
+ scale=1.0 / math.sqrt(self.head_size),
274
+ kernel_options={"PRESCALE_QK": True, "BLOCK_N": 32},
275
+ )
276
+ else:
277
+ raise AssertionError(f"Unhandled attention backend {self.backend.value!r}.")
278
+ context = context.transpose(1, 2).contiguous().view(
279
+ batch_size,
280
+ sequence_length,
281
+ self.hidden_size,
282
+ )
283
+ return self.output(context), attention_weights
284
+
285
+
286
+ class ProteinTransformerProbe(nn.Module):
287
+ """Project residue embeddings and refine them with exactly one pre-LN block."""
288
+
289
+ def __init__(self, config: Any, input_size: int) -> None:
290
+ super().__init__()
291
+ hidden_size = int(_config_value(config, "classifier_probe_hidden_size", 512))
292
+ num_heads = int(_config_value(config, "classifier_probe_num_heads", 4))
293
+ dropout = float(_config_value(config, "classifier_probe_dropout", 0.1))
294
+ use_bias = bool(
295
+ _config_value(
296
+ config,
297
+ "classifier_use_bias",
298
+ _config_value(config, "use_bias", False),
299
+ )
300
+ )
301
+ if hidden_size != 512 or num_heads != 4 or hidden_size // num_heads != 128:
302
+ raise ValueError(
303
+ "The folding classification probe requires a 512-wide projection with "
304
+ "four 128-wide attention heads."
305
+ )
306
+ self.hidden_size = hidden_size
307
+ self.input_norm = nn.LayerNorm(input_size)
308
+ self.input_projection = nn.Linear(input_size, hidden_size, bias=use_bias)
309
+ self.attention_norm = nn.LayerNorm(hidden_size)
310
+ self.attention = ProbeSelfAttention(
311
+ hidden_size,
312
+ num_heads,
313
+ dropout,
314
+ _attention_backend(config),
315
+ use_bias,
316
+ )
317
+ intermediate_size = int(math.ceil((8 / 3) * hidden_size / 256) * 256)
318
+ self.feed_forward_norm = nn.LayerNorm(hidden_size)
319
+ self.feed_forward = nn.Sequential(
320
+ nn.Linear(hidden_size, 2 * intermediate_size, bias=use_bias),
321
+ SwiGLU(),
322
+ nn.Dropout(dropout),
323
+ nn.Linear(intermediate_size, hidden_size, bias=use_bias),
324
+ )
325
+ self.residual_dropout = nn.Dropout(dropout)
326
+
327
+ @property
328
+ def attn_backend(self) -> str:
329
+ return self.attention.backend.value
330
+
331
+ def forward(
332
+ self,
333
+ embeddings: torch.Tensor,
334
+ attention_mask: torch.Tensor | None = None,
335
+ *,
336
+ output_attentions: bool = False,
337
+ output_hidden_states: bool = False,
338
+ return_dict: bool = True,
339
+ ) -> BaseModelOutput | tuple[torch.Tensor, ...]:
340
+ if embeddings.ndim != 3:
341
+ raise ValueError("embeddings must have shape (batch, residue, channel).")
342
+ embeddings = embeddings.to(dtype=self.input_projection.weight.dtype)
343
+ hidden_states = self.input_projection(self.input_norm(embeddings))
344
+ attention_output, attention_weights = self.attention(
345
+ self.attention_norm(hidden_states),
346
+ attention_mask=attention_mask,
347
+ output_attentions=output_attentions,
348
+ )
349
+ hidden_states = hidden_states + self.residual_dropout(attention_output)
350
+ hidden_states = hidden_states + self.residual_dropout(
351
+ self.feed_forward(self.feed_forward_norm(hidden_states))
352
+ )
353
+ output = BaseModelOutput(
354
+ last_hidden_state=hidden_states,
355
+ hidden_states=(hidden_states,) if output_hidden_states else None,
356
+ attentions=(attention_weights,) if output_attentions else None,
357
+ )
358
+ return output if return_dict else output.to_tuple()
359
+
360
+
361
+ class _ClassificationProbe(nn.Module):
362
+ def __init__(self, config: Any, input_size: int, *, sequence_task: bool) -> None:
363
+ super().__init__()
364
+ self.config = config
365
+ self.num_labels = int(_config_value(config, "num_labels", 2))
366
+ self.transformer = ProteinTransformerProbe(config, input_size)
367
+ self.sequence_task = sequence_task
368
+ pooling_types = _config_value(config, "classifier_pooling_types", ["mean"])
369
+ self.pooler = Pooler(pooling_types) if sequence_task else None
370
+ if self.pooler is not None:
371
+ unsupported = sorted(set(self.pooler.names) & _UNSUPPORTED_POOLING)
372
+ if unsupported:
373
+ raise ValueError(
374
+ "Classification probes consume residue-only representations and do not "
375
+ f"support pooling operation(s) {unsupported}."
376
+ )
377
+ hidden_size = self.transformer.hidden_size
378
+ classifier_input = hidden_size * (len(self.pooler.names) if self.pooler else 1)
379
+ classifier_hidden = int(_config_value(config, "classifier_hidden_size", 4096))
380
+ classifier_dropout = float(_config_value(config, "classifier_dropout", 0.2))
381
+ use_bias = bool(
382
+ _config_value(
383
+ config,
384
+ "classifier_use_bias",
385
+ _config_value(config, "use_bias", False),
386
+ )
387
+ )
388
+ projection_size = int(math.ceil((2 * self.num_labels) / 256) * 256)
389
+ classifier_layers: list[nn.Module] = [
390
+ nn.LayerNorm(classifier_input),
391
+ nn.Linear(classifier_input, classifier_hidden, bias=use_bias),
392
+ nn.ReLU(),
393
+ nn.Dropout(classifier_dropout),
394
+ nn.Linear(classifier_hidden, projection_size, bias=use_bias),
395
+ nn.ReLU(),
396
+ nn.Dropout(classifier_dropout),
397
+ ]
398
+ if not sequence_task:
399
+ classifier_layers.extend(
400
+ [
401
+ nn.Linear(projection_size, projection_size, bias=use_bias),
402
+ nn.ReLU(),
403
+ ]
404
+ )
405
+ classifier_layers.append(nn.Linear(projection_size, self.num_labels, bias=use_bias))
406
+ self.classifier = nn.Sequential(*classifier_layers)
407
+
408
+ def _forward_transformer(
409
+ self,
410
+ embeddings: torch.Tensor,
411
+ attention_mask: torch.Tensor | None,
412
+ output_attentions: bool | None,
413
+ output_hidden_states: bool | None,
414
+ ) -> BaseModelOutput:
415
+ output_attentions = (
416
+ bool(output_attentions)
417
+ if output_attentions is not None
418
+ else bool(getattr(self.config, "output_attentions", False))
419
+ )
420
+ output_hidden_states = (
421
+ bool(output_hidden_states)
422
+ if output_hidden_states is not None
423
+ else bool(getattr(self.config, "output_hidden_states", False))
424
+ )
425
+ return self.transformer(
426
+ embeddings,
427
+ attention_mask,
428
+ output_attentions=output_attentions,
429
+ output_hidden_states=output_hidden_states,
430
+ return_dict=True,
431
+ )
432
+
433
+
434
+ class SequenceClassificationProbe(_ClassificationProbe):
435
+ """Protify-style sequence classifier over externally supplied residue embeddings."""
436
+
437
+ def __init__(self, config: Any, input_size: int) -> None:
438
+ super().__init__(config, input_size, sequence_task=True)
439
+
440
+ def forward(
441
+ self,
442
+ embeddings: torch.Tensor,
443
+ attention_mask: torch.Tensor | None = None,
444
+ labels: torch.Tensor | None = None,
445
+ output_attentions: bool | None = None,
446
+ output_hidden_states: bool | None = None,
447
+ return_dict: bool | None = None,
448
+ ) -> SequenceClassifierOutput | tuple[torch.Tensor, ...]:
449
+ if attention_mask is None:
450
+ attention_mask = torch.ones(
451
+ embeddings.shape[:2],
452
+ device=embeddings.device,
453
+ dtype=torch.bool,
454
+ )
455
+ outputs = self._forward_transformer(
456
+ embeddings,
457
+ attention_mask,
458
+ output_attentions,
459
+ output_hidden_states,
460
+ )
461
+ if self.pooler is None:
462
+ raise AssertionError("Sequence classification requires a configured pooler.")
463
+ pooled = self.pooler(outputs.last_hidden_state, attention_mask)
464
+ logits = self.classifier(pooled)
465
+ loss = None
466
+ if labels is not None:
467
+ problem_type = resolve_problem_type(self.config, labels, num_labels=self.num_labels)
468
+ loss = sequence_classification_loss(
469
+ logits,
470
+ labels,
471
+ problem_type=problem_type,
472
+ num_labels=self.num_labels,
473
+ )
474
+ result = SequenceClassifierOutput(
475
+ loss=loss,
476
+ logits=logits,
477
+ hidden_states=outputs.hidden_states,
478
+ attentions=outputs.attentions,
479
+ )
480
+ use_return_dict = (
481
+ bool(return_dict)
482
+ if return_dict is not None
483
+ else bool(getattr(self.config, "use_return_dict", True))
484
+ )
485
+ return result if use_return_dict else result.to_tuple()
486
+
487
+
488
+ class TokenClassificationProbe(_ClassificationProbe):
489
+ """Protify-style residue classifier or regressor over supplied embeddings."""
490
+
491
+ def __init__(self, config: Any, input_size: int) -> None:
492
+ super().__init__(config, input_size, sequence_task=False)
493
+
494
+ def forward(
495
+ self,
496
+ embeddings: torch.Tensor,
497
+ attention_mask: torch.Tensor | None = None,
498
+ labels: torch.Tensor | None = None,
499
+ output_attentions: bool | None = None,
500
+ output_hidden_states: bool | None = None,
501
+ return_dict: bool | None = None,
502
+ ) -> TokenClassifierOutput | tuple[torch.Tensor, ...]:
503
+ outputs = self._forward_transformer(
504
+ embeddings,
505
+ attention_mask,
506
+ output_attentions,
507
+ output_hidden_states,
508
+ )
509
+ logits = self.classifier(outputs.last_hidden_state)
510
+ loss = None
511
+ if labels is not None:
512
+ problem_type = resolve_problem_type(self.config, labels, num_labels=self.num_labels)
513
+ loss = token_classification_loss(
514
+ logits,
515
+ labels,
516
+ problem_type=problem_type,
517
+ num_labels=self.num_labels,
518
+ )
519
+ result = TokenClassifierOutput(
520
+ loss=loss,
521
+ logits=logits,
522
+ hidden_states=outputs.hidden_states,
523
+ attentions=outputs.attentions,
524
+ )
525
+ use_return_dict = (
526
+ bool(return_dict)
527
+ if return_dict is not None
528
+ else bool(getattr(self.config, "use_return_dict", True))
529
+ )
530
+ return result if use_return_dict else result.to_tuple()
531
+
532
+
533
+ __all__ = [
534
+ "ProbeSelfAttention",
535
+ "ProteinTransformerProbe",
536
+ "SequenceClassificationProbe",
537
+ "SwiGLU",
538
+ "TokenClassificationProbe",
539
+ "resolve_problem_type",
540
+ "sequence_classification_loss",
541
+ "token_classification_loss",
542
+ ]
fastplms/models/esm_plusplus/__init__.py ADDED
File without changes
fastplms/models/esm_plusplus/modeling_esm_plusplus.py ADDED
@@ -0,0 +1,2056 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face-compatible ESMC models implemented by FastPLMs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.metadata
7
+ import math
8
+ import os
9
+ from collections.abc import Sequence
10
+ from contextlib import contextmanager
11
+ from dataclasses import asdict, dataclass
12
+ from functools import partial
13
+ from typing import Any, ClassVar
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+ from einops import rearrange
19
+ from tokenizers import Tokenizer
20
+ from tokenizers.models import BPE
21
+ from tokenizers.processors import TemplateProcessing
22
+ from transformers import PretrainedConfig, PreTrainedModel, PreTrainedTokenizerFast
23
+ from transformers.modeling_outputs import (
24
+ MaskedLMOutput,
25
+ ModelOutput,
26
+ SequenceClassifierOutput,
27
+ TokenClassifierOutput,
28
+ )
29
+
30
+ from .modeling_esm_plusplus_sae import ESMplusplusSAELayer, load_esmc_sae_layers
31
+
32
+
33
+ try:
34
+ from fastplms.attention import (
35
+ AttentionBackend,
36
+ BlockMask,
37
+ FastPLMsAttentionMixin,
38
+ _get_flex_attention_fn,
39
+ _get_flex_block_mask,
40
+ flex_attention,
41
+ get_attention_mask,
42
+ kernels_flash_attention_func,
43
+ resolve_attention_backend,
44
+ resolve_attention_backend_for_call,
45
+ )
46
+ from fastplms.embeddings import EmbeddingMixin, Pooler, select_hidden_state_embeddings
47
+ from fastplms.models.ttt import FastPLMTestTimeTrainingMixin
48
+ except ModuleNotFoundError as error:
49
+ _COMPOSITE_REQUIRED_NAMES = (
50
+ "AttentionBackend",
51
+ "BlockMask",
52
+ "EmbeddingMixin",
53
+ "FastPLMsAttentionMixin",
54
+ "FastPLMTestTimeTrainingMixin",
55
+ "Pooler",
56
+ "_get_flex_attention_fn",
57
+ "_get_flex_block_mask",
58
+ "flex_attention",
59
+ "get_attention_mask",
60
+ "kernels_flash_attention_func",
61
+ "resolve_attention_backend",
62
+ "resolve_attention_backend_for_call",
63
+ "select_hidden_state_embeddings",
64
+ )
65
+ if error.name != "fastplms" or any(
66
+ name not in globals() for name in _COMPOSITE_REQUIRED_NAMES
67
+ ):
68
+ raise
69
+ # Legacy flat Hub composites define every shared symbol above this block.
70
+
71
+
72
+ _ESMC_FP8_LINEAR_SUFFIX = ".attn.out_proj"
73
+ _ESMC_FP8_ALIGNMENT = 16
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class ESMplusplusFP8Status:
78
+ """Resolved state of the explicit Transformer Engine ESMC FP8 path."""
79
+
80
+ enabled: bool
81
+ reason: str
82
+ device: str
83
+ transformer_engine_version: str | None
84
+ converted_projections: int
85
+
86
+ def as_dict(self) -> dict[str, str | int | bool | None]:
87
+ return asdict(self)
88
+
89
+
90
+ def _transformer_engine_version() -> str | None:
91
+ try:
92
+ return importlib.metadata.version("transformer-engine")
93
+ except importlib.metadata.PackageNotFoundError:
94
+ return None
95
+
96
+
97
+ def _load_transformer_engine() -> tuple[Any, Any]:
98
+ """Load Transformer Engine lazily so ordinary ESM++ imports stay portable."""
99
+
100
+ try:
101
+ te = importlib.import_module("transformer_engine.pytorch")
102
+ recipe = importlib.import_module("transformer_engine.common.recipe")
103
+ except (ImportError, OSError, RuntimeError) as error:
104
+ raise RuntimeError(
105
+ f"Transformer Engine could not be imported: {type(error).__name__}: {error}"
106
+ ) from error
107
+ if not hasattr(recipe, "Float8CurrentScaling"):
108
+ raise RuntimeError(
109
+ "Transformer Engine does not expose Float8CurrentScaling, which is "
110
+ "required by the validated ESMC FP8 path."
111
+ )
112
+ return te, recipe
113
+
114
+
115
+ def _te_fp8_capability(device: torch.device) -> tuple[bool, str]:
116
+ """Return whether the strict Transformer Engine FP8 path can run."""
117
+
118
+ if device.type != "cuda":
119
+ return False, "FP8 requires ESM++ on a CUDA device."
120
+ if not torch.cuda.is_available():
121
+ return False, "CUDA is unavailable."
122
+ try:
123
+ major, minor = torch.cuda.get_device_capability(device)
124
+ except (AssertionError, RuntimeError, ValueError) as error:
125
+ return False, f"CUDA capability query failed: {error}"
126
+ if not (major >= 9 or (major == 8 and minor >= 9)):
127
+ return False, f"CUDA capability {major}.{minor} does not support FP8."
128
+ try:
129
+ te, _ = _load_transformer_engine()
130
+ except RuntimeError as error:
131
+ return False, str(error)
132
+
133
+ probe = getattr(te, "is_fp8_available", None)
134
+ if probe is None:
135
+ try:
136
+ probe = importlib.import_module("transformer_engine.pytorch.fp8").is_fp8_available
137
+ except (ImportError, AttributeError, OSError, RuntimeError) as error:
138
+ return False, f"Transformer Engine has no usable FP8 probe: {error}"
139
+ try:
140
+ try:
141
+ result = probe(return_reason=True)
142
+ except TypeError:
143
+ result = probe()
144
+ except (OSError, RuntimeError) as error:
145
+ return False, f"Transformer Engine FP8 probe failed: {error}"
146
+ if isinstance(result, tuple):
147
+ available = bool(result[0])
148
+ detail = str(result[1]) if len(result) > 1 and result[1] else ""
149
+ else:
150
+ available = bool(result)
151
+ detail = ""
152
+ if not available:
153
+ return False, detail or "Transformer Engine reports FP8 unavailable."
154
+ return True, "Transformer Engine reports FP8 availability."
155
+
156
+
157
+ def _convert_esmc_attention_outputs_to_te(
158
+ module: nn.Module,
159
+ *,
160
+ expected_projections: int,
161
+ ) -> tuple[str, ...]:
162
+ """Replace exactly one attention output projection per ESMC block."""
163
+
164
+ targets = [
165
+ (path, child)
166
+ for path, child in module.named_modules()
167
+ if isinstance(child, nn.Linear) and path.endswith(_ESMC_FP8_LINEAR_SUFFIX)
168
+ ]
169
+ if len(targets) != expected_projections:
170
+ raise RuntimeError(
171
+ "ESMC FP8 conversion expected exactly "
172
+ f"{expected_projections} attention output projections, found {len(targets)}."
173
+ )
174
+
175
+ te, _ = _load_transformer_engine()
176
+ modules = dict(module.named_modules())
177
+ converted: list[str] = []
178
+ for path, child in targets:
179
+ owner_path, name = path.rsplit(".", 1)
180
+ owner = modules[owner_path]
181
+ replacement = te.Linear(
182
+ child.in_features,
183
+ child.out_features,
184
+ bias=child.bias is not None,
185
+ params_dtype=child.weight.dtype,
186
+ device=child.weight.device,
187
+ )
188
+ with torch.no_grad():
189
+ replacement.weight.copy_(child.weight)
190
+ if child.bias is not None:
191
+ replacement.bias.copy_(child.bias)
192
+ replacement.eval().requires_grad_(False)
193
+ setattr(owner, name, replacement)
194
+ converted.append(path)
195
+ return tuple(converted)
196
+
197
+
198
+ @contextmanager
199
+ def _esmplusplus_fp8_context(enabled: bool, device: torch.device):
200
+ """Enter the validated BF16-storage, Transformer Engine FP8 context."""
201
+
202
+ if not enabled:
203
+ yield
204
+ return
205
+ if torch.is_grad_enabled():
206
+ raise RuntimeError("ESM++ FP8 is inference-only; use torch.inference_mode() or no_grad().")
207
+ te, recipe = _load_transformer_engine()
208
+ fp8_recipe = recipe.Float8CurrentScaling(
209
+ use_power_2_scales=False,
210
+ fp8_format=recipe.Format.HYBRID,
211
+ )
212
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16):
213
+ with te.autocast(enabled=True, recipe=fp8_recipe):
214
+ yield
215
+
216
+
217
+ class ESMplusplusConfig(PretrainedConfig):
218
+ """Configuration class for ESM++ model.
219
+
220
+ Args:
221
+ vocab_size: Size of the vocabulary
222
+ hidden_size: Dimension of hidden layers
223
+ num_attention_heads: Number of attention heads
224
+ num_hidden_layers: Number of transformer layers
225
+ num_labels: Number of output labels for classification
226
+ problem_type: Type of problem - regression, single/multi label classification
227
+ """
228
+
229
+ model_type = "ESMplusplus"
230
+
231
+ def __init__(
232
+ self,
233
+ vocab_size: int = 64,
234
+ hidden_size: int = 960,
235
+ num_attention_heads: int = 15,
236
+ num_hidden_layers: int = 30,
237
+ num_labels: int | None = None,
238
+ problem_type: str | None = None,
239
+ dropout: float = 0.0,
240
+ initializer_range: float = 0.02,
241
+ classifier_dropout: float = 0.1,
242
+ classifier_pooling_types: list[str] | None = None,
243
+ attn_backend: str | None = None,
244
+ pad_token_id: int = 1,
245
+ mask_token_id: int = 32,
246
+ **kwargs,
247
+ ):
248
+ if num_labels is None:
249
+ configured_labels = kwargs.get("id2label")
250
+ num_labels = len(configured_labels) if configured_labels else 2
251
+ super().__init__(
252
+ pad_token_id=pad_token_id,
253
+ mask_token_id=mask_token_id,
254
+ num_labels=num_labels,
255
+ **kwargs,
256
+ )
257
+ self.vocab_size = vocab_size
258
+ self.hidden_size = hidden_size
259
+ self.num_attention_heads = num_attention_heads
260
+ self.num_hidden_layers = num_hidden_layers
261
+ self.problem_type = problem_type
262
+ self.dropout = dropout
263
+ self.initializer_range = initializer_range
264
+ self.classifier_dropout = classifier_dropout
265
+ self.classifier_pooling_types = (
266
+ list(classifier_pooling_types) if classifier_pooling_types is not None else None
267
+ )
268
+ self.tie_word_embeddings = False
269
+ self.attn_backend = attn_backend
270
+
271
+
272
+ ### Rotary Embeddings
273
+ def rotate_half(x: torch.Tensor, interleaved: bool = False) -> torch.Tensor:
274
+ """Rotate the final axis of X by 90 degrees in each two-dimensional plane."""
275
+ if interleaved:
276
+ paired = x.unflatten(-1, (-1, 2))
277
+ return torch.stack((-paired[..., 1], paired[..., 0]), dim=-1).flatten(-2)
278
+
279
+ # torch.chunk assigns an odd remainder to the first half. Express the same
280
+ # public behavior explicitly while keeping the ESMC path branch-free.
281
+ midpoint = (x.shape[-1] + 1) // 2
282
+ return torch.cat((-x[..., midpoint:], x[..., :midpoint]), dim=-1)
283
+
284
+
285
+ def apply_rotary_emb_torch(
286
+ x: torch.Tensor,
287
+ cos: torch.Tensor,
288
+ sin: torch.Tensor,
289
+ interleaved: bool = False,
290
+ _inplace: bool = False,
291
+ ) -> torch.Tensor:
292
+ """Apply cached rotary angles to X while preserving any unrotated features."""
293
+ del _inplace # Kept in the signature for checkpoint remote-code compatibility.
294
+ rotary_width = 2 * cos.shape[-1]
295
+ if rotary_width > x.shape[-1]:
296
+ raise AssertionError("rotary width exceeds the attention head dimension")
297
+
298
+ token_count = x.shape[1]
299
+ cos_full = torch.cat((cos[:token_count], cos[:token_count]), dim=-1).unsqueeze(1)
300
+ sin_full = torch.cat((sin[:token_count], sin[:token_count]), dim=-1).unsqueeze(1)
301
+ x_rotary = x[..., :rotary_width]
302
+ y_rotary = x_rotary * cos_full + rotate_half(x_rotary, interleaved) * sin_full
303
+ if rotary_width == x.shape[-1]:
304
+ return y_rotary
305
+ return torch.cat((y_rotary, x[..., rotary_width:]), dim=-1)
306
+
307
+
308
+ class RotaryEmbedding(torch.nn.Module):
309
+ """Rotary position embeddings.
310
+
311
+ Based on the paper "RoFormer: Enhanced Transformer with Rotary Position Embedding"
312
+
313
+ Args:
314
+ dim: Dimension of the embedding
315
+ base: Base for computing angular frequencies
316
+ interleaved: Whether to use interleaved rotations
317
+ scale_base: Base for scaling
318
+ scaling_factor: Factor for scaling positions
319
+ pos_idx_in_fp32: Whether to compute position indices in fp32
320
+ device: Computation device
321
+ """
322
+
323
+ def __init__(
324
+ self,
325
+ dim: int,
326
+ base: float = 10000.0,
327
+ interleaved: bool = False,
328
+ scale_base: float | None = None,
329
+ scaling_factor: float = 1.0,
330
+ pos_idx_in_fp32: bool = True,
331
+ device: torch.device | None = None,
332
+ ) -> None:
333
+ super().__init__()
334
+ self.dim, self.base = dim, float(base)
335
+ self.interleaved, self.scale_base = interleaved, scale_base
336
+ self.scaling_factor, self.pos_idx_in_fp32 = scaling_factor, pos_idx_in_fp32
337
+ self.device = device
338
+ self._clear_cache()
339
+ self.reset_parameters()
340
+
341
+ def _clear_cache(self) -> None:
342
+ self._seq_len_cached = 0
343
+ self._cos_cached: torch.Tensor | None = None
344
+ self._sin_cached: torch.Tensor | None = None
345
+ self._cos_k_cached: torch.Tensor | None = None
346
+ self._sin_k_cached: torch.Tensor | None = None
347
+
348
+ def reset_parameters(self, device: torch.device | str | None = None) -> None:
349
+ """Rebuild the non-persistent frequency buffers on ``device``."""
350
+ if device is not None:
351
+ buffer_device = torch.device(device)
352
+ elif "inv_freq" in self._buffers and isinstance(self._buffers["inv_freq"], torch.Tensor):
353
+ buffer_device = self._buffers["inv_freq"].device
354
+ else:
355
+ buffer_device = self.device
356
+ inv_freq = self._compute_inv_freq(buffer_device)
357
+ self._clear_cache()
358
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
359
+ arange = torch.arange(0, self.dim, 2, device=buffer_device, dtype=torch.float32)
360
+ scale = (
361
+ (arange + 0.4 * self.dim) / (1.4 * self.dim) if self.scale_base is not None else None
362
+ )
363
+ self.register_buffer("scale", scale)
364
+
365
+ def _compute_inv_freq(self, device: torch.device | None = None) -> torch.Tensor:
366
+ """Compute inverse frequency bands on their execution device."""
367
+ return 1 / (
368
+ self.base
369
+ ** (torch.arange(0, self.dim, 2, device=device, dtype=torch.float32) / self.dim)
370
+ )
371
+
372
+ def _apply(self, fn, recurse: bool = True):
373
+ """Move the module, then regenerate device-specific RoPE frequencies."""
374
+ if self.inv_freq.is_meta:
375
+ self.reset_parameters(device="cpu")
376
+ result = super()._apply(fn, recurse=recurse)
377
+ self.register_buffer(
378
+ "inv_freq",
379
+ self._compute_inv_freq(self.inv_freq.device),
380
+ persistent=False,
381
+ )
382
+ self._clear_cache()
383
+ return result
384
+
385
+ def _cache_is_current(
386
+ self,
387
+ token_count: int,
388
+ device: torch.device | None,
389
+ dtype: torch.dtype | None,
390
+ ) -> bool:
391
+ cached = self._cos_cached
392
+ return (
393
+ cached is not None
394
+ and self._seq_len_cached >= token_count
395
+ and cached.device == device
396
+ and cached.dtype == dtype
397
+ and not (self.training and cached.is_inference())
398
+ )
399
+
400
+ def _rotary_angles(
401
+ self,
402
+ token_count: int,
403
+ device: torch.device | None,
404
+ ) -> torch.Tensor:
405
+ position_dtype = torch.float32 if self.pos_idx_in_fp32 else self.inv_freq.dtype
406
+ positions = torch.arange(token_count, device=device, dtype=position_dtype) # (l,)
407
+ positions.div_(self.scaling_factor)
408
+ frequencies = (
409
+ self.inv_freq.to(torch.float32)
410
+ if self.pos_idx_in_fp32 and self.inv_freq.dtype != torch.float32
411
+ else self.inv_freq
412
+ )
413
+ return torch.outer(positions, frequencies) # (l, d / 2)
414
+
415
+ def _update_cos_sin_cache(
416
+ self, seqlen: int, device: torch.device | None = None, dtype: torch.dtype | None = None
417
+ ) -> None:
418
+ """Build angle tables when the requested cache identity has changed."""
419
+ if self._cache_is_current(seqlen, device, dtype):
420
+ return
421
+
422
+ self._seq_len_cached = seqlen
423
+ angles = self._rotary_angles(seqlen, device) # (l, d / 2)
424
+ cos_angles = torch.cos(angles) # (l, d / 2)
425
+ sin_angles = torch.sin(angles) # (l, d / 2)
426
+ if self.scale is None:
427
+ self._cos_cached = cos_angles.to(dtype)
428
+ self._sin_cached = sin_angles.to(dtype)
429
+ return
430
+
431
+ centered_positions = (
432
+ torch.arange(seqlen, dtype=self.scale.dtype, device=self.scale.device) - seqlen // 2
433
+ ) / self.scale_base
434
+ scale = self.scale ** centered_positions.unsqueeze(-1)
435
+ self._cos_cached = (cos_angles * scale).to(dtype)
436
+ self._sin_cached = (sin_angles * scale).to(dtype)
437
+ self._cos_k_cached = (cos_angles / scale).to(dtype)
438
+ self._sin_k_cached = (sin_angles / scale).to(dtype)
439
+
440
+ def forward(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
441
+ """Apply rotary embeddings to queries and keys.
442
+
443
+ Args:
444
+ q: Query tensor Q with shape (b, l, h, d).
445
+ k: Key tensor K with shape (b, l, h, d).
446
+
447
+ Returns:
448
+ Tuple of rotated query and key tensors
449
+ """
450
+ # The pinned Biohub Transformers oracle recomputes inverse frequencies
451
+ # on the execution device. CPU and CUDA differ by about one FP32 ULP in
452
+ # some bands, which is immaterial in BF16 but accumulates measurably in
453
+ # deep FP32 execution.
454
+ self._update_cos_sin_cache(q.shape[1], device=q.device, dtype=q.dtype)
455
+ if self._cos_cached is None or self._sin_cached is None:
456
+ raise RuntimeError(
457
+ "Rotary cache initialization did not produce cosine and sine values."
458
+ )
459
+ if self.scale is not None:
460
+ raise AssertionError("Scaled rotary embeddings are unsupported for ESMC.")
461
+
462
+ cos_angles = self._cos_cached
463
+ sin_angles = self._sin_cached
464
+ return (
465
+ apply_rotary_emb_torch(q, cos_angles, sin_angles, self.interleaved, True),
466
+ apply_rotary_emb_torch(k, cos_angles, sin_angles, self.interleaved, True),
467
+ )
468
+
469
+
470
+ def swiglu_correction_fn(expansion_ratio: float, d_model: int) -> int:
471
+ """Compute corrected dimension for SwiGLU."""
472
+ return int(((expansion_ratio * d_model) + 255) // 256 * 256)
473
+
474
+
475
+ class SwiGLU(nn.Module):
476
+ """SwiGLU activation function."""
477
+
478
+ def __init__(self) -> None:
479
+ super().__init__()
480
+
481
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
482
+ x1, x2 = x.chunk(2, dim=-1)
483
+ return F.silu(x1) * x2
484
+
485
+
486
+ def swiglu_ln_ffn(d_model: int, expansion_ratio: float) -> nn.Sequential:
487
+ """Create SwiGLU feedforward network with layer normalization."""
488
+ return nn.Sequential(
489
+ nn.LayerNorm(d_model),
490
+ nn.Linear(d_model, swiglu_correction_fn(expansion_ratio, d_model) * 2, bias=False),
491
+ SwiGLU(),
492
+ nn.Linear(swiglu_correction_fn(expansion_ratio, d_model), d_model, bias=False),
493
+ )
494
+
495
+
496
+ class MultiHeadAttention(nn.Module):
497
+ """Multi-head attention with rotary embeddings and configurable backend.
498
+
499
+ Args:
500
+ d_model: Model dimension
501
+ n_heads: Number of attention heads
502
+ attn_backend: One of "eager", "sdpa", or "flex_attention".
503
+ """
504
+
505
+ def __init__(
506
+ self,
507
+ d_model: int,
508
+ n_heads: int,
509
+ attn_backend: str = "sdpa",
510
+ ) -> None:
511
+ super().__init__()
512
+ self.d_model = d_model
513
+ self.n_heads = n_heads
514
+ self.d_head = self.d_model // self.n_heads
515
+ self.scale = 1.0 / math.sqrt(self.d_head)
516
+ self.attn_backend = resolve_attention_backend(attn_backend)
517
+ self.layernorm_qkv = nn.Sequential(
518
+ nn.LayerNorm(d_model), nn.Linear(d_model, d_model * 3, bias=False)
519
+ )
520
+ self.out_proj = nn.Linear(d_model, d_model, bias=False)
521
+ self.q_ln = nn.LayerNorm(d_model, bias=False)
522
+ self.k_ln = nn.LayerNorm(d_model, bias=False)
523
+ self.reshaper = partial(rearrange, pattern="b s (h d) -> b h s d", h=n_heads)
524
+ self.rotary = RotaryEmbedding(d_model // n_heads)
525
+
526
+ def _apply_rotary(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
527
+ # q, k: (b, l, d)
528
+ q = q.unflatten(-1, (self.n_heads, self.d_head)) # (b, l, h, d_h)
529
+ k = k.unflatten(-1, (self.n_heads, self.d_head)) # (b, l, h, d_h)
530
+ q, k = self.rotary(q, k)
531
+ q = q.flatten(-2, -1)
532
+ k = k.flatten(-2, -1)
533
+ return q, k
534
+
535
+ def forward(
536
+ self,
537
+ x: torch.Tensor,
538
+ attention_mask_2d: torch.Tensor | None = None,
539
+ attention_mask_4d: torch.Tensor | None = None,
540
+ flex_block_mask: BlockMask | None = None,
541
+ output_attentions: bool = False,
542
+ output_s_max: bool = False,
543
+ ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
544
+ # x: (b, l, d)
545
+ qkv = self.layernorm_qkv(x) # (b, l, 3 * d)
546
+ query_sequence, key_sequence, value_sequence = torch.chunk(qkv, 3, dim=-1)
547
+ query_sequence, key_sequence = (
548
+ self.q_ln(query_sequence).to(query_sequence.dtype),
549
+ self.k_ln(key_sequence).to(query_sequence.dtype),
550
+ )
551
+ query_sequence, key_sequence = self._apply_rotary(query_sequence, key_sequence)
552
+ query_heads, key_heads, value_heads = map(
553
+ self.reshaper, (query_sequence, key_sequence, value_sequence)
554
+ ) # each (b, h, l, d_h)
555
+
556
+ attn_output, attn_weights, s_max = self._attn(
557
+ query_heads,
558
+ key_heads,
559
+ value_heads,
560
+ attention_mask_2d=attention_mask_2d,
561
+ attention_mask_4d=attention_mask_4d,
562
+ flex_block_mask=flex_block_mask,
563
+ output_attentions=output_attentions,
564
+ output_s_max=output_s_max,
565
+ )
566
+
567
+ output = self.out_proj(attn_output)
568
+ return output, attn_weights, s_max
569
+
570
+ def _attn(
571
+ self,
572
+ query_heads: torch.Tensor,
573
+ key_heads: torch.Tensor,
574
+ value_heads: torch.Tensor,
575
+ attention_mask_2d: torch.Tensor | None = None,
576
+ attention_mask_4d: torch.Tensor | None = None,
577
+ flex_block_mask: BlockMask | None = None,
578
+ output_attentions: bool = False,
579
+ output_s_max: bool = False,
580
+ ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
581
+ if output_attentions:
582
+ return self._manual_attn(
583
+ query_heads, key_heads, value_heads, attention_mask_4d, output_s_max
584
+ )
585
+
586
+ if self.attn_backend == AttentionBackend.EAGER:
587
+ attn_output, _, s_max = self._manual_attn(
588
+ query_heads, key_heads, value_heads, attention_mask_4d, output_s_max
589
+ )
590
+ return attn_output, None, s_max
591
+ if self.attn_backend.is_flash:
592
+ attn_output, attn_weights = self._kernels_flash_attn(
593
+ query_heads, key_heads, value_heads, attention_mask_2d
594
+ )
595
+ elif self.attn_backend == AttentionBackend.FLEX:
596
+ attn_output, attn_weights = self._flex_attn(
597
+ query_heads,
598
+ key_heads,
599
+ value_heads,
600
+ flex_block_mask,
601
+ attention_mask_2d,
602
+ )
603
+ elif self.attn_backend == AttentionBackend.SDPA:
604
+ attn_output, attn_weights = self._sdpa_attn(
605
+ query_heads, key_heads, value_heads, attention_mask_4d
606
+ )
607
+ else:
608
+ raise AssertionError(f"Unsupported resolved backend: {self.attn_backend}")
609
+
610
+ s_max = self._compute_s_max(query_heads, key_heads) if output_s_max else None
611
+ return attn_output, attn_weights, s_max
612
+
613
+ @torch.no_grad()
614
+ def _compute_s_max(
615
+ self, query_heads: torch.Tensor, key_heads: torch.Tensor
616
+ ) -> list[torch.Tensor]:
617
+ q_norm = torch.linalg.vector_norm(query_heads, dim=-1) # (b, h, l)
618
+ k_norm = torch.linalg.vector_norm(key_heads, dim=-1) # (b, h, l)
619
+ s_max_bound = (q_norm.max(dim=-1).values * k_norm.max(dim=-1).values).max(
620
+ dim=0
621
+ ).values * self.scale
622
+ return [s_max_bound[h] for h in range(self.n_heads)]
623
+
624
+ def _manual_attn(
625
+ self,
626
+ query_heads: torch.Tensor,
627
+ key_heads: torch.Tensor,
628
+ value_heads: torch.Tensor,
629
+ attention_mask_4d: torch.Tensor | None = None,
630
+ output_s_max: bool = False,
631
+ ) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor] | None]:
632
+ # query_heads, key_heads, value_heads: (b, h, l, d_h)
633
+ attn_weights = (
634
+ torch.matmul(query_heads, key_heads.transpose(-2, -1)) * self.scale
635
+ ) # (b, h, l, l)
636
+ if attention_mask_4d is not None:
637
+ attn_weights = attn_weights.masked_fill(attention_mask_4d.logical_not(), float("-inf"))
638
+ attn_weights = F.softmax(attn_weights, dim=-1)
639
+ context_heads = torch.matmul(attn_weights, value_heads) # (b, h, l, d_h)
640
+ attn_output = rearrange(context_heads, "b h s d -> b s (h d)") # (b, l, d)
641
+ s_max = self._compute_s_max(query_heads, key_heads) if output_s_max else None
642
+ return attn_output, attn_weights, s_max
643
+
644
+ def _kernels_flash_attn(
645
+ self,
646
+ query_heads: torch.Tensor,
647
+ key_heads: torch.Tensor,
648
+ value_heads: torch.Tensor,
649
+ attention_mask_2d: torch.Tensor | None = None,
650
+ ) -> tuple[torch.Tensor, None]:
651
+ query_tokens = query_heads.transpose(1, 2).contiguous()
652
+ key_tokens = key_heads.transpose(1, 2).contiguous()
653
+ value_tokens = value_heads.transpose(1, 2).contiguous()
654
+ attn_output = kernels_flash_attention_func(
655
+ query_states=query_tokens,
656
+ key_states=key_tokens,
657
+ value_states=value_tokens,
658
+ attention_mask_2d=attention_mask_2d,
659
+ causal=False,
660
+ implementation=self.attn_backend.value,
661
+ )
662
+ return rearrange(attn_output, "b s h d -> b s (h d)"), None
663
+
664
+ def _flex_attn(
665
+ self,
666
+ query_heads: torch.Tensor,
667
+ key_heads: torch.Tensor,
668
+ value_heads: torch.Tensor,
669
+ flex_block_mask: BlockMask | None = None,
670
+ attention_mask_2d: torch.Tensor | None = None,
671
+ ) -> tuple[torch.Tensor, None]:
672
+ if flex_attention is None:
673
+ raise RuntimeError("Flex attention is not available in this environment.")
674
+ fn = _get_flex_attention_fn(
675
+ device=query_heads.device,
676
+ dtype=query_heads.dtype,
677
+ shape=tuple(query_heads.shape),
678
+ mask_semantics="padding",
679
+ )
680
+ context_heads = fn(
681
+ query_heads,
682
+ key_heads,
683
+ value_heads,
684
+ block_mask=flex_block_mask,
685
+ scale=self.scale,
686
+ kernel_options={"PRESCALE_QK": True, "BLOCK_N": 32},
687
+ )
688
+ return rearrange(context_heads, "b h s d -> b s (h d)"), None
689
+
690
+ def _sdpa_attn(
691
+ self,
692
+ query_heads: torch.Tensor,
693
+ key_heads: torch.Tensor,
694
+ value_heads: torch.Tensor,
695
+ attention_mask_4d: torch.Tensor | None = None,
696
+ ) -> tuple[torch.Tensor, None]:
697
+ context_heads = F.scaled_dot_product_attention(
698
+ query_heads,
699
+ key_heads,
700
+ value_heads,
701
+ attn_mask=attention_mask_4d,
702
+ scale=self.scale,
703
+ )
704
+ return rearrange(context_heads, "b h s d -> b s (h d)"), None
705
+
706
+
707
+ def RegressionHead(d_model: int, output_dim: int, hidden_dim: int | None = None) -> nn.Module:
708
+ """Create a regression head with optional hidden dimension.
709
+
710
+ Args:
711
+ d_model: Input dimension
712
+ output_dim: Output dimension
713
+ hidden_dim: Optional hidden dimension (defaults to d_model)
714
+ """
715
+ hidden_dim = hidden_dim if hidden_dim is not None else d_model
716
+ return nn.Sequential(
717
+ nn.Linear(d_model, hidden_dim),
718
+ nn.GELU(),
719
+ nn.LayerNorm(hidden_dim),
720
+ nn.Linear(hidden_dim, output_dim),
721
+ )
722
+
723
+
724
+ class UnifiedTransformerBlock(nn.Module):
725
+ """Transformer block with attention and feedforward layers."""
726
+
727
+ def __init__(
728
+ self,
729
+ d_model: int,
730
+ n_heads: int,
731
+ residue_scaling_factor: float = 1,
732
+ expansion_ratio: float = 8 / 3,
733
+ dropout: float = 0.0,
734
+ attn_backend: str = "sdpa",
735
+ ) -> None:
736
+ super().__init__()
737
+ self.attn = MultiHeadAttention(d_model=d_model, n_heads=n_heads, attn_backend=attn_backend)
738
+ self.ffn = swiglu_ln_ffn(d_model, expansion_ratio)
739
+ self.scaling_factor = residue_scaling_factor
740
+ self.dropout = nn.Dropout(dropout)
741
+
742
+ def forward(
743
+ self,
744
+ x: torch.Tensor,
745
+ attention_mask_2d: torch.Tensor | None = None,
746
+ attention_mask_4d: torch.Tensor | None = None,
747
+ flex_block_mask: BlockMask | None = None,
748
+ output_attentions: bool = False,
749
+ output_s_max: bool = False,
750
+ ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
751
+ attn_output, attn_weights, s_max = self.attn(
752
+ x,
753
+ attention_mask_2d=attention_mask_2d,
754
+ attention_mask_4d=attention_mask_4d,
755
+ flex_block_mask=flex_block_mask,
756
+ output_attentions=output_attentions,
757
+ output_s_max=output_s_max,
758
+ )
759
+ x = x + self.dropout(attn_output) / self.scaling_factor
760
+ x = x + self.dropout(self.ffn(x)) / self.scaling_factor
761
+ return x, attn_weights, s_max
762
+
763
+
764
+ @dataclass
765
+ class TransformerOutput(ModelOutput):
766
+ """Output type for transformer encoder."""
767
+
768
+ last_hidden_state: torch.Tensor | None = None
769
+ hidden_states: tuple[torch.Tensor] | None = None
770
+ attentions: tuple[torch.Tensor] | None = None
771
+ s_max: tuple[list[torch.Tensor], ...] | None = None
772
+ sae_outputs: dict[str, torch.Tensor] | None = None
773
+ sae_hidden_states: dict[int, torch.Tensor] | None = None
774
+
775
+
776
+ @dataclass
777
+ class ESMplusplusOutput(MaskedLMOutput):
778
+ """Masked-LM output with FastPLMs fields after the HF contract."""
779
+
780
+ s_max: tuple[list[torch.Tensor], ...] | None = None
781
+ last_hidden_state: torch.Tensor | None = None
782
+ sae_outputs: dict[str, torch.Tensor] | None = None
783
+
784
+
785
+ @dataclass
786
+ class ESMplusplusSequenceClassifierOutput(SequenceClassifierOutput):
787
+ """Sequence-classification output with optional attention diagnostics."""
788
+
789
+ s_max: tuple[list[torch.Tensor], ...] | None = None
790
+ sae_outputs: dict[str, torch.Tensor] | None = None
791
+
792
+
793
+ @dataclass
794
+ class ESMplusplusTokenClassifierOutput(TokenClassifierOutput):
795
+ """Token-classification output with optional attention diagnostics."""
796
+
797
+ s_max: tuple[list[torch.Tensor], ...] | None = None
798
+ sae_outputs: dict[str, torch.Tensor] | None = None
799
+
800
+
801
+ class TransformerStack(nn.Module):
802
+ """Stack of transformer blocks."""
803
+
804
+ def __init__(
805
+ self,
806
+ d_model: int,
807
+ n_heads: int,
808
+ n_layers: int,
809
+ dropout: float = 0.0,
810
+ attn_backend: str = "sdpa",
811
+ ) -> None:
812
+ super().__init__()
813
+ self.attention_backend = resolve_attention_backend(attn_backend)
814
+ self.blocks = nn.ModuleList(
815
+ [
816
+ UnifiedTransformerBlock(
817
+ d_model,
818
+ n_heads,
819
+ residue_scaling_factor=math.sqrt(n_layers / 36),
820
+ dropout=dropout,
821
+ attn_backend=attn_backend,
822
+ )
823
+ for i in range(n_layers)
824
+ ]
825
+ )
826
+ self.norm = nn.LayerNorm(d_model, bias=False)
827
+ self.gradient_checkpointing = False
828
+
829
+ @property
830
+ def attn_backend(self) -> AttentionBackend:
831
+ return self.attention_backend
832
+
833
+ @attn_backend.setter
834
+ def attn_backend(self, backend: str) -> None:
835
+ resolved = resolve_attention_backend(backend)
836
+ self.attention_backend = resolved
837
+ for block in self.blocks:
838
+ block.attn.attn_backend = resolved
839
+
840
+ def forward(
841
+ self,
842
+ x: torch.Tensor,
843
+ attention_mask: torch.Tensor | None = None,
844
+ sequence_id: torch.Tensor | None = None,
845
+ output_hidden_states: bool | None = False,
846
+ output_attentions: bool | None = False,
847
+ output_s_max: bool | None = False,
848
+ esmfold2_hidden_states: bool = False,
849
+ sae_layers: tuple[int, ...] = (),
850
+ ) -> TransformerOutput:
851
+ # x: (b, l, d); attention_mask, sequence_id: (b, l)
852
+ hidden_states = () if output_hidden_states else None
853
+ attentions = () if output_attentions else None
854
+ full_s_max = () if output_s_max else None
855
+ sae_layer_set = set(sae_layers)
856
+ sae_hidden_states = {} if sae_layer_set else None
857
+ # Match the pinned Biohub Transformers contract: a supplied sequence_id
858
+ # is authoritative and must encode padding as -1. attention_mask is
859
+ # ignored in that mode rather than intersected with the chain mask.
860
+ attention_mask_2d, attention_mask_4d, flex_block_mask = (
861
+ self._prepare_attention_masks(
862
+ attention_mask=attention_mask,
863
+ sequence_id=sequence_id,
864
+ batch_size=x.shape[0],
865
+ seq_len=x.shape[1],
866
+ device=x.device,
867
+ dtype=x.dtype,
868
+ output_attentions=bool(output_attentions),
869
+ )
870
+ )
871
+
872
+ for layer_index, block in enumerate(self.blocks):
873
+ if output_hidden_states:
874
+ if hidden_states is None:
875
+ raise RuntimeError(
876
+ "Hidden-state collection was not initialized for an enabled request."
877
+ )
878
+ # Biohub Transformers records the input to each block followed
879
+ # by the final normalized state. This gives n_layers + 1 states
880
+ # and, for ESMC-6B, the 81-state order consumed by ESMFold2.
881
+ hidden_states += (x,)
882
+ if sae_hidden_states is not None and layer_index in sae_layer_set:
883
+ sae_hidden_states[layer_index] = x
884
+ if self.gradient_checkpointing and self.training:
885
+ x, attn_weights, s_max = self._gradient_checkpointing_func(
886
+ block.__call__,
887
+ x=x,
888
+ attention_mask_2d=attention_mask_2d,
889
+ attention_mask_4d=attention_mask_4d,
890
+ flex_block_mask=flex_block_mask,
891
+ output_attentions=output_attentions,
892
+ output_s_max=output_s_max,
893
+ )
894
+ else:
895
+ x, attn_weights, s_max = block(
896
+ x=x,
897
+ attention_mask_2d=attention_mask_2d,
898
+ attention_mask_4d=attention_mask_4d,
899
+ flex_block_mask=flex_block_mask,
900
+ output_attentions=output_attentions,
901
+ output_s_max=output_s_max,
902
+ )
903
+
904
+ if attentions is not None:
905
+ attentions += (attn_weights,)
906
+ if full_s_max is not None:
907
+ full_s_max += (s_max,)
908
+
909
+ last_hidden_state = self.norm(x)
910
+ if output_hidden_states:
911
+ hidden_states += (last_hidden_state,)
912
+ final_layer_index = len(self.blocks)
913
+ if sae_hidden_states is not None and final_layer_index in sae_layer_set:
914
+ sae_hidden_states[final_layer_index] = last_hidden_state
915
+
916
+ return TransformerOutput(
917
+ last_hidden_state=last_hidden_state,
918
+ hidden_states=hidden_states,
919
+ attentions=attentions,
920
+ s_max=full_s_max,
921
+ sae_hidden_states=sae_hidden_states,
922
+ )
923
+
924
+ @torch.compiler.disable
925
+ def _prepare_attention_masks(
926
+ self,
927
+ attention_mask: torch.Tensor | None,
928
+ sequence_id: torch.Tensor | None,
929
+ batch_size: int,
930
+ seq_len: int,
931
+ device: torch.device,
932
+ dtype: torch.dtype | None = None,
933
+ effective_backend: AttentionBackend | None = None,
934
+ output_attentions: bool = False,
935
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None, BlockMask | None]:
936
+ mask_name = "sequence_id" if sequence_id is not None else "attention_mask"
937
+ mask_pattern = sequence_id if sequence_id is not None else attention_mask
938
+ if mask_pattern is None:
939
+ backend = resolve_attention_backend_for_call(
940
+ self.attention_backend,
941
+ output_attentions=output_attentions,
942
+ )
943
+ return get_attention_mask(
944
+ effective_backend=backend,
945
+ batch_size=batch_size,
946
+ seq_len=seq_len,
947
+ device=device,
948
+ attention_mask=None,
949
+ dtype=dtype,
950
+ mask_semantics="padding",
951
+ )
952
+
953
+ expected_shape = (batch_size, seq_len)
954
+ if mask_pattern.ndim != 2 or tuple(mask_pattern.shape) != expected_shape:
955
+ raise ValueError(
956
+ f"{mask_name} must have shape {expected_shape}; "
957
+ f"received {tuple(mask_pattern.shape)}."
958
+ )
959
+ if mask_pattern.device != device:
960
+ mask_pattern = mask_pattern.to(device=device)
961
+ if sequence_id is None:
962
+ mask_pattern = mask_pattern.to(dtype=torch.bool)
963
+ attention_mask_2d = (
964
+ mask_pattern if mask_pattern.dtype == torch.bool else mask_pattern != -1
965
+ )
966
+ if not bool(attention_mask_2d.any(dim=1).all()):
967
+ raise ValueError("attention_mask must keep at least one valid key per batch row.")
968
+
969
+ if mask_pattern.dtype == torch.bool:
970
+ # Biohub's boolean single-chain form groups biological positions
971
+ # together and padding positions together. Padding queries remain
972
+ # finite without allowing their states to enter residue attention.
973
+ attention_mask_4d = (
974
+ mask_pattern[:, None, :, None] == mask_pattern[:, None, None, :]
975
+ )
976
+ else:
977
+ attention_mask_4d = (
978
+ mask_pattern.unsqueeze(-1) == mask_pattern.unsqueeze(-2)
979
+ ).unsqueeze(1)
980
+ backend = (
981
+ resolve_attention_backend_for_call(
982
+ self.attention_backend,
983
+ output_attentions=output_attentions,
984
+ )
985
+ if effective_backend is None
986
+ else resolve_attention_backend(effective_backend)
987
+ )
988
+
989
+ if backend.is_flash:
990
+ if mask_pattern.dtype != torch.bool:
991
+ raise ValueError(
992
+ "ESM++ FlashAttention only supports boolean sequence_id padding masks. "
993
+ "Use eager, sdpa, or flex_attention for chain-aware integer sequence_id "
994
+ "masks."
995
+ )
996
+ return attention_mask_2d, attention_mask_4d, None
997
+
998
+ if backend == AttentionBackend.FLEX:
999
+ if mask_pattern.dtype == torch.bool:
1000
+
1001
+ def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
1002
+ del head_idx
1003
+ return mask_pattern[batch_idx, q_idx] == mask_pattern[batch_idx, kv_idx]
1004
+
1005
+ else:
1006
+
1007
+ def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
1008
+ del head_idx
1009
+ q_id = mask_pattern[batch_idx, q_idx]
1010
+ kv_id = mask_pattern[batch_idx, kv_idx]
1011
+ return q_id == kv_id
1012
+
1013
+ flex_block_mask = _get_flex_block_mask(
1014
+ mask_pattern=mask_pattern,
1015
+ batch_size=batch_size,
1016
+ query_length=seq_len,
1017
+ key_value_length=seq_len,
1018
+ device=device,
1019
+ dtype=dtype,
1020
+ mask_semantics=(
1021
+ "boolean_sequence_id"
1022
+ if mask_pattern.dtype == torch.bool
1023
+ else "integer_sequence_id"
1024
+ ),
1025
+ mask_mod=mask_mod,
1026
+ )
1027
+ return attention_mask_2d, attention_mask_4d, flex_block_mask
1028
+
1029
+ return attention_mask_2d, attention_mask_4d, None
1030
+
1031
+
1032
+ class PreTrainedESMplusplusModel(FastPLMsAttentionMixin, PreTrainedModel):
1033
+ """
1034
+ init weights for ESM++ models
1035
+ """
1036
+
1037
+ config_class = ESMplusplusConfig
1038
+ base_model_prefix = "esm++"
1039
+ supports_gradient_checkpointing = True
1040
+ all_tied_weights_keys: ClassVar[dict[str, str]] = {}
1041
+ _supports_flash_attn = True
1042
+ _supports_flash_attn_2 = True
1043
+ _supports_flash_attn_3 = True
1044
+ _fastplms_attention_implementations = (
1045
+ "eager",
1046
+ "sdpa",
1047
+ "flex_attention",
1048
+ "flash_attention_2",
1049
+ "flash_attention_3",
1050
+ )
1051
+
1052
+ def __init__(self, config: ESMplusplusConfig, *args: object, **kwargs: object) -> None:
1053
+ super().__init__(config, *args, **kwargs)
1054
+ self._sae_models = nn.ModuleDict()
1055
+ self._esmc_fp8 = False
1056
+ self._esmc_fp8_module_paths: tuple[str, ...] = ()
1057
+ self._esmc_precision_status = ESMplusplusFP8Status(
1058
+ enabled=False,
1059
+ reason="FP8 has not been enabled; canonical checkpoint precision is unchanged.",
1060
+ device="cpu",
1061
+ transformer_engine_version=_transformer_engine_version(),
1062
+ converted_projections=0,
1063
+ )
1064
+
1065
+ @property
1066
+ def esmc_precision_status(self) -> ESMplusplusFP8Status:
1067
+ """Return the explicit FP8 conversion status for this ESM++ instance."""
1068
+
1069
+ return self._esmc_precision_status
1070
+
1071
+ def enable_fp8(self) -> ESMplusplusFP8Status:
1072
+ """Enable the strict inference-only Transformer Engine FP8 path.
1073
+
1074
+ Canonical parameters must already be BF16 on one supported CUDA device.
1075
+ Exactly one attention output projection per transformer block is replaced;
1076
+ all other operations and all SAE weights remain BF16.
1077
+ """
1078
+
1079
+ if self.config.hidden_size != 2560 or self.config.num_hidden_layers != 80:
1080
+ raise ValueError("FP8 is supported only for the ESMC-6B backbone.")
1081
+ if self._esmc_fp8:
1082
+ return self._esmc_precision_status
1083
+ if self.training:
1084
+ raise RuntimeError("ESM++ FP8 is inference-only; call eval() before enable_fp8().")
1085
+ parameter_devices = {parameter.device for parameter in self.parameters()}
1086
+ if len(parameter_devices) != 1:
1087
+ raise RuntimeError(
1088
+ "ESM++ FP8 requires every parameter on one CUDA device; sharded device maps "
1089
+ f"are unsupported, found {sorted(map(str, parameter_devices))}."
1090
+ )
1091
+ device = next(iter(parameter_devices), self.device)
1092
+ available, reason = _te_fp8_capability(device)
1093
+ if not available:
1094
+ raise RuntimeError(f"ESM++ FP8 is unavailable: {reason}")
1095
+ non_bf16 = [
1096
+ name
1097
+ for name, parameter in self.named_parameters()
1098
+ if parameter.is_floating_point() and parameter.dtype != torch.bfloat16
1099
+ ]
1100
+ if non_bf16:
1101
+ examples = ", ".join(non_bf16[:3])
1102
+ raise RuntimeError(
1103
+ "ESM++ FP8 requires canonical BF16 parameters before conversion; "
1104
+ f"found {len(non_bf16)} non-BF16 parameters (for example: {examples})."
1105
+ )
1106
+ paths = _convert_esmc_attention_outputs_to_te(
1107
+ self,
1108
+ expected_projections=self.config.num_hidden_layers,
1109
+ )
1110
+ self._esmc_fp8 = True
1111
+ self._esmc_fp8_module_paths = paths
1112
+ self._esmc_precision_status = ESMplusplusFP8Status(
1113
+ enabled=True,
1114
+ reason=(
1115
+ f"{reason} Converted {len(paths)} attention output projections; "
1116
+ "canonical checkpoint and SAE weights remain BF16."
1117
+ ),
1118
+ device=str(device),
1119
+ transformer_engine_version=_transformer_engine_version(),
1120
+ converted_projections=len(paths),
1121
+ )
1122
+ return self._esmc_precision_status
1123
+
1124
+ def load_sae_models(
1125
+ self,
1126
+ repository: str | os.PathLike[str],
1127
+ layers: Sequence[int],
1128
+ *,
1129
+ revision: str | None = None,
1130
+ cache_dir: str | os.PathLike[str] | None = None,
1131
+ token: str | bool | None = None,
1132
+ local_files_only: bool = False,
1133
+ dtype: torch.dtype | None = None,
1134
+ ) -> dict[int, ESMplusplusSAELayer]:
1135
+ """Load hidden-state SAE layers from a Hub repository or local directory, then attach them.
1136
+
1137
+ The layers land on this model's device and, unless ``dtype`` says otherwise, in this
1138
+ model's parameter dtype, so they consume its hidden states without a dtype mismatch.
1139
+ """
1140
+
1141
+ sae_layers = load_esmc_sae_layers(
1142
+ repository,
1143
+ layers,
1144
+ revision=revision,
1145
+ cache_dir=cache_dir,
1146
+ token=token,
1147
+ local_files_only=local_files_only,
1148
+ device=self.device,
1149
+ dtype=self.dtype if dtype is None else dtype,
1150
+ )
1151
+ self.add_sae_models(list(sae_layers.values()))
1152
+ return sae_layers
1153
+
1154
+ def add_sae_models(self, sae_models: list[nn.Module]) -> None:
1155
+ """Attach hidden-state SAE layers to this ESM++ model.
1156
+
1157
+ Accepts layers from ``load_sae_models`` and official Biohub ``ESMCSAEModel.layers``
1158
+ entries, which share one attachment contract.
1159
+ """
1160
+
1161
+ for sae_model in sae_models:
1162
+ if not isinstance(sae_model, nn.Module):
1163
+ raise TypeError(
1164
+ "Each SAE must be an nn.Module exposing the hidden-state SAE contract, such "
1165
+ "as a load_sae_models layer or an official Biohub ESMCSAEModel.layers entry."
1166
+ )
1167
+ layer = getattr(sae_model, "layer", None)
1168
+ if isinstance(layer, bool) or not isinstance(layer, int):
1169
+ raise TypeError("Each SAE layer must expose an integer .layer attribute.")
1170
+ if not 0 <= layer <= self.config.num_hidden_layers:
1171
+ raise ValueError(
1172
+ f"SAE target layer {layer} is outside the ESM++ hidden-state range "
1173
+ f"0..{self.config.num_hidden_layers}."
1174
+ )
1175
+ params = getattr(sae_model, "params", None)
1176
+ d_model = getattr(params, "d_model", None)
1177
+ if d_model != self.config.hidden_size:
1178
+ raise ValueError(
1179
+ f"SAE layer {layer} expects d_model={d_model!r}, but this ESM++ "
1180
+ f"checkpoint has hidden_size={self.config.hidden_size}."
1181
+ )
1182
+ if not callable(getattr(sae_model, "get_sae_output", None)):
1183
+ raise TypeError("Each SAE layer must expose get_sae_output(layer_states, token_mask).")
1184
+ for name in ("idf", "max"):
1185
+ if not isinstance(getattr(sae_model, name, None), torch.Tensor):
1186
+ raise TypeError(f"Each SAE layer must expose a tensor {name!r} buffer.")
1187
+ key = f"layer{layer}"
1188
+ if key in self._sae_models:
1189
+ raise ValueError(
1190
+ f"An SAE is already registered at {key!r}; only one SAE per layer "
1191
+ "can be active."
1192
+ )
1193
+ self._sae_models[key] = sae_model
1194
+
1195
+ def _prepare_sae_forward(
1196
+ self,
1197
+ *,
1198
+ compute_sae: bool,
1199
+ input_ids: torch.Tensor | None,
1200
+ attention_mask: torch.Tensor | None,
1201
+ sequence_id: torch.Tensor | None,
1202
+ ) -> tuple[tuple[int, ...], torch.Tensor | None]:
1203
+ if not compute_sae or not self._sae_models:
1204
+ return (), None
1205
+ if input_ids is None and attention_mask is None and sequence_id is None:
1206
+ # Embedding inputs carry no token identities, so the caller must supply the mask.
1207
+ # Rejecting masked tokens is then the caller's precondition rather than ours.
1208
+ raise ValueError(
1209
+ "SAE computation from inputs_embeds requires an explicit attention_mask or "
1210
+ "sequence_id, because the token mask cannot be recovered from embeddings."
1211
+ )
1212
+ if input_ids is not None and torch.any(input_ids == self.config.mask_token_id):
1213
+ raise ValueError("SAE inputs must not contain mask tokens; SAEs were trained unmasked.")
1214
+ if sequence_id is not None:
1215
+ token_mask = sequence_id >= 0
1216
+ elif attention_mask is not None:
1217
+ token_mask = attention_mask.to(dtype=torch.bool)
1218
+ else:
1219
+ token_mask = input_ids != self.config.pad_token_id
1220
+ layers = tuple(sorted(int(name.removeprefix("layer")) for name in self._sae_models))
1221
+ return layers, token_mask
1222
+
1223
+ def _get_sae_outputs(
1224
+ self,
1225
+ hidden_states: dict[int, torch.Tensor] | None,
1226
+ token_mask: torch.Tensor | None,
1227
+ *,
1228
+ normalize_sae: bool,
1229
+ differentiable_sae: bool = False,
1230
+ ) -> dict[str, torch.Tensor] | None:
1231
+ """Encode collected hidden states with every attached SAE.
1232
+
1233
+ The default detaches and sparsifies, which is right for interpretation and matches the
1234
+ official implementation. ``differentiable_sae`` instead keeps the result attached to the
1235
+ graph and dense, so a gradient-based sequence designer can optimize an objective built on
1236
+ SAE features. The arithmetic is identical either way; only the tape and the layout differ.
1237
+ """
1238
+ if not self._sae_models:
1239
+ return None
1240
+ if hidden_states is None or token_mask is None:
1241
+ raise RuntimeError("SAE hidden-state collection was not initialized.")
1242
+ outputs: dict[str, torch.Tensor] = {}
1243
+ for key, sae_model in self._sae_models.items():
1244
+ layer = int(key.removeprefix("layer"))
1245
+ if layer not in hidden_states:
1246
+ raise RuntimeError(f"ESM++ did not collect the requested SAE layer {layer}.")
1247
+ layer_states = hidden_states[layer]
1248
+ sae_output = sae_model.get_sae_output(
1249
+ layer_states if differentiable_sae else layer_states.clone(), token_mask
1250
+ )
1251
+ features = getattr(sae_output, "feature_magnitudes", None)
1252
+ if not isinstance(features, torch.Tensor):
1253
+ raise TypeError("SAE get_sae_output must return tensor feature_magnitudes.")
1254
+ if not differentiable_sae:
1255
+ features = features.detach()
1256
+ if normalize_sae:
1257
+ features = (features / sae_model.max) * sae_model.idf
1258
+ outputs[key] = features if differentiable_sae else features.to_sparse()
1259
+ return outputs
1260
+
1261
+ def _pad_fp8_inputs(
1262
+ self,
1263
+ input_ids: torch.Tensor | None,
1264
+ attention_mask: torch.Tensor | None,
1265
+ sequence_id: torch.Tensor | None,
1266
+ inputs_embeds: torch.Tensor | None,
1267
+ ) -> tuple[
1268
+ torch.Tensor | None,
1269
+ torch.Tensor | None,
1270
+ torch.Tensor | None,
1271
+ torch.Tensor | None,
1272
+ int | None,
1273
+ ]:
1274
+ if not self._esmc_fp8:
1275
+ return input_ids, attention_mask, sequence_id, inputs_embeds, None
1276
+ source = input_ids if input_ids is not None else inputs_embeds
1277
+ if source is None:
1278
+ return input_ids, attention_mask, sequence_id, inputs_embeds, None
1279
+ sequence_length = source.shape[1]
1280
+ padded_length = (
1281
+ (sequence_length + _ESMC_FP8_ALIGNMENT - 1) // _ESMC_FP8_ALIGNMENT
1282
+ ) * _ESMC_FP8_ALIGNMENT
1283
+ padding = padded_length - sequence_length
1284
+ if padding == 0:
1285
+ return input_ids, attention_mask, sequence_id, inputs_embeds, None
1286
+ if input_ids is not None:
1287
+ input_ids = F.pad(input_ids, (0, padding), value=self.config.pad_token_id)
1288
+ if inputs_embeds is not None:
1289
+ inputs_embeds = F.pad(inputs_embeds, (0, 0, 0, padding), value=0.0)
1290
+ if sequence_id is not None:
1291
+ sequence_id = F.pad(sequence_id.to(dtype=torch.long), (0, padding), value=-1)
1292
+ else:
1293
+ if attention_mask is None:
1294
+ attention_mask = (
1295
+ source != self.config.pad_token_id
1296
+ if input_ids is not None
1297
+ else torch.ones(
1298
+ source.shape[:2],
1299
+ dtype=torch.bool,
1300
+ device=source.device,
1301
+ )
1302
+ )
1303
+ attention_mask = F.pad(attention_mask, (0, padding), value=0)
1304
+ return input_ids, attention_mask, sequence_id, inputs_embeds, sequence_length
1305
+
1306
+ @staticmethod
1307
+ def _trim_transformer_output(
1308
+ output: TransformerOutput,
1309
+ sequence_length: int | None,
1310
+ ) -> TransformerOutput:
1311
+ if sequence_length is None:
1312
+ return output
1313
+ hidden_states = (
1314
+ tuple(state[:, :sequence_length] for state in output.hidden_states)
1315
+ if output.hidden_states is not None
1316
+ else None
1317
+ )
1318
+ attentions = (
1319
+ tuple(
1320
+ attention[..., :sequence_length, :sequence_length]
1321
+ for attention in output.attentions
1322
+ )
1323
+ if output.attentions is not None
1324
+ else None
1325
+ )
1326
+ return TransformerOutput(
1327
+ last_hidden_state=output.last_hidden_state[:, :sequence_length],
1328
+ hidden_states=hidden_states,
1329
+ attentions=attentions,
1330
+ s_max=output.s_max,
1331
+ sae_hidden_states=output.sae_hidden_states,
1332
+ )
1333
+
1334
+ @property
1335
+ def tokenizer(self) -> EsmSequenceTokenizer:
1336
+ """Construct the sequence tokenizer only when a raw-sequence API needs it."""
1337
+
1338
+ tokenizer = self.__dict__.get("_fastplms_tokenizer")
1339
+ if tokenizer is None:
1340
+ tokenizer = EsmSequenceTokenizer()
1341
+ self.__dict__["_fastplms_tokenizer"] = tokenizer
1342
+ return tokenizer
1343
+
1344
+ @tokenizer.setter
1345
+ def tokenizer(self, value: EsmSequenceTokenizer | None) -> None:
1346
+ self.__dict__["_fastplms_tokenizer"] = value
1347
+
1348
+ def _init_weights(self, module):
1349
+ """Initialize the weights"""
1350
+ # HF from_pretrained marks loaded parameters with `_is_hf_initialized`.
1351
+ # Skip this module if any local parameter is already marked as loaded.
1352
+ for parameter in module.parameters(recurse=False):
1353
+ if parameter.__dict__.get("_is_hf_initialized"):
1354
+ return
1355
+
1356
+ if isinstance(module, nn.Linear):
1357
+ nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range)
1358
+ if module.bias is not None:
1359
+ nn.init.zeros_(module.bias)
1360
+ elif isinstance(module, nn.Embedding):
1361
+ nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range)
1362
+ if module.padding_idx is not None:
1363
+ with torch.no_grad():
1364
+ module.weight[module.padding_idx].zero_()
1365
+ elif isinstance(module, nn.LayerNorm):
1366
+ if module.bias is not None:
1367
+ nn.init.zeros_(module.bias)
1368
+ nn.init.ones_(module.weight)
1369
+
1370
+ @property
1371
+ def attn_backend(self) -> str:
1372
+ return self.config.attn_backend
1373
+
1374
+ @attn_backend.setter
1375
+ def attn_backend(self, backend: str) -> None:
1376
+ if backend not in self._fastplms_attention_implementations:
1377
+ raise ValueError(
1378
+ f"{type(self).__name__} does not support {backend!r}; expected one of "
1379
+ f"{self._fastplms_attention_implementations}."
1380
+ )
1381
+ self.set_attn_implementation(backend)
1382
+
1383
+ def _reset_rotary_embeddings(self):
1384
+ """Refresh non-persistent rotary buffers after checkpoint loading."""
1385
+ for module in self.modules():
1386
+ if isinstance(module, RotaryEmbedding):
1387
+ module.reset_parameters()
1388
+
1389
+ @classmethod
1390
+ def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
1391
+ output_loading_info = (
1392
+ bool(kwargs["output_loading_info"]) if "output_loading_info" in kwargs else False
1393
+ )
1394
+ loaded = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)
1395
+ if output_loading_info:
1396
+ model, loading_info = loaded
1397
+ model._reset_rotary_embeddings()
1398
+ return model, loading_info
1399
+ loaded._reset_rotary_embeddings()
1400
+ return loaded
1401
+
1402
+
1403
+ ### ESM++ Models
1404
+ class ESMplusplusModel(PreTrainedESMplusplusModel, EmbeddingMixin):
1405
+ """
1406
+ ESM++ transformer backbone.
1407
+
1408
+ Official ESM++ checkpoints contain the sequence head even when loaded through
1409
+ ``AutoModel``. Keep that module in the base class so the checkpoint has one
1410
+ exact state-dict contract across ``AutoModel`` and ``AutoModelForMaskedLM``;
1411
+ the base forward path intentionally does not compute or return logits.
1412
+ """
1413
+
1414
+ config_class = ESMplusplusConfig
1415
+
1416
+ def __init__(self, config: ESMplusplusConfig, **kwargs) -> None:
1417
+ PreTrainedESMplusplusModel.__init__(self, config, **kwargs)
1418
+ self.config = config
1419
+ self.vocab_size = config.vocab_size
1420
+ self.embed = nn.Embedding(self.vocab_size, config.hidden_size)
1421
+ self.transformer = TransformerStack(
1422
+ d_model=config.hidden_size,
1423
+ n_heads=config.num_attention_heads,
1424
+ n_layers=config.num_hidden_layers,
1425
+ dropout=config.dropout,
1426
+ attn_backend=config.attn_backend,
1427
+ )
1428
+ self.sequence_head = RegressionHead(config.hidden_size, self.vocab_size)
1429
+ self.init_weights()
1430
+
1431
+ def get_input_embeddings(self):
1432
+ return self.embed
1433
+
1434
+ def set_input_embeddings(self, value):
1435
+ self.embed = value
1436
+
1437
+ def get_output_embeddings(self):
1438
+ return self.sequence_head[-1]
1439
+
1440
+ def set_output_embeddings(self, new_embeddings):
1441
+ self.sequence_head[-1] = new_embeddings
1442
+
1443
+ def _embed(
1444
+ self,
1445
+ input_ids: torch.Tensor,
1446
+ attention_mask: torch.Tensor | None = None,
1447
+ hidden_state_index: int = -1,
1448
+ store_all_hidden_states: bool = False,
1449
+ ) -> torch.Tensor:
1450
+ if attention_mask is None:
1451
+ attention_mask = input_ids.ne(self.config.pad_token_id)
1452
+ x = self.embed(input_ids)
1453
+ output_hidden_states = store_all_hidden_states or hidden_state_index != -1
1454
+ output = self.transformer(
1455
+ x=x,
1456
+ attention_mask=attention_mask,
1457
+ output_hidden_states=output_hidden_states,
1458
+ output_attentions=False,
1459
+ )
1460
+ return select_hidden_state_embeddings(
1461
+ output.last_hidden_state,
1462
+ output.hidden_states,
1463
+ hidden_state_index=hidden_state_index,
1464
+ store_all_hidden_states=store_all_hidden_states,
1465
+ )
1466
+
1467
+ def forward(
1468
+ self,
1469
+ input_ids: torch.Tensor | None = None,
1470
+ attention_mask: torch.Tensor | None = None,
1471
+ sequence_id: torch.Tensor | None = None,
1472
+ inputs_embeds: torch.Tensor | None = None,
1473
+ output_attentions: bool | None = None,
1474
+ output_hidden_states: bool | None = None,
1475
+ output_s_max: bool | None = False,
1476
+ esmfold2_hidden_states: bool = False,
1477
+ return_dict: bool | None = None,
1478
+ compute_sae: bool = True,
1479
+ normalize_sae: bool = False,
1480
+ differentiable_sae: bool = False,
1481
+ ) -> TransformerOutput | tuple[torch.Tensor, ...]:
1482
+ """Run ESMC inference with the pinned Biohub mask precedence.
1483
+
1484
+ ``sequence_id`` is authoritative when supplied: non-negative integers
1485
+ identify chains and ``-1`` identifies padding. In that mode
1486
+ ``attention_mask`` is ignored, matching the official implementation.
1487
+ Without ``sequence_id``, ``attention_mask`` is the ordinary padding
1488
+ mask and defaults to ``input_ids != pad_token_id``.
1489
+ """
1490
+ if input_ids is None and inputs_embeds is None:
1491
+ raise ValueError("You have to specify either input_ids or inputs_embeds")
1492
+ if input_ids is not None and inputs_embeds is not None:
1493
+ raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
1494
+ output_attentions = (
1495
+ output_attentions if output_attentions is not None else self.config.output_attentions
1496
+ )
1497
+ output_hidden_states = (
1498
+ output_hidden_states
1499
+ if output_hidden_states is not None
1500
+ else self.config.output_hidden_states
1501
+ )
1502
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
1503
+
1504
+ input_ids, attention_mask, sequence_id, inputs_embeds, original_length = (
1505
+ self._pad_fp8_inputs(input_ids, attention_mask, sequence_id, inputs_embeds)
1506
+ )
1507
+ if attention_mask is None and sequence_id is None and input_ids is not None:
1508
+ attention_mask = input_ids.ne(self.config.pad_token_id)
1509
+ sae_layers, sae_token_mask = self._prepare_sae_forward(
1510
+ compute_sae=compute_sae,
1511
+ input_ids=input_ids,
1512
+ attention_mask=attention_mask,
1513
+ sequence_id=sequence_id,
1514
+ )
1515
+
1516
+ x = self.embed(input_ids) if inputs_embeds is None else inputs_embeds
1517
+
1518
+ with _esmplusplus_fp8_context(self._esmc_fp8, self.device):
1519
+ transformer_output = self.transformer(
1520
+ x=x,
1521
+ attention_mask=attention_mask,
1522
+ sequence_id=sequence_id,
1523
+ output_hidden_states=output_hidden_states,
1524
+ output_attentions=output_attentions,
1525
+ output_s_max=output_s_max,
1526
+ esmfold2_hidden_states=esmfold2_hidden_states,
1527
+ sae_layers=sae_layers,
1528
+ )
1529
+ sae_outputs = (
1530
+ self._get_sae_outputs(
1531
+ transformer_output.sae_hidden_states,
1532
+ sae_token_mask,
1533
+ normalize_sae=normalize_sae,
1534
+ differentiable_sae=differentiable_sae,
1535
+ )
1536
+ if sae_layers
1537
+ else None
1538
+ )
1539
+ transformer_output = self._trim_transformer_output(transformer_output, original_length)
1540
+ result = TransformerOutput(
1541
+ last_hidden_state=transformer_output.last_hidden_state,
1542
+ hidden_states=transformer_output.hidden_states,
1543
+ attentions=transformer_output.attentions,
1544
+ s_max=transformer_output.s_max,
1545
+ sae_outputs=sae_outputs,
1546
+ )
1547
+ return result if return_dict else result.to_tuple()
1548
+
1549
+
1550
+ class ESMplusplusForMaskedLM(
1551
+ FastPLMTestTimeTrainingMixin, PreTrainedESMplusplusModel, EmbeddingMixin
1552
+ ):
1553
+ """
1554
+ ESM++ model for masked language modeling.
1555
+ Implements the base ESM++ architecture with a masked language modeling head.
1556
+ """
1557
+
1558
+ config_class = ESMplusplusConfig
1559
+
1560
+ def __init__(self, config: ESMplusplusConfig, **kwargs) -> None:
1561
+ PreTrainedESMplusplusModel.__init__(self, config, **kwargs)
1562
+ self.config = config
1563
+ self.vocab_size = config.vocab_size
1564
+ self.embed = nn.Embedding(self.vocab_size, config.hidden_size)
1565
+ self.transformer = TransformerStack(
1566
+ d_model=config.hidden_size,
1567
+ n_heads=config.num_attention_heads,
1568
+ n_layers=config.num_hidden_layers,
1569
+ dropout=config.dropout,
1570
+ attn_backend=config.attn_backend,
1571
+ )
1572
+ self.sequence_head = RegressionHead(config.hidden_size, self.vocab_size)
1573
+ self.ce_loss = nn.CrossEntropyLoss()
1574
+ self.init_weights()
1575
+ self.init_ttt({"lora_target_replace_module": "MultiHeadAttention"})
1576
+
1577
+ def get_input_embeddings(self):
1578
+ return self.embed
1579
+
1580
+ def set_input_embeddings(self, value):
1581
+ self.embed = value
1582
+
1583
+ def get_output_embeddings(self):
1584
+ return self.sequence_head[-1]
1585
+
1586
+ def set_output_embeddings(self, new_embeddings):
1587
+ self.sequence_head[-1] = new_embeddings
1588
+
1589
+ def _embed(
1590
+ self,
1591
+ input_ids: torch.Tensor,
1592
+ attention_mask: torch.Tensor | None = None,
1593
+ hidden_state_index: int = -1,
1594
+ store_all_hidden_states: bool = False,
1595
+ ) -> torch.Tensor:
1596
+ if attention_mask is None:
1597
+ attention_mask = input_ids.ne(self.config.pad_token_id)
1598
+ x = self.embed(input_ids)
1599
+ output_hidden_states = store_all_hidden_states or hidden_state_index != -1
1600
+ output = self.transformer(
1601
+ x=x,
1602
+ attention_mask=attention_mask,
1603
+ output_hidden_states=output_hidden_states,
1604
+ output_attentions=False,
1605
+ )
1606
+ return select_hidden_state_embeddings(
1607
+ output.last_hidden_state,
1608
+ output.hidden_states,
1609
+ hidden_state_index=hidden_state_index,
1610
+ store_all_hidden_states=store_all_hidden_states,
1611
+ )
1612
+
1613
+ def _ttt_get_trainable_modules(self) -> list[nn.Module]:
1614
+ return [self.transformer]
1615
+
1616
+ def forward(
1617
+ self,
1618
+ input_ids: torch.Tensor | None = None,
1619
+ attention_mask: torch.Tensor | None = None,
1620
+ sequence_id: torch.Tensor | None = None,
1621
+ inputs_embeds: torch.Tensor | None = None,
1622
+ labels: torch.Tensor | None = None,
1623
+ output_attentions: bool | None = None,
1624
+ output_hidden_states: bool | None = None,
1625
+ output_s_max: bool | None = False,
1626
+ esmfold2_hidden_states: bool = False,
1627
+ return_dict: bool | None = None,
1628
+ compute_logits: bool = True,
1629
+ compute_sae: bool = True,
1630
+ normalize_sae: bool = False,
1631
+ differentiable_sae: bool = False,
1632
+ ) -> ESMplusplusOutput | tuple[torch.Tensor, ...]:
1633
+ if input_ids is None and inputs_embeds is None:
1634
+ raise ValueError("You have to specify either input_ids or inputs_embeds")
1635
+ if input_ids is not None and inputs_embeds is not None:
1636
+ raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
1637
+ if labels is not None and not compute_logits:
1638
+ raise ValueError("labels require compute_logits=True.")
1639
+ output_attentions = (
1640
+ output_attentions if output_attentions is not None else self.config.output_attentions
1641
+ )
1642
+ output_hidden_states = (
1643
+ output_hidden_states
1644
+ if output_hidden_states is not None
1645
+ else self.config.output_hidden_states
1646
+ )
1647
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
1648
+ input_ids, attention_mask, sequence_id, inputs_embeds, original_length = (
1649
+ self._pad_fp8_inputs(input_ids, attention_mask, sequence_id, inputs_embeds)
1650
+ )
1651
+ if attention_mask is None and sequence_id is None and input_ids is not None:
1652
+ attention_mask = input_ids.ne(self.config.pad_token_id)
1653
+ sae_layers, sae_token_mask = self._prepare_sae_forward(
1654
+ compute_sae=compute_sae,
1655
+ input_ids=input_ids,
1656
+ attention_mask=attention_mask,
1657
+ sequence_id=sequence_id,
1658
+ )
1659
+
1660
+ x = self.embed(input_ids) if inputs_embeds is None else inputs_embeds
1661
+
1662
+ with _esmplusplus_fp8_context(self._esmc_fp8, self.device):
1663
+ output = self.transformer(
1664
+ x=x,
1665
+ attention_mask=attention_mask,
1666
+ sequence_id=sequence_id,
1667
+ output_hidden_states=output_hidden_states,
1668
+ output_attentions=output_attentions,
1669
+ output_s_max=output_s_max,
1670
+ esmfold2_hidden_states=esmfold2_hidden_states,
1671
+ sae_layers=sae_layers,
1672
+ )
1673
+ sae_outputs = (
1674
+ self._get_sae_outputs(
1675
+ output.sae_hidden_states,
1676
+ sae_token_mask,
1677
+ normalize_sae=normalize_sae,
1678
+ differentiable_sae=differentiable_sae,
1679
+ )
1680
+ if sae_layers
1681
+ else None
1682
+ )
1683
+ output = self._trim_transformer_output(output, original_length)
1684
+
1685
+ last_hidden_state = output.last_hidden_state
1686
+ logits = self.sequence_head(last_hidden_state) if compute_logits else None
1687
+ loss = None
1688
+ if labels is not None:
1689
+ if logits is None:
1690
+ raise ValueError("labels require compute_logits=True.")
1691
+ labels = labels.to(logits.device)
1692
+ loss = self.ce_loss(logits.view(-1, self.vocab_size), labels.view(-1))
1693
+
1694
+ result = ESMplusplusOutput(
1695
+ loss=loss,
1696
+ logits=logits,
1697
+ hidden_states=output.hidden_states,
1698
+ attentions=output.attentions,
1699
+ s_max=output.s_max,
1700
+ last_hidden_state=last_hidden_state,
1701
+ sae_outputs=sae_outputs,
1702
+ )
1703
+ return result if return_dict else result.to_tuple()
1704
+
1705
+
1706
+ class ESMplusplusForSequenceClassification(ESMplusplusForMaskedLM, EmbeddingMixin):
1707
+ """
1708
+ ESM++ model for sequence classification.
1709
+ Extends the base ESM++ model with a classification head.
1710
+ """
1711
+
1712
+ def __init__(self, config: ESMplusplusConfig, **kwargs) -> None:
1713
+ pooling_types = kwargs.pop("pooling_types", None)
1714
+ if pooling_types is None:
1715
+ pooling_types = config.classifier_pooling_types or ["mean", "var"]
1716
+ elif not isinstance(pooling_types, list):
1717
+ raise TypeError("pooling_types must be a non-empty list of strings.")
1718
+ elif not pooling_types:
1719
+ raise ValueError("pooling_types must contain at least one pooling operation.")
1720
+ elif not all(isinstance(pooling_type, str) for pooling_type in pooling_types):
1721
+ raise TypeError("pooling_types must be a non-empty list of strings.")
1722
+ if "parti" in pooling_types:
1723
+ raise ValueError(
1724
+ "pooling_types cannot contain 'parti' for sequence classification "
1725
+ "because the classifier does not expose layer attentions to its pooler."
1726
+ )
1727
+ config.classifier_pooling_types = list(pooling_types)
1728
+
1729
+ ESMplusplusForMaskedLM.__init__(self, config, **kwargs)
1730
+ self.config = config
1731
+ self.num_labels = config.num_labels
1732
+ self.classifier = RegressionHead(
1733
+ config.hidden_size * len(pooling_types),
1734
+ config.num_labels,
1735
+ config.hidden_size * 4,
1736
+ )
1737
+ # Large intermediate projections help with sequence classification tasks (*4)
1738
+ self.mse = nn.MSELoss()
1739
+ self.ce = nn.CrossEntropyLoss()
1740
+ self.bce = nn.BCEWithLogitsLoss()
1741
+ self.pooler = Pooler(pooling_types)
1742
+ self.init_weights()
1743
+
1744
+ def _embed(
1745
+ self,
1746
+ input_ids: torch.Tensor,
1747
+ attention_mask: torch.Tensor | None = None,
1748
+ hidden_state_index: int = -1,
1749
+ store_all_hidden_states: bool = False,
1750
+ ) -> torch.Tensor:
1751
+ x = self.embed(input_ids)
1752
+ output_hidden_states = store_all_hidden_states or hidden_state_index != -1
1753
+ output = self.transformer(
1754
+ x=x,
1755
+ attention_mask=attention_mask,
1756
+ output_hidden_states=output_hidden_states,
1757
+ output_attentions=False,
1758
+ )
1759
+ return select_hidden_state_embeddings(
1760
+ output.last_hidden_state,
1761
+ output.hidden_states,
1762
+ hidden_state_index=hidden_state_index,
1763
+ store_all_hidden_states=store_all_hidden_states,
1764
+ )
1765
+
1766
+ def forward(
1767
+ self,
1768
+ input_ids: torch.Tensor | None = None,
1769
+ attention_mask: torch.Tensor | None = None,
1770
+ sequence_id: torch.Tensor | None = None,
1771
+ inputs_embeds: torch.Tensor | None = None,
1772
+ labels: torch.Tensor | None = None,
1773
+ output_attentions: bool | None = None,
1774
+ output_hidden_states: bool | None = None,
1775
+ output_s_max: bool | None = False,
1776
+ return_dict: bool | None = None,
1777
+ compute_sae: bool = True,
1778
+ normalize_sae: bool = False,
1779
+ ) -> ESMplusplusSequenceClassifierOutput | tuple[torch.Tensor, ...]:
1780
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
1781
+ pooling_mask = attention_mask
1782
+ if pooling_mask is None:
1783
+ if sequence_id is not None:
1784
+ pooling_mask = (
1785
+ sequence_id if sequence_id.dtype == torch.bool else sequence_id.ne(-1)
1786
+ )
1787
+ elif input_ids is not None:
1788
+ pooling_mask = input_ids.ne(self.config.pad_token_id)
1789
+ else:
1790
+ if inputs_embeds is None:
1791
+ raise ValueError("You have to specify either input_ids or inputs_embeds")
1792
+ pooling_mask = torch.ones(
1793
+ inputs_embeds.shape[:2],
1794
+ dtype=torch.bool,
1795
+ device=inputs_embeds.device,
1796
+ )
1797
+
1798
+ output = super().forward(
1799
+ input_ids=input_ids,
1800
+ attention_mask=attention_mask,
1801
+ sequence_id=sequence_id,
1802
+ inputs_embeds=inputs_embeds,
1803
+ labels=None,
1804
+ output_attentions=output_attentions,
1805
+ output_hidden_states=output_hidden_states,
1806
+ output_s_max=output_s_max,
1807
+ return_dict=True,
1808
+ compute_logits=False,
1809
+ compute_sae=compute_sae,
1810
+ normalize_sae=normalize_sae,
1811
+ )
1812
+
1813
+ last_hidden_state = output.last_hidden_state
1814
+ features = self.pooler(last_hidden_state, pooling_mask)
1815
+ logits = self.classifier(features)
1816
+
1817
+ loss = None
1818
+ if labels is not None:
1819
+ labels = labels.to(logits.device)
1820
+ if self.config.problem_type is None:
1821
+ if self.num_labels == 1:
1822
+ self.config.problem_type = "regression"
1823
+ elif self.num_labels > 1 and (
1824
+ labels.dtype == torch.long or labels.dtype == torch.int
1825
+ ):
1826
+ self.config.problem_type = "single_label_classification"
1827
+ else:
1828
+ self.config.problem_type = "multi_label_classification"
1829
+
1830
+ if self.config.problem_type == "regression":
1831
+ if self.num_labels == 1:
1832
+ loss = self.mse(logits.flatten(), labels.flatten())
1833
+ else:
1834
+ loss = self.mse(logits, labels)
1835
+ elif self.config.problem_type == "single_label_classification":
1836
+ loss = self.ce(logits.view(-1, self.num_labels), labels.view(-1))
1837
+ elif self.config.problem_type == "multi_label_classification":
1838
+ loss = self.bce(logits, labels)
1839
+
1840
+ result = ESMplusplusSequenceClassifierOutput(
1841
+ loss=loss,
1842
+ logits=logits,
1843
+ hidden_states=output.hidden_states,
1844
+ attentions=output.attentions,
1845
+ s_max=output.s_max,
1846
+ sae_outputs=output.sae_outputs,
1847
+ )
1848
+ return result if return_dict else result.to_tuple()
1849
+
1850
+
1851
+ class ESMplusplusForTokenClassification(ESMplusplusForMaskedLM, EmbeddingMixin):
1852
+ """
1853
+ ESM++ model for token classification.
1854
+ Extends the base ESM++ model with a token classification head.
1855
+ """
1856
+
1857
+ def __init__(self, config: ESMplusplusConfig, **kwargs) -> None:
1858
+ ESMplusplusForMaskedLM.__init__(self, config, **kwargs)
1859
+ self.config = config
1860
+ self.num_labels = config.num_labels
1861
+ self.classifier = RegressionHead(
1862
+ config.hidden_size, config.num_labels, config.hidden_size * 4
1863
+ )
1864
+ # Large intermediate projections help with sequence classification tasks (*4)
1865
+ self.loss_fct = nn.CrossEntropyLoss()
1866
+ self.init_weights()
1867
+
1868
+ def _embed(
1869
+ self,
1870
+ input_ids: torch.Tensor,
1871
+ attention_mask: torch.Tensor | None = None,
1872
+ hidden_state_index: int = -1,
1873
+ store_all_hidden_states: bool = False,
1874
+ ) -> torch.Tensor:
1875
+ x = self.embed(input_ids)
1876
+ output_hidden_states = store_all_hidden_states or hidden_state_index != -1
1877
+ output = self.transformer(
1878
+ x,
1879
+ attention_mask,
1880
+ output_hidden_states=output_hidden_states,
1881
+ output_attentions=False,
1882
+ )
1883
+ return select_hidden_state_embeddings(
1884
+ output.last_hidden_state,
1885
+ output.hidden_states,
1886
+ hidden_state_index=hidden_state_index,
1887
+ store_all_hidden_states=store_all_hidden_states,
1888
+ )
1889
+
1890
+ def forward(
1891
+ self,
1892
+ input_ids: torch.Tensor | None = None,
1893
+ attention_mask: torch.Tensor | None = None,
1894
+ sequence_id: torch.Tensor | None = None,
1895
+ inputs_embeds: torch.Tensor | None = None,
1896
+ labels: torch.Tensor | None = None,
1897
+ output_attentions: bool | None = None,
1898
+ output_hidden_states: bool | None = None,
1899
+ output_s_max: bool | None = False,
1900
+ return_dict: bool | None = None,
1901
+ compute_sae: bool = True,
1902
+ normalize_sae: bool = False,
1903
+ ) -> ESMplusplusTokenClassifierOutput | tuple[torch.Tensor, ...]:
1904
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
1905
+ output = super().forward(
1906
+ input_ids=input_ids,
1907
+ attention_mask=attention_mask,
1908
+ sequence_id=sequence_id,
1909
+ inputs_embeds=inputs_embeds,
1910
+ labels=None,
1911
+ output_attentions=output_attentions,
1912
+ output_hidden_states=output_hidden_states,
1913
+ output_s_max=output_s_max,
1914
+ return_dict=True,
1915
+ compute_logits=False,
1916
+ compute_sae=compute_sae,
1917
+ normalize_sae=normalize_sae,
1918
+ )
1919
+
1920
+ last_hidden_state = output.last_hidden_state
1921
+ logits = self.classifier(last_hidden_state)
1922
+ loss = None
1923
+ if labels is not None:
1924
+ labels = labels.to(logits.device)
1925
+ loss = self.loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
1926
+
1927
+ result = ESMplusplusTokenClassifierOutput(
1928
+ loss=loss,
1929
+ logits=logits,
1930
+ hidden_states=output.hidden_states,
1931
+ attentions=output.attentions,
1932
+ s_max=output.s_max,
1933
+ sae_outputs=output.sae_outputs,
1934
+ )
1935
+ return result if return_dict else result.to_tuple()
1936
+
1937
+
1938
+ ### Tokenization
1939
+ SEQUENCE_VOCAB = [
1940
+ "<cls>",
1941
+ "<pad>",
1942
+ "<eos>",
1943
+ "<unk>",
1944
+ "L",
1945
+ "A",
1946
+ "G",
1947
+ "V",
1948
+ "S",
1949
+ "E",
1950
+ "R",
1951
+ "T",
1952
+ "I",
1953
+ "D",
1954
+ "P",
1955
+ "K",
1956
+ "Q",
1957
+ "N",
1958
+ "F",
1959
+ "Y",
1960
+ "M",
1961
+ "H",
1962
+ "W",
1963
+ "C",
1964
+ "X",
1965
+ "B",
1966
+ "U",
1967
+ "Z",
1968
+ "O",
1969
+ ".",
1970
+ "-",
1971
+ "|",
1972
+ "<mask>",
1973
+ ]
1974
+
1975
+
1976
+ def _build_sequence_tokenizer_backend(
1977
+ *,
1978
+ unk_token: str,
1979
+ cls_token: str,
1980
+ pad_token: str,
1981
+ mask_token: str,
1982
+ eos_token: str,
1983
+ chain_break_token: str,
1984
+ ) -> Tokenizer:
1985
+ """Build the fixed ESMC character vocabulary and boundary-token policy."""
1986
+ vocabulary = dict(zip(SEQUENCE_VOCAB, range(len(SEQUENCE_VOCAB)), strict=True))
1987
+ backend = Tokenizer(BPE(vocabulary, merges=[], unk_token=unk_token))
1988
+ backend.add_special_tokens([cls_token, pad_token, mask_token, eos_token, chain_break_token])
1989
+ backend.post_processor = TemplateProcessing(
1990
+ single="<cls> $A <eos>",
1991
+ pair="<cls>:0 $A:0 <eos>:0 $B:1 <eos>:1",
1992
+ special_tokens=[
1993
+ ("<cls>", backend.token_to_id("<cls>")),
1994
+ ("<eos>", backend.token_to_id("<eos>")),
1995
+ ],
1996
+ )
1997
+ return backend
1998
+
1999
+
2000
+ class EsmSequenceTokenizer(PreTrainedTokenizerFast):
2001
+ model_input_names: ClassVar[list[str]] = ["input_ids", "attention_mask"]
2002
+
2003
+ def __init__(
2004
+ self,
2005
+ unk_token="<unk>",
2006
+ cls_token="<cls>",
2007
+ pad_token="<pad>",
2008
+ mask_token="<mask>",
2009
+ eos_token="<eos>",
2010
+ chain_break_token="|",
2011
+ **kwargs,
2012
+ ):
2013
+ backend = _build_sequence_tokenizer_backend(
2014
+ unk_token=unk_token,
2015
+ cls_token=cls_token,
2016
+ pad_token=pad_token,
2017
+ mask_token=mask_token,
2018
+ eos_token=eos_token,
2019
+ chain_break_token=chain_break_token,
2020
+ )
2021
+ self.cb_token = chain_break_token
2022
+ super().__init__(
2023
+ tokenizer_object=backend,
2024
+ unk_token=unk_token,
2025
+ cls_token=cls_token,
2026
+ pad_token=pad_token,
2027
+ mask_token=mask_token,
2028
+ eos_token=eos_token,
2029
+ additional_special_tokens=[chain_break_token],
2030
+ **kwargs,
2031
+ )
2032
+
2033
+ # ESMC does not use BOS, so expose the sequence-start token through the HF BOS fields.
2034
+ @property
2035
+ def bos_token(self):
2036
+ return self.cls_token
2037
+
2038
+ @property
2039
+ def bos_token_id(self):
2040
+ return self.cls_token_id
2041
+
2042
+ @property
2043
+ def chain_break_token(self):
2044
+ return self.cb_token
2045
+
2046
+ @property
2047
+ def chain_break_token_id(self):
2048
+ return self.convert_tokens_to_ids(self.chain_break_token)
2049
+
2050
+ @property
2051
+ def all_token_ids(self):
2052
+ return list(range(self.vocab_size))
2053
+
2054
+ @property
2055
+ def special_token_ids(self):
2056
+ return self.all_special_ids
fastplms/models/esm_plusplus/modeling_esm_plusplus_sae.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hidden-state sparse autoencoders for ESM++ (ESMC) models.
2
+
3
+ FastPLMs implements the published Biohub hidden-state SAE contract directly, so attaching an SAE
4
+ needs only PyTorch, Transformers, and the checkpoint itself. Biohub still owns the SAE weights:
5
+ this module reads their published repository layout, one shared ``config.json`` plus one
6
+ ``layer_{index}.safetensors`` shard per backbone layer, and never redistributes those tensors.
7
+
8
+ A layer produced here satisfies the same attachment contract as an official Biohub
9
+ ``ESMCSAEModel.layers`` entry, so ``PreTrainedESMplusplusModel.add_sae_models`` accepts either.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from huggingface_hub import hf_hub_download
24
+ from safetensors.torch import load_file
25
+ from torch import Tensor
26
+
27
+
28
+ SAE_CONFIG_FILE = "config.json"
29
+ SAE_REQUIRED_CONFIG_FIELDS = ("d_model", "codebook_dim", "k")
30
+ # Trained per-feature statistics. Shards that never trained them omit them, and the ones default
31
+ # makes the (features / max) * idf normalization an identity.
32
+ SAE_OPTIONAL_STATE_NAMES = ("idf", "max")
33
+ STANDARDIZATION_EPSILON = 1e-5
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ESMplusplusSAEParams:
38
+ """Shape contract of one hidden-state SAE.
39
+
40
+ ``layer`` indexes the ESM++ hidden state the SAE reads, where ``0`` is the embedding output and
41
+ ``num_hidden_layers`` is the final normalized state.
42
+ """
43
+
44
+ d_model: int
45
+ codebook_dim: int
46
+ k: int
47
+ layer: int
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class ESMplusplusSAEOutput:
52
+ """Sparse features, and the optional reconstruction error, for one batch of residues."""
53
+
54
+ feature_magnitudes: Tensor
55
+ reconstruction_loss: Tensor | None = None
56
+
57
+
58
+ def _standardize_residue_states(residue_states: Tensor) -> Tensor:
59
+ """Center and scale each residue vector the way the SAEs were trained."""
60
+
61
+ # residue_states: (n, d)
62
+ centered = residue_states - residue_states.mean(dim=-1, keepdim=True) # (n, d)
63
+ return centered / (centered.std(dim=-1, keepdim=True) + STANDARDIZATION_EPSILON) # (n, d)
64
+
65
+
66
+ class ESMplusplusSAELayer(nn.Module):
67
+ """Top-k sparse autoencoder over one ESM++ hidden state.
68
+
69
+ Encoding standardizes each residue vector, projects it into a wide codebook, and keeps only the
70
+ ``k`` largest activations. The decoder is used only to measure reconstruction error, which is
71
+ why it is opt-in: interpretation and gradient-based design read ``feature_magnitudes`` alone.
72
+ """
73
+
74
+ idf: Tensor
75
+ max: Tensor
76
+
77
+ def __init__(self, params: ESMplusplusSAEParams) -> None:
78
+ super().__init__()
79
+ self.params = params
80
+ self.W_enc = nn.Parameter(torch.empty(params.d_model, params.codebook_dim)) # (d, c)
81
+ self.W_dec = nn.Parameter(torch.empty(params.codebook_dim, params.d_model)) # (c, d)
82
+ self.b_dec = nn.Parameter(torch.zeros(params.d_model)) # (d,)
83
+ self.register_buffer("idf", torch.ones(params.codebook_dim)) # (c,)
84
+ self.register_buffer("max", torch.ones(params.codebook_dim)) # (c,)
85
+
86
+ @property
87
+ def layer(self) -> int:
88
+ """ESM++ hidden-state index this SAE was trained against."""
89
+
90
+ return self.params.layer
91
+
92
+ def forward(
93
+ self,
94
+ residue_states: Tensor,
95
+ *,
96
+ compute_reconstruction_loss: bool = False,
97
+ ) -> ESMplusplusSAEOutput:
98
+ # residue_states: (n, d) for n residues; c is the codebook width, k the retained features
99
+ standardized = _standardize_residue_states(residue_states) # (n, d)
100
+ preactivations = F.relu((standardized - self.b_dec) @ self.W_enc) # (n, c)
101
+ retained = torch.topk(preactivations, self.params.k, dim=-1) # values, indices: (n, k)
102
+ feature_magnitudes = torch.zeros_like(preactivations).scatter(
103
+ -1, retained.indices, retained.values
104
+ ) # (n, c)
105
+ if not compute_reconstruction_loss:
106
+ return ESMplusplusSAEOutput(feature_magnitudes=feature_magnitudes)
107
+
108
+ reconstructed = feature_magnitudes @ self.W_dec + self.b_dec # (n, d)
109
+ return ESMplusplusSAEOutput(
110
+ feature_magnitudes=feature_magnitudes,
111
+ reconstruction_loss=(reconstructed - standardized).pow(2).mean(dim=-1), # (n,)
112
+ )
113
+
114
+ def get_sae_output(self, layer_states: Tensor, token_mask: Tensor) -> ESMplusplusSAEOutput:
115
+ """Encode the unpadded residues of one ESM++ hidden state.
116
+
117
+ This name and signature are the attachment contract that
118
+ ``PreTrainedESMplusplusModel.add_sae_models`` validates and calls.
119
+ """
120
+
121
+ # layer_states: (b, l, d); token_mask: (b, l)
122
+ residue_states = layer_states[token_mask] # (n, d) for n valid tokens
123
+ encoded: ESMplusplusSAEOutput = self(residue_states)
124
+ return encoded
125
+
126
+
127
+ def _repository_file(
128
+ repository: str | os.PathLike[str],
129
+ filename: str,
130
+ *,
131
+ revision: str | None,
132
+ cache_dir: str | os.PathLike[str] | None,
133
+ token: str | bool | None,
134
+ local_files_only: bool,
135
+ ) -> Path:
136
+ """Resolve one repository file, from a local directory or the Hub cache."""
137
+
138
+ # A directory counts as local only when it holds the shared config, so a stale directory named
139
+ # like a Hub identifier cannot shadow the download.
140
+ local_directory = Path(repository)
141
+ if (local_directory / SAE_CONFIG_FILE).is_file():
142
+ path = local_directory / filename
143
+ if not path.is_file():
144
+ raise FileNotFoundError(f"SAE repository {local_directory} has no {filename}.")
145
+ return path
146
+
147
+ return Path(
148
+ hf_hub_download(
149
+ repo_id=str(repository),
150
+ filename=filename,
151
+ revision=revision,
152
+ cache_dir=None if cache_dir is None else str(cache_dir),
153
+ token=token,
154
+ local_files_only=local_files_only,
155
+ )
156
+ )
157
+
158
+
159
+ def _load_sae_layer(
160
+ shard_path: Path,
161
+ *,
162
+ params: ESMplusplusSAEParams,
163
+ device: torch.device,
164
+ dtype: torch.dtype | None,
165
+ ) -> ESMplusplusSAELayer:
166
+ state = load_file(str(shard_path), device=str(device))
167
+ encoder = state.get("W_enc")
168
+ if encoder is None:
169
+ raise ValueError(f"{shard_path} is not an ESMC SAE shard; it has no 'W_enc' entry.")
170
+
171
+ # Build on the meta device so the shard tensors are the only materialized copy of a codebook
172
+ # that reaches roughly one gigabyte for the widest published SAEs.
173
+ with torch.device("meta"):
174
+ sae_layer = ESMplusplusSAELayer(params)
175
+ sae_layer.to(dtype=encoder.dtype if dtype is None else dtype)
176
+ sae_layer.to_empty(device=device)
177
+ # to_empty leaves the statistics buffers uninitialized, so restore the identity defaults that
178
+ # shards without trained statistics rely on.
179
+ sae_layer.idf.fill_(1.0)
180
+ sae_layer.max.fill_(1.0)
181
+
182
+ incompatible = sae_layer.load_state_dict(state, strict=False)
183
+ missing = tuple(
184
+ name for name in incompatible.missing_keys if name not in SAE_OPTIONAL_STATE_NAMES
185
+ )
186
+ unexpected = tuple(incompatible.unexpected_keys)
187
+ if missing or unexpected:
188
+ raise ValueError(
189
+ f"{shard_path} does not match the ESMC SAE state contract; "
190
+ f"missing {list(missing)}, unexpected {list(unexpected)}."
191
+ )
192
+ return sae_layer
193
+
194
+
195
+ def load_esmc_sae_layers(
196
+ repository: str | os.PathLike[str],
197
+ layers: Sequence[int],
198
+ *,
199
+ revision: str | None = None,
200
+ cache_dir: str | os.PathLike[str] | None = None,
201
+ token: str | bool | None = None,
202
+ local_files_only: bool = False,
203
+ device: torch.device | str = "cpu",
204
+ dtype: torch.dtype | None = None,
205
+ ) -> dict[int, ESMplusplusSAELayer]:
206
+ """Load the requested hidden-state SAE layers from a Hub repository or a local directory.
207
+
208
+ Only the shared config and the requested shards are read, so a repository that publishes every
209
+ backbone layer costs one shard per requested layer. ``dtype`` defaults to the dtype stored in
210
+ the shard; pass the ESM++ model dtype when the SAE has to consume its hidden states directly.
211
+ """
212
+
213
+ requested = tuple(dict.fromkeys(int(layer) for layer in layers))
214
+ if not requested:
215
+ raise ValueError("Loading SAE layers requires at least one backbone layer index.")
216
+
217
+ config_path = _repository_file(
218
+ repository,
219
+ SAE_CONFIG_FILE,
220
+ revision=revision,
221
+ cache_dir=cache_dir,
222
+ token=token,
223
+ local_files_only=local_files_only,
224
+ )
225
+ config = json.loads(config_path.read_text(encoding="utf-8"))
226
+ missing_fields = [name for name in SAE_REQUIRED_CONFIG_FIELDS if name not in config]
227
+ if missing_fields:
228
+ raise ValueError(f"{config_path} is not an ESMC SAE config; it omits {missing_fields}.")
229
+ available = tuple(int(index) for index in config.get("available_layers", ()))
230
+
231
+ target_device = torch.device(device)
232
+ sae_layers: dict[int, ESMplusplusSAELayer] = {}
233
+ for layer in requested:
234
+ if available and layer not in available:
235
+ raise ValueError(
236
+ f"SAE repository {repository} does not publish layer {layer}; "
237
+ f"available layers are {list(available)}."
238
+ )
239
+ shard_path = _repository_file(
240
+ repository,
241
+ f"layer_{layer}.safetensors",
242
+ revision=revision,
243
+ cache_dir=cache_dir,
244
+ token=token,
245
+ local_files_only=local_files_only,
246
+ )
247
+ sae_layers[layer] = _load_sae_layer(
248
+ shard_path,
249
+ params=ESMplusplusSAEParams(
250
+ d_model=int(config["d_model"]),
251
+ codebook_dim=int(config["codebook_dim"]),
252
+ k=int(config["k"]),
253
+ layer=layer,
254
+ ),
255
+ device=target_device,
256
+ dtype=dtype,
257
+ )
258
+ return sae_layers
259
+
260
+
261
+ __all__ = [
262
+ "ESMplusplusSAELayer",
263
+ "ESMplusplusSAEOutput",
264
+ "ESMplusplusSAEParams",
265
+ "load_esmc_sae_layers",
266
+ ]
fastplms/models/esmfold2/__init__.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_classification import (
13
+ ESMFold2ExperimentalForSequenceClassification as ESMFold2ExperimentalForSequenceClassification,
14
+ ESMFold2ExperimentalForTokenClassification as ESMFold2ExperimentalForTokenClassification,
15
+ ESMFold2ForSequenceClassification as ESMFold2ForSequenceClassification,
16
+ ESMFold2ForTokenClassification as ESMFold2ForTokenClassification,
17
+ )
18
+ from .modeling_esmfold2_experimental import (
19
+ ESMFold2ExperimentalModel as ESMFold2ExperimentalModel,
20
+ )
21
+ from .reproducibility import seed_context as seed_context
22
+
23
+ _EXPORT_MODULES = {
24
+ "ESMFold2Config": ".configuration_esmfold2",
25
+ "ESMFold2ExperimentalModel": ".modeling_esmfold2_experimental",
26
+ "ESMFold2ExperimentalForSequenceClassification": ".modeling_esmfold2_classification",
27
+ "ESMFold2ExperimentalForTokenClassification": ".modeling_esmfold2_classification",
28
+ "ESMFold2ForSequenceClassification": ".modeling_esmfold2_classification",
29
+ "ESMFold2ForTokenClassification": ".modeling_esmfold2_classification",
30
+ "ESMFold2Model": ".modeling_esmfold2",
31
+ "ESMFold2Output": ".modeling_esmfold2",
32
+ "seed_context": ".reproducibility",
33
+ }
34
+
35
+
36
+ def __getattr__(name: str) -> Any:
37
+ module_name = _EXPORT_MODULES.get(name)
38
+ if module_name is None:
39
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
40
+ value = getattr(import_module(module_name, __name__), name)
41
+ globals()[name] = value
42
+ return value
43
+
44
+
45
+ def __dir__() -> list[str]:
46
+ return sorted(set(globals()) | set(_EXPORT_MODULES))
47
+
48
+
49
+ __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,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from fastplms.attention import canonical_checkpoint_attention_backend
25
+
26
+
27
+ _ESMC_ATTENTION_IMPLEMENTATIONS = frozenset({"eager", "flex_attention", "sdpa"})
28
+ _ESMC_PRECISIONS = frozenset({"auto", "bf16", "fp32", "fp8"})
29
+
30
+
31
+ def _esmc_backbone_checkpoint_ids() -> tuple[str, str]:
32
+ """Return the manifest-pinned official and FastPLMs ESMC repositories."""
33
+
34
+ from fastplms.registry import RegistryError, get_model_registry
35
+
36
+ registry = get_model_registry()
37
+ family = registry.families["esmfold2"]
38
+ if family.backbone_model is None:
39
+ raise RegistryError("families.esmfold2 must declare backbone_model.")
40
+ backbone = registry[family.backbone_model]
41
+ return backbone.official.repo_id, backbone.fast.repo_id
42
+
43
+
44
+ def normalize_esmc_id(esmc_id: str) -> str:
45
+ """Resolve an official ESMC identifier to its FastPLMs checkpoint mirror."""
46
+
47
+ from fastplms.registry import get_model_registry
48
+
49
+ official_repo, fast_repo = _esmc_backbone_checkpoint_ids()
50
+ if esmc_id == official_repo:
51
+ return fast_repo
52
+ registry = get_model_registry()
53
+ for spec in registry.by_family("esmfold2"):
54
+ if spec.backbone is not None and spec.backbone_model is not None:
55
+ backbone = registry[spec.backbone_model]
56
+ if esmc_id in {spec.backbone.repo_id, backbone.official.repo_id}:
57
+ return backbone.fast.repo_id
58
+ return esmc_id
59
+
60
+
61
+ def normalize_esmc_attention_implementation(
62
+ implementation: str | dict[str, str] | None,
63
+ ) -> str | None:
64
+ """Validate the ESMC backend and translate the historical ``flex`` name."""
65
+
66
+ if isinstance(implementation, dict):
67
+ if tuple(implementation) != ("",):
68
+ raise ValueError(
69
+ "ESMFold2 has one ESMC attention backbone; use a string or {'': implementation}."
70
+ )
71
+ implementation = implementation[""]
72
+ canonical = canonical_checkpoint_attention_backend(implementation)
73
+ if canonical is not None and canonical not in _ESMC_ATTENTION_IMPLEMENTATIONS:
74
+ expected = sorted(_ESMC_ATTENTION_IMPLEMENTATIONS)
75
+ raise ValueError(
76
+ f"Unsupported ESMFold2 attention implementation {canonical!r}; "
77
+ f"expected one of {expected}."
78
+ )
79
+ return canonical
80
+
81
+
82
+ NestedConfig = TypeVar("NestedConfig")
83
+
84
+
85
+ def _nested_config(value: Any, config_type: type[NestedConfig]) -> NestedConfig:
86
+ if isinstance(value, config_type):
87
+ return value
88
+ return config_type(**value) if isinstance(value, dict) else config_type()
89
+
90
+
91
+ def _coerce_nested_field(
92
+ value: NestedConfig | dict[str, Any], config_type: type[NestedConfig]
93
+ ) -> NestedConfig:
94
+ """Convert serialized nested dictionaries while retaining supplied objects."""
95
+
96
+ return config_type(**value) if isinstance(value, dict) else value
97
+
98
+
99
+ @dataclass
100
+ class AtomAttentionConfig:
101
+ """Sliding-window atom attention and three-dimensional RoPE settings."""
102
+
103
+ d_atom: int = field(default=128)
104
+ d_token: int = field(default=768)
105
+ n_blocks: int = field(default=3)
106
+ n_heads: int = field(default=4)
107
+ swa_window_size: int = field(default=128)
108
+ expansion_ratio: int = field(default=2)
109
+ spatial_rope_base_frequency: float = field(default=20.0)
110
+ n_spatial_rope_pairs_per_axis: int = field(default=2)
111
+ n_uid_rope_pairs: int = field(default=10)
112
+ uid_rope_base_frequency: float = field(default=10000.0)
113
+
114
+
115
+ @dataclass
116
+ class DiffusionModuleConfig:
117
+ """Dimensions and depth of the coordinate diffusion network."""
118
+
119
+ sigma_data: float = field(default=16.0)
120
+ c_atom: int = field(default=128)
121
+ c_token: int = field(default=768)
122
+ c_z: int = field(default=256)
123
+ c_s_inputs: int = field(default=451)
124
+ fourier_dim: int = field(default=256)
125
+ relpos_r_max: int = field(default=32)
126
+ relpos_s_max: int = field(default=2)
127
+ atom_num_blocks: int = field(default=3)
128
+ atom_num_heads: int = field(default=4)
129
+ token_num_blocks: int = field(default=12)
130
+ token_num_heads: int = field(default=16)
131
+ transition_multiplier: int = field(default=2)
132
+
133
+
134
+ @dataclass
135
+ class FoldingTrunkConfig:
136
+ """Iterative pair/single trunk dimensions."""
137
+
138
+ n_layers: int = field(default=24)
139
+ n_heads: int = field(default=8)
140
+ dropout: float = field(default=0.0)
141
+
142
+
143
+ @dataclass
144
+ class InputsEmbedderConfig:
145
+ """Input feature width and atom encoder settings."""
146
+
147
+ d_inputs: int = field(default=451)
148
+ atom_encoder: AtomAttentionConfig = field(default_factory=AtomAttentionConfig)
149
+
150
+ def __post_init__(self) -> None:
151
+ self.atom_encoder = _coerce_nested_field(self.atom_encoder, AtomAttentionConfig)
152
+
153
+
154
+ @dataclass
155
+ class DiffusionStructureHeadConfig:
156
+ """Training and inference schedules for coordinate denoising."""
157
+
158
+ diffusion_module: DiffusionModuleConfig = field(default_factory=DiffusionModuleConfig)
159
+ distogram_bins: int = field(default=128)
160
+ train_noise_log_mean: float = field(default=-1.2)
161
+ train_noise_log_std: float = field(default=1.5)
162
+ gamma_0: float = field(default=0.605)
163
+ gamma_min: float = field(default=1.107)
164
+ noise_scale: float = field(default=0.0)
165
+ step_scale: float = field(default=1.0)
166
+ inference_s_max: float = field(default=160.0)
167
+ inference_s_min: float = field(default=4e-4)
168
+ inference_p: float = field(default=8.0)
169
+ inference_num_steps: int = field(default=68)
170
+
171
+ def __post_init__(self) -> None:
172
+ self.diffusion_module = _coerce_nested_field(self.diffusion_module, DiffusionModuleConfig)
173
+
174
+
175
+ @dataclass
176
+ class ConfidenceHeadConfig:
177
+ """Confidence-bin definitions and the compact confidence trunk."""
178
+
179
+ enabled: bool = field(default=True)
180
+ num_plddt_bins: int = field(default=50)
181
+ num_pde_bins: int = field(default=64)
182
+ num_pae_bins: int = field(default=64)
183
+ min_dist: float = field(default=2.0)
184
+ max_dist: float = field(default=52.0)
185
+ distogram_bins: int = field(default=128)
186
+ folding_trunk: FoldingTrunkConfig = field(
187
+ default_factory=lambda: FoldingTrunkConfig(n_layers=4)
188
+ )
189
+
190
+ def __post_init__(self) -> None:
191
+ self.folding_trunk = _coerce_nested_field(self.folding_trunk, FoldingTrunkConfig)
192
+
193
+
194
+ @dataclass
195
+ class MSAEncoderConfig:
196
+ """Optional multiple-sequence-alignment encoder settings."""
197
+
198
+ enabled: bool = field(default=False)
199
+ d_msa: int = field(default=128)
200
+ d_hidden: int = field(default=32)
201
+ n_layers: int = field(default=4)
202
+ n_heads_msa: int = field(default=8)
203
+ msa_head_width: int = field(default=32)
204
+
205
+
206
+ @dataclass
207
+ class LMEncoderConfig:
208
+ """Release-model pair encoder derived from language-model states."""
209
+
210
+ enabled: bool = field(default=True)
211
+ n_layers: int = field(default=4)
212
+ lm_dropout: float = field(default=0.25)
213
+ per_loop_lm_dropout: bool = field(default=True)
214
+
215
+
216
+ @dataclass
217
+ class ParcaeConfig:
218
+ """Release-model diffusion-loop scheduler settings."""
219
+
220
+ enabled: bool = field(default=True)
221
+ poisson_mean: float = field(default=3.0)
222
+ min_steps: int = field(default=1)
223
+ max_steps: int | None = field(default=6)
224
+ coda_n_layers: int = field(default=2)
225
+
226
+
227
+ _SCALAR_DEFAULTS: tuple[tuple[str, Any], ...] = (
228
+ ("d_single", 384),
229
+ ("d_pair", 256),
230
+ ("n_relative_residx_bins", 32),
231
+ ("n_relative_chain_bins", 2),
232
+ ("num_loops", 10),
233
+ ("num_diffusion_samples", 8),
234
+ ("disable_msa_features", False),
235
+ ("lm_dropout", 0.0),
236
+ ("force_lm_dropout_during_inference", False),
237
+ ("lm_mask_pct", 0.0),
238
+ ("lm_d_model", 2560),
239
+ ("lm_num_layers", 80),
240
+ )
241
+ _NESTED_CONFIGS = (
242
+ ("inputs", InputsEmbedderConfig),
243
+ ("folding_trunk", FoldingTrunkConfig),
244
+ ("structure_head", DiffusionStructureHeadConfig),
245
+ ("confidence_head", ConfidenceHeadConfig),
246
+ ("msa_encoder", MSAEncoderConfig),
247
+ ("parcae", ParcaeConfig),
248
+ ("lm_encoder", LMEncoderConfig),
249
+ )
250
+
251
+
252
+ class ESMFold2Config(PretrainedConfig):
253
+ """Serializable ESMFold2 architecture, runtime, and precision settings."""
254
+
255
+ model_type = "esmfold2"
256
+ has_no_defaults_at_init = True
257
+
258
+ def __init__(self, **kwargs: Any) -> None:
259
+ legacy_backend = normalize_esmc_attention_implementation(kwargs.get("esmc_attn_backend"))
260
+ requested_backend = normalize_esmc_attention_implementation(
261
+ kwargs.get("attn_implementation")
262
+ )
263
+ resolved_backend = requested_backend or legacy_backend
264
+ kwargs["attn_implementation"] = resolved_backend
265
+ super().__init__(**kwargs)
266
+
267
+ self.type = kwargs.get("type", "release")
268
+ if self.type not in {"experimental", "release"}:
269
+ raise ValueError(
270
+ f"ESMFold2Config.type must be 'release' or 'experimental', got {self.type!r}"
271
+ )
272
+
273
+ for name, default in _SCALAR_DEFAULTS:
274
+ setattr(self, name, kwargs.get(name, default))
275
+
276
+ _official_esmc_repo, default_esmc_repo = _esmc_backbone_checkpoint_ids()
277
+ self.esmc_id = normalize_esmc_id(kwargs.get("esmc_id", default_esmc_repo))
278
+ self.esmc_attn_backend = resolved_backend
279
+ self.esmc_precision = str(kwargs.get("esmc_precision", "auto"))
280
+ if self.esmc_precision not in _ESMC_PRECISIONS:
281
+ raise ValueError(
282
+ "esmc_precision must be 'auto', 'bf16', 'fp32', or 'fp8', "
283
+ f"got {self.esmc_precision!r}."
284
+ )
285
+
286
+ for name, config_type in _NESTED_CONFIGS:
287
+ setattr(self, name, _nested_config(kwargs.get(name), config_type))
288
+ if not isinstance(self.msa_encoder.enabled, bool):
289
+ raise TypeError("msa_encoder.enabled must be a boolean.")
290
+ declared_msa_conditioning = kwargs.get("msa_conditioning")
291
+ if "msa_conditioning" in kwargs and not isinstance(declared_msa_conditioning, bool):
292
+ raise TypeError("msa_conditioning must be a boolean when provided.")
293
+ self.msa_conditioning = (
294
+ self.msa_encoder.enabled
295
+ if "msa_conditioning" not in kwargs
296
+ else declared_msa_conditioning
297
+ )
298
+ if self.msa_conditioning != self.msa_encoder.enabled:
299
+ raise ValueError(
300
+ "msa_conditioning must match msa_encoder.enabled; received "
301
+ f"{self.msa_conditioning!r} and {self.msa_encoder.enabled!r}."
302
+ )
303
+ self.msa_encoder_overwrite = bool(kwargs.get("msa_encoder_overwrite", True))
304
+
305
+ self.classifier_train_scope = str(kwargs.get("classifier_train_scope", "probe"))
306
+ if self.classifier_train_scope not in {"probe", "projection"}:
307
+ raise ValueError(
308
+ "classifier_train_scope must be 'probe' or 'projection', "
309
+ f"got {self.classifier_train_scope!r}."
310
+ )
311
+ self.classifier_probe_hidden_size = int(
312
+ kwargs.get("classifier_probe_hidden_size", 512)
313
+ )
314
+ self.classifier_probe_num_heads = int(kwargs.get("classifier_probe_num_heads", 4))
315
+ self.classifier_probe_dropout = float(kwargs.get("classifier_probe_dropout", 0.1))
316
+ self.classifier_hidden_size = int(kwargs.get("classifier_hidden_size", 4096))
317
+ self.classifier_dropout = float(kwargs.get("classifier_dropout", 0.2))
318
+ self.classifier_pooling_types = list(
319
+ kwargs.get("classifier_pooling_types", ["mean"])
320
+ )
321
+
322
+ def to_dict(self) -> dict[str, Any]:
323
+ output = cast(dict[str, Any], super().to_dict())
324
+ for name, _config_type in _NESTED_CONFIGS:
325
+ output[name] = asdict(getattr(self, name))
326
+ return output
327
+
328
+
329
+ __all__ = [
330
+ "ESMFold2Config",
331
+ "LMEncoderConfig",
332
+ "MSAEncoderConfig",
333
+ "ParcaeConfig",
334
+ "normalize_esmc_attention_implementation",
335
+ "normalize_esmc_id",
336
+ ]
fastplms/models/esmfold2/embedding.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ESMFold2 integration for the shared FastPLMs embedding API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ from typing import Any, ClassVar
7
+ from torch import Tensor
8
+
9
+ from ...embeddings import EmbeddingBatch, EmbeddingResult, embed_dataset
10
+ from .esmfold2_constants_esm3 import SEQUENCE_PAD_TOKEN, SEQUENCE_VOCAB
11
+
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
+ # hidden_states: (b, l, 81, d_model); residue_mask: (b, l) or None.
57
+ # d_z is the learned projection width: 256 for released ESMFold2 checkpoints.
58
+ if hidden_states.ndim != 4 or hidden_states.shape[-2] != 81:
59
+ raise ValueError(
60
+ "ESMFold2 projection requires the official ordered 81-state "
61
+ "ESMC tensor H with shape (b, l, 81, d_model)."
62
+ )
63
+ return self.language_model.project_sequence(
64
+ hidden_states, residue_mask
65
+ ) # (b, l, d_z)
66
+
67
+ def _embedding_batch(self, sequences: list[str], **kwargs: Any) -> EmbeddingBatch:
68
+ if kwargs:
69
+ raise TypeError(f"Unexpected ESMFold2 embedding options: {', '.join(sorted(kwargs))}.")
70
+ if self._esmc is None:
71
+ raise RuntimeError("ESMFold2 embeddings require load_esmc=True.")
72
+ encoded = [_encode_single_chain(sequence) for sequence in sequences]
73
+ sequence_length = max(map(len, encoded)) # l
74
+ b = len(encoded) # b
75
+ device = self.device
76
+ input_ids = torch.full(
77
+ (b, sequence_length),
78
+ SEQUENCE_PAD_TOKEN,
79
+ dtype=torch.long,
80
+ device=device,
81
+ ) # (b, l)
82
+ residue_mask = torch.zeros(
83
+ (b, sequence_length), dtype=torch.bool, device=device
84
+ ) # (b, l)
85
+ for batch_index, token_ids in enumerate(encoded):
86
+ length = len(token_ids) # l_i
87
+ input_ids[batch_index, :length] = torch.tensor(
88
+ token_ids, dtype=torch.long, device=device
89
+ ) # (l_i,) -> input_ids[batch_index, :l_i]: (l_i,)
90
+ residue_mask[batch_index, :length] = True # (l_i,)
91
+
92
+ residue_index = torch.arange(sequence_length, device=device).expand(b, -1) # (b, l)
93
+ asym_id = torch.zeros_like(input_ids) # (b, l)
94
+ mol_type = torch.zeros_like(input_ids) # (b, l)
95
+ hidden_states = self._compute_lm_hidden_states(
96
+ input_ids,
97
+ asym_id,
98
+ residue_index,
99
+ mol_type,
100
+ residue_mask,
101
+ ) # (b, l, 81, d_model)
102
+ projected = self.project_esmc_hidden_states(
103
+ hidden_states, residue_mask
104
+ ) # (b, l, d_z)
105
+ return EmbeddingBatch(
106
+ X=projected, residue_mask=residue_mask
107
+ ) # X: (b, l, d_z); residue_mask: (b, l)
108
+
109
+ def embed_dataset(self, inputs: Any, **kwargs: Any) -> EmbeddingResult:
110
+ """Embed single-chain proteins using the learned 256-wide ESMFold2 summary."""
111
+
112
+ return embed_dataset(self, inputs, **kwargs)
113
+
114
+
115
+ __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,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ if input.distogram_conditioning is not None:
127
+ raise NotImplementedError(
128
+ "ESMFold2 distogram conditioning is present in the upstream input schema but "
129
+ "the published ESMFold2 forward does not consume it. FastPLMs refuses this "
130
+ "input instead of silently ignoring the supplied distogram."
131
+ )
132
+
133
+ cleaned: list[Any] = []
134
+ for item in input.sequences:
135
+ if not isinstance(item, ProteinInput):
136
+ cleaned.append(item)
137
+ continue
138
+ sequence = ":".join(item.sequence.split("|"))
139
+ if ":" not in sequence:
140
+ cleaned.append(item)
141
+ continue
142
+ if input.covalent_bonds is not None:
143
+ raise ValueError(
144
+ "Covalent bonds are not supported when using chainbreaks. "
145
+ "Chains must be separated into multiple ProteinInput objects."
146
+ )
147
+ pending, _state = _split_protein(item)
148
+ cleaned.extend(pending)
149
+
150
+ resolved = [
151
+ _resolve_pending(item) if isinstance(item, _PendingProtein) else item
152
+ for item in cleaned
153
+ ]
154
+ return StructurePredictionInput(
155
+ sequences=resolved,
156
+ pocket=input.pocket,
157
+ distogram_conditioning=input.distogram_conditioning,
158
+ covalent_bonds=input.covalent_bonds,
159
+ )
160
+
161
+
162
+ def _batch_features(
163
+ features: dict[str, Any],
164
+ device: torch.device | str | None,
165
+ ) -> dict[str, Any]:
166
+ return {
167
+ name: (value[None].to(device) if device is not None else value[None])
168
+ if isinstance(value, Tensor)
169
+ else value
170
+ for name, value in features.items()
171
+ }
172
+
173
+
174
+ def _sampler_overrides(
175
+ noise_scale: float | None,
176
+ step_scale: float | None,
177
+ max_inference_sigma: int | None,
178
+ ) -> dict[str, Any]:
179
+ values = {
180
+ "noise_scale": noise_scale,
181
+ "step_scale": step_scale,
182
+ "max_inference_sigma": max_inference_sigma,
183
+ }
184
+ return {name: value for name, value in values.items() if value is not None}
185
+
186
+
187
+ class ESMFold2InputBuilder:
188
+ """Prepare public input objects, run folding, and decode model tensors."""
189
+
190
+ def __init__(self, ccd_cache: Path | None = None) -> None:
191
+ load_ccd(ccd_cache)
192
+
193
+ def prepare_input(
194
+ self,
195
+ input: StructurePredictionInput,
196
+ seed: int | None = None,
197
+ device: torch.device | str | None = None,
198
+ ) -> tuple[dict[str, Any], list[ChainInfo]]:
199
+ cleaned = clean_esmfold2_input(input)
200
+ with seed_context(seed):
201
+ features, chain_infos = prepare_esmfold2_input(cleaned, seed=seed)
202
+ return _batch_features(features, device), chain_infos
203
+
204
+ def prepare_model_input(
205
+ self,
206
+ model: Any,
207
+ input: StructurePredictionInput,
208
+ seed: int | None = None,
209
+ device: torch.device | str | None = None,
210
+ ) -> tuple[dict[str, Any], list[ChainInfo]]:
211
+ """Prepare features while enforcing the checkpoint's MSA contract."""
212
+
213
+ msa_conditioning = getattr(model.config, "msa_conditioning", None)
214
+ if not isinstance(msa_conditioning, bool):
215
+ raise RuntimeError("The ESMFold2 config has no Boolean msa_conditioning contract.")
216
+ if not msa_conditioning:
217
+ explicit_msa_ids = [
218
+ item.id
219
+ for item in input.sequences
220
+ if isinstance(item, ProteinInput) and item.msa is not None
221
+ ]
222
+ if explicit_msa_ids:
223
+ raise ValueError(
224
+ "This ESMFold2 checkpoint was trained without MSA conditioning and "
225
+ f"rejects explicit MSAs for protein inputs {explicit_msa_ids!r}."
226
+ )
227
+ features, chain_infos = self.prepare_input(input, seed=seed, device=device)
228
+ if not msa_conditioning:
229
+ for name in MSA_CONDITIONING_INPUT_NAMES:
230
+ features.pop(name, None)
231
+ return features, chain_infos
232
+
233
+ def __call__(
234
+ self,
235
+ input: StructurePredictionInput,
236
+ seed: int | None = None,
237
+ device: torch.device | str | None = None,
238
+ ) -> tuple[dict[str, Any], list[ChainInfo]]:
239
+ return self.prepare_input(input, seed=seed, device=device)
240
+
241
+ def _decode_sample(
242
+ self,
243
+ output: Mapping[str, Tensor],
244
+ features: dict[str, Tensor],
245
+ chain_infos: list[ChainInfo],
246
+ sample: int,
247
+ complex_id: str,
248
+ ) -> MolecularComplexResult:
249
+ plddt = output["plddt"][sample]
250
+ molecular_complex = build_molecular_complex_from_features(
251
+ coords=output["sample_atom_coords"][sample],
252
+ plddt=plddt,
253
+ atom_mask=features["atom_attention_mask"][0],
254
+ ref_element=features["ref_element"][0],
255
+ ref_atom_name_chars=features["ref_atom_name_chars"][0],
256
+ chain_infos=chain_infos,
257
+ complex_id=complex_id,
258
+ )
259
+
260
+ def sample_tensor(name: str) -> Tensor | None:
261
+ value = output.get(name)
262
+ return None if value is None else value[sample].detach().cpu()
263
+
264
+ def shared_tensor(name: str) -> Tensor | None:
265
+ value = output.get(name)
266
+ return None if value is None else value[0].detach().cpu()
267
+
268
+ ptm = output.get("ptm")
269
+ iptm = output.get("iptm")
270
+ return MolecularComplexResult(
271
+ complex=molecular_complex,
272
+ plddt=plddt.detach().cpu(),
273
+ ptm=float(ptm[sample].item()) if ptm is not None else None,
274
+ iptm=float(iptm[sample].item()) if iptm is not None else None,
275
+ pae=sample_tensor("pae"),
276
+ distogram=shared_tensor("distogram_logits"),
277
+ pair_chains_iptm=sample_tensor("pair_chains_iptm"),
278
+ residue_index=shared_tensor("residue_index"),
279
+ entity_id=shared_tensor("entity_id"),
280
+ )
281
+
282
+ def decode(
283
+ self,
284
+ output: Mapping[str, Tensor],
285
+ features: dict[str, Tensor],
286
+ chain_infos: list[ChainInfo],
287
+ *,
288
+ num_diffusion_samples: int = 1,
289
+ complex_id: str = "pred",
290
+ ) -> MolecularComplexResult | list[MolecularComplexResult]:
291
+ results = [
292
+ self._decode_sample(output, features, chain_infos, sample, complex_id)
293
+ for sample in range(output["sample_atom_coords"].shape[0])
294
+ ]
295
+ return results[0] if num_diffusion_samples == 1 and len(results) == 1 else results
296
+
297
+ def fold(
298
+ self,
299
+ model: Any,
300
+ input: StructurePredictionInput,
301
+ *,
302
+ num_loops: int = 3,
303
+ num_sampling_steps: int = 200,
304
+ num_diffusion_samples: int = 1,
305
+ seed: int | None = None,
306
+ noise_scale: float | None = None,
307
+ step_scale: float | None = None,
308
+ max_inference_sigma: int | None = None,
309
+ early_exit: bool = False,
310
+ complex_id: str = "pred",
311
+ ) -> MolecularComplexResult | list[MolecularComplexResult]:
312
+ features, chain_infos = self.prepare_model_input(
313
+ model,
314
+ input,
315
+ seed=seed,
316
+ device=model.device,
317
+ )
318
+ overrides = _sampler_overrides(noise_scale, step_scale, max_inference_sigma)
319
+ with torch.no_grad(), seed_context(seed):
320
+ output = model(
321
+ **features,
322
+ num_loops=num_loops,
323
+ num_sampling_steps=num_sampling_steps,
324
+ num_diffusion_samples=num_diffusion_samples,
325
+ early_exit=early_exit,
326
+ return_dict=True,
327
+ **overrides,
328
+ )
329
+ return self.decode(
330
+ output,
331
+ features,
332
+ chain_infos,
333
+ num_diffusion_samples=num_diffusion_samples,
334
+ complex_id=complex_id,
335
+ )
336
+
337
+
338
+ __all__ = ["ESMFold2InputBuilder", "clean_esmfold2_input", "seed_context"]