diff --git a/LICENSES/FastPLMs-Apache-2.0.txt b/LICENSES/FastPLMs-Apache-2.0.txt new file mode 100644 index 0000000000000000000000000000000000000000..66c354fb5c07a2de923f8de7cfe1e869dac39200 --- /dev/null +++ b/LICENSES/FastPLMs-Apache-2.0.txt @@ -0,0 +1,203 @@ +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 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/biohub-esm/LICENSE.md b/LICENSES/biohub-esm/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..d6021558652f83cd373d2273f16d9cf402134287 --- /dev/null +++ b/LICENSES/biohub-esm/LICENSE.md @@ -0,0 +1,9 @@ +**License (MIT)** + +Copyright 2026 Chan Zuckerberg Biohub, Inc. + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. diff --git a/LICENSES/biohub-esm/THIRD_PARTY_NOTICE.md b/LICENSES/biohub-esm/THIRD_PARTY_NOTICE.md new file mode 100644 index 0000000000000000000000000000000000000000..56d8280e67083eb023fda21b0091f61ea6bdab33 --- /dev/null +++ b/LICENSES/biohub-esm/THIRD_PARTY_NOTICE.md @@ -0,0 +1,13 @@ +The code in this repository depends on the following third-party libraries: + +| Library | License | Link | +|----------|----------|----------| +| flash-attn | BSD | https://github.com/Dao-AILab/flash-attention/blob/main/LICENSE | +| PyTorch | BSD | https://github.com/pytorch/pytorch/blob/main/LICENSE | +| xformers | BSD | https://github.com/facebookresearch/xformers/blob/main/LICENSE | +| jaxtyping | MIT | https://github.com/patrick-kidger/jaxtyping/blob/main/LICENSE | +| einops | MIT | https://github.com/arogozhnikov/einops/blob/main/LICENSE | +| omegaconf | BSD | https://github.com/omry/omegaconf/blob/master/LICENSE | +| attrs | MIT | https://github.com/python-attrs/attrs/blob/main/LICENSE | +| scipy | BSD-3-Clause | https://github.com/scipy/scipy/blob/main/LICENSE.txt
https://github.com/scipy/scipy/blob/main/LICENSES_bundled.txt | +| lightning / torchmetrics | Apache 2.0 | https://github.com/Lightning-AI/torchmetrics/blob/master/LICENSE | diff --git a/LICENSES/biohub-transformers/LICENSE b/LICENSES/biohub-transformers/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..68b7d66c97d66c58de883ed0c451af2b3183e6f3 --- /dev/null +++ b/LICENSES/biohub-transformers/LICENSE @@ -0,0 +1,203 @@ +Copyright 2018- The Hugging Face team. All rights reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/protein-ttt/LICENSE b/LICENSES/protein-ttt/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..2e8ce69ee735b7021ac28defffdfecb43d287db6 --- /dev/null +++ b/LICENSES/protein-ttt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Anton Bushuiev + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/LICENSES/protein-ttt/PROVENANCE.md b/LICENSES/protein-ttt/PROVENANCE.md new file mode 100644 index 0000000000000000000000000000000000000000..b218841986dc236e71f4f124a8f1b0d668ca36b1 --- /dev/null +++ b/LICENSES/protein-ttt/PROVENANCE.md @@ -0,0 +1,8 @@ +# ProteinTTT provenance + +FastPLMs uses `anton-bushuiev/ProteinTTT` revision +`fde2817cd84b936167cc76ccabf31e5c0fe49962` as the official reference for the +optional protein test-time training workflow. The repository is pinned at +`vendor/upstream/protein-ttt/` and is not a production dependency or runtime +image component. The accompanying `LICENSE` is the verbatim MIT text from that +revision. diff --git a/README.md b/README.md index 61b455e366e6c1b9e6d8465f7a1fbe3f08b69b0b..df489671b665bc4f7fe1107de339f0e77cc3b627 100644 --- a/README.md +++ b/README.md @@ -1,247 +1,268 @@ ---- -library_name: transformers -tags: - - biology - - protein-structure - - esmfold2 - - multimodal-protein-model ---- - -# FastPLMs ESMFold2 - -FastPLMs ESMFold2 is a self-contained Hugging Face `AutoModel` wrapper for -Biohub's ESMFold2, ESMFold2-Fast, and experimental ESMFold2 structure -predictors. It vendors the released Biohub ESMFold2 model code, input builder, -MSA helpers, and structure export utilities, while loading the PLM backbone -through FastPLMs ESM++. - -## Load With AutoModel - -```python -import torch -from transformers import AutoModel - -model = AutoModel.from_pretrained( - "Synthyra/ESMFold2-Fast", - trust_remote_code=True, - dtype=torch.float32, -).eval().cuda() -``` - -Use `Synthyra/ESMFold2` for the full model, `Synthyra/ESMFold2-Fast` for the -faster release variant, and the `Synthyra/ESMFold2-Experimental*` checkpoints -for differentiable binder design and experimental critic ensembles. -The folding trunk runs in fp32; the 6B FastPLMs ESM++ backbone is loaded in -bf16 by default via `esmc_precision="bf16"` and uses the flex attention backend -by default inside ESMFold2. - -## Fold One Protein - -```python -sequence = "MKTLLILAVVAAALA" - -result = model.fold_protein( - sequence, - num_loops=3, - num_sampling_steps=50, - num_diffusion_samples=1, - seed=0, -) - -print(float(result.plddt.mean())) -print(float(result.ptm)) -``` - -## Experimental Test-Time Training - -TTT is disabled by default. Standard `fold_protein(...)`, `fold(...)`, raw tensor -inference, and `state_dict()` keys are unchanged unless you explicitly pass -`ttt=True` or call `fold_protein_ttt(...)`. - -The ESMFold2 TTT path is experimental and protein-only in v1. It trains local -LoRA adapters only on `_esmc` with a masked language modeling objective. The -folding trunk, confidence head, diffusion head, and structure input pipeline are -frozen. TTT can improve difficult low-confidence folds, but it adds substantial -test-time compute and can degrade already confident predictions. - -```python -result = model.fold_protein( - "MSTNPKPQRKTKRNT", - num_loops=1, - num_sampling_steps=10, - num_diffusion_samples=1, - seed=0, - ttt=True, - ttt_config={ - "steps": 1, - "ags": 1, - "batch_size": 1, - "lora_rank": 8, - "lora_alpha": 32.0, - }, -) - -print(result.ttt_metrics["losses"]) -print(result.ttt_metrics["step_plddts"]) -print(result.ttt_metrics["best_step"]) -``` - -`load_esmc=True` is required for TTT because the ESM++ MLM head is loaded lazily -from `config.esmc_id`. If that pretrained MLM head cannot be loaded, TTT raises -an assertion instead of silently using a random head. - -## Save mmCIF or PDB - -```python -model.save_as_cif(result, "prediction.cif") -model.save_as_pdb(result, "prediction.pdb") - -cif_text = model.result_to_cif(result) -pdb_text = model.result_to_pdb(result) -``` - -`result_to_cif` preserves the full `MolecularComplex`. `result_to_pdb` converts through Biohub's protein-only `ProteinComplex` representation, so use mmCIF for complexes with ligands or nucleic acids. - -## Fold Complexes - -```python -types = model.input_types - -complex_input = types.StructurePredictionInput( - sequences=[ - types.ProteinInput(id="A", sequence="MKTLLILAVVAAALA"), - types.DNAInput(id="B", sequence="GATAGC"), - types.LigandInput(id="L", ccd=["SAH"]), - ] -) - -result = model.fold( - complex_input, - num_loops=3, - num_sampling_steps=50, - num_diffusion_samples=1, - seed=0, -) - -model.save_as_cif(result, "complex_prediction.cif") -``` - -## Binder Design With FastPLMs ESMFold2 - -FastPLMs includes a FastPLMs-only port of the Biohub ESMFold2 binder design -tutorial at `cookbook/tutorials/binder_design_fastplms.py`. The workflow uses -ESMFold2 experimental checkpoints for differentiable folding losses, ESM++ for -sequence regularization, and ESMFold2 hero critics for final confidence scoring. - -![FastPLMs EGFR minibinder design](https://raw.githubusercontent.com/Synthyra/FastPLMs/main/docs/assets/egfr_fastplms_binder_design.png) - -The optimizer follows the official strategy: - -1. Optimize mutable `#` residues as continuous amino acid logits. -2. Suppress cysteine design by masking cysteine logits and gradients. -3. Backpropagate through ESMFold2 `res_type_soft` using intra-contact, - inter-contact, and globularity losses from the distogram. -4. Add an ESM++ masked-LM pseudoperplexity regularizer on mutable binder - residues. -5. Keep the late-trajectory sequence with the best iPTM. -6. Fold the selected sequence with the final critic ensemble and write - `results.parquet`, `selection.parquet`, `trajectory.jsonl`, - `best_sequences.fasta`, and per-critic PDB/CIF/logit files. - -Run the verified EGFR 128 amino acid de novo minibinder example: - -```bash -cd /home/ubuntu/FastPLMs - -sudo -n docker run --gpus all --rm \ - -v /home/ubuntu/FastPLMs:/app \ - -v /home/ubuntu/FastPLMs:/workspace \ - -v /home/ubuntu/.cache/huggingface:/workspace/.cache/huggingface \ - -w /workspace fastplms-esmfold2 \ - python /app/cookbook/tutorials/binder_design_fastplms.py \ - --backend local \ - --target-name egfr \ - --binder-sequence '################################################################################################################################' \ - --not-antibody \ - --steps 150 \ - --batch-size 1 \ - --seed 103 \ - --output-dir /workspace/campaign_egfr_len128_b1_s150_seed103_consensus_cli -``` - -Verified result: - -| Metric | Value | -| :--- | :--- | -| Binder length | `128` | -| Seed | `103` | -| Steps | `150` | -| Hero mean iPTM | `0.913870` | -| Hero min iPTM | `0.904600` | -| All four hero critics above 0.9 | `True` | - -Binder sequence: - -```text -SAVKHLLEIVKYLEEAIEKALEVDPVFLVPPAAEELLIAAKVIKELAKENPELIEVYELLMKAVKGLKKLVRSNDKEILREVIRLLRKAAKVIREILKNNPDLDPELRKALEELAKVLEEIAEVLEQQ -``` - -See the full guide in [`docs/binder_design.md`](https://github.com/Synthyra/FastPLMs/blob/main/docs/binder_design.md) -for Modal execution, official pI and selection scoring, per-critic metrics, and -the tested cheaper step-count boundary. - -## Use MSAs - -```python -types = model.input_types - -msa = types.MSA.from_a3m("query.a3m", max_sequences=128) -input_with_msa = types.StructurePredictionInput( - sequences=[ - types.ProteinInput(id="A", sequence=msa.query, msa=msa), - ] -) - -result = model.fold(input_with_msa, num_sampling_steps=50, seed=0) -``` - -## Raw Tensor Inference - -```python -features, chain_infos = model.prepare_structure_input(complex_input, seed=0) - -with torch.inference_mode(): - output = model( - **features, - num_loops=3, - num_sampling_steps=50, - num_diffusion_samples=1, - ) - -decoded = model.input_builder.decode(output, features, chain_infos) -``` - -Set `load_esmc=False` when loading if you want to provide precomputed `lm_hidden_states` manually or run folding-trunk tests without loading the 6B ESM++ backbone: - -```python -model = AutoModel.from_pretrained( - "Synthyra/ESMFold2-Fast", - trust_remote_code=True, - load_esmc=False, -).cuda().eval() -``` - -For FP8 LM inference, install `transformer_engine.pytorch` in a CUDA -environment with FP8-capable hardware and load the shared FastPLMs ESM++ -backbone with: - -```python -model = AutoModel.from_pretrained( - "Synthyra/ESMFold2-Fast", - trust_remote_code=True, - esmc_precision="fp8", -).cuda().eval() -``` - -FP8 is inference-only for the ESMFold2 LM backbone. TTT remains a bf16/fp32 -path. +--- +library_name: transformers +license: "mit" +tags: + - protein-language-model + - fastplms +--- + + + +# Synthyra/ESMFold2-Experimental-Cutoff2025 + +This checkpoint packages the FastPLMs `ESMFold2` implementation. + +Accepted inputs are raw amino-acid sequences or typed molecular-complex +specifications; low-level forward accepts prepared feature tensors. +Supported Transformers entry points are `AutoConfig`, `AutoModel`. + +## Install and platform requirements + +Install FastPLMs from the exact source revision paired with this model card: + +```bash +python -m pip install \ + "fastplms[structure] @ git+https://github.com/Synthyra/FastPLMs.git@1b9ce023f1e06571cf3e6324be0610ffa53e0a4a" +``` + +Python 3.11-3.14, PyTorch 2.13, and Transformers 5.13 are required. Structure inference requires the `structure` extra and a CUDA device for the published execution contract. The current validated release target is the exact NVIDIA GH200 on Linux aarch64; Linux x86-64, CPU-only, Windows, and macOS structure runs are not current release evidence. The Hub quick start below requires network +access on first download. For an air-gapped run, first build the manifest-pinned +local artifact and use the offline form shown in the example. + +## Quick start + +```python +from transformers import AutoModel + +model_id = "Synthyra/ESMFold2-Experimental-Cutoff2025" +model = AutoModel.from_pretrained( + model_id, + trust_remote_code=True, +).eval() +``` + +This example uses the published Hub repository. For offline validation, build +the manifest-pinned artifact and replace `model_id` with its local +`dist/hub/ESMFold2-Experimental-Cutoff2025` path, then pass `local_files_only=True`. + +Leave attention unspecified for the Transformers default. Supported explicit +choices are `eager`, `sdpa`, `flex_attention`. +Pass the selected name through `attn_implementation`. +When an optimized backend cannot return full attention tensors, +`output_attentions=True` emits one explicit runtime warning and uses a correctly +masked eager implementation for that call only. The warning identifies the +configured backend, effective backend, and reason. Configuration and later +calls are unchanged. +For BF16 execution, this family uses FP32 parameters with CUDA BF16 autocast. + +## Alignment-conditioning contract + +This is a full 48-block ESMFold2 checkpoint. It supports both +single-sequence inference and optional MSA-conditioned inference. Typed +multichain and multimolecule inputs may attach an MSA to each applicable +protein chain. + + +## Protein folding + +The single-protein helper returns typed structure and confidence outputs: + +```python +result = model.fold_protein( + "MSTNPKPQRKTKRNT", + num_loops=1, + num_sampling_steps=200, + num_diffusion_samples=1, + seed=7, +) +pdb_text = model.result_to_pdb(result) +cif_text = model.result_to_cif(result) +print(result.ptm, result.plddt.mean().item()) +``` + +No target structure is required. For complexes, construct the input from the +types exposed by the loaded artifact: + +```python +types = model.input_types +complex_input = types.StructurePredictionInput( + sequences=[ + types.ProteinInput(id="A", sequence="MSTNPKPQRKTKRNT"), + types.ProteinInput(id="B", sequence="MKTIIALSYIFCLVFA"), + types.DNAInput(id="C", sequence="ATGC"), + types.LigandInput(id="L", smiles="O"), + ] +) +complex_result = model.fold( + complex_input, + num_loops=1, + num_sampling_steps=200, + seed=7, +) +print(complex_result.ptm, complex_result.plddt.mean().item()) +``` + +The typed interface also supports RNA, protein MSAs, modifications, covalent +bonds, and distogram conditioning. The public schema recognizes +`PocketConditioning`, but the pinned official runtime discards it and hard-codes +a zero pocket feature. FastPLMs therefore rejects non-null pocket conditioning +instead of silently ignoring it. Prepared `ref_pos` values are component +reference geometries created during featurization, not target coordinates. +Predicted coordinates and confidence scores are outputs and do not establish +biochemical activity. + +## Learned representation and ESMC precision + +ESMFold2 combines the ordered 81 ESMC-6B states `H: (b, l, 81, 2560)` with the +checkpoint's learned projection. Retrieve the resulting residue representation +through the public embedding API: + +```python +representations = model.embed_dataset( + ["MSTNPKPQRKTKRNT", "MKTIIALSYIFCLVFA"], + batch_size=2, + full_embeddings=True, +) +print(representations[0].tensor.shape) # (sequence_length, 256) +``` + +`model.embed_dataset(..., full_embeddings=True)` returns one `(l, 256)` residue +tensor per single-chain input. It rejects complexes, ligands, MSAs, +chain-separated inputs, `cls`, and `parti` in the embedding path. + +Set `esmc_precision` to `auto`, `bf16`, `fp32`, or `fp8` when loading. +`auto` always resolves to BF16. Explicit FP8 is experimental, inference-only, +and strict: + +```python +model.reload_esmc(precision="fp8", device="cuda:0") +print(model.esmc_precision_status) +``` + +FP8 raises when the validated CUDA and Transformer Engine path is unavailable. +Canonical BF16 weights are retained, and transient quantization state is never +serialized. + +The ESMC backbone uses SDPA as the recommended highest-fidelity path. Flex +Attention is supported and non-experimental but can be numerically divergent; +ESMFold2 does not advertise FlashAttention for the folding interface. + +| Backend | Support | Measurement status | +| --- | --- | --- | +| `sdpa` | Recommended fidelity path | Pending complete validated 30-record frozen-head GH200/aarch64 set | +| `eager` | Supported | Pending complete validated 30-record frozen-head GH200/aarch64 set | +| `flex_attention` | Supported, numerically divergent | Pending complete validated 30-record frozen-head GH200/aarch64 set | + +No threshold, report from another checkpoint, or result from another +accelerator is substituted for a measurement. A release set contains all +30 model/backend/panel records from one exact GH200 device and aarch64 runtime: +18 eager/SDPA/Flex measurements include relative L2, Q99.9, residue +cosine, pooled cosine, top-1, and Jensen-Shannon distributions; 12 +FlashAttention 2/3 records explicitly attest locked-platform unavailability. + +## Locked oracle package compatibility exception + +The frozen oracle lock permits exactly one nonzero `pip check` diagnostic: +`nvidia-cusparselt-cu13 0.8.1 is not supported on this platform`. It applies only to +`nvidia-cusparselt-cu13==0.8.1` on +`NVIDIA GH200 480GB` / `linux` / +`aarch64`. The vendor filename tag is +`py3-none-manylinux2014_aarch64`, while the wheel metadata declares +`py3-none-manylinux2014_sbsa`. The exact wheel is +`nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl` with SHA-256 `4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f`. +FastPLMs accepts this vendor metadata mismatch only after the lock, installed +inventory, wheel bytes, metadata tag, and target identity all match. The wheel +is not rewritten (`validated-vendor-metadata-exception-no-wheel-rewrite`). Any additional diagnostic or +identity drift fails closed. + + +Metrics must be tied to the exact ESMFold2 and ESMC revisions, dtype, current +GH200/aarch64 device and container images, dependency lock, source attestations, +and sequence panel. Pending cells are not performance or parity claims. + +## Hash-pinned CCD runtime asset + +Structure preparation requires `ccd.pkl` from +`biohub/ESMFold2@1ebf0e3481a5184eb6171d40615c79e384b48796`. The manifest pins +its 417,306,584-byte size and SHA-256 +`9ff44b1927c6b9198e38ffe0928706827a09a350c15530beeeabebfa88038fc5` +under MIT terms. This is a trusted-deserialization boundary: FastPLMs only +allows the exact manifest repository/revision snapshot link to resolve within +that repository's contained blob directory; user-supplied asset and `cache_dir` +symlinks are rejected. The loader creates a private temporary snapshot, verifies +its size and SHA-256, and unpickles only that loader-owned snapshot, closing +path-replacement and in-place source-write races. Offline execution requires the +exact cache object and never downloads a replacement. + +## Binder-design research example + +The FastPLMs binder-design workflow uses the experimental Fast Cutoff2025 +checkpoint for differentiable inversion, both experimental Cutoff2025 +checkpoints as critics, and ESM++ as the sequence prior: + +![FastPLMs EGFR minibinder design](https://raw.githubusercontent.com/Synthyra/FastPLMs/main/docs/assets/egfr_fastplms_binder_design.png) + +```bash +python examples/binder_design_fastplms.py \ + --target-name pd-l1 \ + --binder-name minibinder \ + --batch-size 4 \ + --steps 150 \ + --output-dir artifacts/binder-design +``` + +The workflow ranks candidates by mean iPTM across the approved critics after +the minibinder isoelectric-point filter. These are model-based prioritization +signals, not experimental evidence of affinity or specificity. See the +[complete workflow](https://github.com/Synthyra/FastPLMs/blob/main/docs/binder_design.md). + +## Runtime contract + +- Public input: Raw amino-acid sequences or typed molecular-complex specifications; low-level forward accepts prepared feature tensors +- Advertised AutoClasses: `AutoConfig`, `AutoModel` +- AutoClass weight status: `AutoConfig` = `FastPLMs extension`, `AutoModel` = `pretrained` +- Attention implementations: `eager`, `sdpa`, `flex_attention` +- Precision policies: `auto`, `fp32`, `bf16`, `fp8` (experimental) +- BF16 execution: `fp32_parameters_autocast` +- Generation contract: `not_applicable` +- Optional dependency group: `structure` +- Weight publication allowed: `true` +- Weight license status: `resolved` +- Redistributable: `true` +- Complete weight publication required: `false` + +## Provenance + +- FastPLMs weights: `Synthyra/ESMFold2-Experimental-Cutoff2025@632ff4a9e68f1de78ee956a613267bdcdb5b354d` +- Runtime revision: `1b9ce023f1e06571cf3e6324be0610ffa53e0a4a` +- Runtime source-tree SHA-256: `15e781c5f1cd2ba8486e22076df15ffab37d3c00a689bf25280d803f2d60ee74` +- Runtime bundle SHA-256: `278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232` +- Generator/schema version and complete/runtime-only attestations: recorded in `provenance.json` +- Official checkpoint: `biohub/ESMFold2-Experimental-Cutoff2025@56f94f5c1069ecde17512c96928850518340d287` +- Artifact source: `fast` +- State transform: `identity` +- BF16 execution: `fp32_parameters_autocast` +- Pinned upstreams: `biohub-esm`, `biohub-transformers`, `protein-ttt` +- Reference container: `reference-esmfold2` +- Release tiers: `check`, `compliance`, `structure`, `feature`, `artifact`, `benchmark` +- Unresolved required file identities: `0` + +The local artifact records exact file identities, conversion provenance, source +revisions, and legal texts in `provenance.json`. A nonzero unresolved count is a +release blocker. + +## Validation boundary + +For tiers declared by the manifest, the release contract compares applicable +semantic configuration, tokenizer behavior, state keys, shapes, dtypes, +values, aliases, and representative inference with the pinned official +implementation. This metadata does not by itself claim that a particular build +passed, that one backend is faster, or that an output has biological or +therapeutic validity. + +## License + +Checkpoint terms: MIT. The Hub model-card identifier is +`mit`. Applicable source licenses, notices, attribution, +and conversion records are distributed with the local artifact. Review them +before use. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000000000000000000000000000000000..23006ade40f19f7b9df3619e3a546dec4beed40e --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,99 @@ +# Third-party notices + +FastPLMs implements interfaces and checkpoint mappings for independently +released protein models. The pinned repositories under `vendor/upstream/` are +parity oracles. Production code does not import them, and runtime images do not +contain them. + +This notice is informational and is not legal advice. A checkpoint license can +differ from the license covering its source implementation. The typed inventory +in `src/fastplms/models.toml` and the verbatim files under `LICENSES/` are the +distribution record. + +## ANKH + +The pinned ANKH implementation and the mirrored ANKH checkpoints are identified +as CC BY-NC-SA 4.0. FastPLMs displays those terms but does not enforce them in +software. Users are responsible for determining whether their use and +redistribution comply. The complete text is in `LICENSES/ankh/LICENSE.md`. + +## Profluent-E1 + +Profluent identifies its E1 model code as Apache-2.0. The E1 weights and full +release are subject to the Profluent-E1 Clickthrough License Agreement and the +incorporated attribution requirements. Any E1 distribution must retain all of +the following files: + +- `LICENSES/e1/LICENSE`, the Profluent-E1 agreement +- `LICENSES/e1/ATTRIBUTION`, the attribution guidelines +- `LICENSES/e1/NOTICE`, the required notice +- `LICENSES/e1/Apache-2.0.txt`, the code license +- `LICENSES/e1/BSD-3-Clause.txt`, covering the FlashAttention-derived padding + utility identified by the official E1 source +- `LICENSES/e1/MODIFICATIONS.md`, the FastPLMs modified-file notice + +The exact text `Profluent-E1` must remain prominently displayed in E1 +documentation and at each launch of an executable E1 workflow, as required by +the upstream attribution guidelines. Certain commercial outputs, including +specified pharmaceutical and target-related outputs, can require the separate +`Built with Profluent-E1` statement described in `ATTRIBUTION`. + +## DPLM + +The pinned ByteDance DPLM repository is Apache-2.0. Its +[README](https://github.com/bytedance/dplm/blob/8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d/README.md#overview) +explicitly defines the repository release as including pretrained DPLM1 and +DPLM2 weights, and the same revision carries the complete +[Apache-2.0 license](https://github.com/bytedance/dplm/blob/8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d/LICENSE). +FastPLMs records both checkpoint families as Apache-2.0 and distributes the +verbatim license plus `LICENSES/dplm/PROVENANCE.md`. Converted weights retain +those terms and remain subject to the ordinary artifact and publication gates. + +## Biohub + +The pinned Biohub ESM implementation is MIT and includes a separate +`THIRD_PARTY_NOTICE.md`; both files are distributed under +`LICENSES/biohub-esm/`. The pinned Biohub Transformers fork is Apache-2.0, with +its complete text under `LICENSES/biohub-transformers/`. + +## Boltz + +The pinned Boltz source is MIT. The verbatim notice is in +`LICENSES/boltz/LICENSE`. + +## Meta ESM and OpenFold + +The pinned Meta ESM source is MIT. The pinned OpenFold source is Apache-2.0. +Their verbatim texts and revision-specific provenance notices are under +`LICENSES/fair-esm/` and `LICENSES/openfold/`. + +The native H100 ESMFold reference image applies the tracked +`docker/constraints/openfold-sm90.patch` to the copied OpenFold `setup.py`. +This build-only change restricts the CUDA extension to `sm90` and selects the +C++17 standard required by the reference PyTorch version. It leaves the pinned +submodule, extension source, model classes, checkpoint data, and public API +unchanged. The complete modified-file record is in +`LICENSES/openfold/MODIFICATIONS.md`. + +The isolated reference image also includes Apache-2.0 PyTorch Lightning, +TorchMetrics, Lightning Utilities, and NVIDIA DLLogger. Their exact versions or +revision are pinned in `docker/constraints/esmfold.txt`; OpenFold imports them +eagerly, and FastPLMs production code does not depend on them. DLLogger's exact +source identity and installed-license handling are recorded in +`LICENSES/dllogger/PROVENANCE.md`. + +## ProteinTTT + +The optional test-time training workflow is validated against the pinned +ProteinTTT repository under its MIT license. Its verbatim license and +revision-specific provenance are under `LICENSES/protein-ttt/`. + +## Conversion and packaging record + +For every supported family, `src/fastplms/models.toml` records an immutable +official checkpoint revision, an immutable FastPLMs checkpoint revision, file +digests, a named state transformation, and a mechanism-level conversion record. +Generated artifacts reproduce that record in `provenance.json`. A release or +artifact build must fail when a required file identity, legal text, attribution +notice, modified-file notice, upstream revision, or conversion record is absent +or differs from its manifest digest. diff --git a/config.json b/config.json index 41119745d38bc5503a0212ad923e75211dec565f..20c5c32aee617b0d039423e68a0b277ff1071dde 100644 --- a/config.json +++ b/config.json @@ -3,8 +3,8 @@ "ESMFold2ExperimentalModel" ], "auto_map": { - "AutoConfig": "configuration_esmfold2.ESMFold2Config", - "AutoModel": "modeling_esmfold2_experimental.ESMFold2ExperimentalModel" + "AutoConfig": "modeling_fastplms.ESMFold2Config", + "AutoModel": "modeling_fastplms.ESMFold2ExperimentalModel" }, "confidence_head": { "distogram_bins": 128, @@ -25,6 +25,16 @@ "disable_msa_features": false, "dtype": "float32", "esmc_id": "biohub/ESMC-6B", + "fastplms_checkpoint_hash": "3a40758a12594cab337bbe7e664d305df722dc1eb1f0cc1ba0c840bf46d22834", + "fastplms_checkpoint_repo_id": "Synthyra/ESMFold2-Experimental-Cutoff2025", + "fastplms_checkpoint_revision": "632ff4a9e68f1de78ee956a613267bdcdb5b354d", + "fastplms_model_id": "esmfold2_experimental_cutoff2025", + "fastplms_release_tool_revision": "1b9ce023f1e06571cf3e6324be0610ffa53e0a4a", + "fastplms_release_tool_sha256": "1459b5d7d13d9b07bd97b3eee764f2ce73623e15e32d07ddf6825c2a9509afb9", + "fastplms_runtime_bundle_sha256": "278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232", + "fastplms_runtime_revision": "1b9ce023f1e06571cf3e6324be0610ffa53e0a4a", + "fastplms_source_tree_sha256": "15e781c5f1cd2ba8486e22076df15ffab37d3c00a689bf25280d803f2d60ee74", + "fastplms_weights_revision": "632ff4a9e68f1de78ee956a613267bdcdb5b354d", "folding_trunk": { "dropout": 0.25, "n_heads": 8, @@ -56,6 +66,7 @@ }, "lm_num_layers": 80, "model_type": "esmfold2", + "msa_conditioning": true, "msa_encoder": { "d_hidden": 32, "d_msa": 128, diff --git a/fastplms/__init__.py b/fastplms/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f81a48ec58310db7b2d524b119cd0f9529abc0ee --- /dev/null +++ b/fastplms/__init__.py @@ -0,0 +1,48 @@ +"""FastPLMs public package interface. + +The module uses lazy exports so importing :mod:`fastplms` does not initialize +Torch, download checkpoints, construct tokenizers, or compile kernels. +""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +__version__ = "1.0.0" + +_LAZY_EXPORTS = { + "CheckpointSource": ("fastplms.registry", "CheckpointSource"), + "EmbeddingInput": ("fastplms.embeddings", "EmbeddingInput"), + "EmbeddingRecord": ("fastplms.embeddings", "EmbeddingRecord"), + "EmbeddingResult": ("fastplms.embeddings", "EmbeddingResult"), + "FileDigest": ("fastplms.registry", "FileDigest"), + "ModelFamily": ("fastplms.registry", "ModelFamily"), + "ModelRegistry": ("fastplms.registry", "ModelRegistry"), + "ModelSpec": ("fastplms.registry", "ModelSpec"), + "OracleAsset": ("fastplms.registry", "OracleAsset"), + "RegistryError": ("fastplms.registry", "RegistryError"), + "RuntimeProfile": ("fastplms.runtime", "RuntimeProfile"), + "UpstreamSource": ("fastplms.registry", "UpstreamSource"), + "embed_dataset": ("fastplms.embeddings", "embed_dataset"), + "get_model_registry": ("fastplms.registry", "get_model_registry"), + "get_model_spec": ("fastplms.registry", "get_model_spec"), + "load_model_registry": ("fastplms.registry", "load_model_registry"), + "runtime_profile": ("fastplms.runtime", "runtime_profile"), +} + +__all__ = ["__version__", *_LAZY_EXPORTS] + + +def __getattr__(name: str) -> Any: + try: + module_name, attribute_name = _LAZY_EXPORTS[name] + except KeyError as error: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from error + value = getattr(import_module(module_name), attribute_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()).union(__all__)) diff --git a/fastplms/attention/__init__.py b/fastplms/attention/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5a2291b9556f167d17cc35d21a44f9266efcadb7 --- /dev/null +++ b/fastplms/attention/__init__.py @@ -0,0 +1,63 @@ +"""Shared attention backends, masks, and optional optimized kernels.""" + +from ._core import ( + VALID_ATTENTION_BACKENDS, + AttentionBackend, + BlockMask, + _ensure_flash_kernels_loaded, + _get_flex_attention_fn, + _get_flex_block_mask, + _kernels_flash_forward, + _kernels_flash_varlen_forward, + _unpad_input, + bool_to_additive_mask, + clear_flex_attention_caches, + create_block_mask, + flex_attention, + get_attention_mask, + get_attn_implementation, + index_first_axis, + index_put_first_axis, + kernels_flash_attention_func, + pad_input, + resolve_attention_backend, + resolve_attention_backend_for_call, + set_config_attn_implementation, + warn_attention_backend_fallback, +) +from .interfaces import ( + FASTPLMS_ATTENTION_FUNCTIONS, + FASTPLMS_ATTENTION_MASKS, + FastPLMsAttentionMixin, + validate_transformers_attention_interfaces, +) + +__all__ = [ + "FASTPLMS_ATTENTION_FUNCTIONS", + "FASTPLMS_ATTENTION_MASKS", + "VALID_ATTENTION_BACKENDS", + "AttentionBackend", + "BlockMask", + "FastPLMsAttentionMixin", + "_ensure_flash_kernels_loaded", + "_get_flex_attention_fn", + "_get_flex_block_mask", + "_kernels_flash_forward", + "_kernels_flash_varlen_forward", + "_unpad_input", + "bool_to_additive_mask", + "clear_flex_attention_caches", + "create_block_mask", + "flex_attention", + "get_attention_mask", + "get_attn_implementation", + "index_first_axis", + "index_put_first_axis", + "kernels_flash_attention_func", + "pad_input", + "resolve_attention_backend", + "resolve_attention_backend_for_call", + "set_config_attn_implementation", + "validate_transformers_attention_interfaces", + "warn_attention_backend_fallback", +] diff --git a/fastplms/attention/_core.py b/fastplms/attention/_core.py new file mode 100644 index 0000000000000000000000000000000000000000..347eb2c3513402fd1f373908de999584e0b600f7 --- /dev/null +++ b/fastplms/attention/_core.py @@ -0,0 +1,779 @@ +"""Low-level attention kernels and mask construction. + +The public backend contract lives in :mod:`fastplms.attention`. Optional +kernels are resolved only after a caller explicitly requests them, so importing +FastPLMs never downloads or compiles code. +""" + +from __future__ import annotations + +import warnings +from collections import OrderedDict +from collections.abc import Callable +from enum import Enum +from threading import RLock + +import torch +from einops import rearrange +from torch.nn import functional as F + +from ._kernel_lock import load_locked_kernel + +try: + from torch.nn.attention.flex_attention import BlockMask, create_block_mask, flex_attention +except ImportError: + create_block_mask = None + flex_attention = None + BlockMask = None + +_MAX_FLEX_CACHE_ENTRIES = 128 +_compiled_flex_attention: OrderedDict[tuple, object] = OrderedDict() +_flex_block_masks: OrderedDict[tuple, BlockMask] = OrderedDict() +_flex_cache_lock = RLock() + + +def _remember(cache: OrderedDict, key: tuple, value): + """Insert an item into a bounded least-recently-used cache.""" + cache[key] = value + cache.move_to_end(key) + while len(cache) > _MAX_FLEX_CACHE_ENTRIES: + cache.popitem(last=False) + return value + + +def clear_flex_attention_caches() -> None: + """Drop FastPLMs-owned compiled Flex callables and block masks. + + This deliberately does not call :func:`torch.compiler.reset`, which would + clear process-global Torch compilation state owned by unrelated models. + Active forwards retain their local references and can complete safely. + """ + + with _flex_cache_lock: + _compiled_flex_attention.clear() + _flex_block_masks.clear() + + +def _get_flex_attention_fn( + *, + device: torch.device | None = None, + dtype: torch.dtype | None = None, + shape: tuple[int, ...] | None = None, + sequence_lengths: tuple[int, ...] | None = None, + mask_semantics: str = "padding", +): + """Return a compiled Flex callable for an explicit execution signature. + + Compilation depends on execution shape, device, dtype, and mask semantics. + Per-example padding lengths are represented by the ``BlockMask`` argument + and must not create a new compiled graph for every batch composition. + """ + if flex_attention is None: + return None + # Retain the keyword for compatibility with remote-code artifacts while + # deliberately excluding data-dependent lengths from the compile key. + del sequence_lengths + flex_mod = torch.nn.attention.flex_attention + if getattr(flex_mod, "_FLEX_ATTENTION_DISABLE_COMPILE_DEBUG", False): + return flex_attention + key = ( + None if device is None else str(device), + None if dtype is None else str(dtype), + shape, + mask_semantics, + ) + with _flex_cache_lock: + compiled = _compiled_flex_attention.get(key) + if compiled is None: + compiled = torch.compile(flex_attention, dynamic=False) + _remember(_compiled_flex_attention, key, compiled) + else: + _compiled_flex_attention.move_to_end(key) + return compiled + + +def _get_flex_block_mask( + *, + mask_pattern: torch.Tensor, + batch_size: int, + query_length: int, + key_value_length: int, + device: torch.device, + dtype: torch.dtype | None, + mask_semantics: str, + mask_mod: Callable[ + [torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor], + torch.Tensor, + ], +) -> BlockMask: + """Return a bounded, exact-pattern cached Flex ``BlockMask``. + + The complete pattern is transferred to the host once to avoid a CUDA + synchronization per batch row. Execution dtype remains part of the key + because compiled Flex plans can specialize on it even though the pattern + tensor itself is boolean or integer. + """ + if create_block_mask is None: + raise RuntimeError( + "'flex_attention' was requested, but torch.create_block_mask is unavailable." + ) + pattern = mask_pattern.detach().to(device=device).contiguous() + # One device-to-host transfer is required for an exact cache identity. Use + # the contiguous buffer directly instead of materializing one Python int + # per byte, which is prohibitively expensive for long batched sequences. + host_pattern = pattern.to(device="cpu").contiguous() + pattern_bytes = host_pattern.view(torch.uint8).numpy().tobytes(order="C") + cache_key = ( + str(device), + None if dtype is None else str(dtype), + (batch_size, query_length, key_value_length), + str(pattern.dtype), + pattern_bytes, + mask_semantics, + ) + with _flex_cache_lock: + flex_block_mask = _flex_block_masks.get(cache_key) + if flex_block_mask is None: + flex_block_mask = create_block_mask( + mask_mod, + batch_size, + 1, + query_length, + key_value_length, + device=device, + ) + _remember(_flex_block_masks, cache_key, flex_block_mask) + else: + _flex_block_masks.move_to_end(cache_key) + return flex_block_mask + + +# Hugging Face `kernels` exposes slightly different APIs for FlashAttention 2 +# and 3. Detect the loaded variant once so every caller uses the same dispatch. +def _infer_kernels_flash_variant(kernel) -> str | None: + if hasattr(kernel, "fwd") and hasattr(kernel, "varlen_fwd"): + return "flash_attn2" + if hasattr(kernel, "flash_attn_func") and hasattr(kernel, "flash_attn_varlen_func"): + return "flash_attn3" + return None + + +def _load_kernels_flash(implementation: str) -> tuple[object, str]: + """Load exactly the requested FlashAttention kernel. + + Loading is deferred until backend selection. A FlashAttention-2 request + never falls through to FlashAttention-3, or vice versa. + """ + from fastplms.registry import get_model_registry + + kernel_spec = get_model_registry().attention_kernels[implementation] + repository = kernel_spec.repository + try: + flash_kernel = load_locked_kernel(repository, kernel_spec.revision) + except Exception as error: + raise RuntimeError( + f"Unable to load the manifest-pinned kernel " + f"{repository}@{kernel_spec.revision} for {implementation!r}." + ) from error + flash_kernel_variant = _infer_kernels_flash_variant(flash_kernel) + if flash_kernel_variant != kernel_spec.expected_variant: + raise RuntimeError( + f"{repository}@{kernel_spec.revision} exposed {flash_kernel_variant!r}; " + f"expected {kernel_spec.expected_variant!r}." + ) + if not all( + callable(getattr(flash_kernel, name, None)) + for name in ("flash_attn_func", "flash_attn_varlen_func") + ): + raise RuntimeError( + f"{repository}@{kernel_spec.revision} does not expose the " + "autograd-enabled flash_attn_func and flash_attn_varlen_func APIs." + ) + return flash_kernel, flash_kernel_variant + + +_FLASH_KERNELS: dict[str, tuple[object, str]] = {} + + +def _validate_kernels_flash_dtype( + query_states: torch.Tensor, + key_states: torch.Tensor, + value_states: torch.Tensor, + implementation: str, +) -> torch.dtype: + """Reject dtypes outside the immutable kernel manifest before dispatch.""" + + tensor_dtypes = {query_states.dtype, key_states.dtype, value_states.dtype} + if len(tensor_dtypes) != 1: + observed = ", ".join(sorted(str(dtype) for dtype in tensor_dtypes)) + raise RuntimeError( + f"{implementation!r} requires Q, K, and V to share one dtype; received {observed}." + ) + runtime_dtype = query_states.dtype + if ( + runtime_dtype == torch.float32 + and query_states.is_cuda + and torch.is_autocast_enabled("cuda") + ): + runtime_dtype = torch.get_autocast_dtype("cuda") + dtype_names = { + torch.float32: "float32", + torch.bfloat16: "bfloat16", + torch.float16: "float16", + } + runtime_dtype_name = dtype_names.get(runtime_dtype, str(runtime_dtype)) + from fastplms.registry import get_model_registry + + supported = get_model_registry().attention_kernels[implementation].dtypes + if runtime_dtype_name not in supported: + expected = ", ".join(supported) + raise RuntimeError( + f"{implementation!r} supports only manifest-declared dtype(s) {expected}; " + f"received {runtime_dtype_name}. Use CUDA BF16 autocast for FP32-resident " + "models." + ) + return runtime_dtype + + +def _validate_kernels_flash_device( + query_states: torch.Tensor, + key_states: torch.Tensor, + value_states: torch.Tensor, + implementation: str, +) -> torch.device: + """Require Q, K, and V on one CUDA device before loading a kernel.""" + + devices = (query_states.device, key_states.device, value_states.device) + if len(set(devices)) != 1: + observed = ", ".join(str(device) for device in devices) + raise RuntimeError( + f"{implementation!r} requires Q, K, and V on one device; received {observed}." + ) + device = devices[0] + if device.type != "cuda" or not all( + tensor.is_cuda for tensor in (query_states, key_states, value_states) + ): + raise RuntimeError( + f"{implementation!r} requires CUDA Q, K, and V; received device {device}." + ) + return device + + +def _ensure_flash_kernels_loaded(implementation: str) -> tuple[object, str]: + cached = _FLASH_KERNELS.get(implementation) + if cached is not None: + return cached + loaded = _load_kernels_flash(implementation) + _FLASH_KERNELS[implementation] = loaded + return loaded + + +def _kernels_flash_forward( + query_states: torch.Tensor, + key_states: torch.Tensor, + value_states: torch.Tensor, + causal: bool = False, + softmax_scale: float | None = None, + implementation: str = "flash_attention_3", +) -> torch.Tensor: + """Flash-attention forward, optionally overriding the softmax scale. + + When `softmax_scale is None`, the flash kernel applies its default + `1 / sqrt(head_dim)`. Pass `softmax_scale=1.0` if the caller has already + pre-scaled Q (the convention used by ESM2, DPLM, DPLM2, E1, ESMFold). + Failing to override when Q is pre-scaled applies the scale twice and breaks + parity with eager attention and SDPA. + """ + flash_kernel, flash_kernel_variant = _ensure_flash_kernels_loaded(implementation) + if flash_kernel_variant == "flash_attn2": + output = flash_kernel.flash_attn_func( + q=query_states, + k=key_states, + v=value_states, + dropout_p=0.0, + softmax_scale=softmax_scale, + causal=causal, + ) + return output[0] if isinstance(output, tuple) else output + if flash_kernel_variant == "flash_attn3": + output = flash_kernel.flash_attn_func( + q=query_states, + k=key_states, + v=value_states, + softmax_scale=softmax_scale, + causal=causal, + ) + if isinstance(output, tuple): + return output[0] + return output + raise RuntimeError(f"Unsupported FlashAttention kernel variant: {flash_kernel_variant}") + + +def _kernels_flash_varlen_forward( + query_states: torch.Tensor, + key_states: torch.Tensor, + value_states: torch.Tensor, + cu_seqlens_q: torch.Tensor, + cu_seqlens_k: torch.Tensor, + max_seqlen_in_batch_q: int, + max_seqlen_in_batch_k: int, + causal: bool = False, + softmax_scale: float | None = None, + implementation: str = "flash_attention_3", +) -> torch.Tensor: + """Varlen flash-attention forward, optionally overriding the softmax scale. + + See `_kernels_flash_forward` docstring for why `softmax_scale=1.0` must be + passed when Q has been pre-scaled by the caller. + """ + flash_kernel, flash_kernel_variant = _ensure_flash_kernels_loaded(implementation) + if flash_kernel_variant == "flash_attn2": + output = flash_kernel.flash_attn_varlen_func( + q=query_states, + k=key_states, + v=value_states, + cu_seqlens_q=cu_seqlens_q, + cu_seqlens_k=cu_seqlens_k, + max_seqlen_q=max_seqlen_in_batch_q, + max_seqlen_k=max_seqlen_in_batch_k, + dropout_p=0.0, + softmax_scale=softmax_scale, + causal=causal, + ) + return output[0] if isinstance(output, tuple) else output + if flash_kernel_variant == "flash_attn3": + output = flash_kernel.flash_attn_varlen_func( + q=query_states, + k=key_states, + v=value_states, + cu_seqlens_q=cu_seqlens_q, + cu_seqlens_k=cu_seqlens_k, + max_seqlen_q=max_seqlen_in_batch_q, + max_seqlen_k=max_seqlen_in_batch_k, + softmax_scale=softmax_scale, + causal=causal, + ) + if isinstance(output, tuple): + return output[0] + return output + raise RuntimeError(f"Unsupported FlashAttention kernel variant: {flash_kernel_variant}") + + +# Varlen flash attention runs only on real tokens. These helpers remove padding +# before the kernel call and restore the original padded batch shape afterward. +class IndexFirstAxis(torch.autograd.Function): + @staticmethod + def forward(ctx, input, indices) -> torch.Tensor: + ctx.save_for_backward(indices) + if input.ndim < 2: + raise ValueError( + "index_first_axis input must have at least two dimensions; " + f"received shape {tuple(input.shape)}." + ) + if indices.ndim != 1: + raise ValueError( + "index_first_axis indices must be one-dimensional; " + f"received shape {tuple(indices.shape)}." + ) + ctx.first_axis_dim, other_shape = input.shape[0], input.shape[1:] + second_dim = other_shape.numel() + return torch.gather( + rearrange(input, "b ... -> b (...)"), 0, indices.unsqueeze(1).expand(-1, second_dim) + ).reshape(-1, *other_shape) + + @staticmethod + def backward(ctx, grad_output) -> tuple[torch.Tensor, None]: + (indices,) = ctx.saved_tensors + if grad_output.ndim < 2: + raise RuntimeError( + "index_first_axis received an invalid gradient with fewer than " + "two dimensions." + ) + other_shape = grad_output.shape[1:] + grad_output = rearrange(grad_output, "b ... -> b (...)") + grad_input = torch.zeros( + [ctx.first_axis_dim, grad_output.shape[1]], + device=grad_output.device, + dtype=grad_output.dtype, + ) + grad_input.scatter_(0, indices.unsqueeze(1).expand(-1, grad_output.shape[1]), grad_output) + return grad_input.reshape(ctx.first_axis_dim, *other_shape), None + + +class IndexPutFirstAxis(torch.autograd.Function): + @staticmethod + def forward(ctx, values, indices, first_axis_dim) -> torch.Tensor: + ctx.save_for_backward(indices) + if indices.ndim != 1: + raise ValueError( + "index_put_first_axis indices must be one-dimensional; " + f"received shape {tuple(indices.shape)}." + ) + if values.ndim < 2: + raise ValueError( + "index_put_first_axis values must have at least two dimensions; " + f"received shape {tuple(values.shape)}." + ) + output = torch.zeros( + first_axis_dim, *values.shape[1:], device=values.device, dtype=values.dtype + ) + output[indices] = values + return output + + @staticmethod + def backward(ctx, grad_output) -> tuple[torch.Tensor, None, None]: + (indices,) = ctx.saved_tensors + return grad_output[indices], None, None + + +index_first_axis = IndexFirstAxis.apply +index_put_first_axis = IndexPutFirstAxis.apply + + +def pad_input( + hidden_states: torch.Tensor, indices: torch.Tensor, batch: int, seqlen: int +) -> torch.Tensor: + output = index_put_first_axis(hidden_states, indices, batch * seqlen) + return rearrange(output, "(b s) ... -> b s ...", b=batch) + + +def _unpad_input( + query_layer: torch.Tensor, + key_layer: torch.Tensor, + value_layer: torch.Tensor, + attention_mask_2d: torch.Tensor, +) -> tuple[ + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, + tuple[torch.Tensor, torch.Tensor], + tuple[int, int], +]: + batch_size, seq_len, num_heads, head_dim = query_layer.shape + seqlens = attention_mask_2d.sum(dim=1).int() + cu_seqlens = F.pad(seqlens.cumsum(0, dtype=torch.int32), (1, 0)) + max_seqlen = int(seqlens.max().item()) + indices = attention_mask_2d.flatten().nonzero(as_tuple=False).flatten() + query_layer = index_first_axis( + query_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices + ) + key_layer = index_first_axis( + key_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices + ) + value_layer = index_first_axis( + value_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices + ) + return ( + query_layer, + key_layer, + value_layer, + indices, + (cu_seqlens, cu_seqlens), + (max_seqlen, max_seqlen), + ) + + +def _validate_flash_padding_mask( + query_states: torch.Tensor, + key_states: torch.Tensor, + value_states: torch.Tensor, + attention_mask_2d: torch.Tensor, +) -> torch.Tensor: + """Validate the self-attention padding mask used by the varlen kernels.""" + + if attention_mask_2d.ndim != 2: + raise ValueError("FlashAttention padding masks must have shape (batch, sequence_length).") + expected_shape = query_states.shape[:2] + if tuple(attention_mask_2d.shape) != tuple(expected_shape): + raise ValueError( + "FlashAttention padding mask shape must match the query batch and " + f"sequence dimensions; expected {tuple(expected_shape)}, received " + f"{tuple(attention_mask_2d.shape)}." + ) + if key_states.shape[:2] != expected_shape or value_states.shape[:2] != expected_shape: + raise ValueError( + "Masked FlashAttention requires Q, K, and V to share batch and sequence dimensions." + ) + if attention_mask_2d.device != query_states.device: + raise ValueError("FlashAttention padding mask and Q, K, and V must be on the same device.") + return attention_mask_2d.to(dtype=torch.bool) + + +def kernels_flash_attention_func( + query_states: torch.Tensor, + key_states: torch.Tensor, + value_states: torch.Tensor, + attention_mask_2d: torch.Tensor | None = None, + causal: bool = False, + softmax_scale: float | None = None, + implementation: str = "flash_attention_3", +) -> torch.Tensor: + """Public flash-attention entry point with optional padding handling. + + `softmax_scale`: + None -> kernel applies its default `1 / sqrt(head_dim)`. + float -> kernel uses the given scale (pass 1.0 when Q is pre-scaled + by the caller). + + Caller contract: if a model family pre-scales Q by `1/sqrt(head_dim)` + before calling this function (ESM2, DPLM, DPLM2, E1, and ESMFold do), pass + `softmax_scale=1.0`. Otherwise the flash kernel applies its default scale + again, yielding an effective `1/head_dim` scale that drifts across layers. + """ + _validate_kernels_flash_device( + query_states, + key_states, + value_states, + implementation, + ) + runtime_dtype = _validate_kernels_flash_dtype( + query_states, + key_states, + value_states, + implementation, + ) + if query_states.dtype != runtime_dtype: + query_states = query_states.to(dtype=runtime_dtype) + key_states = key_states.to(dtype=runtime_dtype) + value_states = value_states.to(dtype=runtime_dtype) + if attention_mask_2d is not None: + attention_mask_2d = _validate_flash_padding_mask( + query_states, + key_states, + value_states, + attention_mask_2d, + ) + _ensure_flash_kernels_loaded(implementation) + if attention_mask_2d is not None: + batch_size, q_len = query_states.shape[:2] + ( + query_states, + key_states, + value_states, + indices_q, + (cu_seqlens_q, cu_seqlens_k), + (max_seqlen_q, max_seqlen_k), + ) = _unpad_input(query_states, key_states, value_states, attention_mask_2d) + attn_output_unpad = _kernels_flash_varlen_forward( + query_states=query_states, + key_states=key_states, + value_states=value_states, + cu_seqlens_q=cu_seqlens_q, + cu_seqlens_k=cu_seqlens_k, + max_seqlen_in_batch_q=max_seqlen_q, + max_seqlen_in_batch_k=max_seqlen_k, + causal=causal, + softmax_scale=softmax_scale, + implementation=implementation, + ) + output = pad_input(attn_output_unpad, indices_q, batch_size, q_len) + return output.masked_fill(~attention_mask_2d[:, :, None, None], 0) + else: + return _kernels_flash_forward( + query_states=query_states, + key_states=key_states, + value_states=value_states, + causal=causal, + softmax_scale=softmax_scale, + implementation=implementation, + ) + + +# User-facing backend strings follow the Transformers attention interface. +# Keep ``str`` plus ``Enum`` so stringification stays compatible with existing +# configuration serialization rather than adopting ``StrEnum.__str__``. +class AttentionBackend(str, Enum): # noqa: UP042 + EAGER = "eager" + SDPA = "sdpa" + FLEX_ATTENTION = "flex_attention" + FLASH_ATTENTION_2 = "flash_attention_2" + FLASH_ATTENTION_3 = "flash_attention_3" + + # Internal spelling retained to keep attention modules concise. It is an + # enum alias, not an accepted public backend string. + FLEX = FLEX_ATTENTION + + @property + def is_flash(self) -> bool: + return self in { + AttentionBackend.FLASH_ATTENTION_2, + AttentionBackend.FLASH_ATTENTION_3, + } + + +VALID_ATTENTION_BACKENDS = tuple(b.value for b in AttentionBackend) + + +def warn_attention_backend_fallback( + requested_backend: str | AttentionBackend, + *, + effective_backend: str | AttentionBackend, + reason: str, +) -> None: + """Warn when one forward call cannot honor the configured backend.""" + + requested = resolve_attention_backend(requested_backend).value + effective = resolve_attention_backend(effective_backend).value + if requested == effective: + return + warnings.warn( + f"{reason} The requested {requested!r} attention implementation cannot " + f"satisfy this call, so FastPLMs is using {effective!r} attention for this " + "call only. This can change performance and memory use; the configured " + "backend remains unchanged for subsequent calls.", + RuntimeWarning, + stacklevel=3, + ) + + +def resolve_attention_backend_for_call( + requested_backend: str | AttentionBackend, + *, + output_attentions: bool, +) -> AttentionBackend: + """Resolve the effective backend for one call and report substitutions once.""" + + requested = resolve_attention_backend(requested_backend) + if not output_attentions or requested == AttentionBackend.EAGER: + return requested + warn_attention_backend_fallback( + requested, + effective_backend=AttentionBackend.EAGER, + reason=( + "output_attentions=True requires the full materialized attention probability " + "matrix, which optimized PyTorch attention APIs do not return." + ), + ) + return AttentionBackend.EAGER + + +def resolve_attention_backend( + requested_backend: str | AttentionBackend | None, +) -> AttentionBackend: + """Validate a backend without silently substituting another implementation.""" + if requested_backend is None: + requested_backend = AttentionBackend.SDPA.value + if isinstance(requested_backend, AttentionBackend): + resolved = requested_backend + else: + try: + resolved = AttentionBackend(requested_backend) + except ValueError as error: + raise ValueError( + f"Unsupported attention implementation {requested_backend!r}; " + f"expected one of {VALID_ATTENTION_BACKENDS}." + ) from error + if resolved == AttentionBackend.FLEX_ATTENTION and flex_attention is None: + raise RuntimeError( + "'flex_attention' was requested, but this PyTorch build does not provide it." + ) + return resolved + + +def get_attn_implementation(config) -> str: + """Read the Transformers attention setting, defaulting to SDPA.""" + requested = getattr(config, "_attn_implementation", None) + if requested is None: + requested = getattr(config, "attn_backend", None) + return resolve_attention_backend(requested).value + + +def set_config_attn_implementation(config, implementation: str) -> str: + """Set both the Transformers field and the internal dispatch field.""" + resolved = resolve_attention_backend(implementation).value + if hasattr(config, "_attn_implementation_internal"): + config._attn_implementation_internal = resolved + else: + config._attn_implementation = resolved + # Existing checkpoint configs contain this field. Keeping it synchronized + # preserves their state schema while the public API uses attn_implementation. + config.attn_backend = resolved + return resolved + + +@torch.compiler.disable +def get_attention_mask( + effective_backend: AttentionBackend, + batch_size: int, + seq_len: int, + device: torch.device, + attention_mask: torch.Tensor | None = None, + dtype: torch.dtype | None = None, + mask_semantics: str = "padding", +) -> tuple[torch.Tensor | None, torch.Tensor | None, BlockMask | None]: + """Build padding masks once for all encoder layers. + + Returns (attention_mask_2d, attention_mask_4d, flex_block_mask). + """ + if attention_mask is None: + return None, None, None + + if attention_mask.ndim != 2: + raise ValueError( + "attention_mask must have shape (batch, sequence_length); " + f"received rank {attention_mask.ndim} with shape {tuple(attention_mask.shape)}." + ) + expected_shape = (batch_size, seq_len) + if tuple(attention_mask.shape) != expected_shape: + raise ValueError( + "attention_mask shape must match the input batch and sequence dimensions; " + f"expected {expected_shape}, received {tuple(attention_mask.shape)}." + ) + attention_mask_2d = attention_mask.to(device=device, dtype=torch.bool) + if not bool(attention_mask_2d.any(dim=1).all()): + raise ValueError("attention_mask must keep at least one valid key per batch row.") + + effective_backend = resolve_attention_backend(effective_backend) + + if effective_backend.is_flash: + return attention_mask_2d, None, None + + if effective_backend == AttentionBackend.FLEX_ATTENTION: + if create_block_mask is None: + raise RuntimeError( + "'flex_attention' was requested, but torch.create_block_mask is unavailable." + ) + def mask_mod(batch_idx, head_idx, q_idx, kv_idx): + del head_idx, q_idx + # Match eager and SDPA: padding masks suppress invalid keys only. + # Invalid queries still attend to real keys and therefore remain + # finite; downstream residue masks exclude their outputs. + return attention_mask_2d[batch_idx, kv_idx] + + flex_block_mask = _get_flex_block_mask( + mask_pattern=attention_mask_2d, + batch_size=batch_size, + query_length=seq_len, + key_value_length=seq_len, + device=device, + dtype=dtype, + mask_semantics=mask_semantics, + mask_mod=mask_mod, + ) + return attention_mask_2d, None, flex_block_mask + + # SDPA/manual masks only keys. Padding queries still attend to real keys, so + # their outputs stay finite instead of softmaxing over all -inf scores. + attention_mask_4d = attention_mask_2d[:, None, None, :] + return attention_mask_2d, attention_mask_4d, None + + +def bool_to_additive_mask( + bool_mask: torch.Tensor, + dtype: torch.dtype, +) -> torch.Tensor: + """Convert a bool mask (True = valid) to a float additive mask (0.0 valid, -inf invalid). + + Why this exists: calling `bool_mask.masked_fill(bool_mask.logical_not(), float('-inf'))` + directly on a bool tensor returns a bool tensor because `-inf` casts to `True`. + That silently drops the mask. Always allocate a float tensor first, then fill it. + This helper is the sanctioned way to build an SDPA additive mask from a bool validity mask. + """ + if bool_mask.dtype != torch.bool: + raise TypeError( + f"bool_to_additive_mask requires a bool tensor, got dtype={bool_mask.dtype}" + ) + additive = torch.zeros_like(bool_mask, dtype=dtype) + additive.masked_fill_(bool_mask.logical_not(), float("-inf")) + return additive diff --git a/fastplms/attention/_kernel_lock.py b/fastplms/attention/_kernel_lock.py new file mode 100644 index 0000000000000000000000000000000000000000..f959f2fd0914a01bccdc015059d2079660ea3550 --- /dev/null +++ b/fastplms/attention/_kernel_lock.py @@ -0,0 +1,191 @@ +"""Resolve and validate Hugging Face kernels before importing their binaries.""" + +from __future__ import annotations + +import importlib.metadata +import json +import os +from pathlib import Path +from typing import Any + + +def require_kernels_package() -> None: + """Fail early when the precompiled-kernel runtime is not installed.""" + try: + import kernels # noqa: F401 + except ImportError as error: + raise RuntimeError( + "Precompiled FlashAttention requires the FastPLMs 'flash' extra." + ) from error + + +def _kernel_lock_path() -> Path: + """Return the lock from an artifact, checkout, or installed distribution.""" + source_path = Path(__file__).resolve() + candidates = [ + source_path.parents[1] / "kernels.lock", + source_path.parents[3] / "kernels.lock", + ] + try: + import fastplms + + candidates.extend(Path(root) / "kernels.lock" for root in fastplms.__path__) + except (ImportError, AttributeError): + pass + for candidate in candidates: + if candidate.is_file(): + return candidate + + try: + distribution = importlib.metadata.distribution("fastplms") + except importlib.metadata.PackageNotFoundError as error: + raise RuntimeError("FastPLMs was installed without kernels.lock.") from error + for relative in distribution.files or (): + if relative.name != "kernels.lock": + continue + candidate = Path(distribution.locate_file(relative)) + if candidate.is_file(): + return candidate + raise RuntimeError("The installed FastPLMs distribution does not contain kernels.lock.") + + +def _locked_entry(lock_path: Path, repository: str) -> dict[str, Any]: + try: + data = json.loads(lock_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"Unable to read the packaged kernel lock: {lock_path}") from error + if not isinstance(data, list): + raise RuntimeError("kernels.lock must contain a JSON list.") + if any(not isinstance(entry, dict) for entry in data): + raise RuntimeError("Every kernels.lock entry must be a JSON object.") + matches = [entry for entry in data if entry.get("repo_id") == repository] + if len(matches) != 1: + raise RuntimeError( + f"kernels.lock must contain exactly one entry for {repository!r}; found {len(matches)}." + ) + return matches[0] + + +def _offline_mode() -> bool: + """Return whether Hub access was explicitly disabled for this process.""" + + enabled_values = {"1", "on", "true", "yes"} + return any( + os.environ.get(name, "").strip().lower() in enabled_values + for name in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE") + ) + + +def _offline_snapshot_path(repository: str, revision: str) -> Path: + """Locate one exact, possibly sparse, kernel snapshot without using Hub APIs.""" + + try: + from huggingface_hub import constants + from huggingface_hub.file_download import repo_folder_name + except ImportError as error: + raise RuntimeError("Offline kernel loading requires huggingface-hub.") from error + + cache_root = Path(os.environ.get("KERNELS_CACHE") or constants.HF_HUB_CACHE).resolve() + repository_root = ( + cache_root / repo_folder_name(repo_id=repository, repo_type="kernel") + ).resolve() + snapshot = repository_root / "snapshots" / revision + if not snapshot.is_dir(): + raise RuntimeError( + f"The exact offline kernel snapshot {repository}@{revision} is not cached under " + f"{cache_root}. Run `kernels download` before enabling offline mode." + ) + if repository_root not in snapshot.resolve().parents: + raise RuntimeError(f"Refusing kernel snapshot outside its cache repository: {snapshot}") + return snapshot + + +def _load_offline_locked_kernel( + repository: str, + revision: str, + variant_locks: dict[str, object], +) -> object: + """Validate and import the one compatible variant from a sparse Hub snapshot.""" + snapshot = _offline_snapshot_path(repository, revision) + build_root = snapshot / "build" + if not build_root.is_dir(): + raise RuntimeError(f"The cached kernel snapshot has no build directory: {snapshot}") + + cached_names = sorted(entry.name for entry in build_root.iterdir() if entry.is_dir()) + unexpected = sorted(set(cached_names).difference(variant_locks)) + if unexpected: + raise RuntimeError( + f"The cached {repository}@{revision} snapshot contains unlocked variants: " + f"{', '.join(unexpected)}" + ) + + try: + from kernels import get_local_kernel + from kernels.utils import validate_kernel + from kernels.variants import get_variants_local, resolve_variants + except ImportError as error: + raise RuntimeError( + "Precompiled FlashAttention requires the FastPLMs 'flash' extra." + ) from error + + parsed = get_variants_local(build_root) + parsed_names = {variant.variant_str for variant in parsed} + invalid = sorted(set(cached_names).difference(parsed_names)) + if invalid: + raise RuntimeError( + f"The cached {repository}@{revision} snapshot contains invalid variants: " + f"{', '.join(invalid)}" + ) + + compatible, _ = resolve_variants(parsed) + if len(compatible) != 1: + names = ", ".join(variant.variant_str for variant in compatible) or "none" + raise RuntimeError( + f"Expected exactly one compatible cached variant for {repository}@{revision}; " + f"found {names}." + ) + variant_name = compatible[0].variant_str + variant_lock = variant_locks.get(variant_name) + expected_hash = getattr(variant_lock, "hash", None) + if not isinstance(expected_hash, str) or not expected_hash.startswith("sha256-"): + raise RuntimeError(f"The kernel lock for {variant_name} has no valid SHA-256 digest.") + + # Hash validation deliberately happens before import. This operates on the + # sparse snapshot produced by `kernels download` and avoids Hub 1.23's + # full-snapshot completeness check in offline mode. + validate_kernel(repo_path=snapshot, variant=variant_name, hash=expected_hash) + return get_local_kernel(build_root / variant_name) + + +def load_locked_kernel(repository: str, revision: str) -> object: + """Download, hash-validate, then import one immutable precompiled kernel.""" + require_kernels_package() + try: + from kernels import get_local_kernel, install_kernel + from kernels.lockfile import KernelLock + except ImportError as error: + raise RuntimeError( + "Precompiled FlashAttention requires the FastPLMs 'flash' extra." + ) from error + + lock_path = _kernel_lock_path() + kernel_lock = KernelLock.from_json(_locked_entry(lock_path, repository)) + if kernel_lock.sha != revision: + raise RuntimeError( + f"The typed manifest pins {repository}@{revision}, but kernels.lock pins " + f"{kernel_lock.sha}." + ) + + if _offline_mode(): + return _load_offline_locked_kernel(repository, revision, kernel_lock.variants) + + # `install_kernel` downloads data without importing it and validates the + # selected build against the tracked variant hash. Only then is the exact + # validated path imported directly. Offline mode uses the sparse-cache + # resolver above because Hub 1.23 rejects partial snapshots as incomplete. + validated_path = install_kernel( + repository, + revision=kernel_lock.sha, + variant_locks=kernel_lock.variants, + ) + return get_local_kernel(validated_path) diff --git a/fastplms/attention/interfaces.py b/fastplms/attention/interfaces.py new file mode 100644 index 0000000000000000000000000000000000000000..2973e8bbfcbf8f5ae1b0e3cf2f224763cffd8147 --- /dev/null +++ b/fastplms/attention/interfaces.py @@ -0,0 +1,242 @@ +"""Transformers-compatible attention selection for FastPLMs models.""" + +from __future__ import annotations + +from collections.abc import Mapping +from functools import partial +from typing import Any + +import torch +from transformers import AttentionInterface, AttentionMaskInterface + +from ._core import ( + AttentionBackend, + get_attn_implementation, + kernels_flash_attention_func, + resolve_attention_backend, + set_config_attn_implementation, +) +from ._kernel_lock import require_kernels_package + + +def _kernels_attention_forward( + module: torch.nn.Module, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attention_mask: torch.Tensor | None, + *, + implementation: str, + **kwargs: Any, +) -> tuple[torch.Tensor, None]: + """Run one canonical FlashAttention backend through Hugging Face kernels. + + Transformers attention functions receive Q, K, and V with shape + (b, h, l, d) and return an output with shape (b, l, h, d). The shared + FastPLMs kernel adapter uses the latter layout internally. + """ + + dropout = float(kwargs.get("dropout", 0.0) or 0.0) + if module.training and dropout: + raise RuntimeError( + "Hugging Face kernels FlashAttention is inference-only when attention dropout " + "is nonzero. Use SDPA for this training configuration." + ) + causal = bool(kwargs.get("is_causal", getattr(module, "is_causal", False))) + softmax_scale = kwargs.get("scaling") + output = kernels_flash_attention_func( + query_states=query.transpose(1, 2).contiguous(), + key_states=key.transpose(1, 2).contiguous(), + value_states=value.transpose(1, 2).contiguous(), + attention_mask_2d=attention_mask, + causal=causal, + softmax_scale=softmax_scale, + implementation=implementation, + ) + return output, None + + +# Keep FastPLMs' kernels-only adapters local to this registry instance. +# ``GeneralInterface.register`` updates Transformers' class-wide mapping, so +# using it here would replace the canonical FlashAttention handlers for every +# model in the process, including models unrelated to FastPLMs. +FASTPLMS_ATTENTION_FUNCTIONS = AttentionInterface() +FASTPLMS_ATTENTION_MASKS = AttentionMaskInterface() +FASTPLMS_ATTENTION_FUNCTIONS["flash_attention_2"] = partial( + _kernels_attention_forward, + implementation="flash_attention_2", +) +FASTPLMS_ATTENTION_FUNCTIONS["flash_attention_3"] = partial( + _kernels_attention_forward, + implementation="flash_attention_3", +) +for _flash_name in ("flash_attention_2", "flash_attention_3"): + FASTPLMS_ATTENTION_MASKS[_flash_name] = FASTPLMS_ATTENTION_MASKS[_flash_name] + + +class FastPLMsAttentionMixin: + """Synchronize Transformers attention selection with custom model layers. + + Model families retain their checkpoint parameter names. Only runtime + attributes are updated when ``set_attn_implementation`` is called. + """ + + _supports_sdpa = True + _supports_flex_attn = True + # Transformers 5.13 uses the singular flag during model construction. A + # family opts in only when its manifest entry advertises at least one of + # the two FastPLMs kernels-only FlashAttention implementations. + _supports_flash_attn = False + _supports_flash_attn_2 = False + _supports_flash_attn_3 = False + _fastplms_attention_implementations = ( + "eager", + "sdpa", + "flex_attention", + ) + + def _validate_attention_name(self, implementation: str) -> None: + if implementation not in self._fastplms_attention_implementations: + raise ValueError( + f"{type(self).__name__} does not support {implementation!r}; expected one of " + f"{self._fastplms_attention_implementations}." + ) + + def _check_and_adjust_attn_implementation( + self, + attn_implementation: str | None, + is_init_check: bool = False, + allow_all_kernels: bool = False, + ) -> str: + """Resolve attention without invoking Transformers' source-Flash probe. + + The standard ``flash_attention_2`` and ``flash_attention_3`` names are + retained for the Transformers API, but FastPLMs resolves them only + through the exact Hugging Face ``kernels`` artifacts pinned by + ``models.toml``. Repository-qualified or otherwise external kernels + are never accepted through this model hook. + """ + + if allow_all_kernels: + raise ValueError("FastPLMs does not load external attention kernels.") + if attn_implementation is None: + return super()._check_and_adjust_attn_implementation( + None, + is_init_check=is_init_check, + allow_all_kernels=False, + ) + + self._validate_attention_name(attn_implementation) + if attn_implementation in {"flash_attention_2", "flash_attention_3"}: + if not self._supports_flash_attn: + raise ValueError( + f"{type(self).__name__} does not advertise kernels-only FlashAttention." + ) + # Validate the lightweight Python dependency here, but defer binary + # download and import until Q, K, and V have passed the CUDA gate. + require_kernels_package() + return attn_implementation + + return super()._check_and_adjust_attn_implementation( + attn_implementation, + is_init_check=is_init_check, + allow_all_kernels=False, + ) + + def __init__(self, config, *args: Any, **kwargs: Any) -> None: + sentinel = object() + internal = getattr(config, "_attn_implementation_internal", sentinel) + canonical = ( + getattr(config, "_attn_implementation", None) if internal is sentinel else internal + ) + legacy = getattr(config, "attn_backend", None) + requested = canonical if canonical is not None else legacy + if requested is not None: + if not isinstance(requested, str): + raise TypeError( + "The configured attention implementation must be a string or None; " + f"received {type(requested).__name__}." + ) + self._validate_attention_name(requested) + # ``PreTrainedModel.__init__`` resolves a missing Transformers + # implementation to the family default. Legacy FastPLMs configs + # persist their explicit choice in ``attn_backend``, so forward it + # into the canonical Transformers field before the base class can + # replace it with SDPA. A non-None canonical value still wins, + # including an explicit ``attn_implementation=...`` load override. + if canonical is None and legacy is not None: + set_config_attn_implementation(config, legacy) + super().__init__(config, *args, **kwargs) + # Transformers resolves an unspecified implementation during the base + # model initialization. Synchronize that choice before family layers + # are constructed. + resolved = get_attn_implementation(config) + self._validate_attention_name(resolved) + set_config_attn_implementation(config, resolved) + + def set_attn_implementation( + self, + attn_implementation: str | Mapping[str, str], + allow_all_kernels: bool = False, + ) -> None: + """Select an advertised backend and update every instantiated layer.""" + if isinstance(attn_implementation, Mapping): + if set(attn_implementation) == {""}: + attn_implementation = attn_implementation[""] + else: + raise ValueError( + "FastPLMs models have one attention backbone; pass a string or {'': name}." + ) + resolved_name = self._check_and_adjust_attn_implementation( + attn_implementation, + is_init_check=False, + allow_all_kernels=allow_all_kernels, + ) + set_config_attn_implementation(self.config, resolved_name) + resolved = resolve_attention_backend(resolved_name) + for module in self.modules(): + if module is self: + continue + for attribute in ("attn_backend", "attention_backend", "_attn_backend"): + if attribute not in module.__dict__: + continue + current = module.__dict__[attribute] + module.__dict__[attribute] = ( + resolved if isinstance(current, AttentionBackend) else resolved_name + ) + + +def validate_transformers_attention_interfaces() -> None: + """Verify that Transformers exposes functions and masks for every backend. + + Transformers 5.13 registers these canonical names. The FastPLMs function + overrides remain instance-local and do not replace process-global handlers. + """ + function_registry = FASTPLMS_ATTENTION_FUNCTIONS + mask_registry = FASTPLMS_ATTENTION_MASKS + missing_functions = [ + name + for name in ( + "sdpa", + "flex_attention", + "flash_attention_2", + "flash_attention_3", + ) + if name not in function_registry + ] + missing_masks = [ + name + for name in ( + "eager", + "sdpa", + "flex_attention", + "flash_attention_2", + "flash_attention_3", + ) + if name not in mask_registry + ] + if missing_functions or missing_masks: + raise RuntimeError( + "Transformers attention registry is incomplete: " + f"functions={missing_functions}, masks={missing_masks}." + ) diff --git a/fastplms/embeddings/__init__.py b/fastplms/embeddings/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..20ea613da857f64116daa04b7625390a83a71e85 --- /dev/null +++ b/fastplms/embeddings/__init__.py @@ -0,0 +1,65 @@ +"""Ordered, residue-aware protein embedding utilities.""" + +from .pooling import POOLING_NAMES, Pooler, pagerank_weights +from .runner import ( + EmbeddingMixin, + embed_dataset, + iter_fasta, + parse_fasta, + select_hidden_state_embeddings, +) +from .storage import ( + DEFAULT_SHARD_SIZE, + append_sqlite_records, + convert_legacy_sqlite, + garbage_collect_safetensors_generations, + initialize_sqlite_run, + load_legacy_pth, + load_result, + load_safetensors_result, + load_sqlite_result, + save_result, + save_safetensors_result, + save_sqlite_result, + tensor_sha256, + update_sqlite_run_metadata, +) +from .types import ( + EmbeddingBatch, + EmbeddingInput, + EmbeddingRecord, + EmbeddingResult, + LazyTensorReference, + TensorValue, +) + +__all__ = [ + "DEFAULT_SHARD_SIZE", + "POOLING_NAMES", + "EmbeddingBatch", + "EmbeddingInput", + "EmbeddingMixin", + "EmbeddingRecord", + "EmbeddingResult", + "LazyTensorReference", + "Pooler", + "TensorValue", + "append_sqlite_records", + "convert_legacy_sqlite", + "embed_dataset", + "garbage_collect_safetensors_generations", + "initialize_sqlite_run", + "iter_fasta", + "load_legacy_pth", + "load_result", + "load_safetensors_result", + "load_sqlite_result", + "pagerank_weights", + "parse_fasta", + "save_result", + "save_safetensors_result", + "save_sqlite_result", + "select_hidden_state_embeddings", + "tensor_sha256", + "update_sqlite_run_metadata", +] diff --git a/fastplms/embeddings/pooling.py b/fastplms/embeddings/pooling.py new file mode 100644 index 0000000000000000000000000000000000000000..57b5bc231d5b381b0aaa933beec00bb592e2a9d0 --- /dev/null +++ b/fastplms/embeddings/pooling.py @@ -0,0 +1,210 @@ +"""Residue-aware pooling implemented entirely with PyTorch.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +import torch +from torch import Tensor + +POOLING_NAMES = frozenset({"mean", "max", "norm", "median", "std", "var", "cls", "parti"}) + + +def _validate_inputs(X: Tensor, M: Tensor) -> Tensor: + if not isinstance(X, Tensor) or not isinstance(M, Tensor): + raise TypeError("X and M must be tensors.") + if X.ndim != 3: + raise ValueError(f"X must have shape (b, l, d), got {tuple(X.shape)}.") + if not X.is_floating_point(): + raise TypeError("X must use a floating-point embedding dtype.") + if M.shape != X.shape[:2]: + raise ValueError(f"M must have shape (b, l)={tuple(X.shape[:2])}, got {tuple(M.shape)}.") + if M.is_complex(): + raise TypeError("M must be a boolean or binary numeric residue mask.") + if not bool(torch.isfinite(M).all()) or not bool(((M == 0) | (M == 1)).all()): + raise ValueError("M must contain only finite binary mask values.") + M = M.to(device=X.device, dtype=torch.bool) + if not bool(M.any(dim=1).all()): + raise ValueError("Every sample must contain at least one biological residue.") + if not bool((torch.isfinite(X) | ~M.unsqueeze(-1)).all()): + raise ValueError("Biological residue embeddings produced non-finite output.") + return M + + +def _pooled_attention(attentions: Tensor | Sequence[Tensor], *, batch_size: int) -> Tensor: + """Max-pool layer/head attention A to shape ``(b, l, l)``. + + ``parti`` historically keeps the strongest directed edge across the + available attention maps before PageRank. Replacing NetworkX with Torch + must not change that reduction. + """ + + if isinstance(attentions, Sequence): + if not attentions: + raise ValueError("parti received an empty attention sequence.") + # Each A_i has shape (b, h, l, l). + A = torch.stack(tuple(attentions), dim=1) + else: + A = attentions + + if A.ndim == 5: + if A.shape[0] != batch_size and A.shape[1] == batch_size: + A = A.transpose(0, 1) + if A.shape[0] != batch_size: + raise ValueError("Five-dimensional attentions must use (b, n, h, l, l).") + A = A.flatten(1, 2).amax(dim=1) + elif A.ndim == 4: + if A.shape[0] != batch_size: + raise ValueError("Four-dimensional attentions must use (b, h, l, l).") + A = A.amax(dim=1) + elif A.ndim == 3: + if A.shape[0] != batch_size: + raise ValueError("Three-dimensional attentions must use (b, l, l).") + else: + raise ValueError("Attentions must have shape (b, l, l), (b, h, l, l), or (b, n, h, l, l).") + return A + + +def pagerank_weights( + A: Tensor, + *, + damping: float = 0.85, + tolerance: float = 1e-6, + max_iterations: int = 100, +) -> Tensor: + """Compute PageRank weights for a non-negative attention matrix A. + + A has shape ``(l, l)``. Rows are normalized into transition + probabilities; dangling rows transition uniformly. + """ + + if not isinstance(A, Tensor): + raise TypeError("A must be a tensor.") + if A.ndim != 2 or A.shape[0] != A.shape[1]: + raise ValueError(f"A must be square, got shape {tuple(A.shape)}.") + if not A.is_floating_point(): + raise TypeError("A must use a floating-point attention dtype.") + if not isinstance(damping, (int, float)) or isinstance(damping, bool): + raise TypeError("damping must be a finite float in [0, 1).") + if not math.isfinite(float(damping)) or not 0 <= damping < 1: + raise ValueError("damping must be a finite float in [0, 1).") + if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool): + raise TypeError("tolerance must be a positive finite float.") + if not math.isfinite(float(tolerance)) or tolerance <= 0: + raise ValueError("tolerance must be a positive finite float.") + if not isinstance(max_iterations, int) or isinstance(max_iterations, bool): + raise TypeError("max_iterations must be a positive integer.") + if max_iterations <= 0: + raise ValueError("max_iterations must be a positive integer.") + length = A.shape[0] + if length == 0: + raise ValueError("PageRank requires at least one residue.") + if not bool(torch.isfinite(A).all()): + raise ValueError("A must contain only finite attention values.") + work_dtype = torch.float64 if A.dtype == torch.float64 else torch.float32 + P = A.detach().to(dtype=work_dtype).clamp_min(0) + row_sum = P.sum(dim=-1, keepdim=True) + uniform = torch.full_like(P, 1.0 / length) + P = torch.where(row_sum > 0, P / row_sum.clamp_min(torch.finfo(work_dtype).tiny), uniform) + p = torch.full((length,), 1.0 / length, device=P.device, dtype=work_dtype) + teleport = (1.0 - damping) / length + for _ in range(max_iterations): + p_next = teleport + damping * (P.transpose(0, 1) @ p) + if torch.linalg.vector_norm(p_next - p, ord=1) <= tolerance: + p = p_next + break + p = p_next + return p / p.sum() + + +class Pooler: + """Apply one or more pooling operations to biological residue rows.""" + + def __init__(self, pooling: str | Sequence[str] = ("mean",)) -> None: + pooling_value: object = pooling + if isinstance(pooling_value, (bytes, bytearray)) or not isinstance( + pooling_value, (str, Sequence) + ): + raise TypeError("pooling must be a name or a sequence of names.") + names = (pooling_value,) if isinstance(pooling_value, str) else tuple(pooling_value) + if not all(isinstance(name, str) for name in names): + raise TypeError("pooling names must be strings.") + if not names: + raise ValueError("At least one pooling operation is required.") + unknown = set(names) - POOLING_NAMES + if unknown: + raise ValueError(f"Unknown pooling operations: {sorted(unknown)}.") + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError(f"Duplicate pooling operations are not supported: {duplicates}.") + self.names = names + + def output_slices(self, d: int) -> dict[str, tuple[int, int]]: + """Return the output interval assigned to each pooler.""" + + if not isinstance(d, int) or isinstance(d, bool): + raise TypeError("d must be a positive integer.") + if d <= 0: + raise ValueError("d must be a positive integer.") + return {name: (i * d, (i + 1) * d) for i, name in enumerate(self.names)} + + def __call__( + self, + X: Tensor, + residue_mask: Tensor, + *, + attentions: Tensor | Sequence[Tensor] | None = None, + attention_backend: str | None = None, + ) -> Tensor: + M = _validate_inputs(X, residue_mask) + M_expanded = M.unsqueeze(-1) + count = M_expanded.sum(dim=1).clamp_min(1) + X_residues = X.masked_fill(~M_expanded, 0) + outputs: list[Tensor] = [] + + for name in self.names: + if name == "mean": + Y = X_residues.sum(dim=1) / count + elif name == "max": + Y = X.masked_fill(~M_expanded, -torch.inf).max(dim=1).values + elif name == "norm": + Y = torch.linalg.vector_norm(X_residues, ord=2, dim=1) + elif name == "median": + Y = X.masked_fill(~M_expanded, torch.nan).nanmedian(dim=1).values + elif name in {"var", "std"}: + mean = X_residues.sum(dim=1, keepdim=True) / count.unsqueeze(1) + centered = (X - mean).masked_fill(~M_expanded, 0) + variance = (centered**2).sum(dim=1) / count + Y = variance.sqrt() if name == "std" else variance + elif name == "cls": + Y = X[:, 0] + else: + if attention_backend != "eager": + raise ValueError( + "parti requires attn_implementation='eager' so full " + "attention matrices are available." + ) + if attentions is None: + raise ValueError("parti requires model attention matrices.") + if int(M.sum(dim=1).max().item()) > 2048: + raise ValueError("parti supports at most 2,048 biological residues.") + A = _pooled_attention(attentions, batch_size=X.shape[0]).to(X.device) + pooled: list[Tensor] = [] + for X_i, M_i, A_i in zip(X, M, A, strict=True): + indices = M_i.nonzero(as_tuple=True)[0] + A_residue = A_i.index_select(0, indices).index_select(1, indices) + w = pagerank_weights(A_residue).to(dtype=X.dtype) + pooled.append(w @ X_i.index_select(0, indices)) + Y = torch.stack(pooled) + if not bool(torch.isfinite(Y).all()): + raise ValueError( + f"Pooling operation {name!r} produced non-finite output from " + "biological residue embeddings." + ) + outputs.append(Y) + + return torch.cat(outputs, dim=-1) + + +__all__ = ["POOLING_NAMES", "Pooler", "pagerank_weights"] diff --git a/fastplms/embeddings/runner.py b/fastplms/embeddings/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..bd9361047b74a4b6c10ec8e264405ec01a084158 --- /dev/null +++ b/fastplms/embeddings/runner.py @@ -0,0 +1,1559 @@ +"""Model-independent dataset embedding orchestration.""" + +from __future__ import annotations + +import hashlib +import json +import platform +import sqlite3 +import tempfile +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +from contextlib import contextmanager +from pathlib import Path +from typing import Any, overload + +import torch +from torch import Tensor + +from .pooling import Pooler +from .storage import ( + SafetensorsStreamWriter, + append_sqlite_records, + initialize_sqlite_run, + load_result, + load_sqlite_result, + safetensors_result_exists, + save_result, + tensor_sha256, + update_sqlite_run_metadata, +) +from .types import ( + EmbeddingBatch, + EmbeddingInput, + EmbeddingRecord, + EmbeddingResult, + LazyTensorReference, +) + +_MAX_PARTI_RESIDUES = 2_048 +_RUN_FINGERPRINT_SCHEMA_VERSION = 3 +_MODEL_STATE_HASH_CHUNK_BYTES = 16 * 1024**2 +_DEFAULT_BATCH_WINDOW_MULTIPLIER = 16 +_SUPPORTED_STORAGE_FORMATS = frozenset({"safetensors", "sqlite"}) + + +def _validate_parti_length(M: Tensor) -> None: + """Reject an oversized attention graph before model inference.""" + + n_residues = int(M.to(dtype=torch.int64).sum(dim=1).max().item()) + if n_residues > _MAX_PARTI_RESIDUES: + raise ValueError(f"parti supports at most {_MAX_PARTI_RESIDUES:,} biological residues.") + + +def select_hidden_state_embeddings( + last_hidden_state: Tensor, + hidden_states: tuple[Tensor, ...] | None, + *, + hidden_state_index: int = -1, + store_all_hidden_states: bool = False, +) -> Tensor: + """Select one hidden state or stack every state without changing values.""" + if store_all_hidden_states: + if not hidden_states: + raise ValueError("store_all_hidden_states requires model hidden states.") + # H has shape (b, n, l, d), where n follows the model's output order. + return torch.stack(hidden_states, dim=1) + if hidden_state_index == -1: + return last_hidden_state + if not hidden_states: + raise ValueError("hidden_state_index requires model hidden states.") + return hidden_states[hidden_state_index] + + +def iter_fasta(path: str | Path) -> Iterator[EmbeddingInput]: + """Yield FASTA records in source order without reading the file into memory.""" + + identifier: str | None = None + sequence_parts: list[str] = [] + found_record = False + with Path(path).open("r", encoding="utf-8") as handle: + for line_number, raw_line in enumerate(handle, start=1): + line = raw_line.strip() + if not line: + continue + if line.startswith(">"): + if identifier is not None: + found_record = True + yield EmbeddingInput(identifier, "".join(sequence_parts)) + identifier = line[1:].strip().split(maxsplit=1)[0] + if not identifier: + raise ValueError(f"Missing FASTA identifier on line {line_number}.") + sequence_parts = [] + else: + if identifier is None: + raise ValueError( + f"Sequence data precedes the first FASTA header on line {line_number}." + ) + sequence_parts.append("".join(line.split())) + if identifier is not None: + found_record = True + yield EmbeddingInput(identifier, "".join(sequence_parts)) + if not found_record: + raise ValueError(f"No FASTA records found in {path}.") + + +def parse_fasta(path: str | Path) -> list[EmbeddingInput]: + """Parse FASTA records while preserving identifiers, order, and duplicates.""" + + return list(iter_fasta(path)) + + +def _normalize_input_item( + position: int, + item: str | EmbeddingInput | tuple[str, str], +) -> EmbeddingInput: + if isinstance(item, EmbeddingInput): + return item + if isinstance(item, str): + return EmbeddingInput(str(position), item) + if isinstance(item, tuple) and len(item) == 2: + return EmbeddingInput(str(item[0]), str(item[1])) + raise TypeError( + "inputs must contain sequences, EmbeddingInput values, or (id, sequence) tuples." + ) + + +class _InputSpool(Sequence[EmbeddingInput]): + """Immutable disk-backed normalized inputs with an incremental digest.""" + + def __init__( + self, + values: Iterable[str | EmbeddingInput | tuple[str, str]], + ) -> None: + self._temporary: tempfile.TemporaryDirectory[str] | None = tempfile.TemporaryDirectory( + prefix="fastplms-inputs-" + ) + self.path = Path(self._temporary.name) / "inputs.sqlite" + self._connection: sqlite3.Connection | None = sqlite3.connect(self.path) + self._connection.execute( + "CREATE TABLE inputs (" + "position INTEGER PRIMARY KEY, input_id TEXT NOT NULL, sequence TEXT NOT NULL)" + ) + digest = hashlib.sha256() + count = 0 + pending: list[tuple[int, str, str]] = [] + try: + for position, item in enumerate(values): + record = _normalize_input_item(position, item) + for value in (record.id, record.sequence): + encoded = value.encode("utf-8") + digest.update(len(encoded).to_bytes(8, "big")) + digest.update(encoded) + pending.append((position, record.id, record.sequence)) + count += 1 + if len(pending) == 1_024: + self._connection.executemany("INSERT INTO inputs VALUES (?, ?, ?)", pending) + pending.clear() + if pending: + self._connection.executemany("INSERT INTO inputs VALUES (?, ?, ?)", pending) + if count == 0: + raise ValueError("inputs must contain at least one sequence.") + self._connection.commit() + self._connection.close() + self._connection = sqlite3.connect( + f"{self.path.resolve().as_uri()}?mode=ro", + uri=True, + ) + except BaseException: + self.close() + raise + digest.update(count.to_bytes(8, "big")) + self.input_fingerprint = digest.hexdigest() + self._count = count + + def _require_connection(self) -> sqlite3.Connection: + if self._connection is None: + raise RuntimeError("Input spool is closed.") + return self._connection + + def __len__(self) -> int: + return self._count + + def __iter__(self) -> Iterator[EmbeddingInput]: + cursor = self._require_connection().execute( + "SELECT input_id, sequence FROM inputs ORDER BY position" + ) + while rows := cursor.fetchmany(1_024): + for input_id, sequence in rows: + yield EmbeddingInput(input_id, sequence) + + @overload + def __getitem__(self, index: int, /) -> EmbeddingInput: ... + + @overload + def __getitem__(self, index: slice, /) -> list[EmbeddingInput]: ... + + def __getitem__(self, index: int | slice) -> EmbeddingInput | list[EmbeddingInput]: + connection = self._require_connection() + + if isinstance(index, slice): + start, stop, step = index.indices(self._count) + if step != 1: + return [self[position] for position in range(start, stop, step)] + rows = connection.execute( + "SELECT input_id, sequence FROM inputs " + "WHERE position >= ? AND position < ? ORDER BY position", + (start, stop), + ).fetchall() + return [EmbeddingInput(input_id, sequence) for input_id, sequence in rows] + position = index + self._count if index < 0 else index + if position < 0 or position >= self._count: + raise IndexError(index) + row = connection.execute( + "SELECT input_id, sequence FROM inputs WHERE position = ?", (position,) + ).fetchone() + if row is None: + raise IndexError(index) + return EmbeddingInput(row[0], row[1]) + + def close(self) -> None: + connection = getattr(self, "_connection", None) + if connection is not None: + connection.close() + self._connection = None + temporary = getattr(self, "_temporary", None) + if temporary is not None: + temporary.cleanup() + self._temporary = None + + def __del__(self) -> None: + self.close() + + +def _normalize_inputs( + inputs: (Iterable[str | EmbeddingInput | tuple[str, str]] | Mapping[str, str] | str | Path), + *, + disk_backed: bool, +) -> Sequence[EmbeddingInput]: + is_fasta_path = isinstance(inputs, Path) + if isinstance(inputs, str): + try: + is_fasta_path = Path(inputs).is_file() + except OSError: + is_fasta_path = False + should_spool = disk_backed or is_fasta_path or not isinstance(inputs, (str, Sequence, Mapping)) + values: Iterable[str | EmbeddingInput | tuple[str, str]] + if isinstance(inputs, Path): + values = iter_fasta(inputs) + elif isinstance(inputs, str): + values = iter_fasta(inputs) if is_fasta_path else [inputs] + elif isinstance(inputs, Mapping): + values = inputs.items() + else: + values = inputs + if should_spool: + return _InputSpool(values) + records: list[EmbeddingInput] = [] + for position, item in enumerate(values): + records.append(_normalize_input_item(position, item)) + if not records: + raise ValueError("inputs must contain at least one sequence.") + return records + + +def _validate_untruncated_lengths( + records: Sequence[EmbeddingInput], + *, + max_length: int | None, + truncate: bool, +) -> None: + """Fail before inference when a biological-residue limit would be exceeded.""" + + if max_length is None or truncate: + return + for position, record in enumerate(records): + residue_count = len(record.sequence) + if residue_count > max_length: + raise ValueError( + f"Input at position {position} with id {record.id!r} has " + f"{residue_count} biological residues, exceeding max_length={max_length} " + "while truncate=False." + ) + + +def _model_device(model: Any) -> torch.device: + try: + return torch.device(next(model.parameters()).device) + except (AttributeError, StopIteration): + return torch.device("cpu") + + +def _attention_backend(model: Any) -> str | None: + config = getattr(model, "config", None) + for name in ("_attn_implementation", "attn_implementation", "attn_backend"): + value = getattr(config, name, None) + if value: + return str(value) + return None + + +def _attention_kernel_metadata(backend: str | None) -> dict[str, Any] | None: + if backend not in {"flash_attention_2", "flash_attention_3"}: + return None + from fastplms.registry import get_model_registry + + spec = get_model_registry().attention_kernels[backend] + return { + "repository": spec.repository, + "revision": spec.revision, + "version": spec.version, + "expected_variant": spec.expected_variant, + "dtypes": list(spec.dtypes), + } + + +def _fingerprint_jsonable(value: Any) -> Any: + if isinstance(value, Mapping): + return {str(key): _fingerprint_jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_fingerprint_jsonable(item) for item in value] + if isinstance(value, (set, frozenset)): + return sorted((_fingerprint_jsonable(item) for item in value), key=repr) + if isinstance(value, Path): + return str(value) + if isinstance(value, Tensor): + return { + "dtype": str(value.dtype).removeprefix("torch."), + "shape": list(value.shape), + "sha256": tensor_sha256(value), + } + if isinstance(value, torch.dtype): + return str(value).removeprefix("torch.") + if isinstance(value, torch.device): + return str(value) + if value is None or isinstance(value, (str, int, float, bool)): + return value + return { + "class": f"{value.__class__.__module__}.{value.__class__.__qualname__}", + "value": str(value), + } + + +def _tokenizer_content_sha256(tokenizer: Any) -> str: + content: dict[str, Any] = { + "init_kwargs": getattr(tokenizer, "init_kwargs", None), + "special_tokens_map": getattr(tokenizer, "special_tokens_map", None), + "model_max_length": getattr(tokenizer, "model_max_length", None), + "padding_side": getattr(tokenizer, "padding_side", None), + "truncation_side": getattr(tokenizer, "truncation_side", None), + } + get_vocab = getattr(tokenizer, "get_vocab", None) + if callable(get_vocab): + content["vocabulary"] = get_vocab() + get_added_vocab = getattr(tokenizer, "get_added_vocab", None) + if callable(get_added_vocab): + content["added_vocabulary"] = get_added_vocab() + backend = getattr(tokenizer, "backend_tokenizer", None) + backend_to_str = getattr(backend, "to_str", None) + if callable(backend_to_str): + content["backend"] = backend_to_str() + serialized = json.dumps( + _fingerprint_jsonable(content), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + return hashlib.sha256(serialized).hexdigest() + + +def _tokenizer_metadata(model: Any, tokenizer: Any | None) -> dict[str, Any]: + resolved = tokenizer if tokenizer is not None else getattr(model, "tokenizer", None) + if resolved is None: + # Raw-sequence families such as E1 retain their loader context on the + # model/encoder rather than exposing a Transformers tokenizer. Bind the + # non-secret source policy to resume identity without serializing a Hub + # token or forcing lazy tokenizer initialization. + for candidate in (model, getattr(model, "model", None)): + settings = getattr(candidate, "__dict__", {}).get("_fastplms_tokenizer_kwargs") + if isinstance(settings, Mapping): + token_value = settings.get("token") + return { + "mode": "native-sequence", + "source": ( + str(settings.get("tokenizer_source")) + if settings.get("tokenizer_source") is not None + else None + ), + "revision": settings.get("revision"), + "cache_dir": ( + str(settings.get("cache_dir")) + if settings.get("cache_dir") is not None + else None + ), + "local_files_only": bool(settings.get("local_files_only", False)), + "token_policy": ( + "disabled" + if token_value is False + else "provided" + if token_value is not None + else "default" + ), + } + return {"mode": "native-sequence"} + return { + "mode": "tokenizer", + "class": f"{resolved.__class__.__module__}.{resolved.__class__.__qualname__}", + "name_or_path": getattr(resolved, "name_or_path", None), + "vocab_size": getattr(resolved, "vocab_size", None), + "special_token_ids": list(getattr(resolved, "all_special_ids", ())), + "content_sha256": _tokenizer_content_sha256(resolved), + } + + +@contextmanager +def _temporary_eval(model: Any) -> Iterator[None]: + was_training = getattr(model, "training", None) + eval_method = getattr(model, "eval", None) + train_method = getattr(model, "train", None) + if ( + not isinstance(was_training, bool) + or not callable(eval_method) + or not callable(train_method) + ): + yield + return + eval_method() + try: + yield + finally: + train_method(was_training) + + +def _software_versions() -> dict[str, str | None]: + try: + import fastplms + + fastplms_version = fastplms.__version__ + except (AttributeError, ImportError): + fastplms_version = None + try: + import safetensors + + safetensors_version = safetensors.__version__ + except ImportError: + safetensors_version = None + try: + import transformers + + transformers_version = transformers.__version__ + except ImportError: + transformers_version = None + return { + "fastplms": fastplms_version, + "python": platform.python_version(), + "safetensors": safetensors_version, + "torch": torch.__version__, + "torch_cuda": torch.version.cuda, + "transformers": transformers_version, + } + + +def _adapter_identity_metadata(model: Any) -> dict[str, Any] | None: + """Return deterministic PEFT/adapter identity without tensor payloads.""" + + peft_config = getattr(model, "peft_config", None) + if not isinstance(peft_config, Mapping) or not peft_config: + return None + configurations: dict[str, Any] = {} + for name, config in sorted(peft_config.items(), key=lambda item: str(item[0])): + to_dict = getattr(config, "to_dict", None) + if callable(to_dict): + value = to_dict() + else: + try: + value = vars(config) + except TypeError: + value = config + configurations[str(name)] = _fingerprint_jsonable(value) + active_adapters = getattr(model, "active_adapters", None) + if callable(active_adapters): + active_adapters = active_adapters() + return { + "active": _fingerprint_jsonable(active_adapters), + "configurations": configurations, + } + + +def _execution_identity_metadata(model: Any) -> dict[str, Any]: + """Capture runtime policy that can change persisted numerical results.""" + + parameter_dtypes = sorted( + { + str(parameter.dtype).removeprefix("torch.") + for parameter in getattr(model, "parameters", lambda: ())() + } + ) + return { + "device": _model_device(model).type, + "hf_device_map": _fingerprint_jsonable(getattr(model, "hf_device_map", None)), + "parameter_dtypes": parameter_dtypes, + "software": _software_versions(), + } + + +def _biological_residue_mask( + input_ids: Tensor, + attention_mask: Tensor, + tokenizer: Any, +) -> Tensor: + """Remove padding and tokenizer-declared special tokens from M.""" + + M = attention_mask.to(dtype=torch.bool) + special_ids = tuple(int(token_id) for token_id in getattr(tokenizer, "all_special_ids", ())) + if special_ids: + specials = torch.tensor(special_ids, device=input_ids.device, dtype=input_ids.dtype) + M = M & ~torch.isin(input_ids, specials) + return M + + +def _generic_embedding_batch( + model: Any, + sequences: list[str], + *, + tokenizer: Any | None, + max_length: int | None, + truncate: bool, + need_attentions: bool, + model_kwargs: dict[str, Any], +) -> EmbeddingBatch: + config = getattr(model, "config", None) + model_type = str(getattr(config, "model_type", "")).lower() + if tokenizer is None: + tokenizer = getattr(model, "tokenizer", None) + + if tokenizer is None and model_type == "e1": + output = model._embed(sequences, return_attention_mask=True, **model_kwargs) + if not isinstance(output, tuple) or len(output) != 2: + raise TypeError("E1 _embed must return (X, residue_mask).") + X, M = output + preparer = getattr(model, "prep_tokens", None) + if preparer is not None and hasattr(preparer, "get_batch_kwargs"): + prepared = preparer.get_batch_kwargs(sequences, device=X.device) + input_ids = prepared["input_ids"] + boundary_ids = preparer.boundary_token_ids.to( + device=input_ids.device, dtype=input_ids.dtype + ) + # E1 wraps each raw sequence in BOS, context-label, terminal-label, + # and EOS tokens. Only amino-acid rows are biological residues. + M = M.to(dtype=torch.bool) & ~torch.isin(input_ids, boundary_ids) + if need_attentions: + raise ValueError("parti is not available for tokenizer-free E1 embedding.") + return EmbeddingBatch(X=X, residue_mask=M.to(dtype=torch.bool)) + if tokenizer is None: + raise ValueError("A tokenizer is required for this model's embedding path.") + + tokenize_kwargs: dict[str, Any] = { + "return_tensors": "pt", + "padding": True, + "truncation": truncate, + } + if max_length is not None and truncate: + # ``max_length`` is a biological-residue limit. Tokenizer limits include + # boundary tokens, so reserve their declared width instead of dropping + # residues at the exact boundary. + special_token_count = 0 + num_special_tokens_to_add = getattr(tokenizer, "num_special_tokens_to_add", None) + if callable(num_special_tokens_to_add): + special_token_count = int(num_special_tokens_to_add(pair=False)) + tokenize_kwargs["max_length"] = max_length + special_token_count + sequence_tokenizer = getattr(model, "_tokenize_sequence_batch", None) + if callable(sequence_tokenizer): + encoded = sequence_tokenizer(sequences, tokenizer=tokenizer, **tokenize_kwargs) + else: + encoded = tokenizer(sequences, **tokenize_kwargs) + device = _model_device(model) + input_ids = encoded["input_ids"].to(device) + attention_mask = encoded.get("attention_mask", input_ids.new_ones(input_ids.shape)).to(device) + M = _biological_residue_mask(input_ids, attention_mask, tokenizer) + if need_attentions: + # Validate l before either the backbone or its quadratic attention graph + # is materialized. M has shape (b, l). + _validate_parti_length(M) + X = model._embed(input_ids, attention_mask, **model_kwargs) + attentions = None + if need_attentions: + output = model( + input_ids=input_ids, + attention_mask=attention_mask, + output_attentions=True, + return_dict=True, + ) + attentions = getattr(output, "attentions", None) + if attentions is None: + raise ValueError("The model did not return attentions required by parti.") + return EmbeddingBatch(X=X, residue_mask=M, attentions=attentions) + + +def _first_metadata_value(*values: Any) -> Any: + for value in values: + if isinstance(value, str): + if value.strip(): + return value + elif value is not None: + return value + return None + + +def _model_identity_metadata(model: Any) -> dict[str, Any]: + """Resolve model and checkpoint identity, including local artifact fallbacks.""" + + config = getattr(model, "config", None) + checkpoint_revision = _first_metadata_value( + getattr(config, "fastplms_checkpoint_revision", None), + getattr(config, "_commit_hash", None), + ) + return { + "model_id": _first_metadata_value( + getattr(config, "fastplms_model_id", None), + getattr(config, "_name_or_path", None), + ), + "model_revision": _first_metadata_value( + getattr(config, "_commit_hash", None), + checkpoint_revision, + ), + "checkpoint_repo_id": getattr(config, "fastplms_checkpoint_repo_id", None), + "checkpoint_revision": checkpoint_revision, + "checkpoint_hash": _first_metadata_value( + getattr(model, "checkpoint_hash", None), + getattr(config, "checkpoint_hash", None), + getattr(config, "fastplms_checkpoint_hash", None), + ), + "weights_revision": getattr(config, "fastplms_weights_revision", None), + "runtime_revision": getattr(config, "fastplms_runtime_revision", None), + "source_tree_sha256": getattr(config, "fastplms_source_tree_sha256", None), + "runtime_bundle_sha256": getattr(config, "fastplms_runtime_bundle_sha256", None), + } + + +def _bounded_tensor_chunks(X: Tensor, max_elements: int) -> Iterable[Tensor]: + """Yield X in logical row-major order without materializing a full copy.""" + + if X.numel() == 0: + return + if X.ndim == 0: + yield X + return + trailing_elements = 1 + for size in X.shape[1:]: + trailing_elements *= int(size) + if trailing_elements <= max_elements: + rows_per_chunk = max(1, max_elements // trailing_elements) + for start in range(0, X.shape[0], rows_per_chunk): + yield X[start : start + rows_per_chunk] + return + for row in X: + yield from _bounded_tensor_chunks(row, max_elements) + + +def _model_state_sha256(model: Any) -> str: + """Hash named parameters and persistent buffers using bounded CPU copies.""" + + # Never cache this digest from tensor identity or ``Tensor._version``. + # ``Parameter.data`` and independent tensor aliases can mutate shared storage + # without changing either signal, while persisted resume identity must bind + # the authoritative bytes visible at the start of this run. + state = model.state_dict(keep_vars=True) + digest = hashlib.sha256() + for name, value in sorted(state.items()): + if not isinstance(value, Tensor): + raise TypeError(f"Model state entry {name!r} is not a tensor.") + if value.is_meta: + raise ValueError( + f"Cannot fingerprint meta-device model state entry {name!r}; pass " + "model_state_fingerprint with a caller-owned state identity." + ) + header = json.dumps( + { + "name": name, + "dtype": str(value.dtype).removeprefix("torch."), + "shape": list(value.shape), + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + digest.update(len(header).to_bytes(8, "big")) + digest.update(header) + max_elements = max(1, _MODEL_STATE_HASH_CHUNK_BYTES // value.element_size()) + for chunk in _bounded_tensor_chunks(value.detach(), max_elements): + cpu_chunk = chunk.to(device="cpu").contiguous() + digest.update(cpu_chunk.reshape(-1).view(torch.uint8).numpy().tobytes()) + return digest.hexdigest() + + +def _input_sha256(records: Iterable[EmbeddingInput]) -> str: + """Hash an ordered input stream without constructing a duplicate JSON payload.""" + + precomputed = getattr(records, "input_fingerprint", None) + if isinstance(precomputed, str): + return precomputed + digest = hashlib.sha256() + count = 0 + for record in records: + count += 1 + for value in (record.id, record.sequence): + encoded = value.encode("utf-8") + digest.update(len(encoded).to_bytes(8, "big")) + digest.update(encoded) + digest.update(count.to_bytes(8, "big")) + return digest.hexdigest() + + +def _run_fingerprint( + model: Any, + records: Sequence[EmbeddingInput], + *, + pooling: Sequence[str], + full_embeddings: bool, + max_length: int | None, + truncate: bool, + dtype: torch.dtype | None, + model_kwargs: dict[str, Any], + tokenizer_metadata: dict[str, Any], + model_state_fingerprint: str | None, + persist_output: bool, + embedding_context: Mapping[str, Any], + batch_size: int, + batch_window_size: int, + max_tokens_per_batch: int | None, +) -> tuple[str, str, str | None, str]: + input_fingerprint = _input_sha256(records) + attention_backend = _attention_backend(model) + model_identity = _model_identity_metadata(model) + if model_state_fingerprint is None and persist_output: + resolved_model_state_fingerprint = _model_state_sha256(model) + model_state_fingerprint_source = "computed" + elif model_state_fingerprint is not None: + resolved_model_state_fingerprint = model_state_fingerprint.strip() + if not resolved_model_state_fingerprint: + raise ValueError("model_state_fingerprint must not be empty.") + model_state_fingerprint_source = "caller" + else: + resolved_model_state_fingerprint = None + model_state_fingerprint_source = "not-computed" + payload = { + "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION, + "input_fingerprint": input_fingerprint, + "model_state_fingerprint": resolved_model_state_fingerprint, + "model_state_fingerprint_source": model_state_fingerprint_source, + "model_class": f"{model.__class__.__module__}.{model.__class__.__qualname__}", + **model_identity, + "attention_backend": attention_backend, + "attention_kernel": _attention_kernel_metadata(attention_backend), + "layer": repr( + getattr(model, "embedding_layer", model_kwargs.get("hidden_state_index", -1)) + ), + "projection": getattr(model, "embedding_projection", None), + "esmc_source": getattr(model, "_esmc_source", None), + "esmc_revision": getattr(model, "_esmc_source_revision", None), + "esmc_files": getattr(model, "_esmc_source_files", None), + "token_policy": getattr(model, "embedding_token_policy", None), + "tokenizer": tokenizer_metadata, + "adapter": _adapter_identity_metadata(model), + "execution": _execution_identity_metadata(model), + "embedding_context": _fingerprint_jsonable(embedding_context), + "pooling": list(pooling), + "full_embeddings": full_embeddings, + "max_length": max_length, + "truncate": truncate, + "dtype": str(dtype) if dtype is not None else None, + "batching": { + "batch_size": batch_size, + "batch_window_size": batch_window_size, + "max_tokens_per_batch": max_tokens_per_batch, + "input_storage": ("disk-spool" if isinstance(records, _InputSpool) else "memory"), + }, + "model_kwargs": { + key: _fingerprint_jsonable(value) for key, value in sorted(model_kwargs.items()) + }, + "residue_mask_policy": "attention-mask-minus-special-tokens", + } + run_fingerprint = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return ( + input_fingerprint, + run_fingerprint, + resolved_model_state_fingerprint, + model_state_fingerprint_source, + ) + + +def _output_exists(path: str | Path, format: str) -> bool: + path = Path(path) + if format == "sqlite": + return path.is_file() + return safetensors_result_exists(path) + + +def _output_descriptor(position: int, record: EmbeddingRecord) -> dict[str, Any]: + tensor = record.tensor + if isinstance(tensor, LazyTensorReference): + dtype = tensor.dtype + shape = tensor.shape + digest = tensor.sha256 + else: + dtype = str(tensor.dtype).removeprefix("torch.") + shape = tuple(tensor.shape) + digest = tensor_sha256(tensor) + return { + "position": position, + "id": record.id, + "dtype": dtype, + "shape": shape, + "sha256": digest, + } + + +def _ordered_string_sha256(values: Sequence[str]) -> str: + digest = hashlib.sha256() + for value in values: + encoded = value.encode("utf-8") + digest.update(len(encoded).to_bytes(8, "big")) + digest.update(encoded) + digest.update(len(values).to_bytes(8, "big")) + return digest.hexdigest() + + +def _embedding_context( + model: Any, + records: Sequence[EmbeddingInput], + *, + hidden_state_source: str, + decoder_inputs: Sequence[str] | None, + decoder_input_ids: Tensor | None, + decoder_attention_mask: Tensor | None, + model_kwargs: Mapping[str, Any], +) -> tuple[dict[str, Any], tuple[str, ...] | None]: + if hidden_state_source not in {"encoder", "decoder"}: + raise ValueError("hidden_state_source must be 'encoder' or 'decoder'.") + hidden_state_index = model_kwargs.get("hidden_state_index", -1) + if not isinstance(hidden_state_index, int) or isinstance(hidden_state_index, bool): + raise TypeError("hidden_state_index must be an integer.") + store_all_hidden_states = model_kwargs.get("store_all_hidden_states", False) + if not isinstance(store_all_hidden_states, bool): + raise TypeError("store_all_hidden_states must be a boolean.") + normalized_decoder_inputs: tuple[str, ...] | None = None + has_decoder_inputs = decoder_inputs is not None + has_decoder_ids = decoder_input_ids is not None + if hidden_state_source == "encoder": + if has_decoder_inputs or has_decoder_ids or decoder_attention_mask is not None: + raise ValueError("Decoder inputs are only valid when hidden_state_source='decoder'.") + else: + if has_decoder_inputs == has_decoder_ids: + raise ValueError( + "Decoder embedding requires exactly one of decoder_inputs or decoder_input_ids." + ) + decoder_input_fingerprint: str | None = None + if decoder_inputs is not None: + if isinstance(decoder_inputs, (str, bytes)) or not isinstance(decoder_inputs, Sequence): + raise TypeError("decoder_inputs must be an aligned sequence of strings.") + normalized_decoder_inputs = tuple(decoder_inputs) + if not all(isinstance(value, str) and value for value in normalized_decoder_inputs): + raise ValueError("decoder_inputs must contain non-empty strings.") + if len(normalized_decoder_inputs) != len(records): + raise ValueError("decoder_inputs must align one-to-one with embedding inputs.") + decoder_input_fingerprint = _ordered_string_sha256(normalized_decoder_inputs) + if decoder_attention_mask is not None: + raise ValueError("decoder_attention_mask requires decoder_input_ids.") + if decoder_input_ids is not None: + if not isinstance(decoder_input_ids, Tensor) or decoder_input_ids.ndim != 2: + raise ValueError("decoder_input_ids must have shape (batch, sequence).") + if decoder_input_ids.shape[0] != len(records): + raise ValueError("decoder_input_ids must align one-to-one with embedding inputs.") + if decoder_input_ids.dtype == torch.bool or decoder_input_ids.is_floating_point(): + raise TypeError("decoder_input_ids must use an integer token dtype.") + decoder_input_fingerprint = tensor_sha256(decoder_input_ids) + decoder_mask_fingerprint: str | None = None + if decoder_attention_mask is not None: + if not isinstance(decoder_attention_mask, Tensor): + raise TypeError("decoder_attention_mask must be a tensor.") + if decoder_input_ids is None or decoder_attention_mask.shape != decoder_input_ids.shape: + raise ValueError("decoder_attention_mask must match decoder_input_ids shape.") + decoder_mask_fingerprint = tensor_sha256(decoder_attention_mask) + + context: dict[str, Any] = { + "hidden_state_source": hidden_state_source, + "hidden_state_index": hidden_state_index, + "store_all_hidden_states": store_all_hidden_states, + "decoder_input_fingerprint": decoder_input_fingerprint, + "decoder_attention_mask_fingerprint": decoder_mask_fingerprint, + "decoder_alignment": "input-position" if hidden_state_source == "decoder" else None, + } + metadata_hook = getattr(model, "_embedding_metadata", None) + model_metadata: Mapping[str, Any] | None = None + if callable(metadata_hook): + model_metadata = metadata_hook(**context) + if not isinstance(model_metadata, Mapping): + raise TypeError("_embedding_metadata must return a mapping.") + context["model_embedding"] = _fingerprint_jsonable(model_metadata) + if hidden_state_source == "decoder": + has_decoder_batch = callable(getattr(model, "_embedding_batch", None)) + declares_decoder_stack = ( + model_metadata is not None and model_metadata.get("hidden_state_stack") == "decoder" + ) + if not has_decoder_batch or not declares_decoder_stack: + raise ValueError( + f"{model.__class__.__name__} does not declare decoder embedding support." + ) + return context, normalized_decoder_inputs + + +def _planned_batches( + records: Sequence[EmbeddingInput], + positions: range, + *, + batch_size: int, + max_tokens_per_batch: int | None, + max_length: int | None, + truncate: bool, +) -> Iterator[list[int]]: + """Length-bucket one bounded window while retaining stable output positions.""" + + def effective_length(position: int) -> int: + length = len(records[position].sequence) + return min(length, max_length) if truncate and max_length is not None else length + + ordered = sorted(positions, key=lambda position: (-effective_length(position), position)) + batch: list[int] = [] + longest = 0 + for position in ordered: + length = effective_length(position) + if max_tokens_per_batch is not None and length > max_tokens_per_batch: + raise ValueError( + f"Input at position {position} has {length} residues, exceeding " + f"max_tokens_per_batch={max_tokens_per_batch}." + ) + candidate_longest = max(longest, length) + exceeds_tokens = ( + max_tokens_per_batch is not None + and candidate_longest * (len(batch) + 1) > max_tokens_per_batch + ) + if batch and (len(batch) >= batch_size or exceeds_tokens): + yield batch + batch = [] + longest = 0 + batch.append(position) + longest = max(longest, length) + if batch: + yield batch + + +def embed_dataset( + model: Any, + inputs: (Iterable[str | EmbeddingInput | tuple[str, str]] | Mapping[str, str] | str | Path), + *, + batch_size: int = 2, + pooling: str | Sequence[str] | None = None, + full_embeddings: bool = False, + output: str | Path | None = None, + format: str = "safetensors", + resume: bool = True, + tokenizer: Any | None = None, + max_length: int | None = None, + truncate: bool = True, + dtype: torch.dtype | None = torch.float32, + shard_size: int = 2 * 1024**3, + model_state_fingerprint: str | None = None, + batch_window_size: int | None = None, + max_tokens_per_batch: int | None = None, + hidden_state_source: str = "encoder", + decoder_inputs: Sequence[str] | None = None, + decoder_input_ids: Tensor | None = None, + decoder_attention_mask: Tensor | None = None, + _embedding_batch_fn: Callable[..., EmbeddingBatch] | None = None, + _embedding_batch_identity: Mapping[str, Any] | None = None, + _allowed_unsupported_pooling: Sequence[str] = (), + **model_kwargs: Any, +) -> EmbeddingResult: + """Embed protein sequences with stable ordering and residue-only pooling.""" + + for name, value in ( + ("batch_size", batch_size), + ("shard_size", shard_size), + ): + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError(f"{name} must be a positive integer.") + if value <= 0: + raise ValueError(f"{name} must be a positive integer.") + for optional_name, optional_value in ( + ("max_length", max_length), + ("max_tokens_per_batch", max_tokens_per_batch), + ("batch_window_size", batch_window_size), + ): + if optional_value is not None and ( + not isinstance(optional_value, int) or isinstance(optional_value, bool) + ): + raise TypeError(f"{optional_name} must be a positive integer when provided.") + if optional_value is not None and optional_value <= 0: + raise ValueError(f"{optional_name} must be a positive integer when provided.") + for name, value in ( + ("full_embeddings", full_embeddings), + ("resume", resume), + ("truncate", truncate), + ): + if not isinstance(value, bool): + raise TypeError(f"{name} must be a boolean.") + if not isinstance(format, str): + raise TypeError("format must be a string.") + if output is not None and not isinstance(output, (str, Path)): + raise TypeError("output must be a path or None.") + if model_state_fingerprint is not None and ( + not isinstance(model_state_fingerprint, str) or not model_state_fingerprint + ): + raise ValueError("model_state_fingerprint must be a non-empty string when provided.") + if hidden_state_source not in {"encoder", "decoder"}: + raise ValueError("hidden_state_source must be 'encoder' or 'decoder'.") + hidden_state_index = model_kwargs.get("hidden_state_index", -1) + if not isinstance(hidden_state_index, int) or isinstance(hidden_state_index, bool): + raise TypeError("hidden_state_index must be an integer.") + store_all_hidden_states = model_kwargs.get("store_all_hidden_states", False) + if not isinstance(store_all_hidden_states, bool): + raise TypeError("store_all_hidden_states must be a boolean.") + if decoder_input_ids is not None: + if not isinstance(decoder_input_ids, Tensor): + raise TypeError("decoder_input_ids must be a tensor.") + if decoder_input_ids.is_meta: + raise ValueError("decoder_input_ids cannot be a meta tensor.") + if decoder_input_ids.ndim != 2 or decoder_input_ids.shape[1] == 0: + raise ValueError("decoder_input_ids must have non-empty shape (batch, sequence).") + if decoder_input_ids.dtype not in {torch.int32, torch.int64}: + raise TypeError("decoder_input_ids must use torch.int32 or torch.int64.") + if decoder_attention_mask is not None: + if not isinstance(decoder_attention_mask, Tensor): + raise TypeError("decoder_attention_mask must be a tensor.") + if decoder_attention_mask.is_meta: + raise ValueError("decoder_attention_mask cannot be a meta tensor.") + if decoder_attention_mask.is_complex() or not bool( + torch.isfinite(decoder_attention_mask).all() + ): + raise ValueError("decoder_attention_mask must contain finite binary values.") + if not bool(((decoder_attention_mask == 0) | (decoder_attention_mask == 1)).all()): + raise ValueError("decoder_attention_mask must contain finite binary values.") + pooling_names = ( + (("mean",) if not full_embeddings else ()) + if pooling is None + else ((pooling,) if isinstance(pooling, str) else tuple(pooling)) + ) + if full_embeddings and pooling is not None: + raise ValueError("full_embeddings=True cannot be combined with pooling.") + if not full_embeddings and not pooling_names: + raise ValueError("pooling is required unless full_embeddings=True.") + pooler = Pooler(pooling_names) if pooling_names else None + + if batch_size <= 0: + raise ValueError("batch_size must be positive.") + if format == "pth" or (output is not None and Path(output).suffix.lower() == ".pth"): + raise ValueError("Writing pickle-based .pth embeddings is not supported.") + if format not in _SUPPORTED_STORAGE_FORMATS: + raise ValueError("format must be 'safetensors' or 'sqlite'.") + if max_length is not None and max_length <= 0: + raise ValueError("max_length must be positive when provided.") + if max_tokens_per_batch is not None and max_tokens_per_batch <= 0: + raise ValueError("max_tokens_per_batch must be positive when provided.") + if not isinstance(dtype, (torch.dtype, type(None))): + raise TypeError("dtype must be a torch.dtype or None.") + if batch_window_size is not None and batch_window_size <= 0: + raise ValueError("batch_window_size must be positive when provided.") + if _embedding_batch_fn is not None and not callable(_embedding_batch_fn): + raise TypeError("_embedding_batch_fn must be callable when provided.") + if _embedding_batch_fn is not None and _embedding_batch_identity is None: + raise ValueError( + "_embedding_batch_identity is required with _embedding_batch_fn so persisted " + "runs bind the family-specific embedding behavior." + ) + if _embedding_batch_identity is not None and not isinstance(_embedding_batch_identity, Mapping): + raise TypeError("_embedding_batch_identity must be a mapping when provided.") + if isinstance(_allowed_unsupported_pooling, (str, bytes)) or not isinstance( + _allowed_unsupported_pooling, Sequence + ): + raise TypeError("_allowed_unsupported_pooling must be a sequence of pooler names.") + if not all(isinstance(name, str) for name in _allowed_unsupported_pooling): + raise TypeError("_allowed_unsupported_pooling must contain only strings.") + allowed_unsupported_pooling = frozenset(_allowed_unsupported_pooling) + if allowed_unsupported_pooling and _embedding_batch_fn is None: + raise ValueError( + "_allowed_unsupported_pooling is only valid with a family-specific _embedding_batch_fn." + ) + resolved_batch_window_size = ( + batch_size * _DEFAULT_BATCH_WINDOW_MULTIPLIER + if batch_window_size is None + else batch_window_size + ) + if resolved_batch_window_size < batch_size: + raise ValueError("batch_window_size must be at least batch_size.") + records = _normalize_inputs(inputs, disk_backed=output is not None) + _validate_untruncated_lengths( + records, + max_length=max_length, + truncate=truncate, + ) + pooling_names = ( + (("mean",) if not full_embeddings else ()) + if pooling is None + else ((pooling,) if isinstance(pooling, str) else tuple(pooling)) + ) + if full_embeddings: + if pooling is not None: + raise ValueError("full_embeddings=True cannot be combined with pooling.") + elif not pooling_names: + raise ValueError("pooling is required unless full_embeddings=True.") + store_all_hidden_states = bool(model_kwargs.get("store_all_hidden_states", False)) + if store_all_hidden_states and not full_embeddings: + raise ValueError("store_all_hidden_states=True requires full_embeddings=True.") + + unsupported = set(getattr(model, "embedding_unsupported_pooling", ())) + unknown_pooling_overrides = allowed_unsupported_pooling.difference(unsupported) + if unknown_pooling_overrides: + raise ValueError( + "_allowed_unsupported_pooling may only override poolers declared unsupported " + f"by the model; unknown overrides: {sorted(unknown_pooling_overrides)}." + ) + unsupported.difference_update(allowed_unsupported_pooling) + requested_unsupported = unsupported.intersection(pooling_names) + if requested_unsupported: + raise ValueError( + f"{model.__class__.__name__} does not support pooling operations " + f"{sorted(requested_unsupported)}." + ) + + # Constructing the pooler validates names and duplicate operations before + # any checkpoint hashing, tokenization, or inference occurs. + pooler = Pooler(pooling_names) if pooling_names else None + embedding_context, normalized_decoder_inputs = _embedding_context( + model, + records, + hidden_state_source=hidden_state_source, + decoder_inputs=decoder_inputs, + decoder_input_ids=decoder_input_ids, + decoder_attention_mask=decoder_attention_mask, + model_kwargs=model_kwargs, + ) + if _embedding_batch_identity is not None: + embedding_context["family_adapter"] = _fingerprint_jsonable(_embedding_batch_identity) + if allowed_unsupported_pooling: + embedding_context["family_adapter_pooling_override"] = sorted( + allowed_unsupported_pooling + ) + + tokenizer_metadata = _tokenizer_metadata(model, tokenizer) + ( + input_fingerprint, + run_fingerprint, + resolved_model_state_fingerprint, + model_state_fingerprint_source, + ) = _run_fingerprint( + model, + records, + pooling=pooling_names, + full_embeddings=full_embeddings, + max_length=max_length, + truncate=truncate, + dtype=dtype, + model_kwargs=model_kwargs, + tokenizer_metadata=tokenizer_metadata, + model_state_fingerprint=model_state_fingerprint, + persist_output=output is not None, + embedding_context=embedding_context, + batch_size=batch_size, + batch_window_size=resolved_batch_window_size, + max_tokens_per_batch=max_tokens_per_batch, + ) + output_already_exists = output is not None and _output_exists(output, format) + existing: EmbeddingResult | None = None + start_position = 0 + if output is not None and resume and output_already_exists: + if format == "sqlite": + try: + existing = load_sqlite_result(output, run_id=run_fingerprint) + except KeyError: + existing = load_result(output, format=format) + else: + existing = load_result(output, format=format) + if existing.metadata.get("fingerprint_schema_version") != (_RUN_FINGERPRINT_SCHEMA_VERSION): + raise ValueError( + "Existing embeddings use an incompatible run fingerprint schema; " + "choose another output or set resume=False." + ) + if existing.metadata.get("run_fingerprint") != run_fingerprint: + raise ValueError( + "Existing embeddings were produced by a different run fingerprint; " + "choose another output or set resume=False." + ) + if len(existing) > len(records): + raise ValueError( + "Existing embeddings are not an ordered prefix of the requested inputs." + ) + prefix_matches = all( + (observed.id, observed.sequence) == (expected.id, expected.sequence) + for expected, observed in zip(records, existing, strict=False) + ) + if not prefix_matches: + raise ValueError( + "Existing embeddings are not an ordered prefix of the requested inputs." + ) + if len(existing) == len(records) and existing.metadata.get("complete", True): + return existing + start_position = len(existing) + + sqlite_run_id: str | None = None + sqlite_replace_on_first_commit = False + sqlite_initial_metadata: dict[str, Any] | None = None + if output is not None and format == "sqlite": + sqlite_initial_metadata = { + "format_version": 1, + "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION, + "run_fingerprint": run_fingerprint, + "input_fingerprint": input_fingerprint, + "model_state_fingerprint": resolved_model_state_fingerprint, + "model_state_fingerprint_source": model_state_fingerprint_source, + "complete": False, + } + sqlite_run_id = run_fingerprint + if not resume and output_already_exists: + try: + load_sqlite_result(output, run_id=run_fingerprint) + except KeyError: + pass + else: + # Keep an exact prior run readable until replacement inference + # has produced the first complete commit window. + sqlite_replace_on_first_commit = True + if not sqlite_replace_on_first_commit: + initialize_sqlite_run( + output, + sqlite_initial_metadata, + resume=resume, + ) + + stream_safetensors = output is not None and format == "safetensors" + attention_backend = _attention_backend(model) + output_records: list[EmbeddingRecord] = ( + [] if sqlite_run_id is not None or stream_safetensors else list(existing or ()) + ) + output_descriptors: list[dict[str, Any]] | None = [] if output is None else None + pool_slices: dict[str, tuple[int, int]] = {} + if existing and pooler is not None: + pooled_width = existing[0].load_tensor().shape[-1] + if pooled_width % len(pooling_names) != 0: + raise ValueError("Stored pooled width is inconsistent with pooling metadata.") + pool_slices = pooler.output_slices(pooled_width // len(pooling_names)) + + safetensors_writer: SafetensorsStreamWriter | None = None + if stream_safetensors: + if output is None: + raise RuntimeError("Safetensors streaming was enabled without an output destination.") + transactional_overwrite = output_already_exists and not resume + safetensors_writer = SafetensorsStreamWriter( + output, + { + "format_version": 1, + "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION, + "run_fingerprint": run_fingerprint, + "input_fingerprint": input_fingerprint, + "model_state_fingerprint": resolved_model_state_fingerprint, + "model_state_fingerprint_source": model_state_fingerprint_source, + "complete": False, + }, + shard_size=shard_size, + existing=existing or (), + reuse_existing=bool(resume and existing is not None), + publish_initial=not transactional_overwrite, + publish_incremental=not transactional_overwrite, + ) + need_attentions = "parti" in pooling_names + + config = getattr(model, "config", None) + model_type = str(getattr(config, "model_type", "")).lower() + resolved_tokenizer = tokenizer if tokenizer is not None else getattr(model, "tokenizer", None) + with _temporary_eval(model), torch.inference_mode(): + for window_start in range(start_position, len(records), resolved_batch_window_size): + window_stop = min(window_start + resolved_batch_window_size, len(records)) + window_records = records[window_start:window_stop] + if not isinstance(window_records, Sequence): + raise RuntimeError("The immutable embedding spool returned a non-sequence window.") + window_results: dict[int, EmbeddingRecord] = {} + for local_positions in _planned_batches( + window_records, + range(len(window_records)), + batch_size=batch_size, + max_tokens_per_batch=max_tokens_per_batch, + max_length=max_length, + truncate=truncate, + ): + batch_positions = [window_start + position for position in local_positions] + batch_records = [window_records[position] for position in local_positions] + sequences = [ + record.sequence[:max_length] + if truncate and max_length is not None + else record.sequence + for record in batch_records + ] + batch_model_kwargs = dict(model_kwargs) + if model_type == "fast_ankh" or hidden_state_source == "decoder": + batch_model_kwargs["hidden_state_source"] = hidden_state_source + if normalized_decoder_inputs is not None: + batch_model_kwargs["decoder_inputs"] = [ + normalized_decoder_inputs[position] for position in batch_positions + ] + if decoder_input_ids is not None: + indices = torch.tensor( + batch_positions, + device=decoder_input_ids.device, + dtype=torch.long, + ) + batch_model_kwargs["decoder_input_ids"] = decoder_input_ids.index_select( + 0, indices + ) + if decoder_attention_mask is not None: + indices = torch.tensor( + batch_positions, + device=decoder_attention_mask.device, + dtype=torch.long, + ) + batch_model_kwargs["decoder_attention_mask"] = ( + decoder_attention_mask.index_select(0, indices) + ) + custom_batch = _embedding_batch_fn or getattr(model, "_embedding_batch", None) + if custom_batch is not None: + if model_type == "fast_ankh": + batch = custom_batch( + sequences, + tokenizer=resolved_tokenizer, + max_length=max_length, + truncate=truncate, + need_attentions=need_attentions, + **batch_model_kwargs, + ) + else: + batch = custom_batch(sequences, **batch_model_kwargs) + if not isinstance(batch, EmbeddingBatch): + raise TypeError("_embedding_batch must return EmbeddingBatch.") + else: + batch = _generic_embedding_batch( + model, + sequences, + tokenizer=tokenizer, + max_length=max_length, + truncate=truncate, + need_attentions=need_attentions, + model_kwargs=batch_model_kwargs, + ) + X = batch.X + raw_mask = batch.residue_mask + if not isinstance(X, Tensor) or not isinstance(raw_mask, Tensor): + raise TypeError("Embedding batches must provide Tensor X and residue_mask.") + if X.is_meta or raw_mask.is_meta: + raise ValueError("Embedding batches cannot contain meta tensors.") + if not X.is_floating_point(): + raise TypeError("Embedding batches must use a floating-point X dtype.") + if raw_mask.is_complex() or not bool(torch.isfinite(raw_mask).all()): + raise ValueError("Embedding residue_mask must contain finite binary values.") + if not bool(((raw_mask == 0) | (raw_mask == 1)).all()): + raise ValueError("Embedding residue_mask must contain finite binary values.") + M = raw_mask.to(device=X.device, dtype=torch.bool) + valid_X_shape = ( + X.ndim == 3 + and X.shape[0] == len(batch_records) + and X.shape[-1] > 0 + and M.shape == X.shape[:2] + ) + valid_all_states_shape = ( + X.ndim == 4 + and store_all_hidden_states + and full_embeddings + and X.shape[0] == len(batch_records) + and X.shape[1] > 0 + and X.shape[-1] > 0 + and M.shape == (X.shape[0], X.shape[2]) + ) + if not (valid_X_shape or valid_all_states_shape): + raise ValueError( + "Embedding batches must provide X with shape (b, l, d), or " + "(b, states, l, d) when storing all hidden states, and " + "residue_mask with shape (b, l)." + ) + if not bool(M.any(dim=1).all()): + raise ValueError("Every embedding sample must contain a biological residue.") + finite_selected = ( + torch.isfinite(X) | ~M.unsqueeze(-1) + if X.ndim == 3 + else torch.isfinite(X) | ~M[:, None, :, None] + ) + if not bool(finite_selected.all()): + raise ValueError("Biological residue embeddings produced non-finite output.") + if need_attentions: + # Validate the biological graph only after mask integrity is established. + _validate_parti_length(M) + if dtype is not None: + X = X.to(dtype=dtype) + + if full_embeddings: + if X.ndim == 4: + values = [ + X_i[:, M_i, :].detach().cpu() for X_i, M_i in zip(X, M, strict=True) + ] + else: + values = [X_i[M_i].detach().cpu() for X_i, M_i in zip(X, M, strict=True)] + else: + if pooler is None: + raise RuntimeError( + "Pooled embedding output was requested without an initialized pooler." + ) + Y = pooler( + X, + M, + attentions=batch.attentions, + attention_backend=attention_backend, + ) + pool_slices = pooler.output_slices(X.shape[-1]) + values = list(Y.detach().cpu().unbind(0)) + for position, record, value in zip( + batch_positions, batch_records, values, strict=True + ): + window_results[position] = EmbeddingRecord(record.id, record.sequence, value) + + new_records = [ + window_results[position] for position in range(window_start, window_stop) + ] + if output_descriptors is not None: + output_descriptors.extend( + _output_descriptor(window_start + offset, record) + for offset, record in enumerate(new_records) + ) + if output is not None and sqlite_run_id is not None: + append_sqlite_records( + output, + sqlite_run_id, + window_start, + new_records, + replace_metadata=( + sqlite_initial_metadata if sqlite_replace_on_first_commit else None + ), + ) + sqlite_replace_on_first_commit = False + elif safetensors_writer is not None: + safetensors_writer.append(new_records) + else: + output_records.extend(new_records) + + software_versions = _software_versions() + projection = getattr(model, "embedding_projection", None) + resolved_layer = getattr( + model, + "embedding_layer", + model_kwargs.get("hidden_state_index", -1), + ) + token_policy = getattr( + model, + "embedding_token_policy", + { + "unit": "residue", + "include": ["biological residues"], + "exclude": [ + "BOS", + "EOS", + "padding", + "chain delimiters", + "non-protein tokens", + ], + }, + ) + model_identity = _model_identity_metadata(model) + metadata: dict[str, Any] = { + "format_version": 1, + "fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION, + "run_fingerprint": run_fingerprint, + "input_fingerprint": input_fingerprint, + "model_state_fingerprint": resolved_model_state_fingerprint, + "model_state_fingerprint_source": model_state_fingerprint_source, + "model_class": f"{model.__class__.__module__}.{model.__class__.__qualname__}", + **model_identity, + "dtype": str(dtype).removeprefix("torch.") if dtype is not None else "model", + "attention_backend": attention_backend, + "attention_kernel": _attention_kernel_metadata(attention_backend), + "layer": resolved_layer, + "projection": projection, + "esmc_source": getattr(model, "_esmc_source", None), + "esmc_revision": getattr(model, "_esmc_source_revision", None), + "esmc_files": getattr(model, "_esmc_source_files", None), + "token_policy": token_policy, + "tokenizer": tokenizer_metadata, + **embedding_context, + "pooling": list(pooling_names), + "pool_slices": pool_slices, + "full_embeddings": full_embeddings, + "max_length": max_length, + "truncate": truncate, + "truncation": {"enabled": truncate, "max_length": max_length}, + "batching": { + "batch_size": batch_size, + "batch_window_size": resolved_batch_window_size, + "max_tokens_per_batch": max_tokens_per_batch, + "input_storage": ("disk-spool" if isinstance(records, _InputSpool) else "memory"), + "ordering": "bounded-length-bucketed-stable-output", + "resume_commit_granularity": ( + "not-applicable" + if output is None + else "batch-window" + if format == "sqlite" + else "shard-flush" + ), + }, + "residue_mask_policy": "biological-residues-only", + "record_count": len(records), + "descriptor_index": ( + "memory-metadata" + if output is None + else "sqlite-records" + if format == "sqlite" + else "safetensors-generation-index" + ), + "storage_format": format if output is not None else "memory", + "software": software_versions, + "execution": _execution_identity_metadata(model), + "adapter": _adapter_identity_metadata(model), + "torch_version": software_versions["torch"], + "transformers_version": software_versions["transformers"], + "complete": True, + } + if output_descriptors is not None: + metadata["outputs"] = output_descriptors + metadata["tensor_hashes"] = [item["sha256"] for item in output_descriptors] + status = getattr(model, "esmc_precision_status", None) + if status is not None: + metadata["esmc_precision"] = status.as_dict() if hasattr(status, "as_dict") else status + if output is not None and sqlite_run_id is not None: + update_sqlite_run_metadata(output, sqlite_run_id, metadata) + return load_sqlite_result(output, run_id=sqlite_run_id) + if safetensors_writer is not None: + return safetensors_writer.publish(complete=True, metadata=metadata) + result = EmbeddingResult(output_records, metadata) + if output is not None: + return save_result(result, output, format=format, shard_size=shard_size) + return result + + +class EmbeddingMixin: + """Small delegation mixin shared by FastPLMs model classes.""" + + def embed_dataset(self, inputs: Any, **kwargs: Any) -> EmbeddingResult: + return embed_dataset(self, inputs, **kwargs) + + +__all__ = [ + "EmbeddingMixin", + "embed_dataset", + "iter_fasta", + "parse_fasta", + "select_hidden_state_embeddings", +] diff --git a/fastplms/embeddings/storage.py b/fastplms/embeddings/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..f8669636dc8f7bd7f0397d6d77d04f75cbb6ea5e --- /dev/null +++ b/fastplms/embeddings/storage.py @@ -0,0 +1,1594 @@ +"""Lossless, reproducible storage for :mod:`fastplms.embeddings`.""" + +from __future__ import annotations + +import hashlib +import io +import json +import sqlite3 +import struct +from bisect import bisect_right +from collections.abc import Iterable, Iterator, Sequence +from pathlib import Path +from typing import Any, cast, overload +from uuid import uuid4 + +import numpy as np +import torch +from torch import Tensor + +from .types import ( + EmbeddingRecord, + EmbeddingResult, + LazyTensorReference, +) + +_DTYPE_NAMES: dict[torch.dtype, str] = { + torch.float16: "float16", + torch.bfloat16: "bfloat16", + torch.float32: "float32", + torch.float64: "float64", + torch.int64: "int64", + torch.int32: "int32", + torch.int16: "int16", + torch.int8: "int8", + torch.uint8: "uint8", + torch.bool: "bool", +} +_NAME_DTYPES = {name: dtype for dtype, name in _DTYPE_NAMES.items()} +DEFAULT_SHARD_SIZE = 2 * 1024**3 +_MAX_RECORDS_PER_DESCRIPTOR_SHARD = 1_024 +_TENSOR_HASH_CHUNK_BYTES = 16 * 1024**2 + + +def _jsonable(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, Path): + return str(value) + if isinstance(value, torch.dtype): + return str(value).removeprefix("torch.") + if isinstance(value, torch.device): + return str(value) + if value is None or isinstance(value, (str, int, float, bool)): + return value + return repr(value) + + +def _persistent_metadata( + metadata: dict[str, Any], + *, + descriptor_index: str, + record_count: int | None = None, +) -> dict[str, Any]: + """Remove per-record copies from metadata and identify the authoritative index.""" + + cleaned_value = _jsonable(metadata) + if not isinstance(cleaned_value, dict): + raise TypeError("Embedding metadata must serialize to a JSON object.") + cleaned: dict[str, Any] = cleaned_value + cleaned.pop("outputs", None) + cleaned.pop("tensor_hashes", None) + cleaned["descriptor_index"] = descriptor_index + if record_count is not None: + cleaned["record_count"] = record_count + return cleaned + + +def _tensor_bytes(X: Tensor) -> bytes: + """Return the exact contiguous byte representation of X.""" + + X = X.detach().cpu().contiguous() + return X.view(torch.uint8).numpy().tobytes() + + +def _bounded_tensor_chunks(X: Tensor, max_bytes: int) -> Iterator[Tensor]: + """Yield row-major CPU chunks without materializing one full byte string.""" + + flattened = X.detach().to(device="cpu").reshape(-1) + if flattened.numel() == 0: + return + chunk_elements = max(1, max_bytes // flattened.element_size()) + for start in range(0, flattened.numel(), chunk_elements): + chunk = flattened[start : start + chunk_elements] + if chunk.stride(0) != 1: + chunk = chunk.clone(memory_format=torch.contiguous_format) + yield chunk + + +def _tensor_hash_chunks(X: Tensor) -> Iterator[bytes]: + for chunk in _bounded_tensor_chunks(X, _TENSOR_HASH_CHUNK_BYTES): + yield chunk.view(torch.uint8).numpy().tobytes() + + +def tensor_sha256(X: Tensor) -> str: + """Hash dtype, shape, and exact tensor bytes.""" + + if not isinstance(X, Tensor): + raise TypeError("X must be a tensor.") + if X.dtype not in _DTYPE_NAMES: + raise TypeError(f"Unsupported tensor dtype {X.dtype}.") + if X.is_meta: + raise ValueError("Cannot hash a meta tensor without storage.") + if X.layout != torch.strided: + raise TypeError("Only strided tensors can be hashed.") + digest = hashlib.sha256() + digest.update(_DTYPE_NAMES[X.dtype].encode()) + digest.update(json.dumps(tuple(X.shape)).encode()) + for chunk in _tensor_hash_chunks(X): + digest.update(chunk) + return digest.hexdigest() + + +def _encode_tensor(X: Tensor) -> tuple[str, str, bytes]: + if X.dtype not in _DTYPE_NAMES: + raise TypeError(f"Unsupported tensor dtype {X.dtype}.") + shape = json.dumps(tuple(X.shape), separators=(",", ":")) + return _DTYPE_NAMES[X.dtype], shape, _tensor_bytes(X) + + +def _decode_tensor(dtype_name: str, shape_json: str, data: bytes) -> Tensor: + try: + dtype = _NAME_DTYPES[dtype_name] + except KeyError as error: + raise ValueError(f"Unsupported stored dtype {dtype_name!r}.") from error + shape = tuple(json.loads(shape_json)) + # uint8 is used only as a byte-level carrier, preserving BF16 bits exactly. + byte_array = np.frombuffer(data, dtype=np.uint8).copy() + X = torch.from_numpy(byte_array).view(dtype) + return X.reshape(shape).clone() + + +def _index_path(path: str | Path) -> Path: + path = Path(path) + if path.suffix == ".json": + return path + if path.suffix == ".safetensors": + return path.with_suffix(".json") + return path / "index.json" + + +def _run_manifest_path(path: str | Path) -> Path: + path = Path(path) + if path.name == "index.json": + return path.with_name("run.json") + if path.suffix == ".json": + return path.with_name(f"{path.stem}.run.json") + if path.suffix == ".safetensors": + return path.with_suffix(".run.json") + return path / "run.json" + + +def _resolve_index_child(root: Path, relative: str, *, label: str) -> Path: + relative_path = Path(relative) + candidate = (root / relative_path).resolve() + if relative_path.is_absolute() or candidate.parent != root.resolve(): + raise ValueError(f"Safetensors {label} references a file outside its output directory.") + return candidate + + +def _canonical_json_bytes(payload: dict[str, Any]) -> bytes: + return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def _load_authoritative_index( + path: str | Path, +) -> tuple[dict[str, Any], Path, dict[str, Any]]: + """Load the index selected by the atomic run-manifest commit record.""" + + stable_index_path = _index_path(path) + run_manifest_path = _run_manifest_path(path) + if not run_manifest_path.is_file(): + raise ValueError(f"Missing safetensors run manifest: {run_manifest_path}.") + run_manifest = json.loads(run_manifest_path.read_text(encoding="utf-8")) + if not isinstance(run_manifest, dict): + raise ValueError("Safetensors run manifest must contain a JSON object.") + if run_manifest.get("format") != "fastplms-embedding-run": + raise ValueError(f"Not a FastPLMs embedding run manifest: {run_manifest_path}.") + version = run_manifest.get("version") + index_reference = run_manifest.get("index") + if not isinstance(index_reference, dict): + raise ValueError("Safetensors run manifest contains an invalid index reference.") + if version == 1: + snapshot = run_manifest.get("index_payload") + if isinstance(snapshot, dict): + payload = snapshot + index_bytes = _canonical_json_bytes(payload) + elif snapshot is None: + index_bytes = stable_index_path.read_bytes() + payload = json.loads(index_bytes.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("Safetensors index must contain a JSON object.") + else: + raise ValueError("Safetensors run manifest contains an invalid index snapshot.") + expected = { + "file": stable_index_path.name, + "sha256": hashlib.sha256(index_bytes).hexdigest(), + } + index_path = stable_index_path + elif version == 2: + relative = index_reference.get("file") + if not isinstance(relative, str): + raise ValueError("Safetensors run manifest index file is invalid.") + index_path = _resolve_index_child(stable_index_path.parent, relative, label="run manifest") + index_bytes = index_path.read_bytes() + payload = json.loads(index_bytes.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("Safetensors generation index must contain a JSON object.") + if payload.get("version") != 2: + raise ValueError("Safetensors v2 run manifest must reference a v2 generation index.") + expected = { + "file": relative, + "sha256": hashlib.sha256(index_bytes).hexdigest(), + } + else: + raise ValueError(f"Unsupported safetensors run manifest version {version!r}.") + if index_reference != expected: + raise ValueError("Safetensors run manifest does not match its index.") + if payload.get("format") != "fastplms-embedding-safetensors": + raise ValueError(f"Not a FastPLMs embedding index: {index_path}.") + record_count = payload.get("record_count") + if record_count is None: + legacy_records = payload.get("records", ()) + if not isinstance(legacy_records, list): + raise ValueError("Safetensors index contains invalid records.") + record_count = len(legacy_records) + if not isinstance(record_count, int) or isinstance(record_count, bool) or record_count < 0: + raise ValueError("Safetensors record count must be a non-negative integer.") + if run_manifest.get("record_count") != record_count: + raise ValueError("Safetensors run manifest record count does not match its index.") + metadata = payload.get("metadata", {}) + if not isinstance(metadata, dict): + raise ValueError("Safetensors index metadata must contain a JSON object.") + if metadata.get("record_count", record_count) != record_count: + raise ValueError("Safetensors metadata record count does not match its index.") + if version == 1 and run_manifest.get("metadata") != payload.get("metadata"): + raise ValueError("Safetensors run manifest metadata does not match its index.") + return payload, index_path, run_manifest + + +def safetensors_result_exists(path: str | Path) -> bool: + """Return whether an authoritative committed safetensors run exists.""" + + try: + _load_authoritative_index(path) + except (OSError, ValueError, json.JSONDecodeError): + return False + return True + + +def _load_safetensor(path: Path, key: str) -> Tensor: + try: + from safetensors import safe_open + except ImportError as error: + raise ImportError("Loading embeddings requires the 'safetensors' package.") from error + with safe_open(path, framework="pt", device="cpu") as handle: + return cast(Tensor, handle.get_tensor(key)) + + +def _safetensors_shard_prefix(path: str | Path) -> str: + requested_path = Path(path) + if requested_path.suffix in {".json", ".safetensors"}: + return f"{requested_path.stem}-embeddings" + return "embeddings" + + +def _authoritative_index_payload(path: str | Path) -> dict[str, Any] | None: + """Return the last atomically committed generation index when available.""" + + try: + payload, _, _ = _load_authoritative_index(path) + except (OSError, ValueError, json.JSONDecodeError): + return None + return payload + + +def _referenced_shards( + index_path: Path, + payload: dict[str, Any] | None = None, +) -> set[Path]: + if payload is None: + payload = _authoritative_index_payload(index_path) + if payload is None: + return set() + shards: set[Path] = set() + for descriptor_shard in payload.get("descriptor_shards", ()): + tensor_file = descriptor_shard.get("tensor_file") + if isinstance(tensor_file, str): + candidate = _resolve_index_child( + index_path.parent, tensor_file, label="descriptor index" + ) + shards.add(candidate) + for item in payload.get("records", ()): + relative = item.get("tensor", {}).get("file") + if not isinstance(relative, str): + continue + candidate = (index_path.parent / relative).resolve() + if candidate.parent == index_path.parent.resolve(): + shards.add(candidate) + return shards + + +def _validate_tensor_descriptor( + tensor: dict[str, Any], +) -> tuple[str, str, tuple[int, ...], str]: + key = tensor.get("key") + if not isinstance(key, str) or not key: + raise ValueError("Safetensors descriptor tensor key is invalid.") + dtype = tensor.get("dtype") + if not isinstance(dtype, str) or dtype not in _NAME_DTYPES: + raise ValueError("Safetensors descriptor tensor dtype is invalid.") + raw_shape = tensor.get("shape") + if not isinstance(raw_shape, (list, tuple)) or not all( + isinstance(dimension, int) and not isinstance(dimension, bool) and dimension >= 0 + for dimension in raw_shape + ): + raise ValueError("Safetensors descriptor tensor shape is invalid.") + sha256 = tensor.get("sha256") + if ( + not isinstance(sha256, str) + or len(sha256) != 64 + or sha256 != sha256.lower() + or any(character not in "0123456789abcdef" for character in sha256) + ): + raise ValueError("Safetensors descriptor tensor SHA-256 is invalid.") + return key, dtype, tuple(raw_shape), sha256 + + +def _record_from_safetensors_descriptor(root: Path, item: dict[str, Any]) -> EmbeddingRecord: + if not isinstance(item, dict): + raise ValueError("Safetensors record descriptor must contain a JSON object.") + record_id = item.get("id") + sequence = item.get("sequence") + if not isinstance(record_id, str) or not record_id: + raise ValueError("Safetensors descriptor record ID is invalid.") + if not isinstance(sequence, str) or not sequence: + raise ValueError("Safetensors descriptor sequence is invalid.") + tensor = item.get("tensor") + if not isinstance(tensor, dict): + raise ValueError("Safetensors descriptor is missing tensor metadata.") + relative = tensor.get("file") + if not isinstance(relative, str) or not relative: + raise ValueError("Safetensors descriptor tensor file is invalid.") + key, dtype, shape, sha256 = _validate_tensor_descriptor(tensor) + tensor_path = _resolve_index_child(root, relative, label="descriptor") + if not tensor_path.is_file(): + raise ValueError(f"Safetensors tensor shard is missing: {relative}.") + + def load_tensor() -> Tensor: + return _load_safetensor(tensor_path, key) + + reference = LazyTensorReference( + source=str(tensor_path), + key=key, + dtype=dtype, + shape=shape, + sha256=sha256, + _loader=load_tensor, + ) + return EmbeddingRecord(record_id, sequence, reference) + + +class _SafetensorsRecordSequence(Sequence[EmbeddingRecord]): + """Lazy immutable view over bounded descriptor JSONL shards.""" + + _fastplms_immutable_sequence = True + + def __init__(self, root: Path, descriptor_shards: Sequence[dict[str, Any]]) -> None: + if not isinstance(descriptor_shards, (list, tuple)): + raise ValueError("Safetensors generation index has invalid descriptor shards.") + self.root = root + self.shards = tuple(descriptor_shards) + cumulative: list[int] = [] + total = 0 + for shard in self.shards: + if not isinstance(shard, dict): + raise ValueError("Safetensors descriptor shard entry is invalid.") + relative = shard.get("file") + declared_count = shard.get("count") + if ( + not isinstance(declared_count, int) + or isinstance(declared_count, bool) + or declared_count < 0 + ): + raise ValueError("Safetensors descriptor shard count is invalid.") + declared_sha256 = shard.get("sha256") + if not isinstance(declared_sha256, str) or len(declared_sha256) != 64: + raise ValueError("Safetensors descriptor shard SHA-256 is invalid.") + if not isinstance(relative, str): + raise ValueError("Safetensors descriptor index file is invalid.") + descriptor_path = _resolve_index_child(root, relative, label="index") + tensor_file = shard.get("tensor_file") + if not isinstance(tensor_file, str): + raise ValueError("Safetensors descriptor tensor file is invalid.") + tensor_path = _resolve_index_child(root, tensor_file, label="index") + if not tensor_path.is_file(): + raise ValueError(f"Safetensors tensor shard is missing: {tensor_file}.") + digest = hashlib.sha256() + count = 0 + with descriptor_path.open("rb") as handle: + for line in handle: + digest.update(line) + if line.strip(): + item = json.loads(line) + if not isinstance(item, dict): + raise ValueError("Safetensors record descriptor must be a JSON object.") + item_tensor = item.get("tensor") + if not isinstance(item_tensor, dict): + raise ValueError("Safetensors descriptor is missing tensor metadata.") + item_tensor_file = item_tensor.get("file") + if not isinstance(item_tensor_file, str): + raise ValueError("Safetensors descriptor tensor file is invalid.") + _resolve_index_child(root, item_tensor_file, label="descriptor") + if item_tensor_file != tensor_file: + raise ValueError( + "Safetensors descriptor tensor file does not match its shard." + ) + count += 1 + _validate_tensor_descriptor(item_tensor) + if digest.hexdigest() != declared_sha256 or count != declared_count: + raise ValueError( + f"Safetensors descriptor shard failed integrity validation: {relative}." + ) + total += count + cumulative.append(total) + self._cumulative = tuple(cumulative) + self._count = total + + def __len__(self) -> int: + return self._count + + def _iter_shard(self, shard_index: int) -> Iterator[EmbeddingRecord]: + descriptor_path = _resolve_index_child( + self.root, str(self.shards[shard_index]["file"]), label="index" + ) + with descriptor_path.open("r", encoding="utf-8") as handle: + for line in handle: + if line.strip(): + yield _record_from_safetensors_descriptor(self.root, json.loads(line)) + + def __iter__(self) -> Iterator[EmbeddingRecord]: + for shard_index in range(len(self.shards)): + yield from self._iter_shard(shard_index) + + @overload + def __getitem__(self, index: int, /) -> EmbeddingRecord: ... + + @overload + def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ... + + def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]: + if isinstance(index, slice): + start, stop, step = index.indices(self._count) + return [self[position] for position in range(start, stop, step)] + position = index + self._count if index < 0 else index + if position < 0 or position >= self._count: + raise IndexError(index) + shard_index = bisect_right(self._cumulative, position) + previous = self._cumulative[shard_index - 1] if shard_index else 0 + local_position = position - previous + for offset, record in enumerate(self._iter_shard(shard_index)): + if offset == local_position: + return record + raise IndexError(index) + + +class SafetensorsStreamWriter: + """Bounded-memory, resumable publisher with immutable retained generations.""" + + def __init__( + self, + path: str | Path, + metadata: dict[str, Any], + *, + shard_size: int = DEFAULT_SHARD_SIZE, + existing: Iterable[EmbeddingRecord] = (), + reuse_existing: bool = False, + publish_initial: bool = True, + publish_incremental: bool = True, + ) -> None: + try: + from safetensors.torch import save_file + except ImportError as error: + raise ImportError("Saving embeddings requires the 'safetensors' package.") from error + if shard_size <= 0: + raise ValueError("shard_size must be positive.") + + self.path = Path(path) + self.index_path = _index_path(path) + self.run_manifest_path = _run_manifest_path(path) + self.index_path.parent.mkdir(parents=True, exist_ok=True) + self.metadata = _persistent_metadata( + metadata, + descriptor_index="safetensors-generation-index", + record_count=0, + ) + self.shard_size = shard_size + self.publish_incremental = publish_incremental + self._save_file = save_file + authoritative_payload = _authoritative_index_payload(path) + prefix = _safetensors_shard_prefix(path) + # A random generation identity prevents a new writer from reusing a + # previously published or interrupted generation name. Published files + # are immutable and remain available to lazy readers until explicit GC. + self._generation = uuid4().hex + self._prefix = prefix + self._shard_index = 0 + self._seed_index = 0 + self._commit_index = 0 + self._descriptor_shards: list[dict[str, Any]] = [] + self._record_count = 0 + self._current: dict[str, Tensor] = {} + self._pending: list[tuple[EmbeddingRecord, str, str, tuple[int, ...], str]] = [] + self._current_size = 0 + if reuse_existing: + if authoritative_payload is None: + raise ValueError("Cannot resume without an authoritative safetensors index.") + authoritative_metadata = authoritative_payload.get("metadata") + if not isinstance(authoritative_metadata, dict) or authoritative_metadata.get( + "run_fingerprint" + ) != self.metadata.get("run_fingerprint"): + raise ValueError("Cannot resume a safetensors run with a different fingerprint.") + expected_prefix_length = ( + len(existing) if isinstance(existing, Sequence) else sum(1 for _ in existing) + ) + if authoritative_payload.get("version") == 2: + self._descriptor_shards = list(authoritative_payload.get("descriptor_shards", ())) + self._record_count = int(authoritative_payload.get("record_count", 0)) + else: + legacy_records = list(authoritative_payload.get("records", ())) + self._record_count = len(legacy_records) + if legacy_records: + self._descriptor_shards.extend(self._write_descriptor_seed(legacy_records)) + if expected_prefix_length != self._record_count: + raise ValueError( + "The resumable safetensors prefix does not match the validated " + "embedding records." + ) + + if publish_initial: + self._publish_metadata(complete=False) + + def _write_descriptor_file( + self, + name: str, + descriptors: Sequence[dict[str, Any]], + *, + tensor_file: str, + ) -> dict[str, Any]: + temporary = self.index_path.parent / f".{name}.tmp" + destination = self.index_path.parent / name + if temporary.exists() or destination.exists(): + raise FileExistsError( + f"Refusing to reuse immutable safetensors generation path {destination}." + ) + digest = hashlib.sha256() + with temporary.open("wb") as handle: + for item in descriptors: + encoded = ( + json.dumps(item, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + ) + handle.write(encoded) + digest.update(encoded) + temporary.replace(destination) + return { + "file": name, + "sha256": digest.hexdigest(), + "count": len(descriptors), + "tensor_file": tensor_file, + } + + def _write_descriptor_seed(self, records: Sequence[dict[str, Any]]) -> list[dict[str, Any]]: + groups: list[tuple[str, list[dict[str, Any]]]] = [] + for record in records: + tensor_file = str(record["tensor"]["file"]) + if ( + not groups + or groups[-1][0] != tensor_file + or len(groups[-1][1]) == _MAX_RECORDS_PER_DESCRIPTOR_SHARD + ): + groups.append((tensor_file, [])) + groups[-1][1].append(record) + descriptor_shards: list[dict[str, Any]] = [] + for tensor_file, descriptors in groups: + self._seed_index += 1 + name = ( + f"{self._prefix}-records-run-{self._generation}-seed-{self._seed_index:05d}.jsonl" + ) + descriptor_shards.append( + self._write_descriptor_file(name, descriptors, tensor_file=tensor_file) + ) + return descriptor_shards + + def _write_shard(self) -> None: + if not self._current: + return + self._shard_index += 1 + name = f"{self._prefix}-run-{self._generation}-{self._shard_index:05d}.safetensors" + temporary = self.index_path.parent / f".{name}.tmp" + destination = self.index_path.parent / name + if temporary.exists() or destination.exists(): + raise FileExistsError( + f"Refusing to reuse immutable safetensors generation path {destination}." + ) + self._save_file(self._current, temporary) + temporary.replace(destination) + descriptors: list[dict[str, Any]] = [] + for record, key, dtype_name, shape, digest in self._pending: + descriptors.append( + { + "id": record.id, + "sequence": record.sequence, + "tensor": { + "file": name, + "key": key, + "dtype": dtype_name, + "shape": list(shape), + "sha256": digest, + }, + } + ) + descriptor_name = ( + f"{self._prefix}-records-run-{self._generation}-{self._shard_index:05d}.jsonl" + ) + self._descriptor_shards.append( + self._write_descriptor_file(descriptor_name, descriptors, tensor_file=name) + ) + self._record_count += len(descriptors) + self._current = {} + self._pending = [] + self._current_size = 0 + + def append( + self, + records: Iterable[EmbeddingRecord], + *, + publish: bool | None = None, + ) -> None: + """Persist records while retaining at most one shard of tensors.""" + + for record in records: + position = self._record_count + len(self._pending) + tensor = record.load_tensor().detach().cpu().contiguous() + if tensor.dtype not in _DTYPE_NAMES: + raise TypeError(f"Unsupported tensor dtype {tensor.dtype}.") + nbytes = tensor.numel() * tensor.element_size() + if nbytes > self.shard_size: + raise ValueError( + f"Embedding {position} requires {nbytes} bytes and cannot fit in a " + f"{self.shard_size}-byte safetensors shard." + ) + if self._current and ( + self._current_size + nbytes > self.shard_size + or len(self._pending) == _MAX_RECORDS_PER_DESCRIPTOR_SHARD + ): + self._write_shard() + if self.publish_incremental: + self._publish_metadata(complete=False) + position = self._record_count + key = f"embedding_{position:08d}" + self._current[key] = tensor + self._current_size += nbytes + self._pending.append( + ( + record, + key, + _DTYPE_NAMES[tensor.dtype], + tuple(tensor.shape), + tensor_sha256(tensor), + ) + ) + if publish: + self.publish(complete=False) + + def _publish_metadata( + self, + *, + complete: bool, + metadata: dict[str, Any] | None = None, + ) -> EmbeddingResult: + """Atomically expose one self-consistent metadata generation.""" + + if metadata is not None: + self.metadata = _persistent_metadata( + metadata, + descriptor_index="safetensors-generation-index", + ) + self.metadata["complete"] = complete + self.metadata["record_count"] = self._record_count + self._commit_index += 1 + payload = { + "version": 2, + "format": "fastplms-embedding-safetensors", + "metadata": self.metadata, + "record_count": self._record_count, + "descriptor_shards": self._descriptor_shards, + } + generation_index_name = ( + f"{self._prefix}-index-run-{self._generation}-{self._commit_index:05d}.json" + ) + generation_index_path = self.index_path.parent / generation_index_name + temporary_generation_index = generation_index_path.with_name( + f".{generation_index_path.name}.tmp" + ) + if temporary_generation_index.exists() or generation_index_path.exists(): + raise FileExistsError( + f"Refusing to reuse immutable safetensors generation index {generation_index_path}." + ) + encoded_index = _canonical_json_bytes(payload) + temporary_generation_index.write_bytes(encoded_index) + temporary_generation_index.replace(generation_index_path) + + index_sha256 = hashlib.sha256(encoded_index).hexdigest() + index_reference = { + "file": generation_index_name, + "sha256": index_sha256, + } + run_manifest = { + "version": 2, + "format": "fastplms-embedding-run", + "index": index_reference, + "record_count": self._record_count, + } + pointer_identity = f"{self._generation}-{self._commit_index:05d}" + temporary_manifest = self.run_manifest_path.with_name( + f".{self.run_manifest_path.name}.{pointer_identity}.tmp" + ) + temporary_manifest.write_bytes(_canonical_json_bytes(run_manifest)) + temporary_manifest.replace(self.run_manifest_path) + + # ``index.json`` is a non-authoritative convenience pointer. The run + # manifest is committed first, so interruption here cannot invalidate + # the newly committed generation. + stable_pointer = { + "version": 2, + "format": "fastplms-embedding-index-pointer", + "index": index_reference, + } + temporary_index = self.index_path.with_name( + f".{self.index_path.name}.{pointer_identity}.tmp" + ) + temporary_index.write_bytes(_canonical_json_bytes(stable_pointer)) + temporary_index.replace(self.index_path) + + return load_safetensors_result(self.index_path) + + def publish( + self, + *, + complete: bool, + metadata: dict[str, Any] | None = None, + ) -> EmbeddingResult: + """Flush the current shard and atomically expose a consistent generation.""" + + self._write_shard() + return self._publish_metadata(complete=complete, metadata=metadata) + + +def save_safetensors_result( + result: EmbeddingResult, + path: str | Path, + *, + shard_size: int = DEFAULT_SHARD_SIZE, +) -> EmbeddingResult: + """Write sharded safetensors without materializing the full result.""" + + writer = SafetensorsStreamWriter( + path, + result.metadata, + shard_size=shard_size, + publish_initial=False, + publish_incremental=False, + ) + writer.append(result, publish=False) + return writer.publish(complete=bool(result.metadata.get("complete", True))) + + +def load_safetensors_result(path: str | Path) -> EmbeddingResult: + """Load an indexed safetensors result without loading tensor payloads.""" + + payload, index_path, _ = _load_authoritative_index(path) + if payload.get("version") == 2: + lazy_records = _SafetensorsRecordSequence( + index_path.parent, payload.get("descriptor_shards", ()) + ) + if len(lazy_records) != payload.get("record_count"): + raise ValueError("Safetensors descriptor count does not match its generation index.") + return EmbeddingResult(lazy_records, payload.get("metadata", {})) + + records: list[EmbeddingRecord] = [] + for item in payload["records"]: + records.append(_record_from_safetensors_descriptor(index_path.parent, item)) + return EmbeddingResult(records, payload.get("metadata", {})) + + +def garbage_collect_safetensors_generations( + path: str | Path, + *, + dry_run: bool = True, + confirm_no_active_readers_or_writers: bool = False, +) -> tuple[Path, ...]: + """Remove non-authoritative generations after an explicit exclusivity check. + + Safetensors results retain immutable historical generations because an + already-open :class:`EmbeddingResult` resolves tensors through those exact + descriptor and shard paths. Destructive collection is therefore safe only + when the caller guarantees that no reader or writer for ``path`` remains + active. ``dry_run=True`` is the default and returns the paths that would be + removed without changing the output directory. + """ + + if not isinstance(dry_run, bool): + raise TypeError("dry_run must be a bool.") + if not isinstance(confirm_no_active_readers_or_writers, bool): + raise TypeError("confirm_no_active_readers_or_writers must be a bool.") + if not dry_run and not confirm_no_active_readers_or_writers: + raise ValueError( + "Destructive safetensors generation collection requires " + "confirm_no_active_readers_or_writers=True." + ) + + # Validate the full descriptor graph before identifying anything as stale. + load_safetensors_result(path) + payload, authoritative_index_path, _ = _load_authoritative_index(path) + stable_index_path = _index_path(path) + run_manifest_path = _run_manifest_path(path) + root = stable_index_path.parent + prefix = _safetensors_shard_prefix(path) + protected = { + stable_index_path.resolve(), + run_manifest_path.resolve(), + authoritative_index_path.resolve(), + *_referenced_shards(stable_index_path, payload), + } + for descriptor_shard in payload.get("descriptor_shards", ()): + relative = descriptor_shard.get("file") + if isinstance(relative, str): + protected.add(_resolve_index_child(root, relative, label="index").resolve()) + + candidates: set[Path] = set() + for pattern in ( + f"{prefix}-run-*-*.safetensors", + f"{prefix}-records-run-*.jsonl", + f"{prefix}-index-run-*.json", + f".{prefix}-*.tmp", + ): + candidates.update(root.glob(pattern)) + candidates.update(root.glob(f".{stable_index_path.name}.*.tmp")) + candidates.update(root.glob(f".{run_manifest_path.name}.*.tmp")) + + stale = tuple( + sorted( + (candidate for candidate in candidates if candidate.resolve() not in protected), + key=lambda candidate: candidate.name, + ) + ) + if not dry_run: + for candidate in stale: + candidate.unlink(missing_ok=True) + return stale + + +def _ensure_sqlite_schema(connection: sqlite3.Connection) -> None: + connection.executescript( + """ + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_order INTEGER + ); + CREATE TABLE IF NOT EXISTS tensors ( + run_id TEXT NOT NULL, + position INTEGER NOT NULL, + dtype TEXT NOT NULL, + shape_json TEXT NOT NULL, + data BLOB NOT NULL, + sha256 TEXT NOT NULL, + PRIMARY KEY (run_id, position), + FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS records ( + run_id TEXT NOT NULL, + position INTEGER NOT NULL, + record_id TEXT NOT NULL, + sequence TEXT NOT NULL, + PRIMARY KEY (run_id, position), + FOREIGN KEY (run_id, position) REFERENCES tensors(run_id, position) + ON DELETE CASCADE + ); + """ + ) + run_columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(runs)").fetchall()} + if "published_order" not in run_columns: + connection.execute("ALTER TABLE runs ADD COLUMN published_order INTEGER") + # Databases created before staged publication exposed every stored run. + # Preserve that view for historical runs containing committed records. + connection.execute( + "UPDATE runs SET published_order = rowid " + "WHERE published_order IS NULL AND EXISTS (" + "SELECT 1 FROM records WHERE records.run_id = runs.run_id)" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS runs_published_order_idx ON runs(published_order)" + ) + if "published_order" not in run_columns: + # Schema upgrades run before callers open their data transaction. + # End the migration transaction explicitly so BEGIN IMMEDIATE below + # remains valid on existing databases. + connection.commit() + + +def save_sqlite_result(result: EmbeddingResult, path: str | Path) -> EmbeddingResult: + """Transactionally store an ordered result in normalized SQLite tables.""" + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + run_id = str(result.metadata.get("run_fingerprint", "")) + if not run_id: + raise ValueError("SQLite results require metadata['run_fingerprint'].") + metadata_json = json.dumps( + _persistent_metadata( + result.metadata, + descriptor_index="sqlite-records", + record_count=len(result), + ), + sort_keys=True, + ) + with sqlite3.connect(path, timeout=30) as connection: + _ensure_sqlite_schema(connection) + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("BEGIN IMMEDIATE") + connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,)) + connection.execute( + "INSERT INTO runs(run_id, metadata_json, published_order) " + "SELECT ?, ?, COALESCE(MAX(published_order), 0) + 1 FROM runs", + (run_id, metadata_json), + ) + for position, record in enumerate(result): + X = record.load_tensor().detach().cpu().contiguous() + dtype_name, shape_json, data = _encode_tensor(X) + digest = tensor_sha256(X) + connection.execute( + "INSERT INTO tensors VALUES (?, ?, ?, ?, ?, ?)", + (run_id, position, dtype_name, shape_json, data, digest), + ) + connection.execute( + "INSERT INTO records VALUES (?, ?, ?, ?)", + (run_id, position, record.id, record.sequence), + ) + connection.commit() + return load_sqlite_result(path, run_id=run_id) + + +def initialize_sqlite_run( + path: str | Path, + metadata: dict[str, Any], + *, + resume: bool, +) -> str: + """Create a resumable SQLite run without buffering tensor results.""" + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + run_id = str(metadata.get("run_fingerprint", "")) + if not run_id: + raise ValueError("SQLite runs require metadata['run_fingerprint'].") + with sqlite3.connect(path, timeout=30) as connection: + _ensure_sqlite_schema(connection) + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("BEGIN IMMEDIATE") + exists = connection.execute("SELECT 1 FROM runs WHERE run_id = ?", (run_id,)).fetchone() + if exists and not resume: + connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,)) + exists = None + if exists is None: + initial_metadata = _persistent_metadata( + metadata, + descriptor_index="sqlite-records", + record_count=0, + ) + connection.execute( + "INSERT INTO runs(run_id, metadata_json) VALUES (?, ?)", + (run_id, json.dumps(initial_metadata, sort_keys=True)), + ) + connection.commit() + return run_id + + +def append_sqlite_records( + path: str | Path, + run_id: str, + start_position: int, + records: list[EmbeddingRecord], + *, + replace_metadata: dict[str, Any] | None = None, +) -> None: + """Commit one ordered embedding batch so an interrupted run can resume.""" + + if not isinstance(run_id, str) or not run_id: + raise ValueError("run_id must be a non-empty string.") + if not isinstance(start_position, int) or isinstance(start_position, bool): + raise TypeError("start_position must be a non-negative integer.") + if start_position < 0: + raise ValueError("start_position must be a non-negative integer.") + if not isinstance(records, list) or not all( + isinstance(record, EmbeddingRecord) for record in records + ): + raise TypeError("records must be a list of EmbeddingRecord values.") + + with sqlite3.connect(Path(path), timeout=30) as connection: + _ensure_sqlite_schema(connection) + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("BEGIN IMMEDIATE") + if replace_metadata is not None: + replacement_run_id = str(replace_metadata.get("run_fingerprint", "")) + if replacement_run_id != run_id: + raise ValueError("Replacement metadata must match the SQLite run ID.") + initial_metadata = _persistent_metadata( + replace_metadata, + descriptor_index="sqlite-records", + record_count=0, + ) + connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,)) + connection.execute( + "INSERT INTO runs(run_id, metadata_json) VALUES (?, ?)", + (run_id, json.dumps(initial_metadata, sort_keys=True)), + ) + if connection.execute("SELECT 1 FROM runs WHERE run_id = ?", (run_id,)).fetchone() is None: + raise KeyError(f"Missing SQLite embedding run {run_id}.") + current_count, minimum_position, maximum_position = connection.execute( + "SELECT COUNT(*), MIN(position), MAX(position) FROM records WHERE run_id = ?", + (run_id,), + ).fetchone() + if current_count and (minimum_position != 0 or maximum_position != current_count - 1): + raise ValueError("SQLite embedding run has a non-contiguous record prefix.") + if start_position != current_count: + raise ValueError( + f"start_position={start_position} does not match the contiguous " + f"SQLite prefix length {current_count}." + ) + for offset, record in enumerate(records): + position = start_position + offset + X = record.load_tensor().detach().cpu().contiguous() + dtype_name, shape_json, data = _encode_tensor(X) + digest = tensor_sha256(X) + connection.execute( + "INSERT INTO tensors VALUES (?, ?, ?, ?, ?, ?)", + (run_id, position, dtype_name, shape_json, data, digest), + ) + connection.execute( + "INSERT INTO records VALUES (?, ?, ?, ?)", + (run_id, position, record.id, record.sequence), + ) + row = connection.execute( + "SELECT metadata_json FROM runs WHERE run_id = ?", (run_id,) + ).fetchone() + if row is None: + raise KeyError(f"Missing SQLite embedding run {run_id}.") + metadata = json.loads(row[0]) + if not isinstance(metadata, dict): + raise ValueError("SQLite run metadata must contain a JSON object.") + metadata["record_count"] = start_position + len(records) + metadata["descriptor_index"] = "sqlite-records" + connection.execute( + "UPDATE runs SET metadata_json = ? WHERE run_id = ?", + (json.dumps(metadata, sort_keys=True), run_id), + ) + if records: + connection.execute( + "UPDATE runs SET published_order = (" + "SELECT COALESCE(MAX(published_order), 0) + 1 FROM runs" + ") WHERE run_id = ? AND published_order IS NULL", + (run_id,), + ) + connection.commit() + + +def update_sqlite_run_metadata(path: str | Path, run_id: str, metadata: dict[str, Any]) -> None: + """Finalize reproducibility metadata after the last streamed batch.""" + + with sqlite3.connect(Path(path), timeout=30) as connection: + row = connection.execute( + "SELECT COUNT(*) FROM records WHERE run_id = ?", (run_id,) + ).fetchone() + record_count = int(row[0]) if row is not None else 0 + cleaned_metadata = _persistent_metadata( + metadata, + descriptor_index="sqlite-records", + record_count=record_count, + ) + updated = connection.execute( + "UPDATE runs SET metadata_json = ? WHERE run_id = ?", + (json.dumps(cleaned_metadata, sort_keys=True), run_id), + ).rowcount + if updated != 1: + raise KeyError(f"Missing SQLite embedding run {run_id}.") + connection.commit() + + +def _connect_sqlite_read_only(path: Path) -> sqlite3.Connection: + if not path.is_file(): + raise FileNotFoundError(path) + return sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=30) + + +def _validate_sqlite_result_schema(connection: sqlite3.Connection, path: Path) -> None: + tables = { + str(row[0]) + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + required = {"runs", "records", "tensors"} + if not required.issubset(tables): + raise ValueError( + f"Not a FastPLMs embedding SQLite database: {path}. " + "Use convert_legacy_sqlite() for a legacy embeddings table." + ) + + +def _load_sqlite_tensor(path: Path, run_id: str, position: int) -> Tensor: + with _connect_sqlite_read_only(path) as connection: + row = connection.execute( + "SELECT dtype, shape_json, data FROM tensors WHERE run_id = ? AND position = ?", + (run_id, position), + ).fetchone() + if row is None: + raise KeyError(f"Missing SQLite tensor {run_id}:{position}.") + return _decode_tensor(*row) + + +def _validate_sqlite_descriptor_row( + row: Sequence[Any], +) -> tuple[int, str, str, str, str, str]: + if len(row) != 6: + raise ValueError("SQLite embedding descriptor has an invalid column count.") + position, record_id, sequence, dtype_name, shape_json, digest = row + if not isinstance(position, int) or isinstance(position, bool) or position < 0: + raise ValueError("SQLite embedding position is invalid.") + if not isinstance(record_id, str) or not record_id: + raise ValueError("SQLite embedding record ID is invalid.") + if not isinstance(sequence, str) or not sequence: + raise ValueError("SQLite embedding sequence is invalid.") + if not isinstance(shape_json, str): + raise ValueError("SQLite embedding tensor shape is invalid.") + try: + shape = json.loads(shape_json) + except json.JSONDecodeError as error: + raise ValueError("SQLite embedding tensor shape is invalid.") from error + _validate_tensor_descriptor( + { + "key": f"embedding_{position}", + "dtype": dtype_name, + "shape": shape, + "sha256": digest, + } + ) + return position, record_id, sequence, dtype_name, shape_json, digest + + +def _sqlite_record_from_row(path: Path, run_id: str, row: Sequence[Any]) -> EmbeddingRecord: + position, record_id, sequence, dtype_name, shape_json, digest = _validate_sqlite_descriptor_row( + row + ) + + def load_tensor() -> Tensor: + return _load_sqlite_tensor(path, run_id, position) + + reference = LazyTensorReference( + source=str(path), + key=f"{run_id}:{position}", + dtype=dtype_name, + shape=tuple(json.loads(shape_json)), + sha256=digest, + _loader=load_tensor, + ) + return EmbeddingRecord(record_id, sequence, reference) + + +class _SQLiteRecordSequence(Sequence[EmbeddingRecord]): + """Lazy immutable descriptor view over one SQLite embedding run.""" + + _fastplms_immutable_sequence = True + + def __init__(self, path: Path, run_id: str, count: int) -> None: + self.path = path + self.run_id = run_id + self._count = count + + @staticmethod + def _row_query() -> str: + return ( + "SELECT r.position, r.record_id, r.sequence, t.dtype, t.shape_json, t.sha256 " + "FROM records r JOIN tensors t USING (run_id, position) " + "WHERE r.run_id = ?" + ) + + def __len__(self) -> int: + return self._count + + def __iter__(self) -> Iterator[EmbeddingRecord]: + with _connect_sqlite_read_only(self.path) as connection: + cursor = connection.execute(f"{self._row_query()} ORDER BY r.position", (self.run_id,)) + while rows := cursor.fetchmany(1_024): + for row in rows: + yield _sqlite_record_from_row(self.path, self.run_id, row) + + @overload + def __getitem__(self, index: int, /) -> EmbeddingRecord: ... + + @overload + def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ... + + def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]: + if isinstance(index, slice): + start, stop, step = index.indices(self._count) + return [self[position] for position in range(start, stop, step)] + position = index + self._count if index < 0 else index + if position < 0 or position >= self._count: + raise IndexError(index) + with _connect_sqlite_read_only(self.path) as connection: + row = connection.execute( + f"{self._row_query()} AND r.position = ?", + (self.run_id, position), + ).fetchone() + if row is None: + raise IndexError(index) + return _sqlite_record_from_row(self.path, self.run_id, row) + + +def load_sqlite_result( + path: str | Path, + *, + run_id: str | None = None, + positions: Iterable[int] | None = None, + record_ids: Iterable[str] | None = None, + sequences: Iterable[str] | None = None, +) -> EmbeddingResult: + """Load one SQLite run read-only, optionally in explicit selector order. + + Exactly one selector may be supplied. Repeated selectors are retained. An + ID or sequence selector that matches multiple stored rows returns those + rows in their original order for every occurrence of that selector. + """ + + path = Path(path).resolve() + supplied_selectors = sum( + selector is not None for selector in (positions, record_ids, sequences) + ) + if supplied_selectors > 1: + raise ValueError("Choose at most one of positions, record_ids, or sequences.") + normalized_positions = tuple(positions) if positions is not None else None + normalized_ids = tuple(record_ids) if record_ids is not None else None + normalized_sequences = tuple(sequences) if sequences is not None else None + if normalized_positions is not None and not all( + isinstance(position, int) and not isinstance(position, bool) and position >= 0 + for position in normalized_positions + ): + raise ValueError("positions must contain non-negative integers.") + for name, values in ( + ("record_ids", normalized_ids), + ("sequences", normalized_sequences), + ): + if values is not None and not all(isinstance(value, str) for value in values): + raise TypeError(f"{name} must contain strings.") + + with _connect_sqlite_read_only(path) as connection: + _validate_sqlite_result_schema(connection, path) + if run_id is None: + run_columns = { + str(info[1]) for info in connection.execute("PRAGMA table_info(runs)").fetchall() + } + if "published_order" in run_columns: + row = connection.execute( + "SELECT run_id, metadata_json FROM runs " + "WHERE published_order IS NOT NULL " + "ORDER BY published_order DESC, rowid DESC LIMIT 1" + ).fetchone() + else: + row = connection.execute( + "SELECT run_id, metadata_json FROM runs " + "ORDER BY created_at DESC, rowid DESC LIMIT 1" + ).fetchone() + else: + row = connection.execute( + "SELECT run_id, metadata_json FROM runs WHERE run_id = ?", (run_id,) + ).fetchone() + if row is None: + raise KeyError(f"No embedding run found in {path}.") + selected_run, metadata_json = row + metadata = json.loads(metadata_json) + if not isinstance(metadata, dict): + raise ValueError("SQLite run metadata must contain a JSON object.") + row_prefix = ( + "SELECT r.position, r.record_id, r.sequence, t.dtype, t.shape_json, t.sha256 " + "FROM records r JOIN tensors t USING (run_id, position) " + "WHERE r.run_id = ?" + ) + record_count, minimum_position, maximum_position = connection.execute( + "SELECT COUNT(*), MIN(position), MAX(position) FROM records WHERE run_id = ?", + (selected_run,), + ).fetchone() + (tensor_count,) = connection.execute( + "SELECT COUNT(*) FROM tensors WHERE run_id = ?", (selected_run,) + ).fetchone() + (joined_count,) = connection.execute( + "SELECT COUNT(*) FROM records r JOIN tensors t USING (run_id, position) " + "WHERE r.run_id = ?", + (selected_run,), + ).fetchone() + if ( + tensor_count != record_count + or joined_count != record_count + or (record_count and (minimum_position != 0 or maximum_position != record_count - 1)) + ): + raise ValueError("SQLite embedding run has inconsistent or non-contiguous records.") + metadata_count = metadata.get("record_count") + if ( + not isinstance(metadata_count, int) + or isinstance(metadata_count, bool) + or metadata_count != record_count + ): + raise ValueError("SQLite metadata record count does not match stored records.") + descriptor_cursor = connection.execute(f"{row_prefix} ORDER BY r.position", (selected_run,)) + while descriptor_rows := descriptor_cursor.fetchmany(1_024): + for descriptor_row in descriptor_rows: + _validate_sqlite_descriptor_row(descriptor_row) + if supplied_selectors == 0: + rows: list[tuple[Any, ...]] | None = None + else: + selector_values: tuple[Any, ...] + selector_column: str + if normalized_positions is not None: + selector_values = normalized_positions + selector_column = "r.position" + elif normalized_ids is not None: + selector_values = normalized_ids + selector_column = "r.record_id" + else: + if normalized_sequences is None: + raise RuntimeError("Filtered SQLite retrieval resolved no selector values.") + selector_values = normalized_sequences + selector_column = "r.sequence" + fetched: list[tuple[Any, ...]] = [] + unique_values = tuple(dict.fromkeys(selector_values)) + for start in range(0, len(unique_values), 900): + chunk = unique_values[start : start + 900] + placeholders = ",".join("?" for _ in chunk) + fetched.extend( + connection.execute( + f"{row_prefix} AND {selector_column} IN ({placeholders}) " + "ORDER BY r.position", + (selected_run, *chunk), + ).fetchall() + ) + value_index = ( + 0 if normalized_positions is not None else (1 if normalized_ids is not None else 2) + ) + matched: dict[Any, list[tuple[Any, ...]]] = {} + for fetched_row in sorted(fetched, key=lambda item: int(item[0])): + matched.setdefault(fetched_row[value_index], []).append(fetched_row) + missing = [value for value in selector_values if value not in matched] + if missing: + raise KeyError(f"SQLite embedding selectors were not found: {missing!r}.") + rows = [ + fetched_row for value in selector_values for fetched_row in matched.get(value, ()) + ] + + if rows is None: + return EmbeddingResult( + _SQLiteRecordSequence(path, selected_run, int(record_count)), + metadata, + ) + records = [_sqlite_record_from_row(path, selected_run, selected_row) for selected_row in rows] + if supplied_selectors: + metadata = dict(metadata) + metadata["selection"] = { + "kind": ( + "positions" + if normalized_positions is not None + else "record_ids" + if normalized_ids is not None + else "sequences" + ), + "count": len(rows), + "duplicate_policy": "preserve-request-order", + } + return EmbeddingResult(records, metadata) + + +def load_legacy_pth(path: str | Path, *, allow_unsafe_pickle: bool = False) -> EmbeddingResult: + """Import a legacy mapping-only ``.pth`` file after explicit opt-in.""" + + if not allow_unsafe_pickle: + raise ValueError( + "Legacy .pth loading can execute pickle payloads. Pass " + "allow_unsafe_pickle=True only for a trusted file." + ) + payload = torch.load(Path(path), map_location="cpu", weights_only=False) + if not isinstance(payload, dict): + raise ValueError("A legacy .pth embedding file must contain a mapping.") + records: list[EmbeddingRecord] = [] + for position, (sequence, X) in enumerate(payload.items()): + if not isinstance(sequence, str) or not isinstance(X, Tensor): + raise ValueError("Legacy embedding mappings must use str keys and Tensor values.") + records.append(EmbeddingRecord(str(position), sequence, X.detach().cpu())) + return EmbeddingResult(records, {"format": "legacy-pth", "unsafe_pickle": True}) + + +_LEGACY_COMPACT_VERSION = 0x01 +_LEGACY_CODE_DTYPES: dict[int, tuple[np.dtype[Any], torch.dtype]] = { + 0: (np.dtype(np.float16), torch.float16), + # Legacy BF16 blobs stored FP16 payload bytes and converted back to BF16. + 1: (np.dtype(np.float16), torch.bfloat16), + 2: (np.dtype(np.float32), torch.float32), +} + + +def _decode_legacy_sqlite_blob( + data: bytes, + *, + fallback_shape: tuple[int, ...] | None, + allow_unsafe_pickle: bool, +) -> Tensor: + if len(data) >= 6 and data[0] == _LEGACY_COMPACT_VERSION: + dtype_code = int(data[1]) + if dtype_code not in _LEGACY_CODE_DTYPES: + raise ValueError(f"Unsupported legacy compact dtype code {dtype_code}.") + (ndim,) = struct.unpack_from(" 16 or len(data) < 6 + 4 * ndim: + raise ValueError("Malformed legacy compact embedding header.") + shape = tuple(int(value) for value in struct.unpack_from(f"<{ndim}i", data, 6)) + if any(size < 0 for size in shape): + raise ValueError("Malformed negative legacy embedding dimension.") + numpy_dtype, target_dtype = _LEGACY_CODE_DTYPES[dtype_code] + offset = 6 + 4 * ndim + expected = int(np.prod(shape, dtype=np.int64)) * numpy_dtype.itemsize + if len(data) - offset != expected: + raise ValueError("Legacy compact embedding payload length does not match shape.") + array = np.frombuffer(data, dtype=numpy_dtype, offset=offset).copy().reshape(shape) + return torch.from_numpy(array).to(dtype=target_dtype) + + try: + loaded = torch.load(io.BytesIO(data), map_location="cpu", weights_only=True) + except Exception as safe_error: + if allow_unsafe_pickle: + loaded = torch.load(io.BytesIO(data), map_location="cpu", weights_only=False) + elif fallback_shape is None: + raise ValueError( + "Legacy embedding blob is neither compact nor safely loadable. " + "Provide fallback_shape for raw FP32 bytes, or set " + "allow_unsafe_pickle=True only for a trusted database." + ) from safe_error + else: + expected = int(np.prod(fallback_shape, dtype=np.int64)) * 4 + if len(data) != expected: + raise ValueError( + "Legacy raw FP32 payload length does not match fallback_shape." + ) from safe_error + array = np.frombuffer(data, dtype=np.float32).copy().reshape(fallback_shape) + return torch.from_numpy(array) + if not isinstance(loaded, Tensor): + raise ValueError("Legacy serialized embedding payload must contain one tensor.") + return loaded.detach().cpu() + + +def convert_legacy_sqlite( + source: str | Path, + output: str | Path, + *, + fallback_shape: tuple[int, ...] | None = None, + allow_unsafe_pickle: bool = False, + metadata: dict[str, Any] | None = None, +) -> EmbeddingResult: + """Convert the v0 ``embeddings(sequence, embedding)`` database safely. + + The source is opened read-only. Compact blobs and ``weights_only`` Torch + tensors are accepted by default. Unsafe general pickle deserialization + remains an explicit opt-in. + """ + + source_path = Path(source) + output_path = Path(output) + if source_path.resolve() == output_path.resolve(): + raise ValueError("Legacy SQLite conversion requires a different output path.") + if fallback_shape is not None and ( + not fallback_shape or any(not isinstance(size, int) or size < 0 for size in fallback_shape) + ): + raise ValueError("fallback_shape must contain non-negative integer dimensions.") + with _connect_sqlite_read_only(source_path) as connection: + columns = { + str(row[1]) for row in connection.execute("PRAGMA table_info(embeddings)").fetchall() + } + if not {"sequence", "embedding"}.issubset(columns): + raise ValueError("Legacy SQLite database must contain embeddings(sequence, embedding).") + rows = connection.execute( + "SELECT sequence, embedding FROM embeddings ORDER BY rowid" + ).fetchall() + if not rows: + raise ValueError("Legacy SQLite database contains no embeddings.") + + records: list[EmbeddingRecord] = [] + content_digest = hashlib.sha256() + for position, (sequence, data) in enumerate(rows): + if not isinstance(sequence, str) or not sequence: + raise ValueError("Legacy embedding sequences must be non-empty strings.") + if not isinstance(data, bytes): + data = bytes(data) + tensor = _decode_legacy_sqlite_blob( + data, + fallback_shape=fallback_shape, + allow_unsafe_pickle=allow_unsafe_pickle, + ) + tensor_digest = tensor_sha256(tensor) + for value in (sequence.encode("utf-8"), tensor_digest.encode("ascii")): + content_digest.update(len(value).to_bytes(8, "big")) + content_digest.update(value) + records.append(EmbeddingRecord(str(position), sequence, tensor)) + + content_sha256 = content_digest.hexdigest() + run_fingerprint = hashlib.sha256( + f"fastplms-legacy-sqlite-v1:{content_sha256}".encode("ascii") + ).hexdigest() + converted_metadata: dict[str, Any] = { + "format_version": 1, + "run_fingerprint": run_fingerprint, + "source_format": "legacy-fastplms-sqlite-v0", + "source_content_sha256": content_sha256, + "unsafe_pickle": allow_unsafe_pickle, + "complete": True, + } + if metadata: + converted_metadata["conversion_metadata"] = _jsonable(metadata) + return save_sqlite_result( + EmbeddingResult(records, converted_metadata), + output_path, + ) + + +def save_result( + result: EmbeddingResult, + path: str | Path, + *, + format: str = "safetensors", + shard_size: int = DEFAULT_SHARD_SIZE, +) -> EmbeddingResult: + if format == "safetensors": + return save_safetensors_result(result, path, shard_size=shard_size) + if format == "sqlite": + return save_sqlite_result(result, path) + if format == "pth": + raise ValueError("Writing pickle-based .pth embeddings is not supported.") + raise ValueError("format must be 'safetensors' or 'sqlite'.") + + +def load_result(path: str | Path, *, format: str = "safetensors") -> EmbeddingResult: + if format == "safetensors": + return load_safetensors_result(path) + if format == "sqlite": + return load_sqlite_result(path) + raise ValueError("format must be 'safetensors' or 'sqlite'.") + + +__all__ = [ + "DEFAULT_SHARD_SIZE", + "SafetensorsStreamWriter", + "append_sqlite_records", + "convert_legacy_sqlite", + "garbage_collect_safetensors_generations", + "initialize_sqlite_run", + "load_legacy_pth", + "load_result", + "load_safetensors_result", + "load_sqlite_result", + "safetensors_result_exists", + "save_result", + "save_safetensors_result", + "save_sqlite_result", + "tensor_sha256", + "update_sqlite_run_metadata", +] diff --git a/fastplms/embeddings/types.py b/fastplms/embeddings/types.py new file mode 100644 index 0000000000000000000000000000000000000000..03682358c08f82b2f0d2b3806e5a5eba807012ba --- /dev/null +++ b/fastplms/embeddings/types.py @@ -0,0 +1,187 @@ +"""Public value types for dataset embedding.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal, overload + +from torch import Tensor + + +@dataclass(frozen=True, slots=True) +class EmbeddingInput: + """One named protein sequence supplied to :func:`embed_dataset`.""" + + id: str + sequence: str + + def __post_init__(self) -> None: + if not isinstance(self.id, str) or not self.id: + raise ValueError("EmbeddingInput.id must be a non-empty string.") + if not isinstance(self.sequence, str) or not self.sequence: + raise ValueError("EmbeddingInput.sequence must be a non-empty string.") + + +@dataclass(frozen=True, slots=True) +class LazyTensorReference: + """A tensor stored outside memory and loaded only when requested.""" + + source: str + key: str + dtype: str + shape: tuple[int, ...] + sha256: str + _loader: Callable[[], Tensor] = field(repr=False, compare=False) + + def load(self, *, verify: bool = True) -> Tensor: + """Load X and optionally verify its content digest.""" + + if not isinstance(verify, bool): + raise TypeError("verify must be a boolean.") + X = self._loader() + if not isinstance(X, Tensor): + raise TypeError(f"Stored tensor loader for {self.key!r} must return a Tensor.") + if tuple(X.shape) != self.shape: + raise ValueError( + f"Stored tensor {self.key!r} has shape {tuple(X.shape)}, expected {self.shape}." + ) + dtype = str(X.dtype).removeprefix("torch.") + if dtype != self.dtype: + raise ValueError( + f"Stored tensor {self.key!r} has dtype {dtype!r}, expected {self.dtype!r}." + ) + if verify: + from .storage import tensor_sha256 + + digest = tensor_sha256(X) + if digest != self.sha256: + raise ValueError(f"Stored tensor {self.key!r} failed SHA-256 verification.") + return X + + +TensorValue = Tensor | LazyTensorReference + + +@dataclass(frozen=True, slots=True) +class EmbeddingRecord: + """One ordered embedding result.""" + + id: str + sequence: str + tensor: TensorValue + + def __post_init__(self) -> None: + if not isinstance(self.id, str) or not self.id: + raise ValueError("EmbeddingRecord.id must be a non-empty string.") + if not isinstance(self.sequence, str) or not self.sequence: + raise ValueError("EmbeddingRecord.sequence must be a non-empty string.") + if not isinstance(self.tensor, (Tensor, LazyTensorReference)): + raise TypeError("EmbeddingRecord.tensor must be a Tensor or LazyTensorReference.") + + def load_tensor(self, *, verify: bool = True) -> Tensor: + """Return X regardless of whether this record is memory-backed or lazy.""" + + if not isinstance(verify, bool): + raise TypeError("verify must be a boolean.") + if isinstance(self.tensor, LazyTensorReference): + return self.tensor.load(verify=verify) + return self.tensor + + +class EmbeddingResult(Sequence[EmbeddingRecord]): + """Ordered embedding records and the metadata needed to reproduce them.""" + + def __init__( + self, + records: Sequence[EmbeddingRecord], + metadata: Mapping[str, Any] | None = None, + ) -> None: + self.records: Sequence[EmbeddingRecord] = ( + records if getattr(records, "_fastplms_immutable_sequence", False) else tuple(records) + ) + self.metadata = dict(metadata or {}) + + def __len__(self) -> int: + return len(self.records) + + def __iter__(self) -> Iterator[EmbeddingRecord]: + return iter(self.records) + + @overload + def __getitem__(self, index: int, /) -> EmbeddingRecord: ... + + @overload + def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ... + + def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]: + return self.records[index] + + def as_dict( + self, + *, + key: Literal["id", "sequence"] = "id", + duplicates: Literal["error", "first", "last"] = "error", + materialize: bool = True, + ) -> dict[str, TensorValue]: + """Convert records to a mapping under an explicit duplicate policy.""" + + if key not in {"id", "sequence"}: + raise ValueError("key must be 'id' or 'sequence'.") + if duplicates not in {"error", "first", "last"}: + raise ValueError("duplicates must be 'error', 'first', or 'last'.") + if not isinstance(materialize, bool): + raise TypeError("materialize must be a boolean.") + output: dict[str, TensorValue] = {} + for record in self.records: + record_key = getattr(record, key) + if record_key in output: + if duplicates == "error": + raise ValueError( + f"Duplicate {key} {record_key!r}; choose duplicates='first' " + "or duplicates='last' explicitly." + ) + if duplicates == "first": + continue + output[record_key] = record.load_tensor() if materialize else record.tensor + return output + + def materialize(self, *, verify: bool = True) -> EmbeddingResult: + """Return an equivalent result with every X loaded into CPU memory.""" + + if not isinstance(verify, bool): + raise TypeError("verify must be a boolean.") + return EmbeddingResult( + [ + EmbeddingRecord( + id=record.id, + sequence=record.sequence, + tensor=record.load_tensor(verify=verify), + ) + for record in self.records + ], + self.metadata, + ) + + +@dataclass(frozen=True, slots=True) +class EmbeddingBatch: + """Internal model-to-runner contract. + + ``X`` has shape ``(b, l, d)`` and ``residue_mask`` has shape ``(b, l)``. + ``attentions`` may contain layer/head attention matrices for ``parti``. + """ + + X: Tensor + residue_mask: Tensor + attentions: Tensor | tuple[Tensor, ...] | None = None + + +__all__ = [ + "EmbeddingBatch", + "EmbeddingInput", + "EmbeddingRecord", + "EmbeddingResult", + "LazyTensorReference", + "TensorValue", +] diff --git a/fastplms/models.toml b/fastplms/models.toml new file mode 100644 index 0000000000000000000000000000000000000000..1c78876962612ab3284093c8d0483c8c87e3afbd --- /dev/null +++ b/fastplms/models.toml @@ -0,0 +1,1223 @@ +schema_version = 1 +legal_files = [ + "LICENSE=sha256:2d2b50c7b1414bff1189a1db1f0cfb92e3e064b50f4c2b1019827b683e1b629a", + "THIRD_PARTY_NOTICES.md=sha256:25704b3c76404696cae52e7fca13088d329f70f412687340351259e86cd62baa", +] + +[[attention_kernels]] +implementation = "flash_attention_2" +repository = "kernels-community/flash-attn2" +revision = "db6b51744f0cd7061386442c09df890fc6d9f47e" +version = 2 +expected_variant = "flash_attn2" +dtypes = ["bfloat16"] + +[[attention_kernels]] +implementation = "flash_attention_3" +repository = "kernels-community/flash-attn3" +revision = "43f0bd269777115d94ff826e0d113ce9c1c9087b" +version = 1 +expected_variant = "flash_attn3" +dtypes = ["bfloat16"] + +[[runtime_assets]] +id = "esmfold2_ccd" +repository = "biohub/ESMFold2" +revision = "1ebf0e3481a5184eb6171d40615c79e384b48796" +path = "ccd.pkl" +sha256 = "9ff44b1927c6b9198e38ffe0928706827a09a350c15530beeeabebfa88038fc5" +size = 417306584 +consumer_family = "esmfold2" +trust_kind = "hash_pinned_pickle" +license = "MIT" +offline_behavior = "requires_cached_verified_file" + +[[upstreams]] +id = "ankh" +path = "vendor/upstream/ankh" +url = "https://github.com/agemagician/Ankh.git" +revision = "02b4e25ce5389b9e771c9df6e546c62af1216f8e" +license = "CC-BY-NC-SA-4.0" +license_files = ["LICENSE.md"] +license_digests = ["LICENSE.md=sha256:cd041d7f9f52936e8824ac3f754e9c67410763205fc8a7020ba74fc8b6edc088"] +distribution_files = ["LICENSE.md=sha256:cd041d7f9f52936e8824ac3f754e9c67410763205fc8a7020ba74fc8b6edc088"] + +[[upstreams]] +id = "biohub-esm" +path = "vendor/upstream/biohub-esm" +url = "https://github.com/Biohub/esm.git" +revision = "82ee35553d39169d678f784c8d3f8712ffd7d2c4" +license = "MIT" +license_files = ["LICENSE.md", "THIRD_PARTY_NOTICE.md"] +license_digests = [ + "LICENSE.md=sha256:b63df9ca1dd96b3b21eec226b51b236d0bd152ac20eafc43aad46bf832b48d8a", + "THIRD_PARTY_NOTICE.md=sha256:5bff8515ba4e0f53abdc43714c180b79c5b606160497d98de741a369cb9b6a23", +] +distribution_files = [ + "LICENSE.md=sha256:b63df9ca1dd96b3b21eec226b51b236d0bd152ac20eafc43aad46bf832b48d8a", + "THIRD_PARTY_NOTICE.md=sha256:5bff8515ba4e0f53abdc43714c180b79c5b606160497d98de741a369cb9b6a23", +] + +[[upstreams]] +id = "biohub-transformers" +path = "vendor/upstream/biohub-transformers" +url = "https://github.com/Biohub/transformers.git" +revision = "3a8956fb4d4ea16b0ec8e71deef2c2909b6a5cbf" +license = "Apache-2.0" +license_files = ["LICENSE"] +license_digests = ["LICENSE=sha256:77fd4710def9ec3c0f6225800e0235f15a425abd4a8b03559127fcd782612049"] +distribution_files = ["LICENSE=sha256:77fd4710def9ec3c0f6225800e0235f15a425abd4a8b03559127fcd782612049"] + +[[upstreams]] +id = "boltz" +path = "vendor/upstream/boltz" +url = "https://github.com/jwohlwend/boltz.git" +revision = "b1ebfc46ecf57f5414e0d1a6f9027bbb122c53bc" +license = "MIT" +license_files = ["LICENSE"] +license_digests = ["LICENSE=sha256:f0667fd5e66c51e1ba8ddaa0249c6d7225b30037e02c45782d8f2c2943ac2617"] +distribution_files = ["LICENSE=sha256:f0667fd5e66c51e1ba8ddaa0249c6d7225b30037e02c45782d8f2c2943ac2617"] + +[[upstreams]] +id = "dplm" +path = "vendor/upstream/dplm" +url = "https://github.com/bytedance/dplm.git" +revision = "8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d" +license = "Apache-2.0" +license_files = ["LICENSE"] +license_digests = ["LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"] +distribution_files = [ + "LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "PROVENANCE.md=sha256:a659f74be9073cf1ad2d2f7071531ca56959b421f111152cf4c41184ace5970e", +] + +[[upstreams]] +id = "e1" +path = "vendor/upstream/e1" +url = "https://github.com/Profluent-AI/E1.git" +revision = "bfd2620a602248499f3d2583d85a7ecddf0b6e02" +license = "Apache-2.0 AND Profluent-E1-Agreement" +license_files = ["LICENSE", "ATTRIBUTION", "NOTICE"] +license_digests = [ + "LICENSE=sha256:8ef1dd556091544db3044164a8015424a3dcb3450fb3765a81b88463551bbe81", + "ATTRIBUTION=sha256:deb22b250f6491b649eda5c63e080dd56486b8d2736cea6a52ef875436214367", + "NOTICE=sha256:6de9db0320b4ee82f665c0951d8fd4cd53701a659c9dbce9bc3e3ea6afc4c6b3", +] +distribution_files = [ + "LICENSE=sha256:8ef1dd556091544db3044164a8015424a3dcb3450fb3765a81b88463551bbe81", + "ATTRIBUTION=sha256:deb22b250f6491b649eda5c63e080dd56486b8d2736cea6a52ef875436214367", + "NOTICE=sha256:6de9db0320b4ee82f665c0951d8fd4cd53701a659c9dbce9bc3e3ea6afc4c6b3", + "Apache-2.0.txt=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "BSD-3-Clause.txt=sha256:36e1987f2f17db7f8ad36cd7a37dbb7aeaaf0ab68b97ab4b9d3556f3a7a76ae8", + "MODIFICATIONS.md=sha256:2506f47c0f5475af8e8ff2cff13eb8b79e8e25a08a054cdd617bf336536750ca", +] + +[[upstreams]] +id = "fair-esm" +path = "vendor/upstream/fair-esm" +url = "https://github.com/facebookresearch/esm.git" +revision = "2b369911bb5b4b0dda914521b9475cad1656b2ac" +license = "MIT" +license_files = ["LICENSE"] +license_digests = ["LICENSE=sha256:da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93"] +distribution_files = [ + "LICENSE=sha256:da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "PROVENANCE.md=sha256:950adb94daf15e646ddf226dacfe2a8e77801aa0793e439a9a3490a48eb666e7", +] + +[[upstreams]] +id = "openfold" +path = "vendor/upstream/openfold" +url = "https://github.com/aqlaboratory/openfold.git" +revision = "4b41059694619831a7db195b7e0988fc4ff3a307" +license = "Apache-2.0" +license_files = ["LICENSE"] +license_digests = ["LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"] +distribution_files = [ + "LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "MODIFICATIONS.md=sha256:fd6f0aa1086a0c996cf967b326d18e965660cda0ad5c7f36a3474a8490720da3", + "PROVENANCE.md=sha256:48c903db43a217a3126afaefbac60b7ddac7efda2dfcc0cbff0bffc7d6c30081", +] + +[[upstreams]] +id = "protein-ttt" +path = "vendor/upstream/protein-ttt" +url = "https://github.com/anton-bushuiev/ProteinTTT.git" +revision = "fde2817cd84b936167cc76ccabf31e5c0fe49962" +license = "MIT" +license_files = ["LICENSE"] +license_digests = ["LICENSE=sha256:bb01e7d5554f9e2e117172e56551452f68a7818df7bc8e71cd7a776a1d4ba3df"] +distribution_files = [ + "LICENSE=sha256:bb01e7d5554f9e2e117172e56551452f68a7818df7bc8e71cd7a776a1d4ba3df", + "PROVENANCE.md=sha256:dc641c37353c2efd50ccbdb316ca4aae495ec02c1563e0e15bac92f75fc482e5", +] + +[families.esm2] +architecture = "ESM2" +upstreams = ["fair-esm"] +tokenizer_mode = "tokenizer" +public_input = "Amino-acid sequences tokenized to residue IDs" +extra = "core" +reference_container = "reference-esm2" +reference_adapter = "tests.parity.support.reference_adapters.esm2" +attention = ["eager", "sdpa", "flex_attention", "flash_attention_2", "flash_attention_3"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "fp32_parameters_autocast" +precisions = ["default"] +vram_tier = "sequence" +checkpoint_license = "MIT" +hub_license = "mit" +weights_publication_allowed = true +state_transform = "esm2_hf_to_fastplms_v1" +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." +representative = "esm2_8m" +documentation = "docs/models.md#esm2" +test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"] +runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/esm2", "models/ttt.py"] +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" } + +[families.esm_plusplus] +architecture = "ESMC" +upstreams = ["biohub-esm", "biohub-transformers"] +tokenizer_mode = "tokenizer" +public_input = "Amino-acid sequences tokenized to residue IDs" +extra = "core" +reference_container = "reference-biohub-esm" +reference_adapter = "tests.parity.support.reference_adapters.esm_plusplus" +attention = ["eager", "sdpa", "flex_attention", "flash_attention_2", "flash_attention_3"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "static_parameters" +precisions = ["default"] +vram_tier = "sequence" +checkpoint_license = "MIT" +hub_license = "mit" +weights_publication_allowed = true +state_transform = "esmc_to_fastplms_v1" +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." +representative = "esmc_small" +documentation = "docs/models.md#esm-and-esmc" +test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"] +runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm_plusplus", "models/ttt.py"] +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" } + +[families.esm3] +architecture = "ESM3" +upstreams = ["biohub-esm", "biohub-transformers"] +tokenizer_mode = "tokenizer" +public_input = "Sequence, structure, and function tracks prepared through the multimodal helpers" +extra = "core" +reference_container = "reference-biohub-esm" +reference_adapter = "tests.parity.support.reference_adapters.esm3" +attention = ["eager", "sdpa", "flex_attention"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "fp32_parameters_autocast" +precisions = ["default"] +vram_tier = "large-sequence" +checkpoint_license = "MIT" +hub_license = "mit" +weights_publication_allowed = true +state_transform = "esm3_to_fastplms_v1" +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." +representative = "esm3_small" +documentation = "docs/models.md#esm3" +test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"] +runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm3", "models/ttt.py"] +auto_map = { AutoConfig = "fastplms.models.esm3.modeling_esm3.FastESM3Config", AutoModel = "fastplms.models.esm3.modeling_esm3.FastESM3Model" } + +[families.e1] +architecture = "E1" +upstreams = ["e1"] +tokenizer_mode = "sequence" +public_input = "Raw amino-acid sequences prepared by the native E1 adapter" +extra = "core" +reference_container = "reference-e1" +reference_adapter = "tests.parity.support.reference_adapters.e1" +attention = ["sdpa", "flex_attention"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "static_parameters" +precisions = ["default"] +vram_tier = "sequence" +checkpoint_license = "Profluent-E1-Agreement" +hub_license = "other" +hub_license_name = "Profluent-E1 Clickthrough License Agreement" +hub_license_link = "https://github.com/Profluent-AI/E1/blob/bfd2620a602248499f3d2583d85a7ecddf0b6e02/LICENSE" +weights_publication_allowed = true +state_transform = "e1_to_fastplms_v1" +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." +representative = "e1_150m" +documentation = "docs/models.md#e1" +test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"] +runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/e1", "models/ttt.py"] +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" } + +[families.dplm] +architecture = "DPLM" +upstreams = ["dplm"] +tokenizer_mode = "tokenizer" +public_input = "Amino-acid sequences tokenized to masked or partially masked residue IDs" +extra = "core" +reference_container = "reference-dplm" +reference_adapter = "tests.parity.support.reference_adapters.dplm" +attention = ["eager", "sdpa", "flex_attention", "flash_attention_3"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "fp32_parameters_autocast" +precisions = ["default"] +vram_tier = "sequence" +checkpoint_license = "Apache-2.0" +hub_license = "apache-2.0" +weights_publication_allowed = true +state_transform = "dplm_to_fastplms_v1" +conversion_provenance = "Input: the pinned official DPLM1 checkpoint. Transformation: apply dplm_to_fastplms_v1, omitting the unused absolute-position table for rotary checkpoints and materializing the tied input/output embedding values as independent tensors. Output: the pinned Synthyra DPLM checkpoint. Validation: release parity compares exact state identity after the declared transform, tokenizer behavior, generation, and inference. License basis: the pinned ByteDance DPLM Apache-2.0 LICENSE and README explicitly scope the repository release to the pretrained DPLM1 and DPLM2 weights; immutable evidence is recorded in LICENSES/dplm/PROVENANCE.md. Limitation: redistribution remains subject to Apache-2.0 and the pinned provenance record; no broader rights are inferred." +representative = "dplm_150m" +documentation = "docs/models.md#dplm" +test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"] +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"] +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" } + +[families.dplm2] +architecture = "DPLM2" +upstreams = ["dplm"] +tokenizer_mode = "tokenizer" +public_input = "Tokenized amino-acid and structure tracks with explicit modality boundaries" +extra = "core" +reference_container = "reference-dplm" +reference_adapter = "tests.parity.support.reference_adapters.dplm2" +attention = ["sdpa"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "fp32_parameters_autocast" +precisions = ["default"] +vram_tier = "sequence" +checkpoint_license = "Apache-2.0" +hub_license = "apache-2.0" +weights_publication_allowed = true +state_transform = "dplm2_to_fastplms_v1" +conversion_provenance = "Input: the pinned official DPLM2 checkpoint. Transformation: apply dplm2_to_fastplms_v1, retaining the independent language-model head and trained encoder contact head while omitting the unused absolute-position table for rotary checkpoints. Output: the pinned Synthyra DPLM2 checkpoint. Validation: release parity compares exact keys and values after the declared omission, non-aliasing, tokenizer behavior, generation, and inference. License basis: the pinned ByteDance DPLM Apache-2.0 LICENSE and README explicitly scope the repository release to the pretrained DPLM1 and DPLM2 weights; immutable evidence is recorded in LICENSES/dplm/PROVENANCE.md. Limitation: no head exception is permitted by this record, and redistribution remains subject to Apache-2.0." +representative = "dplm2_150m" +documentation = "docs/models.md#dplm2" +test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"] +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"] +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" } +tokenizer_class = "fastplms.models.dplm2.tokenization_dplm2.DPLM2Tokenizer" + +[families.ankh] +architecture = "ANKH" +upstreams = ["ankh"] +tokenizer_mode = "tokenizer" +public_input = "Amino-acid sequences tokenized for encoder or sequence-to-sequence use" +extra = "core" +reference_container = "reference-ankh" +reference_adapter = "tests.parity.support.reference_adapters.ankh" +attention = ["eager", "sdpa"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "static_parameters" +precisions = ["default"] +vram_tier = "large-sequence" +checkpoint_license = "CC-BY-NC-SA-4.0" +hub_license = "cc-by-nc-sa-4.0" +weights_publication_allowed = true +state_transform = "ankh_t5_to_fastplms_v1" +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." +representative = "ankh_base" +documentation = "docs/models.md#ankh" +test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"] +requires_complete_weight_publication = true +runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/ankh", "models/ttt.py"] +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" } + +[families.boltz2] +architecture = "Boltz2" +upstreams = ["boltz"] +tokenizer_mode = "structure" +public_input = "Raw amino-acid sequences through the convenience API, or prepared model features" +extra = "structure" +reference_container = "reference-boltz2" +reference_adapter = "tests.parity.support.reference_adapters.boltz" +attention = ["eager"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "fp32_parameters_autocast" +precisions = ["default"] +vram_tier = "structure" +checkpoint_license = "MIT" +hub_license = "mit" +weights_publication_allowed = true +state_transform = "boltz2_inference_core_v1" +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." +representative = "boltz2" +documentation = "docs/models.md#boltz2" +test_tiers = ["structure", "artifact", "benchmark"] +runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "models/boltz"] +auto_map = { AutoConfig = "fastplms.models.boltz.modeling_boltz2.Boltz2Config", AutoModel = "fastplms.models.boltz.modeling_boltz2.Boltz2Model" } + +[families.esmfold] +architecture = "ESMFold" +upstreams = ["fair-esm", "openfold"] +tokenizer_mode = "structure" +public_input = "Raw amino-acid sequences through folding helpers, or prepared residue tensors" +extra = "structure" +reference_container = "reference-esmfold" +reference_adapter = "tests.parity.support.reference_adapters.esmfold" +attention = ["eager", "sdpa", "flex_attention"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "fp32_parameters_autocast" +precisions = ["default"] +vram_tier = "structure" +checkpoint_license = "MIT" +hub_license = "mit" +weights_publication_allowed = true +state_transform = "esmfold_meta_to_fastplms_v1" +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." +representative = "esmfold" +documentation = "docs/models.md#esmfold" +test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"] +runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/esmfold"] +auto_map = { AutoConfig = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmFoldConfig", AutoModel = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForProteinFolding" } + +[families.esmfold2] +architecture = "ESMFold2" +upstreams = ["biohub-esm", "biohub-transformers", "protein-ttt"] +backbone_model = "esmc_6b" +tokenizer_mode = "structure" +public_input = "Raw amino-acid sequences or typed molecular-complex specifications; low-level forward accepts prepared feature tensors" +extra = "structure" +reference_container = "reference-esmfold2" +reference_adapter = "tests.parity.support.reference_adapters.esmfold2" +attention = ["eager", "sdpa", "flex_attention"] +dtypes = ["float32", "bfloat16"] +bf16_execution = "fp32_parameters_autocast" +precisions = ["auto", "fp32", "bf16", "fp8"] +experimental_precisions = ["fp8"] +vram_tier = "structure-6b" +checkpoint_license = "MIT" +hub_license = "mit" +weights_publication_allowed = true +state_transform = "identity" +conversion_provenance = "Input: each pinned Biohub ESMFold2 checkpoint and its separately pinned ESMC checkpoint. Transformation: apply identity to preserve the folding checkpoint exactly, load its parameters in FP32 for CUDA BF16-autocast execution, retain canonical BF16 ESMC weights, and optionally rebuild exactly 80 ESMC attention output projections as transient Transformer Engine linears. Output: the corresponding pinned Synthyra ESMFold2 checkpoint plus its declared ESMC precision policy. Validation: release parity covers exact canonical state, learned projection, prepared features, and seeded BF16 folding; experimental FP8 validation covers strict unavailable-device behavior, all four variants, and three BF16-to-FP8 reload cycles on the standard variant. Limitation: only the four manifest-listed ESMFold2 variants are supported; FP8 is experimental, applies only to inference-time ESMC execution, and requires direct CUDA loading with Transformer Engine availability." +representative = "esmfold2" +documentation = "docs/esmfold2.md" +test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"] +runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esmfold2", "models/esm_plusplus", "models/ttt.py"] +auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2.ESMFold2Model" } + +[[models]] +id = "esm2_8m" +family = "esm2" +size_category = "small" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esm2_8m.json=sha256:6975e86d1d8f27488bf2a676551feaa48cc19254c9d24b6acb09198122745609", tensors = "tests/goldens/esm2_8m.safetensors=sha256:b40217566c33c71988d28869de353be54a3b3ebfc21fdfd29056e88cf7e99f4c" } +fast_repo = "Synthyra/ESM2-8M" +fast_revision = "185ecbd45665d050a8dae326d91886d330c5f9d0" +fast_files = [ + "config.json=git-sha1:46d0a7b517f59123c6ebc6d1011585731cbab259", + "model.safetensors=sha256:c824e6ded5fb71c72bc5ac05300699947819023cb26cdaf6897665e6b2645e1b", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] +official_repo = "facebook/esm2_t6_8M_UR50D" +official_revision = "c731040fcd8d73dceaa04b0a8e6329b345b0f5df" +official_files = [ + "config.json=git-sha1:c2c6e65a87d9d20d47699ae236d605b80c741dd3", + "model.safetensors=sha256:24c5fa474c48f3b754b86efe752d5f189d2bcd88190fa2270fc92b2ef3034189", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] + +[[models.oracle_assets]] +role = "weights" +path = "models/esm2_t6_8M_UR50D.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t6_8M_UR50D.pt" +sha256 = "46f002a9870c9bdecd0ea887acb1f9a38a6b561e8f8bf8a6990b679b9d31b928" +size = 30099493 + +[[models.oracle_assets]] +role = "contact_regression" +path = "regression/esm2_t6_8M_UR50D-contact-regression.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t6_8M_UR50D-contact-regression.pt" +sha256 = "8f7a4557d57713b97ba0e484303007efb7230d25299c0ac47a0a1b12a87bbb9d" +size = 1511 + +[[models]] +id = "esm2_35m" +family = "esm2" +size_category = "small" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esm2_35m.json=sha256:e919d3ce6d20b6a942d27d92323814ae7594a0129dc9c4de27c5053e96675bcd", tensors = "tests/goldens/esm2_35m.safetensors=sha256:c9b8bb616cf884fb7744521a2fcc6eed23586342d11241e6c9ef16454ec31e17" } +fast_repo = "Synthyra/ESM2-35M" +fast_revision = "37ab9f56b41e365b3bd9e25d6fefe9150fd910f0" +fast_files = [ + "config.json=git-sha1:4d428c9934572f39e2a00db162249971f37c88e4", + "model.safetensors=sha256:21d95ab6bb9aa91bfec87eff11da61a657b732f2df279cbddbae6a7f1f0bba9c", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] +official_repo = "facebook/esm2_t12_35M_UR50D" +official_revision = "6fbf070e65b0b7291e7bbcd451118c216cff79d8" +official_files = [ + "config.json=git-sha1:3f64131bb610ed1ce482c4b5421fc358c785278f", + "model.safetensors=sha256:e35647818e0e064351d4531ed480d225a002567b4b2b93ad3a9246d753150fc0", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] + +[[models.oracle_assets]] +role = "weights" +path = "models/esm2_t12_35M_UR50D.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t12_35M_UR50D.pt" +sha256 = "7f21e80e61d16a71735163ef555d3009afb0c98da74c48e29df08606973cc55e" +size = 134095705 + +[[models.oracle_assets]] +role = "contact_regression" +path = "regression/esm2_t12_35M_UR50D-contact-regression.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t12_35M_UR50D-contact-regression.pt" +sha256 = "16641e05d830d0ce863dd152dbb8c2f3ddfa3c3ec2a66080152c8abad01d8585" +size = 1959 + +[[models]] +id = "esm2_150m" +family = "esm2" +size_category = "medium" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esm2_150m.json=sha256:c04c93486024ba0fa1c81fbfbe92ee79d1d4c7f1cfcc2c9886728522f752feab", tensors = "tests/goldens/esm2_150m.safetensors=sha256:c03fe9916dba137b452a6bbe944c7dc414db4019a6f0921e87b92d4bb6a8a42f" } +fast_repo = "Synthyra/ESM2-150M" +fast_revision = "979e0880dfc9e0c0080839b83d9d2dc05b92786a" +fast_files = [ + "config.json=git-sha1:efeae2af182b7d34dc35740a45f157661e7acdf4", + "model.safetensors=sha256:d1f7c60f98c31af328381519a750972b6a31b13b97aa7cca2e71b5ae1b3f8f53", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] +official_repo = "facebook/esm2_t30_150M_UR50D" +official_revision = "a695f6045e2e32885fa60af20c13cb35398ce30c" +official_files = [ + "config.json=git-sha1:52e04179e6fbad6663a94ea5cc44f09d764c5cd4", + "model.safetensors=sha256:c3f1da8aea53bddd32c246c86168c23b9fd72341fb9db9a94436f855f5053566", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] + +[[models.oracle_assets]] +role = "weights" +path = "models/esm2_t30_150M_UR50D.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t30_150M_UR50D.pt" +sha256 = "881c7176cf198ef8dec26a3c375d40eb58d0c33df95c22562ca6cc6d3f812c62" +size = 592774773 + +[[models.oracle_assets]] +role = "contact_regression" +path = "regression/esm2_t30_150M_UR50D-contact-regression.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t30_150M_UR50D-contact-regression.pt" +sha256 = "6a604b96722ed052eef8a094ad90b275ba2e987d406315dbed0bdc6b3c4238a7" +size = 3431 + +[[models]] +id = "esm2_650m" +family = "esm2" +size_category = "large" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esm2_650m.json=sha256:f18332172fcb3abf5dd2485fd55f5b0d193ad3b93a44cc744e0d02817c927477", tensors = "tests/goldens/esm2_650m.safetensors=sha256:c3a66b75add03628e62e238cb63da6a9e4d321f8160e84bdf2a131c096977f86" } +fast_repo = "Synthyra/ESM2-650M" +fast_revision = "ca0718a5d52b80d5c60dd76860e55e061a95fb0a" +fast_files = [ + "config.json=git-sha1:88f6bd240680b29c3244df8292246048401f5caf", + "model.safetensors=sha256:a15142e94ecf36f0edde9b37796f591e609ebe1694ca411e93640f0ee384994a", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] +official_repo = "facebook/esm2_t33_650M_UR50D" +official_revision = "08e4846e537177426273712802403f7ba8261b6c" +official_files = [ + "config.json=git-sha1:a956a25d277f30bd870d3760b9a116f19ead885e", + "model.safetensors=sha256:a08adabb949fa67ad3c14b509d04fd60368b35007b0095e3358f81200c4f4db0", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] + +[[models.oracle_assets]] +role = "weights" +path = "models/esm2_t33_650M_UR50D.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t33_650M_UR50D.pt" +sha256 = "ea9d0522b335a8778dea6535a65301f10208dece28cd5865482b0b1fc446168c" +size = 2604537549 + +[[models.oracle_assets]] +role = "contact_regression" +path = "regression/esm2_t33_650M_UR50D-contact-regression.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t33_650M_UR50D-contact-regression.pt" +sha256 = "8ffe6edbd4173dc8d45c2cd5cb27d43aad77ec26b4c768200c58ae1f96693575" +size = 3687 + +[[models]] +id = "esm2_3b" +family = "esm2" +size_category = "xlarge" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esm2_3b.json=sha256:5043b2333c57a34d54fac53916722d1acb4b6fd50395b9abafa805435b184a48", tensors = "tests/goldens/esm2_3b.safetensors=sha256:dfd5a8cb05d3e814a080185c4808c8e7ec2277f070f395562fcfbe4376789e4e" } +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." +fast_repo = "Synthyra/ESM2-3B" +fast_revision = "ff89d0180f414ab9c677219a25da79bf09185456" +fast_files = [ + "config.json=git-sha1:94944ad6cabaa40a3ce1cbe6699cf464fdc1b2c0", + "model-00001-of-00003.safetensors=sha256:04b57854545c23779b562ee2ae22f10021ba0f4d586ba0ad482ee6eda187d562", + "model-00002-of-00003.safetensors=sha256:34954aaa05bc91635776ba6672946da5822626753d80db97b38c0538e9525102", + "model-00003-of-00003.safetensors=sha256:a6b3a55b9e3b2e1778de34c665c3dd17bdfdf6da9d6d5c97730c57168709ccae", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] +official_repo = "facebook/esm2_t36_3B_UR50D" +official_revision = "476b639933c8baad5ad09a60ac1a87f987b656fc" +official_files = [ + "config.json=git-sha1:69e7563923f87d2d7439bfb83e5a19b44b46d71b", + "pytorch_model-00001-of-00002.bin=sha256:0f971f11c449d21422aa982b791619c10351972992c735f4c3cd43fe09790412", + "pytorch_model-00002-of-00002.bin=sha256:7560b46fc383c691fb74b915b7d4bcef40d3df181447f16ba4b298845e308d0c", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] + +[[models.oracle_assets]] +role = "weights" +path = "models/esm2_t36_3B_UR50D.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t36_3B_UR50D.pt" +sha256 = "7de8b4082ba15891959ab368b77ce3886697af1efb16d3c9e9e7b0c5d3f07500" +size = 5678116398 + +[[models.oracle_assets]] +role = "contact_regression" +path = "regression/esm2_t36_3B_UR50D-contact-regression.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t36_3B_UR50D-contact-regression.pt" +sha256 = "4da500eab246481dc9c8c95bc7b1d02f2803d761c380b0e95186d4a07d0fc84e" +size = 6759 + +[[models]] +id = "esmc_small" +family = "esm_plusplus" +size_category = "medium" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esmc_small.json=sha256:bb02652cf3cc484756b98ffa4ba55ed4c55870d2cea3342adb1d920ba9dfe10a", tensors = "tests/goldens/esmc_small.safetensors=sha256:03378d0f0fdd8161178ebb2c1f0da1b9776a726c8e8d3a10c009808a24de5654" } +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." +fast_repo = "Synthyra/ESMplusplus_small" +fast_revision = "46c5f7d562e47d4c14165b424c71ab7db008e6fb" +fast_files = [ + "config.json=git-sha1:df2f44187157b0cc371c48c887b77b1783679201", + "model.safetensors=sha256:d099223765bc4f1ae8d6c7e18561ce41df1d54073fdc5327ef0a229235a8f52a", + "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b", + "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71", + "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756", +] +official_repo = "biohub/ESMC-300M" +official_revision = "a59b831785f907e96e6a246b1d142bfb76df31ee" +official_files = [ + "config.json=git-sha1:9a49eacf4e65c39f74381f0f0d240e3b89ef43d7", + "model.safetensors=sha256:0772d8fe64bb25e14fe6f23b80e3c9a7d215d0da3c6cba5bd356d7c0e0bb22cc", + "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b", + "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c", + "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61", +] + +[[models]] +id = "esmc_large" +family = "esm_plusplus" +size_category = "large" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esmc_large.json=sha256:7a4d614f67b6fde417f3fd89f61e7ec442ae284769734b2b73e14945a816a8fd", tensors = "tests/goldens/esmc_large.safetensors=sha256:e13302df4cf7e8381552f1043a8fd0f31f3e0d50b2ab6009fb86b7940ae8ff79" } +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." +fast_repo = "Synthyra/ESMplusplus_large" +fast_revision = "f813401638b3fddab09748aec1ad2bf537aa4208" +fast_files = [ + "config.json=git-sha1:5736371902fe5d04e2859be30ac7dbd31b271b25", + "model.safetensors=sha256:4aff3f8c5de68c4d3e3824eb2c478e4a47355d3f849f3c745e5c8a5ee6cff851", + "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b", + "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71", + "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756", +] +official_repo = "biohub/ESMC-600M" +official_revision = "a7e82012c83126b9eedb055fea9fa84b6c02f094" +official_files = [ + "config.json=git-sha1:71c8241dc28a5fb636248267a0927c0242b264c1", + "model.safetensors=sha256:e4232c30fd35fe2f57051ec88a703996ac94520580b4b836894207a3d45d9ff8", + "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b", + "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c", + "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61", +] + +[[models]] +id = "esmc_6b" +family = "esm_plusplus" +size_category = "xlarge" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esmc_6b.json=sha256:e229d938719782f280fab22dfc4c43e86109fdb0cc523631168c5a491afaace3", tensors = "tests/goldens/esmc_6b.safetensors=sha256:a948945e985c7deaca7be8b7eed09c0a9521a2af3f2b10fc2ec7a7d2a0f99ada" } +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." +fast_repo = "Synthyra/ESMplusplus_6B" +fast_revision = "0d579cce3b0f09efa6b3baddf6cc3fd8c9b616c8" +fast_files = [ + "config.json=git-sha1:e740cbcf211f2511c70c25a1ff6017a757ba7a69", + "model-00001-of-00006.safetensors=sha256:d30d18703453019f2d2d050866309888720c28eebc9a10307d1ddf3799e85a65", + "model-00002-of-00006.safetensors=sha256:b3d85378ab5023f4160a96e9c8cbd4cc6f78a771a83c856e88d48112f555bc13", + "model-00003-of-00006.safetensors=sha256:52595519b59349c5c6e373e6f5ca4a3d48ea6dde345f7e61e24766df5fab0e5b", + "model-00004-of-00006.safetensors=sha256:e46c6113c89c6f3e9b072c1bef02d763a625c37bcd8f9da2ed9363891c9a0758", + "model-00005-of-00006.safetensors=sha256:6d92cb2bf9791de644de2ae86f8523d802ac3b4aaabfff0716ab6c2b97f6fb14", + "model-00006-of-00006.safetensors=sha256:5fc1a8632490bb34162823c35d0d591337b9e4195b22cc0560741397a6e9d0b3", + "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b", + "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71", + "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756", +] +official_repo = "biohub/ESMC-6B" +official_revision = "45b0fa5d7fb06faefbd5e3b89bdcef35d564e79a" +official_files = [ + "config.json=git-sha1:19f5fb09e4f630fb5b748a497183c22a87ec5102", + "model-00001-of-00006.safetensors=sha256:bd90149ff223e6ac1a0cac6147a5ae0df20d3a21df4f65356a1f19cd14f4aa8a", + "model-00002-of-00006.safetensors=sha256:f75e2144d8269fe2eb4b3e0823fb089b94f176d8024153e85b8fb573a42294fa", + "model-00003-of-00006.safetensors=sha256:f699f01ecc9691d9c6470492765fe54b8b5d2e9f277c139e89427433ffdfe0b2", + "model-00004-of-00006.safetensors=sha256:46add1b7be098bbfdc3073884851ba3057f1b33ea23a158b650a37007dabd13d", + "model-00005-of-00006.safetensors=sha256:1e1cb62f060a34e18f54a31a76683ef888b8cec59e73315f5b31d25d45a1f88c", + "model-00006-of-00006.safetensors=sha256:56c73e13ae96e777ce65eee99364056069ef93b646470f352f83c5f1037b1b18", + "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b", + "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c", + "tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61", +] + +[[models]] +id = "esm3_small" +family = "esm3" +tokenizer_source = "esmc_small" +size_category = "large" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esm3_small.json=sha256:5470e8596cbba0e2882647eccbc53c36d8b48b0f3947d1fe0bcea68da1078c32", tensors = "tests/goldens/esm3_small.safetensors=sha256:d957922f810c9ab4c557d80d5aaaf6a3aab79a5a45e4638012a634a4134803b1" } +fast_repo = "Synthyra/ESM3_small" +fast_revision = "7ddb5a740f9e5f93933eb6410c0ee8684bc63ec1" +fast_files = [ + "config.json=git-sha1:60526e2fdd8af9d4fba17f323775458ef5a1a1f9", + "model-00001-of-00002.safetensors=sha256:a4c9b736c4c59d51180e966005a164859b47d5cd36e1f8ecdea619fbd34a0e92", + "model-00002-of-00002.safetensors=sha256:bea60e4e91b03bb00b6cedd29b07606b8543f0869fb74454af7b26e216d80d2b", + "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b", + "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71", + "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756", +] +official_repo = "biohub/esm3-sm-open-v1" +official_revision = "47f0545b2b6daf26a93439a3cd610f4f7f3d5478" +official_files = [ + "config.json=git-sha1:0967ef424bce6791893e9a57bb952f80fd536e93", + "data/weights/esm3_function_decoder_v0.pth=sha256:f76d074efcaccfe21365a4fa96f212dadd66798e1e49d809ab7ffbe025d227c9", + "data/weights/esm3_sm_open_v1.pth=sha256:5ead5a135c658068db6a4f1b933e72d6110992c4668822e1c0e2dcc53e38acd9", + "data/weights/esm3_structure_decoder_v0.pth=sha256:3b726258a44274792b40ce7ea307e10c5da09936368a4ffa2970264d909da65b", + "data/weights/esm3_structure_encoder_v0.pth=sha256:467acbaee703ba3ccde6e75241a912a316952e5ff071355f85c1d33c68704f40", +] + +[[models]] +id = "e1_150m" +family = "e1" +size_category = "small" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/e1_150m.json=sha256:701a64a6ab1a2fec5a427555b6af96232526c15cb3d5b4dc7fb253ac8f20b922", tensors = "tests/goldens/e1_150m.safetensors=sha256:6558bc8f1a7b20629eaaaa6f72601d0c2cdb859a5dc13595549b1773b6e2de41" } +fast_repo = "Synthyra/Profluent-E1-150M" +fast_revision = "7c5f3bbf697226a2e0900db7a100f9201774a907" +fast_files = [ + "config.json=git-sha1:562ef21e722ca708064fc3d54d25b731d4ac8171", + "model.safetensors=sha256:d779ed3a4e23799aafc932dc09c9963428d10aa7075999b5f8851b39c76b67f6", +] +official_repo = "Profluent-Bio/E1-150m" +official_revision = "c4dbfe827e4aa6ed7f95eaef50dc1e084f4d77dc" +official_files = [ + "config.json=git-sha1:485e649199b46fe6ee7456bebf7aae9b3d4baeab", + "model.safetensors=sha256:ba2656339005e6598642836acfdafde480fecc7e145ce0058eb54adf572c3484", +] + +[[models]] +id = "e1_300m" +family = "e1" +size_category = "medium" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/e1_300m.json=sha256:d3478f3f5957a0e0377864074dde0107de890019f96cb63548ee17ffb8f3ec3a", tensors = "tests/goldens/e1_300m.safetensors=sha256:92778b9ef95a803ddc84b3e3ca764c59e045872a94bcff0eb0cd47647732c188" } +fast_repo = "Synthyra/Profluent-E1-300M" +fast_revision = "5ef52c0ad2ae2578f40622696b763523810e8e26" +fast_files = [ + "config.json=git-sha1:f5c91498b76a3e3282a0d716d87738abb1a1b6c1", + "model.safetensors=sha256:9271c4176a8a2e0905a0bb769570ba1c2978fb999a87da92db4cf2b041224864", +] +official_repo = "Profluent-Bio/E1-300m" +official_revision = "5a2871c587eadbcc9237bc686ea45e5b4d28dfb3" +official_files = [ + "config.json=git-sha1:918cb09e6e96d4719ed85951f38c693360f9cdb8", + "model.safetensors=sha256:31e09a2542f45b04e6ce4adafb3b657f21e2d56d12bf68fd2266b1576a80bc9b", +] + +[[models]] +id = "e1_600m" +family = "e1" +size_category = "large" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/e1_600m.json=sha256:914be191c28141c1f84535cdb69ead0588a2057bb19d46c5bc7f3891a3d6739e", tensors = "tests/goldens/e1_600m.safetensors=sha256:22ed8417a4651ded255099f6d15c63c2c40552e700d2b0470d1adfde3a39c513" } +fast_repo = "Synthyra/Profluent-E1-600M" +fast_revision = "6c8bf0ec83b0e0178677c528b101efffd0677742" +fast_files = [ + "config.json=git-sha1:1d35c0b35b473259875fd29ee80167487a0d6afe", + "model.safetensors=sha256:793483b1b3411eab73fe5214b94d1424ca0545992dfac6889cfc0186af472363", +] +official_repo = "Profluent-Bio/E1-600m" +official_revision = "52d959fb87a609d15cf223a485127b29ed5c382a" +official_files = [ + "config.json=git-sha1:8a0a439ed4201462bc01189c9f8b43523b257b5c", + "model.safetensors=sha256:cfc108d4b98baaa62932331b40be265eae39dc382595bc3cde4a5ab55db1bf7a", +] + +[[models]] +id = "dplm_150m" +family = "dplm" +size_category = "small" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/dplm_150m.json=sha256:3228551fe3bed951db9ec97347143ec4462ce7c221ac240b7ce7730948c1dc1f", tensors = "tests/goldens/dplm_150m.safetensors=sha256:392992235195beed97ab8359b90a2e11e52f4326606f99a471447bed81d146bd" } +fast_repo = "Synthyra/DPLM-150M" +fast_revision = "90ba742754151a774f3b7ed580170d0a76b3e69d" +fast_files = [ + "config.json=git-sha1:117ac2c1222152ef378abaad1f605e18c4a18ab0", + "model.safetensors=sha256:8bac5ac767ceb8deb511b272d32883f811768d56cb25e920cea94ba9b979ca14", + "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15", + "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] +official_repo = "airkingbd/dplm_150m" +official_revision = "49b7125a5d28c6418fcc2f3c4fe799352ac1488b" +official_files = [ + "config.json=git-sha1:4910cb02f1840e9ac577026f601829604af58c74", + "pytorch_model.bin=sha256:ea4eaa99536b60ed76f945f71a1a5e604f08447ec3def5104a93ca6001a59961", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] + +[[models]] +id = "dplm_650m" +family = "dplm" +size_category = "large" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/dplm_650m.json=sha256:bf58d0ce73aaac7e6fb1923ef3d9adad67122df2a3dd414c3229488ef9587a6d", tensors = "tests/goldens/dplm_650m.safetensors=sha256:073f0a6abea7e48f28c2d921ff8329a28e22627f01979277cb324908a01b3378" } +fast_repo = "Synthyra/DPLM-650M" +fast_revision = "05dc16d97c5c028aed924c9ed681cee4ab609760" +fast_files = [ + "config.json=git-sha1:3537150eb87b213a676d5840548625e220b60e8b", + "model.safetensors=sha256:e27a47b8ec1c078b3fccb36542210e20f0380c88828db2ca9acf3d8a25048bd8", + "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15", + "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] +official_repo = "airkingbd/dplm_650m" +official_revision = "7a7e651baa667d094aba05e9dc1cf52a3332110a" +official_files = [ + "config.json=git-sha1:625574d625a4178ca6966e9545fee56026c0b634", + "pytorch_model.bin=sha256:db4e54343a89e7600f41c3aacbc593db1b0caee82ec28cab25ff2ae090eba39c", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] + +[[models]] +id = "dplm_3b" +family = "dplm" +size_category = "xlarge" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/dplm_3b.json=sha256:a5b6df8b9c7b371976892ec1d6c45581a32ad3a6325c6c0a0b3267012848c8ed", tensors = "tests/goldens/dplm_3b.safetensors=sha256:75b0a0854fc391133920b0feaaeb8f69ab7568a88b3759627aca1556c4338c1e" } +fast_repo = "Synthyra/DPLM-3B" +fast_revision = "7d764dd3d70ecf1ac0e64693de64a0064aacac65" +fast_files = [ + "config.json=git-sha1:7f5baf9426be06760c86882948b0f4af2e681e22", + "model-00001-of-00003.safetensors=sha256:37b54855d087ef3e7d883464ae9d5ea3127ec15a16c6323d91ad16a6b98305c9", + "model-00002-of-00003.safetensors=sha256:042604fefb05ea8c360a48416ce7ba662a4f90b176b4baf646c5c1814c35e6e8", + "model-00003-of-00003.safetensors=sha256:b9ae04012665163c3fc9781dd04fcd69738ac20c07e615e98fc4483fd2c4de45", + "special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15", + "tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] +official_repo = "airkingbd/dplm_3b" +official_revision = "53849d4a7fe944ae0b9cf2bbc0d2cc0054795b51" +official_files = [ + "config.json=git-sha1:f6206456e8c2f22ebe1d37fce3b5d50fd8073e68", + "pytorch_model-00001-of-00004.bin=sha256:0bcb86a115fe744ed686756db143f78851304e855e2f83cec58681c6080ced5f", + "pytorch_model-00002-of-00004.bin=sha256:daf3324f3be949e7dd1c3c84b28da7fec5151b1890cb0904e73427266856a06f", + "pytorch_model-00003-of-00004.bin=sha256:dbbeb7924a21059854f994931e23590b054aa000b10370a71c052c4aa36e9246", + "pytorch_model-00004-of-00004.bin=sha256:21c01740d091487db43446489d8a893dea1fcc6f2e1c1991ece13945f7ab4e07", + "special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1", + "tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e", + "vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2", +] + +[[models]] +id = "dplm2_150m" +family = "dplm2" +size_category = "small" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/dplm2_150m.json=sha256:d269de779ea1503de72c77e7b2e6224afc9797bd945b40c571ff6faec782e4aa", tensors = "tests/goldens/dplm2_150m.safetensors=sha256:17fc26600938ba5364b8ecb96750786d33e9f92bcd4ea4df3e12a389340748eb" } +artifact_source = "official" +canonical_state_sha256 = "82e1751f59052b8de72b082517557db47947e8d9b4ac2f11278369e6c0cbf001" +fast_repo = "Synthyra/DPLM2-150M" +fast_revision = "182745b8dc5661f898481a4fa60a7af9d53385c4" +fast_files = [ + "config.json=git-sha1:07905a2e4327d27d073cd0390f140aec2976125a", + "model.safetensors=sha256:0a7751b3113027b1d9c966a5bda2d6ab831855de7aaa047b911731665a7c3cc6", + "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b", + "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259", + "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2", +] +official_repo = "airkingbd/dplm2_150m" +official_revision = "3451d984d06497f835ed49634bd68c9dfb54d730" +official_files = [ + "config.json=git-sha1:20f1e55c64fdc4d1d30f7b1df64b6167fa23dc7c", + "pytorch_model.bin=sha256:be7f5cf9e421f59fcc437e63ce1c7391099a314a4e9a4f10b8688785fa581238", + "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a", + "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757", + "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37", +] + +[[models]] +id = "dplm2_650m" +family = "dplm2" +size_category = "large" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/dplm2_650m.json=sha256:d9a7548f9af657a72d441ca70f27379863724fcce8ddd3da4f672104b7bfb772", tensors = "tests/goldens/dplm2_650m.safetensors=sha256:c4e0e467c252c3ac813363d2d4b17a5e3bd99e75fad315e76d97689b4655ddac" } +artifact_source = "official" +canonical_state_sha256 = "cba76b6602d2258de9fffff953b608d93cb8ef4a9e89b0bbd27e160c81e78bb4" +fast_repo = "Synthyra/DPLM2-650M" +fast_revision = "b9d8527a9473a54954fa2764f590b9ea1b435bb2" +fast_files = [ + "config.json=git-sha1:3e079579b214d48a09db57f2c60be6a1acea5baf", + "model.safetensors=sha256:92db08c7dbfd6c5e03fbfeaea3f36b09640ee794dcf5ea8d550527869a9f1d63", + "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b", + "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259", + "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2", +] +official_repo = "airkingbd/dplm2_650m" +official_revision = "0bc69b644976c6680ab7e26669854d1979e8876e" +official_files = [ + "config.json=git-sha1:4cce8d9dc212cdace0e20e89169790bcf199c158", + "pytorch_model.bin=sha256:8d6e08cc05e4858064a714013c74cc88c9caa2cc8b12c34605a3c24bcd877cfb", + "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a", + "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757", + "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37", +] + +[[models]] +id = "dplm2_3b" +family = "dplm2" +size_category = "xlarge" +# The pinned public sampler fails before generation because cls_token_id is None. +# State, tokenizer, and inference parity remain required for this checkpoint. +generation_contract = "official_unavailable" +official_golden = { metadata = "tests/goldens/dplm2_3b.json=sha256:d6e0e02af53b13cb129192f06e264758aa21c9ebf4ee82411cf67037082d2329", tensors = "tests/goldens/dplm2_3b.safetensors=sha256:838b11824d08f83bcb0c0b3268e579f3a87dbfb965370cfe5c3f8793b96b1964" } +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." +artifact_source = "official" +canonical_state_sha256 = "8c46ec09115dbe6cbfb91d94ab5e906369d57e27fe620a7741c6f8cb1b6ca890" +fast_repo = "Synthyra/DPLM2-3B" +fast_revision = "2a63babe8848abf5233d31bd55891dff8285fc50" +fast_files = [ + "config.json=git-sha1:5932b1d501fed28b84614e0d2c1ecc4e89f10d6e", + "model-00001-of-00003.safetensors=sha256:2ff393f6e8df1568ce075d50de69ff4e5e9d9886e5ec47e43d6c24df23459be3", + "model-00002-of-00003.safetensors=sha256:feb3cea852c2aa849cc30783a984a97f0d076990ade6606cda5e38bf2a5a9621", + "model-00003-of-00003.safetensors=sha256:9be363ddb98436af20901981ffbed2f1097377424987f6c1baad27d512b62e71", + "special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b", + "tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259", + "vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2", +] +official_repo = "airkingbd/dplm2_3b" +official_revision = "9e77567926f98d1b997ea9131a8eeb035b9bf827" +official_files = [ + "config.json=git-sha1:22d51ce44cd6da8d819e0d00566987bb51d74753", + "pytorch_model-00001-of-00004.bin=sha256:d8c641eae6bf891581ec64d543169891b093e296f5679ac75c695bcf596b4211", + "pytorch_model-00002-of-00004.bin=sha256:6478ad86ec5fef3d1d26580493af2d8666009d3ff884f3f88548080c8bbf94b5", + "pytorch_model-00003-of-00004.bin=sha256:dde8f88dac4a6355488c2fb433ee12cd69f1169950566624fba43684d4d99dc6", + "pytorch_model-00004-of-00004.bin=sha256:17ec0145152bc10e4dd3b4c2edff337979f6b99ee7c7bfd6cf4e6dbd7262d079", + "special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a", + "tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757", + "vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37", +] + +[[models]] +id = "ankh_base" +family = "ankh" +size_category = "medium" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/ankh_base.json=sha256:ebce8d7de821827ee995789c9b38d79252d3b2f76888130b0a8a7eedafaefe2b", tensors = "tests/goldens/ankh_base.safetensors=sha256:f0e78aa15d11749e0c64ff57f9e88c51cec6538a0adf8951f839df70cc708b65" } +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." +artifact_source = "official" +canonical_state_sha256 = "cdd8d30d88e5bf41f44e1eef4470d8e46607aba5f7c7c805b06c035b89c8c16f" +fast_repo = "Synthyra/ANKH_base" +fast_revision = "7ec329aae8e3e174bf22a1eb9e0e9fcc12b53092" +fast_files = [ + "config.json=git-sha1:7e1cbce6d08f9bb64eee4410899b1c6b4054f418", + "model.safetensors=sha256:b0d3473cac1bda90e39cde54f2abe86da1fc84f872c833ca3415672776dccb95", + "special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1", + "tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be", + "tokenizer_config.json=git-sha1:db0b80de72d3b16242b9eda74ed4663e39c65bcf", +] +official_repo = "ElnaggarLab/ankh-base" +official_revision = "d99cb6b966530dfc2ae96bc69d9255c2a07308b0" +official_files = [ + "config.json=git-sha1:abd44a36b5469e9a7cb019e4059b5ac1392d8422", + "pytorch_model.bin=sha256:9b2a886374f0ff4a893f4e7a989deed76bb2458c8998bd5202ea8e97d92ddcc3", + "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791", + "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a", + "tokenizer_config.json=git-sha1:a8a872ae3441e7cc85ce19210dff1e4c5d2d7bd0", +] + +[[models]] +id = "ankh_large" +family = "ankh" +size_category = "large" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/ankh_large.json=sha256:59492518b021de5cfaea87d672c9448c8558e99a3443ba2cc7ab544963196ecb", tensors = "tests/goldens/ankh_large.safetensors=sha256:3fb8d3ac27716d15a9ea92aeef6acf2b977bcc887d9b535000539e523673459b" } +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." +artifact_source = "official" +canonical_state_sha256 = "e498a2e9aea76ef784cbe3e596c6b3f5e9a40e209ad837f7e3207099e4d74483" +fast_repo = "Synthyra/ANKH_large" +fast_revision = "3be3df34140f49dc4e65bd1f247e3ce819e7fc59" +fast_files = [ + "config.json=git-sha1:272509deedb527e5c2c95b0c269194a44148fdcc", + "model.safetensors=sha256:e70b8f9755ac6bfe95d18359060ae9fe38fac63b12a89a886c83349d1adbaa53", + "special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1", + "tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be", + "tokenizer_config.json=git-sha1:2bcaff2567826f5f51188b00600d2c6e7bcea56e", +] +official_repo = "ElnaggarLab/ankh-large" +official_revision = "74b371dbfa3ee0a05d32ae74df0c2e0b82d6b9a6" +official_files = [ + "config.json=git-sha1:1abf33e52ee3d6be67d780ec57d32ac2b27b5306", + "pytorch_model.bin=sha256:517b6e8b279dedcb477af240b35c46bd6eb3307723eb281e60d4b2c8a87b889b", + "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791", + "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a", + "tokenizer_config.json=git-sha1:d7fe02ba6f2b18d9ccfa19ac129c9fdc9ec24d09", +] + +[[models]] +id = "ankh2_large" +family = "ankh" +size_category = "large" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/ankh2_large.json=sha256:e8df38994ca1a1e0c598ace34a0b257b264937e4fdbb01bc41544985116b02a4", tensors = "tests/goldens/ankh2_large.safetensors=sha256:25fe1569f55c635fab8fa49c1d62a889a35a2a738bad921f5764a85b58fd4b5d" } +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." +artifact_source = "official" +canonical_state_sha256 = "597c4fe2fa8711f11a25317905f1d62fa92905e55fdd5c0a79614cd9c9d2bca3" +fast_repo = "Synthyra/ANKH2_large" +fast_revision = "392de5ed52bbfd73b45f545e378aaebcff096d0e" +fast_files = [ + "config.json=git-sha1:66b6adc7215743a98a3229958bbd1c9c42b6108b", + "model.safetensors=sha256:be8e6242388d93b51cd9719a0e32cfc17a2e804786570c795ba332197eccb915", + "special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1", + "tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be", + "tokenizer_config.json=git-sha1:db0b80de72d3b16242b9eda74ed4663e39c65bcf", +] +official_repo = "ElnaggarLab/ankh2-ext2" +official_revision = "aa9b9fa72288c47d9f618ce80c011e24b54e17a8" +official_files = [ + "config.json=git-sha1:9286bed4ecbc4f7113024919d16ec9719b0c0748", + "generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a", + "pytorch_model.bin=sha256:2df583f28f111276ee22a7b76007f4297e9a69766d60bccd9c8d7169c06ac606", + "special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791", + "tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a", + "tokenizer_config.json=git-sha1:854e5db75dae8b1e9dd39c5bae80dae5508b3e25", +] + +[[models]] +id = "ankh3_large" +family = "ankh" +size_category = "large" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/ankh3_large.json=sha256:2e5bb05b3baa5baa78f61fef7d2a2c669b0da5dbfaf6b50b12abd3e17253a961", tensors = "tests/goldens/ankh3_large.safetensors=sha256:e5c494ac418e0a2fe7bdad1376676d48960d58ec9e044d19bfffccb8c3288513" } +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." +artifact_source = "official" +canonical_state_sha256 = "60acb7ef86e85dc0c51fc1edf4c8e69a0480049723b6b2c95e6e9faa720c112a" +fast_repo = "Synthyra/ANKH3_large" +fast_revision = "53600f175f328f986f43e55ca8ceb14935d337a4" +fast_files = [ + "config.json=git-sha1:432b09625d44a2eeab679fddb7495d42b560b7f9", + "model.safetensors=sha256:9f50f58cf5b3a537a0a41aa918695c3a26d7985dd0b2266642d6f86324c9e7a1", + "special_tokens_map.json=git-sha1:1fc3a4d6d4282e5201cd7c30d5c0a6a8bfa04f82", + "tokenizer.json=git-sha1:3d14291df2d6db3a183c5c4fe133afb330cc44cf", + "tokenizer_config.json=git-sha1:2005fec00a7ae9a49e248a1ecefbbd81c56674d6", +] +official_repo = "ElnaggarLab/ankh3-large" +official_revision = "2be091622e8a393f0ef21735070084123c874b6e" +official_files = [ + "config.json=git-sha1:f5278f77d158cdd8a173df888e3ed365e84a80a3", + "generation_config.json=git-sha1:5767cc0cacebfd06884eb27ae1c796d3ca829fd2", + "pytorch_model.bin=sha256:26321a345e07a25b21c6c41b651c4db91b420892e52c0dcbc55bd7a8f510f95b", + "special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4", + "spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0", + "tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca", + "tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21", +] + +[[models]] +id = "ankh3_xl" +family = "ankh" +size_category = "xlarge" +generation_contract = "required" +official_golden = { metadata = "tests/goldens/ankh3_xl.json=sha256:66bb12e033e4163be225d636108a479393228a4f5061015c8af114e766c3c486", tensors = "tests/goldens/ankh3_xl.safetensors=sha256:72d34567d0228cb6f1ee701c578ed4039fead4346e3f161a52e0e74df28dc8ae" } +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." +artifact_source = "official" +canonical_state_sha256 = "dd2188e0d2ca65232135714eef6de394239734d843ddae4928c7398685d858e7" +fast_repo = "Synthyra/ANKH3_xl" +fast_revision = "3cbf2c22c4f7d67bf0bfcbdcd500f41723e91d29" +fast_files = [ + "config.json=git-sha1:23f6d78ddcb3a031b88f876eaaf04c2fafaea46f", + "model-00001-of-00003.safetensors=sha256:39bd8f75cf98a67cf04055399f9fc401198f6fc2896b112aba9fd9ec9df52ab9", + "model-00002-of-00003.safetensors=sha256:9ff73233b39d2c200abb78e66b320c014ec61431bd6e1af36fb188a3cfa24c34", + "model-00003-of-00003.safetensors=sha256:c13125c02dbcd7f07bd412e9e085f2bca6624d2f1f45fedc95fb777f53161cbe", + "special_tokens_map.json=git-sha1:1fc3a4d6d4282e5201cd7c30d5c0a6a8bfa04f82", + "tokenizer.json=git-sha1:3d14291df2d6db3a183c5c4fe133afb330cc44cf", + "tokenizer_config.json=git-sha1:2005fec00a7ae9a49e248a1ecefbbd81c56674d6", +] +official_repo = "ElnaggarLab/ankh3-xl" +official_revision = "e00113df5c95ef71df7ea3f5a73d56bd00e473a4" +official_files = [ + "config.json=git-sha1:f8997040e8913df75fd2eebe71a2a8eb750ed0d0", + "generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a", + "pytorch_model-00001-of-00003.bin=sha256:2c9793cbee16697cd4149debe07d3a27143e280f6e970fa46042aae820fea981", + "pytorch_model-00002-of-00003.bin=sha256:31c5a860e414513c829ae52affb0970d7cef2c0545df2d6e1338b6806ab7174b", + "pytorch_model-00003-of-00003.bin=sha256:055a853bdd3623db95a637935aa299427e837cd8ea69fc04708b0262508bec75", + "special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4", + "spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0", + "tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca", + "tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21", +] + +[[models]] +id = "boltz2" +family = "boltz2" +size_category = "structure" +generation_contract = "not_applicable" +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." +fast_repo = "Synthyra/Boltz2" +fast_revision = "3b148fc5efea109c065ec82ba8683d024de7134e" +fast_files = [ + "config.json=git-sha1:8682ccb12e177e73bc7a351ff7e3af484bfb6fac", + "model.safetensors=sha256:5c863fd200a1613a0e311071e2ad73ab350635e3fd336e6822cf45c52cb960e5", +] +official_repo = "boltz-community/boltz-2" +official_revision = "6fdef46d763fee7fbb83ca5501ccceff43b85607" +official_files = [ + "boltz2_conf.ckpt=sha256:090e82ac8c92f5e943fa1b39e7410a44027bea7243c0bbb3caa67a77fc1428e1", + "mols.tar=sha256:39e076d96dbec6b4e86982bbda16f3a53a2a60c9bdc17828d88f6f9a0c7d1fd7", +] + +[[models]] +id = "esmfold" +family = "esmfold" +size_category = "structure" +generation_contract = "not_applicable" +official_golden = { metadata = "tests/goldens/esmfold.json=sha256:380b9a96168410717d1f698feaabb826b1606444cbdeec86c2ea06d9ffe8f186", tensors = "tests/goldens/esmfold.safetensors=sha256:873b1b325a43d8e0f35f355c8914a2a9fe611cc48763875e9e6a22e09ec9ebcb" } +fast_repo = "Synthyra/FastESMFold" +fast_revision = "b88c8cb50d19b2cf7ab4fee4b0a61f5e02da7823" +fast_files = [ + "config.json=git-sha1:18e0091dcbf6140bf68924d53c4c8917b9cd90b1", + "model-00001-of-00003.safetensors=sha256:36fab9e5c96d409b2a34a8b4f1273acac8c07f119c32c4fcfa7d47bbcd55b83c", + "model-00002-of-00003.safetensors=sha256:34954aaa05bc91635776ba6672946da5822626753d80db97b38c0538e9525102", + "model-00003-of-00003.safetensors=sha256:2f1178cda0e6cff3b1e158e1acc59c83e3f4fc46e246388a5127bc56b8d9c4f2", + "special_tokens_map.json=git-sha1:53cd95604a28eb7e23da763c8da23f5006ab2179", + "tokenizer_config.json=git-sha1:10213f69b51b4b38876a29271b8f908e853a5800", + "vocab.txt=git-sha1:eee0a1fc93c82568f78f086550fbd7c591cf423a", +] +official_repo = "facebook/esmfold_v1" +official_revision = "75a3841ee059df2bf4d56688166c8fb459ddd97a" +official_files = [ + "config.json=git-sha1:1232d0aee4be551021d8e70e66ed2b062df917bf", + "pytorch_model.bin=sha256:2ee07356b125d1e3e57503c204111fd7323347fc4735d41d3caac57c2a78e116", + "special_tokens_map.json=git-sha1:121c8d54f8ea66cdf678f48b3cb37c05b4de5c0d", + "tokenizer_config.json=git-sha1:aad24fba9f1bad2d74ed79d414ddcd60e6b0f812", + "vocab.txt=git-sha1:9abfdf5472c0ed970648b683b86ab131256b3e42", +] + +[[models.oracle_assets]] +role = "weights" +path = "models/esmfold_3B_v1.pt" +url = "https://dl.fbaipublicfiles.com/fair-esm/models/esmfold_3B_v1.pt" +sha256 = "e9a52579027e77d2d2e0a18218e755821f395730e86624cab9413dc117f5ca62" +size = 2771653574 + +[[models]] +id = "esmfold2" +family = "esmfold2" +size_category = "structure" +generation_contract = "not_applicable" +msa_conditioning = true +official_golden = { metadata = "tests/goldens/esmfold2.json=sha256:f6e0ed1ec400b9a0fcc817db51774be968dc454b7a32645a07c479e42423ab20", tensors = "tests/goldens/esmfold2.safetensors=sha256:e4d6be4344c528e26b13f79a9303549e3de7e582da195c0078db3ce957fad420" } +fast_repo = "Synthyra/ESMFold2" +fast_revision = "cd5a0927cec585a778d983b99a8db23d2e9b281e" +fast_files = [ + "config.json=git-sha1:67e81ff571f393f0b630cd5a22398bd84979c030", + "model.safetensors=sha256:138fd4350d6892b81ce6be7ff9bf5a93ae9d4d3751f46a27438a3f9f0dcefa0e", +] +official_repo = "biohub/ESMFold2" +official_revision = "1ebf0e3481a5184eb6171d40615c79e384b48796" +official_files = [ + "config.json=git-sha1:0300c084b990b2bd600efd9f538aa5de27109fea", + "model.safetensors=sha256:138fd4350d6892b81ce6be7ff9bf5a93ae9d4d3751f46a27438a3f9f0dcefa0e", +] + +[[models]] +id = "esmfold2_fast" +family = "esmfold2" +size_category = "structure" +generation_contract = "not_applicable" +msa_conditioning = false +official_golden = { metadata = "tests/goldens/esmfold2_fast.json=sha256:091b004c0b330217b59c12acd6da3d6edaf91e48d95f6d5f40fc20399cef9478", tensors = "tests/goldens/esmfold2_fast.safetensors=sha256:6e2e1cd07401538b4d9df994f82abe7a5b38a01e8d1ee26681e1216d44a81990" } +fast_repo = "Synthyra/ESMFold2-Fast" +fast_revision = "407875bfcaa42552bfcb25acd67ee1888b790170" +fast_files = [ + "config.json=git-sha1:62ccca15a416a5dcbd02cd6ce161f432c7b4de58", + "model.safetensors=sha256:60ca19f2898188beba92944365f7b909efd9c99212f5018af75cc47cd9a6184a", +] +official_repo = "biohub/ESMFold2-Fast" +official_revision = "b28d8ace5e05e61e5bec1e6820cfd3e221819d12" +official_files = [ + "config.json=git-sha1:c0ca526090fa7f8342ee4666d56e7fe3a4b8cbb2", + "model.safetensors=sha256:60ca19f2898188beba92944365f7b909efd9c99212f5018af75cc47cd9a6184a", +] + +[[models]] +id = "esmfold2_experimental_cutoff2025" +family = "esmfold2" +size_category = "structure" +generation_contract = "not_applicable" +msa_conditioning = true +official_golden = { metadata = "tests/goldens/esmfold2_experimental_cutoff2025.json=sha256:cfd0e35b2bc468a0dc4f614d3acfa2fce004f96e9ae2433256ed095b829d55cc", tensors = "tests/goldens/esmfold2_experimental_cutoff2025.safetensors=sha256:9347466bbe803b6f5dc82e3356ca6cbbf2c2edd8765f9fd273385bda255019f6" } +fast_repo = "Synthyra/ESMFold2-Experimental-Cutoff2025" +fast_revision = "632ff4a9e68f1de78ee956a613267bdcdb5b354d" +fast_files = [ + "config.json=git-sha1:41119745d38bc5503a0212ad923e75211dec565f", + "model.safetensors=sha256:01358c317428d38535e3db513cab177336fc0f7fab0d84002e64b7741d5181b3", +] +official_repo = "biohub/ESMFold2-Experimental-Cutoff2025" +official_revision = "56f94f5c1069ecde17512c96928850518340d287" +official_files = [ + "config.json=git-sha1:79ed0dc0f867b8f09bfa004d6f77397c2ab9b38d", + "model.safetensors=sha256:01358c317428d38535e3db513cab177336fc0f7fab0d84002e64b7741d5181b3", +] +auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel" } + +[[models]] +id = "esmfold2_experimental_fast_cutoff2025" +family = "esmfold2" +size_category = "structure" +generation_contract = "not_applicable" +msa_conditioning = false +official_golden = { metadata = "tests/goldens/esmfold2_experimental_fast_cutoff2025.json=sha256:1d0b2da4f1579243f37ae04bd4b834b747005cd8e8e7665e00d088123c43afd9", tensors = "tests/goldens/esmfold2_experimental_fast_cutoff2025.safetensors=sha256:516e216d05d7e6bee59e77126d3e595e2bb7821929433f00c259c5d5241964bb" } +fast_repo = "Synthyra/ESMFold2-Experimental-Fast-Cutoff2025" +fast_revision = "8f022c2514a6c32692aaca078a8391d6bc6c4bac" +fast_files = [ + "config.json=git-sha1:b9d39e941050179ca51faaed58cbbd77778c1143", + "model.safetensors=sha256:4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f", +] +official_repo = "biohub/ESMFold2-Experimental-Fast-Cutoff2025" +official_revision = "74b88548bf19688b8727432db0d698cb2e1d8783" +official_files = [ + "config.json=git-sha1:0333d68ddb12ed2f066741dcb801142f466c0a2c", + "model.safetensors=sha256:4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f", +] +auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel" } diff --git a/fastplms/models/__init__.py b/fastplms/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..42bf91cd9ae249ad38d21588e79a278230936fc0 --- /dev/null +++ b/fastplms/models/__init__.py @@ -0,0 +1,10 @@ +"""Lazy model-family namespace for FastPLMs. + +Model classes are resolved through Transformers AutoClasses and the typed +registry. Importing this package therefore does not load checkpoints, create +tokenizers, compile kernels, or initialize an accelerator runtime. +""" + +from __future__ import annotations + +__all__: tuple[str, ...] = () diff --git a/fastplms/models/esm_plusplus/__init__.py b/fastplms/models/esm_plusplus/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/fastplms/models/esm_plusplus/modeling_esm_plusplus.py b/fastplms/models/esm_plusplus/modeling_esm_plusplus.py new file mode 100644 index 0000000000000000000000000000000000000000..f9e287683e95dae2e2c2bd53dae4a3f2fd7f9376 --- /dev/null +++ b/fastplms/models/esm_plusplus/modeling_esm_plusplus.py @@ -0,0 +1,1552 @@ +"""Hugging Face-compatible ESMC models implemented by FastPLMs.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from functools import partial +from typing import ClassVar + +import torch +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange +from tokenizers import Tokenizer +from tokenizers.models import BPE +from tokenizers.processors import TemplateProcessing +from transformers import PretrainedConfig, PreTrainedModel, PreTrainedTokenizerFast +from transformers.modeling_outputs import ( + MaskedLMOutput, + ModelOutput, + SequenceClassifierOutput, + TokenClassifierOutput, +) + +try: + from fastplms.attention import ( + AttentionBackend, + BlockMask, + FastPLMsAttentionMixin, + _get_flex_attention_fn, + _get_flex_block_mask, + flex_attention, + get_attention_mask, + kernels_flash_attention_func, + resolve_attention_backend, + resolve_attention_backend_for_call, + ) + from fastplms.embeddings import EmbeddingMixin, Pooler, select_hidden_state_embeddings + from fastplms.models.ttt import FastPLMTestTimeTrainingMixin +except ModuleNotFoundError as error: + _COMPOSITE_REQUIRED_NAMES = ( + "AttentionBackend", + "BlockMask", + "EmbeddingMixin", + "FastPLMsAttentionMixin", + "FastPLMTestTimeTrainingMixin", + "Pooler", + "_get_flex_attention_fn", + "_get_flex_block_mask", + "flex_attention", + "get_attention_mask", + "kernels_flash_attention_func", + "resolve_attention_backend", + "resolve_attention_backend_for_call", + "select_hidden_state_embeddings", + ) + if error.name != "fastplms" or any( + name not in globals() for name in _COMPOSITE_REQUIRED_NAMES + ): + raise + # Legacy flat Hub composites define every shared symbol above this block. + + +class ESMplusplusConfig(PretrainedConfig): + """Configuration class for ESM++ model. + + Args: + vocab_size: Size of the vocabulary + hidden_size: Dimension of hidden layers + num_attention_heads: Number of attention heads + num_hidden_layers: Number of transformer layers + num_labels: Number of output labels for classification + problem_type: Type of problem - regression, single/multi label classification + """ + + model_type = "ESMplusplus" + + def __init__( + self, + vocab_size: int = 64, + hidden_size: int = 960, + num_attention_heads: int = 15, + num_hidden_layers: int = 30, + num_labels: int | None = None, + problem_type: str | None = None, + dropout: float = 0.0, + initializer_range: float = 0.02, + classifier_dropout: float = 0.1, + classifier_pooling_types: list[str] | None = None, + attn_backend: str | None = None, + pad_token_id: int = 1, + mask_token_id: int = 32, + **kwargs, + ): + if num_labels is None: + configured_labels = kwargs.get("id2label") + num_labels = len(configured_labels) if configured_labels else 2 + super().__init__( + pad_token_id=pad_token_id, + mask_token_id=mask_token_id, + num_labels=num_labels, + **kwargs, + ) + self.vocab_size = vocab_size + self.hidden_size = hidden_size + self.num_attention_heads = num_attention_heads + self.num_hidden_layers = num_hidden_layers + self.problem_type = problem_type + self.dropout = dropout + self.initializer_range = initializer_range + self.classifier_dropout = classifier_dropout + self.classifier_pooling_types = ( + list(classifier_pooling_types) if classifier_pooling_types is not None else None + ) + self.tie_word_embeddings = False + self.attn_backend = attn_backend + + +### Rotary Embeddings +def rotate_half(x: torch.Tensor, interleaved: bool = False) -> torch.Tensor: + """Rotate the final axis of X by 90 degrees in each two-dimensional plane.""" + if interleaved: + paired = x.unflatten(-1, (-1, 2)) + return torch.stack((-paired[..., 1], paired[..., 0]), dim=-1).flatten(-2) + + # torch.chunk assigns an odd remainder to the first half. Express the same + # public behavior explicitly while keeping the ESMC path branch-free. + midpoint = (x.shape[-1] + 1) // 2 + return torch.cat((-x[..., midpoint:], x[..., :midpoint]), dim=-1) + + +def apply_rotary_emb_torch( + x: torch.Tensor, + cos: torch.Tensor, + sin: torch.Tensor, + interleaved: bool = False, + _inplace: bool = False, +) -> torch.Tensor: + """Apply cached rotary angles to X while preserving any unrotated features.""" + del _inplace # Kept in the signature for checkpoint remote-code compatibility. + rotary_width = 2 * cos.shape[-1] + if rotary_width > x.shape[-1]: + raise AssertionError("rotary width exceeds the attention head dimension") + + token_count = x.shape[1] + cos_full = torch.cat((cos[:token_count], cos[:token_count]), dim=-1).unsqueeze(1) + sin_full = torch.cat((sin[:token_count], sin[:token_count]), dim=-1).unsqueeze(1) + x_rotary = x[..., :rotary_width] + y_rotary = x_rotary * cos_full + rotate_half(x_rotary, interleaved) * sin_full + if rotary_width == x.shape[-1]: + return y_rotary + return torch.cat((y_rotary, x[..., rotary_width:]), dim=-1) + + +class RotaryEmbedding(torch.nn.Module): + """Rotary position embeddings. + + Based on the paper "RoFormer: Enhanced Transformer with Rotary Position Embedding" + + Args: + dim: Dimension of the embedding + base: Base for computing angular frequencies + interleaved: Whether to use interleaved rotations + scale_base: Base for scaling + scaling_factor: Factor for scaling positions + pos_idx_in_fp32: Whether to compute position indices in fp32 + device: Computation device + """ + + def __init__( + self, + dim: int, + base: float = 10000.0, + interleaved: bool = False, + scale_base: float | None = None, + scaling_factor: float = 1.0, + pos_idx_in_fp32: bool = True, + device: torch.device | None = None, + ): + super().__init__() + self.dim, self.base = dim, float(base) + self.interleaved, self.scale_base = interleaved, scale_base + self.scaling_factor, self.pos_idx_in_fp32 = scaling_factor, pos_idx_in_fp32 + self.device = device + self._clear_cache() + self.reset_parameters() + + def _clear_cache(self) -> None: + self._seq_len_cached = 0 + self._cos_cached: torch.Tensor | None = None + self._sin_cached: torch.Tensor | None = None + self._cos_k_cached: torch.Tensor | None = None + self._sin_k_cached: torch.Tensor | None = None + + def reset_parameters(self, device: torch.device | str | None = None): + """Rebuild the non-persistent frequency buffers on ``device``.""" + if device is not None: + buffer_device = torch.device(device) + elif "inv_freq" in self._buffers and isinstance(self._buffers["inv_freq"], torch.Tensor): + buffer_device = self._buffers["inv_freq"].device + else: + buffer_device = self.device + inv_freq = self._compute_inv_freq(buffer_device) + self._clear_cache() + self.register_buffer("inv_freq", inv_freq, persistent=False) + arange = torch.arange(0, self.dim, 2, device=buffer_device, dtype=torch.float32) + scale = ( + (arange + 0.4 * self.dim) / (1.4 * self.dim) if self.scale_base is not None else None + ) + self.register_buffer("scale", scale) + + def _compute_inv_freq(self, device: torch.device | None = None) -> torch.Tensor: + """Compute inverse frequency bands on their execution device.""" + return 1 / ( + self.base + ** (torch.arange(0, self.dim, 2, device=device, dtype=torch.float32) / self.dim) + ) + + def _apply(self, fn, recurse: bool = True): + """Move the module, then regenerate device-specific RoPE frequencies.""" + if self.inv_freq.is_meta: + self.reset_parameters(device="cpu") + result = super()._apply(fn, recurse=recurse) + self.register_buffer( + "inv_freq", + self._compute_inv_freq(self.inv_freq.device), + persistent=False, + ) + self._clear_cache() + return result + + def _cache_is_current( + self, + token_count: int, + device: torch.device | None, + dtype: torch.dtype | None, + ) -> bool: + cached = self._cos_cached + return ( + cached is not None + and self._seq_len_cached >= token_count + and cached.device == device + and cached.dtype == dtype + and not (self.training and cached.is_inference()) + ) + + def _rotary_angles( + self, + token_count: int, + device: torch.device | None, + ) -> torch.Tensor: + position_dtype = torch.float32 if self.pos_idx_in_fp32 else self.inv_freq.dtype + positions = torch.arange(token_count, device=device, dtype=position_dtype) + positions.div_(self.scaling_factor) + frequencies = ( + self.inv_freq.to(torch.float32) + if self.pos_idx_in_fp32 and self.inv_freq.dtype != torch.float32 + else self.inv_freq + ) + return torch.outer(positions, frequencies) + + def _update_cos_sin_cache( + self, seqlen: int, device: torch.device | None = None, dtype: torch.dtype | None = None + ) -> None: + """Build angle tables when the requested cache identity has changed.""" + if self._cache_is_current(seqlen, device, dtype): + return + + self._seq_len_cached = seqlen + angles = self._rotary_angles(seqlen, device) + cos_angles = torch.cos(angles) + sin_angles = torch.sin(angles) + if self.scale is None: + self._cos_cached = cos_angles.to(dtype) + self._sin_cached = sin_angles.to(dtype) + return + + centered_positions = ( + torch.arange(seqlen, dtype=self.scale.dtype, device=self.scale.device) - seqlen // 2 + ) / self.scale_base + scale = self.scale ** centered_positions.unsqueeze(-1) + self._cos_cached = (cos_angles * scale).to(dtype) + self._sin_cached = (sin_angles * scale).to(dtype) + self._cos_k_cached = (cos_angles / scale).to(dtype) + self._sin_k_cached = (sin_angles / scale).to(dtype) + + def forward(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Apply rotary embeddings to queries and keys. + + Args: + q: Query tensor Q with shape (b, l, h, d). + k: Key tensor K with shape (b, l, h, d). + + Returns: + Tuple of rotated query and key tensors + """ + # The pinned Biohub Transformers oracle recomputes inverse frequencies + # on the execution device. CPU and CUDA differ by about one FP32 ULP in + # some bands, which is immaterial in BF16 but accumulates measurably in + # deep FP32 execution. + self._update_cos_sin_cache(q.shape[1], device=q.device, dtype=q.dtype) + if self._cos_cached is None or self._sin_cached is None: + raise RuntimeError( + "Rotary cache initialization did not produce cosine and sine values." + ) + if self.scale is not None: + raise AssertionError("Scaled rotary embeddings are unsupported for ESMC.") + + cos_angles = self._cos_cached + sin_angles = self._sin_cached + return ( + apply_rotary_emb_torch(q, cos_angles, sin_angles, self.interleaved, True), + apply_rotary_emb_torch(k, cos_angles, sin_angles, self.interleaved, True), + ) + + +### Feedforward Network Components +def swiglu_correction_fn(expansion_ratio: float, d_model: int) -> int: + """Compute corrected dimension for SwiGLU.""" + return int(((expansion_ratio * d_model) + 255) // 256 * 256) + + +class SwiGLU(nn.Module): + """SwiGLU activation function.""" + + def __init__(self): + super().__init__() + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x1, x2 = x.chunk(2, dim=-1) + return F.silu(x1) * x2 + + +def swiglu_ln_ffn(d_model: int, expansion_ratio: float) -> nn.Sequential: + """Create SwiGLU feedforward network with layer normalization.""" + return nn.Sequential( + nn.LayerNorm(d_model), + nn.Linear(d_model, swiglu_correction_fn(expansion_ratio, d_model) * 2, bias=False), + SwiGLU(), + nn.Linear(swiglu_correction_fn(expansion_ratio, d_model), d_model, bias=False), + ) + + +### Attention +class MultiHeadAttention(nn.Module): + """Multi-head attention with rotary embeddings and configurable backend. + + Args: + d_model: Model dimension + n_heads: Number of attention heads + attn_backend: One of "eager", "sdpa", or "flex_attention". + """ + + def __init__( + self, + d_model: int, + n_heads: int, + attn_backend: str = "sdpa", + ): + super().__init__() + self.d_model = d_model + self.n_heads = n_heads + self.d_head = self.d_model // self.n_heads + self.scale = 1.0 / math.sqrt(self.d_head) + self.attn_backend = resolve_attention_backend(attn_backend) + self.layernorm_qkv = nn.Sequential( + nn.LayerNorm(d_model), nn.Linear(d_model, d_model * 3, bias=False) + ) + self.out_proj = nn.Linear(d_model, d_model, bias=False) + self.q_ln = nn.LayerNorm(d_model, bias=False) + self.k_ln = nn.LayerNorm(d_model, bias=False) + self.reshaper = partial(rearrange, pattern="b s (h d) -> b h s d", h=n_heads) + self.rotary = RotaryEmbedding(d_model // n_heads) + + def _apply_rotary(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + q = q.unflatten(-1, (self.n_heads, self.d_head)) + k = k.unflatten(-1, (self.n_heads, self.d_head)) + q, k = self.rotary(q, k) + q = q.flatten(-2, -1) + k = k.flatten(-2, -1) + return q, k + + def forward( + self, + x: torch.Tensor, + attention_mask_2d: torch.Tensor | None = None, + attention_mask_4d: torch.Tensor | None = None, + flex_block_mask: BlockMask | None = None, + output_attentions: bool = False, + output_s_max: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]: + qkv = self.layernorm_qkv(x) + query_sequence, key_sequence, value_sequence = torch.chunk(qkv, 3, dim=-1) + query_sequence, key_sequence = ( + self.q_ln(query_sequence).to(query_sequence.dtype), + self.k_ln(key_sequence).to(query_sequence.dtype), + ) + query_sequence, key_sequence = self._apply_rotary(query_sequence, key_sequence) + query_heads, key_heads, value_heads = map( + self.reshaper, (query_sequence, key_sequence, value_sequence) + ) + + attn_output, attn_weights, s_max = self._attn( + query_heads, + key_heads, + value_heads, + attention_mask_2d=attention_mask_2d, + attention_mask_4d=attention_mask_4d, + flex_block_mask=flex_block_mask, + output_attentions=output_attentions, + output_s_max=output_s_max, + ) + + output = self.out_proj(attn_output) + return output, attn_weights, s_max + + def _attn( + self, + query_heads: torch.Tensor, + key_heads: torch.Tensor, + value_heads: torch.Tensor, + attention_mask_2d: torch.Tensor | None = None, + attention_mask_4d: torch.Tensor | None = None, + flex_block_mask: BlockMask | None = None, + output_attentions: bool = False, + output_s_max: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]: + if output_attentions: + return self._manual_attn( + query_heads, key_heads, value_heads, attention_mask_4d, output_s_max + ) + + if self.attn_backend == AttentionBackend.EAGER: + attn_output, _, s_max = self._manual_attn( + query_heads, key_heads, value_heads, attention_mask_4d, output_s_max + ) + return attn_output, None, s_max + if self.attn_backend.is_flash: + attn_output, attn_weights = self._kernels_flash_attn( + query_heads, key_heads, value_heads, attention_mask_2d + ) + elif self.attn_backend == AttentionBackend.FLEX: + attn_output, attn_weights = self._flex_attn( + query_heads, + key_heads, + value_heads, + flex_block_mask, + attention_mask_2d, + ) + elif self.attn_backend == AttentionBackend.SDPA: + attn_output, attn_weights = self._sdpa_attn( + query_heads, key_heads, value_heads, attention_mask_4d + ) + else: + raise AssertionError(f"Unsupported resolved backend: {self.attn_backend}") + + s_max = self._compute_s_max(query_heads, key_heads) if output_s_max else None + return attn_output, attn_weights, s_max + + @torch.no_grad() + def _compute_s_max( + self, query_heads: torch.Tensor, key_heads: torch.Tensor + ) -> list[torch.Tensor]: + q_norm = torch.linalg.vector_norm(query_heads, dim=-1) + k_norm = torch.linalg.vector_norm(key_heads, dim=-1) + s_max_bound = (q_norm.max(dim=-1).values * k_norm.max(dim=-1).values).max( + dim=0 + ).values * self.scale + return [s_max_bound[h] for h in range(self.n_heads)] + + def _manual_attn( + self, + query_heads: torch.Tensor, + key_heads: torch.Tensor, + value_heads: torch.Tensor, + attention_mask_4d: torch.Tensor | None = None, + output_s_max: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor] | None]: + attn_weights = torch.matmul(query_heads, key_heads.transpose(-2, -1)) * self.scale + if attention_mask_4d is not None: + attn_weights = attn_weights.masked_fill(attention_mask_4d.logical_not(), float("-inf")) + attn_weights = F.softmax(attn_weights, dim=-1) + context_heads = torch.matmul(attn_weights, value_heads) + attn_output = rearrange(context_heads, "b h s d -> b s (h d)") + s_max = self._compute_s_max(query_heads, key_heads) if output_s_max else None + return attn_output, attn_weights, s_max + + def _kernels_flash_attn( + self, + query_heads: torch.Tensor, + key_heads: torch.Tensor, + value_heads: torch.Tensor, + attention_mask_2d: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, None]: + query_tokens = query_heads.transpose(1, 2).contiguous() + key_tokens = key_heads.transpose(1, 2).contiguous() + value_tokens = value_heads.transpose(1, 2).contiguous() + attn_output = kernels_flash_attention_func( + query_states=query_tokens, + key_states=key_tokens, + value_states=value_tokens, + attention_mask_2d=attention_mask_2d, + causal=False, + implementation=self.attn_backend.value, + ) + return rearrange(attn_output, "b s h d -> b s (h d)"), None + + def _flex_attn( + self, + query_heads: torch.Tensor, + key_heads: torch.Tensor, + value_heads: torch.Tensor, + flex_block_mask: BlockMask | None = None, + attention_mask_2d: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, None]: + if flex_attention is None: + raise RuntimeError("Flex attention is not available in this environment.") + fn = _get_flex_attention_fn( + device=query_heads.device, + dtype=query_heads.dtype, + shape=tuple(query_heads.shape), + mask_semantics="padding", + ) + context_heads = fn( + query_heads, + key_heads, + value_heads, + block_mask=flex_block_mask, + scale=self.scale, + kernel_options={"PRESCALE_QK": True, "BLOCK_N": 32}, + ) + return rearrange(context_heads, "b h s d -> b s (h d)"), None + + def _sdpa_attn( + self, + query_heads: torch.Tensor, + key_heads: torch.Tensor, + value_heads: torch.Tensor, + attention_mask_4d: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, None]: + context_heads = F.scaled_dot_product_attention( + query_heads, + key_heads, + value_heads, + attn_mask=attention_mask_4d, + scale=self.scale, + ) + return rearrange(context_heads, "b h s d -> b s (h d)"), None + + +### Regression Head +def RegressionHead(d_model: int, output_dim: int, hidden_dim: int | None = None) -> nn.Module: + """Create a regression head with optional hidden dimension. + + Args: + d_model: Input dimension + output_dim: Output dimension + hidden_dim: Optional hidden dimension (defaults to d_model) + """ + hidden_dim = hidden_dim if hidden_dim is not None else d_model + return nn.Sequential( + nn.Linear(d_model, hidden_dim), + nn.GELU(), + nn.LayerNorm(hidden_dim), + nn.Linear(hidden_dim, output_dim), + ) + + +### Transformer Block +class UnifiedTransformerBlock(nn.Module): + """Transformer block with attention and feedforward layers.""" + + def __init__( + self, + d_model: int, + n_heads: int, + residue_scaling_factor: float = 1, + expansion_ratio: float = 8 / 3, + dropout: float = 0.0, + attn_backend: str = "sdpa", + ): + super().__init__() + self.attn = MultiHeadAttention(d_model=d_model, n_heads=n_heads, attn_backend=attn_backend) + self.ffn = swiglu_ln_ffn(d_model, expansion_ratio) + self.scaling_factor = residue_scaling_factor + self.dropout = nn.Dropout(dropout) + + def forward( + self, + x: torch.Tensor, + attention_mask_2d: torch.Tensor | None = None, + attention_mask_4d: torch.Tensor | None = None, + flex_block_mask: BlockMask | None = None, + output_attentions: bool = False, + output_s_max: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]: + attn_output, attn_weights, s_max = self.attn( + x, + attention_mask_2d=attention_mask_2d, + attention_mask_4d=attention_mask_4d, + flex_block_mask=flex_block_mask, + output_attentions=output_attentions, + output_s_max=output_s_max, + ) + x = x + self.dropout(attn_output) / self.scaling_factor + x = x + self.dropout(self.ffn(x)) / self.scaling_factor + return x, attn_weights, s_max + + +### Model Outputs +@dataclass +class TransformerOutput(ModelOutput): + """Output type for transformer encoder.""" + + last_hidden_state: torch.Tensor | None = None + hidden_states: tuple[torch.Tensor] | None = None + attentions: tuple[torch.Tensor] | None = None + s_max: tuple[list[torch.Tensor], ...] | None = None + + +@dataclass +class ESMplusplusOutput(MaskedLMOutput): + """Masked-LM output with FastPLMs fields after the HF contract.""" + + s_max: tuple[list[torch.Tensor], ...] | None = None + last_hidden_state: torch.Tensor | None = None + + +@dataclass +class ESMplusplusSequenceClassifierOutput(SequenceClassifierOutput): + """Sequence-classification output with optional attention diagnostics.""" + + s_max: tuple[list[torch.Tensor], ...] | None = None + + +@dataclass +class ESMplusplusTokenClassifierOutput(TokenClassifierOutput): + """Token-classification output with optional attention diagnostics.""" + + s_max: tuple[list[torch.Tensor], ...] | None = None + + +### Transformer Stack +class TransformerStack(nn.Module): + """Stack of transformer blocks.""" + + def __init__( + self, + d_model: int, + n_heads: int, + n_layers: int, + dropout: float = 0.0, + attn_backend: str = "sdpa", + ): + super().__init__() + self.attention_backend = resolve_attention_backend(attn_backend) + self.blocks = nn.ModuleList( + [ + UnifiedTransformerBlock( + d_model, + n_heads, + residue_scaling_factor=math.sqrt(n_layers / 36), + dropout=dropout, + attn_backend=attn_backend, + ) + for i in range(n_layers) + ] + ) + self.norm = nn.LayerNorm(d_model, bias=False) + self.gradient_checkpointing = False + + @property + def attn_backend(self) -> AttentionBackend: + return self.attention_backend + + @attn_backend.setter + def attn_backend(self, backend: str) -> None: + resolved = resolve_attention_backend(backend) + self.attention_backend = resolved + for block in self.blocks: + block.attn.attn_backend = resolved + + def forward( + self, + x: torch.Tensor, + attention_mask: torch.Tensor | None = None, + sequence_id: torch.Tensor | None = None, + output_hidden_states: bool | None = False, + output_attentions: bool | None = False, + output_s_max: bool | None = False, + esmfold2_hidden_states: bool = False, + ) -> TransformerOutput: + hidden_states = () if output_hidden_states else None + attentions = () if output_attentions else None + full_s_max = () if output_s_max else None + # Match the pinned Biohub Transformers contract: a supplied sequence_id + # is authoritative and must encode padding as -1. attention_mask is + # ignored in that mode rather than intersected with the chain mask. + if sequence_id is None and attention_mask is not None: + expected_shape = (x.shape[0], x.shape[1]) + if attention_mask.ndim != 2 or tuple(attention_mask.shape) != expected_shape: + raise ValueError( + f"attention_mask must have shape {expected_shape}; " + f"received {tuple(attention_mask.shape)}." + ) + attention_mask = attention_mask.to(device=x.device, dtype=torch.bool) + if not bool(attention_mask.any(dim=1).all()): + raise ValueError("attention_mask must keep at least one valid key per batch row.") + effective_backend = resolve_attention_backend_for_call( + self.attention_backend, + output_attentions=bool(output_attentions), + ) + + if sequence_id is None and attention_mask is not None: + attention_mask_2d, attention_mask_4d, flex_block_mask = ( + self._sequence_id_attention_masks( + sequence_id=attention_mask.to(device=x.device, dtype=torch.bool), + batch_size=x.shape[0], + seq_len=x.shape[1], + device=x.device, + dtype=x.dtype, + effective_backend=effective_backend, + ) + ) + elif sequence_id is None: + attention_mask_2d, attention_mask_4d, flex_block_mask = get_attention_mask( + effective_backend=effective_backend, + batch_size=x.shape[0], + seq_len=x.shape[1], + device=x.device, + attention_mask=attention_mask, + dtype=x.dtype, + mask_semantics="padding", + ) + else: + attention_mask_2d, attention_mask_4d, flex_block_mask = ( + self._sequence_id_attention_masks( + sequence_id=sequence_id, + batch_size=x.shape[0], + seq_len=x.shape[1], + device=x.device, + dtype=x.dtype, + effective_backend=effective_backend, + ) + ) + + for block in self.blocks: + if output_hidden_states: + if hidden_states is None: + raise RuntimeError( + "Hidden-state collection was not initialized for an enabled request." + ) + # Biohub Transformers records the input to each block followed + # by the final normalized state. This gives n_layers + 1 states + # and, for ESMC-6B, the 81-state order consumed by ESMFold2. + hidden_states += (x,) + if self.gradient_checkpointing and self.training: + x, attn_weights, s_max = self._gradient_checkpointing_func( + block.__call__, + x=x, + attention_mask_2d=attention_mask_2d, + attention_mask_4d=attention_mask_4d, + flex_block_mask=flex_block_mask, + output_attentions=output_attentions, + output_s_max=output_s_max, + ) + else: + x, attn_weights, s_max = block( + x=x, + attention_mask_2d=attention_mask_2d, + attention_mask_4d=attention_mask_4d, + flex_block_mask=flex_block_mask, + output_attentions=output_attentions, + output_s_max=output_s_max, + ) + + if attentions is not None: + attentions += (attn_weights,) + if full_s_max is not None: + full_s_max += (s_max,) + + last_hidden_state = self.norm(x) + if output_hidden_states: + hidden_states += (last_hidden_state,) + + return TransformerOutput( + last_hidden_state=last_hidden_state, + hidden_states=hidden_states, + attentions=attentions, + s_max=full_s_max, + ) + + def _sequence_id_attention_masks( + self, + sequence_id: torch.Tensor, + batch_size: int, + seq_len: int, + device: torch.device, + dtype: torch.dtype | None = None, + effective_backend: AttentionBackend | None = None, + ) -> tuple[torch.Tensor | None, torch.Tensor | None, BlockMask | None]: + expected_shape = (batch_size, seq_len) + if sequence_id.ndim != 2 or tuple(sequence_id.shape) != expected_shape: + raise ValueError( + f"sequence_id must have shape {expected_shape}; " + f"received {tuple(sequence_id.shape)}." + ) + if sequence_id.device != device: + sequence_id = sequence_id.to(device=device) + backend = ( + self.attention_backend + if effective_backend is None + else resolve_attention_backend(effective_backend) + ) + if sequence_id.dtype == torch.bool: + attention_mask_2d = sequence_id + # Biohub's boolean single-chain form groups biological positions + # together and padding positions together. Padding queries remain + # finite without allowing their states to enter residue attention. + attention_mask_4d = sequence_id[:, None, :, None] == sequence_id[:, None, None, :] + else: + attention_mask_2d = sequence_id != -1 + attention_mask_4d = (sequence_id.unsqueeze(-1) == sequence_id.unsqueeze(-2)).unsqueeze( + 1 + ) + if not bool(attention_mask_2d.any(dim=1).all()): + raise ValueError("attention_mask must keep at least one valid key per batch row.") + + if backend.is_flash: + if sequence_id.dtype != torch.bool: + raise ValueError( + "ESM++ FlashAttention only supports boolean sequence_id padding masks. " + "Use eager, sdpa, or flex_attention for chain-aware integer sequence_id " + "masks." + ) + return attention_mask_2d, attention_mask_4d, None + + if backend == AttentionBackend.FLEX: + if sequence_id.dtype == torch.bool: + + def mask_mod(batch_idx, head_idx, q_idx, kv_idx): + del head_idx + return sequence_id[batch_idx, q_idx] == sequence_id[batch_idx, kv_idx] + + else: + + def mask_mod(batch_idx, head_idx, q_idx, kv_idx): + del head_idx + q_id = sequence_id[batch_idx, q_idx] + kv_id = sequence_id[batch_idx, kv_idx] + return q_id == kv_id + + flex_block_mask = _get_flex_block_mask( + mask_pattern=sequence_id, + batch_size=batch_size, + query_length=seq_len, + key_value_length=seq_len, + device=device, + dtype=dtype, + mask_semantics=( + "boolean_sequence_id" + if sequence_id.dtype == torch.bool + else "integer_sequence_id" + ), + mask_mod=mask_mod, + ) + return attention_mask_2d, attention_mask_4d, flex_block_mask + + return attention_mask_2d, attention_mask_4d, None + + +class PreTrainedESMplusplusModel(FastPLMsAttentionMixin, PreTrainedModel): + """ + init weights for ESM++ models + """ + + config_class = ESMplusplusConfig + base_model_prefix = "esm++" + supports_gradient_checkpointing = True + all_tied_weights_keys: ClassVar[dict[str, str]] = {} + _supports_flash_attn = True + _supports_flash_attn_2 = True + _supports_flash_attn_3 = True + _fastplms_attention_implementations = ( + "eager", + "sdpa", + "flex_attention", + "flash_attention_2", + "flash_attention_3", + ) + + @property + def tokenizer(self) -> EsmSequenceTokenizer: + """Construct the sequence tokenizer only when a raw-sequence API needs it.""" + + tokenizer = self.__dict__.get("_fastplms_tokenizer") + if tokenizer is None: + tokenizer = EsmSequenceTokenizer() + self.__dict__["_fastplms_tokenizer"] = tokenizer + return tokenizer + + @tokenizer.setter + def tokenizer(self, value: EsmSequenceTokenizer | None) -> None: + self.__dict__["_fastplms_tokenizer"] = value + + def _init_weights(self, module): + """Initialize the weights""" + # HF from_pretrained marks loaded parameters with `_is_hf_initialized`. + # Skip this module if any local parameter is already marked as loaded. + for parameter in module.parameters(recurse=False): + if parameter.__dict__.get("_is_hf_initialized"): + return + + if isinstance(module, nn.Linear): + nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range) + if module.bias is not None: + nn.init.zeros_(module.bias) + elif isinstance(module, nn.Embedding): + nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range) + if module.padding_idx is not None: + with torch.no_grad(): + module.weight[module.padding_idx].zero_() + elif isinstance(module, nn.LayerNorm): + if module.bias is not None: + nn.init.zeros_(module.bias) + nn.init.ones_(module.weight) + + @property + def attn_backend(self) -> str: + return self.config.attn_backend + + @attn_backend.setter + def attn_backend(self, backend: str) -> None: + if backend not in self._fastplms_attention_implementations: + raise ValueError( + f"{type(self).__name__} does not support {backend!r}; expected one of " + f"{self._fastplms_attention_implementations}." + ) + self.set_attn_implementation(backend) + + def _reset_rotary_embeddings(self): + """Refresh non-persistent rotary buffers after checkpoint loading.""" + for module in self.modules(): + if isinstance(module, RotaryEmbedding): + module.reset_parameters() + + @classmethod + def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs): + output_loading_info = ( + bool(kwargs["output_loading_info"]) if "output_loading_info" in kwargs else False + ) + loaded = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs) + if output_loading_info: + model, loading_info = loaded + model._reset_rotary_embeddings() + return model, loading_info + loaded._reset_rotary_embeddings() + return loaded + + +### ESM++ Models +class ESMplusplusModel(PreTrainedESMplusplusModel, EmbeddingMixin): + """ + ESM++ transformer backbone. + + Official ESM++ checkpoints contain the sequence head even when loaded through + ``AutoModel``. Keep that module in the base class so the checkpoint has one + exact state-dict contract across ``AutoModel`` and ``AutoModelForMaskedLM``; + the base forward path intentionally does not compute or return logits. + """ + + config_class = ESMplusplusConfig + + def __init__(self, config: ESMplusplusConfig, **kwargs): + PreTrainedESMplusplusModel.__init__(self, config, **kwargs) + self.config = config + self.vocab_size = config.vocab_size + self.embed = nn.Embedding(self.vocab_size, config.hidden_size) + self.transformer = TransformerStack( + d_model=config.hidden_size, + n_heads=config.num_attention_heads, + n_layers=config.num_hidden_layers, + dropout=config.dropout, + attn_backend=config.attn_backend, + ) + self.sequence_head = RegressionHead(config.hidden_size, self.vocab_size) + self.init_weights() + + def get_input_embeddings(self): + return self.embed + + def set_input_embeddings(self, value): + self.embed = value + + def get_output_embeddings(self): + return self.sequence_head[-1] + + def set_output_embeddings(self, new_embeddings): + self.sequence_head[-1] = new_embeddings + + def _embed( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor | None = None, + hidden_state_index: int = -1, + store_all_hidden_states: bool = False, + ) -> torch.Tensor: + if attention_mask is None: + attention_mask = input_ids.ne(self.config.pad_token_id) + x = self.embed(input_ids) + output_hidden_states = store_all_hidden_states or hidden_state_index != -1 + output = self.transformer( + x=x, + attention_mask=attention_mask, + output_hidden_states=output_hidden_states, + output_attentions=False, + ) + return select_hidden_state_embeddings( + output.last_hidden_state, + output.hidden_states, + hidden_state_index=hidden_state_index, + store_all_hidden_states=store_all_hidden_states, + ) + + def forward( + self, + input_ids: torch.Tensor | None = None, + attention_mask: torch.Tensor | None = None, + sequence_id: torch.Tensor | None = None, + inputs_embeds: torch.Tensor | None = None, + output_attentions: bool | None = None, + output_hidden_states: bool | None = None, + output_s_max: bool | None = False, + esmfold2_hidden_states: bool = False, + return_dict: bool | None = None, + ) -> TransformerOutput | tuple[torch.Tensor, ...]: + """Run ESMC inference with the pinned Biohub mask precedence. + + ``sequence_id`` is authoritative when supplied: non-negative integers + identify chains and ``-1`` identifies padding. In that mode + ``attention_mask`` is ignored, matching the official implementation. + Without ``sequence_id``, ``attention_mask`` is the ordinary padding + mask and defaults to ``input_ids != pad_token_id``. + """ + if input_ids is None and inputs_embeds is None: + raise ValueError("You have to specify either input_ids or inputs_embeds") + if input_ids is not None and inputs_embeds is not None: + raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time") + output_attentions = ( + output_attentions if output_attentions is not None else self.config.output_attentions + ) + output_hidden_states = ( + output_hidden_states + if output_hidden_states is not None + else self.config.output_hidden_states + ) + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + if attention_mask is None and sequence_id is None and input_ids is not None: + attention_mask = input_ids.ne(self.config.pad_token_id) + + x = self.embed(input_ids) if inputs_embeds is None else inputs_embeds + + transformer_output = self.transformer( + x=x, + attention_mask=attention_mask, + sequence_id=sequence_id, + output_hidden_states=output_hidden_states, + output_attentions=output_attentions, + output_s_max=output_s_max, + esmfold2_hidden_states=esmfold2_hidden_states, + ) + result = TransformerOutput( + last_hidden_state=transformer_output.last_hidden_state, + hidden_states=transformer_output.hidden_states, + attentions=transformer_output.attentions, + s_max=transformer_output.s_max, + ) + return result if return_dict else result.to_tuple() + + +class ESMplusplusForMaskedLM( + FastPLMTestTimeTrainingMixin, PreTrainedESMplusplusModel, EmbeddingMixin +): + """ + ESM++ model for masked language modeling. + Implements the base ESM++ architecture with a masked language modeling head. + """ + + config_class = ESMplusplusConfig + + def __init__(self, config: ESMplusplusConfig, **kwargs): + PreTrainedESMplusplusModel.__init__(self, config, **kwargs) + self.config = config + self.vocab_size = config.vocab_size + self.embed = nn.Embedding(self.vocab_size, config.hidden_size) + self.transformer = TransformerStack( + d_model=config.hidden_size, + n_heads=config.num_attention_heads, + n_layers=config.num_hidden_layers, + dropout=config.dropout, + attn_backend=config.attn_backend, + ) + self.sequence_head = RegressionHead(config.hidden_size, self.vocab_size) + self.ce_loss = nn.CrossEntropyLoss() + self.init_weights() + self.init_ttt({"lora_target_replace_module": "MultiHeadAttention"}) + + def get_input_embeddings(self): + return self.embed + + def set_input_embeddings(self, value): + self.embed = value + + def get_output_embeddings(self): + return self.sequence_head[-1] + + def set_output_embeddings(self, new_embeddings): + self.sequence_head[-1] = new_embeddings + + def _embed( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor | None = None, + hidden_state_index: int = -1, + store_all_hidden_states: bool = False, + ) -> torch.Tensor: + if attention_mask is None: + attention_mask = input_ids.ne(self.config.pad_token_id) + x = self.embed(input_ids) + output_hidden_states = store_all_hidden_states or hidden_state_index != -1 + output = self.transformer( + x=x, + attention_mask=attention_mask, + output_hidden_states=output_hidden_states, + output_attentions=False, + ) + return select_hidden_state_embeddings( + output.last_hidden_state, + output.hidden_states, + hidden_state_index=hidden_state_index, + store_all_hidden_states=store_all_hidden_states, + ) + + def _ttt_get_trainable_modules(self) -> list[nn.Module]: + return [self.transformer] + + def forward( + self, + input_ids: torch.Tensor | None = None, + attention_mask: torch.Tensor | None = None, + sequence_id: torch.Tensor | None = None, + inputs_embeds: torch.Tensor | None = None, + labels: torch.Tensor | None = None, + output_attentions: bool | None = None, + output_hidden_states: bool | None = None, + output_s_max: bool | None = False, + esmfold2_hidden_states: bool = False, + return_dict: bool | None = None, + compute_logits: bool = True, + ) -> ESMplusplusOutput | tuple[torch.Tensor, ...]: + if input_ids is None and inputs_embeds is None: + raise ValueError("You have to specify either input_ids or inputs_embeds") + if input_ids is not None and inputs_embeds is not None: + raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time") + if labels is not None and not compute_logits: + raise ValueError("labels require compute_logits=True.") + output_attentions = ( + output_attentions if output_attentions is not None else self.config.output_attentions + ) + output_hidden_states = ( + output_hidden_states + if output_hidden_states is not None + else self.config.output_hidden_states + ) + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + if attention_mask is None and sequence_id is None and input_ids is not None: + attention_mask = input_ids.ne(self.config.pad_token_id) + + x = self.embed(input_ids) if inputs_embeds is None else inputs_embeds + + output = self.transformer( + x=x, + attention_mask=attention_mask, + sequence_id=sequence_id, + output_hidden_states=output_hidden_states, + output_attentions=output_attentions, + output_s_max=output_s_max, + esmfold2_hidden_states=esmfold2_hidden_states, + ) + + last_hidden_state = output.last_hidden_state + logits = self.sequence_head(last_hidden_state) if compute_logits else None + loss = None + if labels is not None: + if logits is None: + raise ValueError("labels require compute_logits=True.") + labels = labels.to(logits.device) + loss = self.ce_loss(logits.view(-1, self.vocab_size), labels.view(-1)) + + result = ESMplusplusOutput( + loss=loss, + logits=logits, + hidden_states=output.hidden_states, + attentions=output.attentions, + s_max=output.s_max, + last_hidden_state=last_hidden_state, + ) + return result if return_dict else result.to_tuple() + + +class ESMplusplusForSequenceClassification(ESMplusplusForMaskedLM, EmbeddingMixin): + """ + ESM++ model for sequence classification. + Extends the base ESM++ model with a classification head. + """ + + def __init__(self, config: ESMplusplusConfig, **kwargs): + pooling_types = kwargs.pop("pooling_types", None) + if pooling_types is None: + pooling_types = config.classifier_pooling_types or ["mean", "var"] + elif not isinstance(pooling_types, list): + raise TypeError("pooling_types must be a non-empty list of strings.") + elif not pooling_types: + raise ValueError("pooling_types must contain at least one pooling operation.") + elif not all(isinstance(pooling_type, str) for pooling_type in pooling_types): + raise TypeError("pooling_types must be a non-empty list of strings.") + if "parti" in pooling_types: + raise ValueError( + "pooling_types cannot contain 'parti' for sequence classification " + "because the classifier does not expose layer attentions to its pooler." + ) + config.classifier_pooling_types = list(pooling_types) + + ESMplusplusForMaskedLM.__init__(self, config, **kwargs) + self.config = config + self.num_labels = config.num_labels + self.classifier = RegressionHead( + config.hidden_size * len(pooling_types), + config.num_labels, + config.hidden_size * 4, + ) + # Large intermediate projections help with sequence classification tasks (*4) + self.mse = nn.MSELoss() + self.ce = nn.CrossEntropyLoss() + self.bce = nn.BCEWithLogitsLoss() + self.pooler = Pooler(pooling_types) + self.init_weights() + + def _embed( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor | None = None, + hidden_state_index: int = -1, + store_all_hidden_states: bool = False, + ) -> torch.Tensor: + x = self.embed(input_ids) + output_hidden_states = store_all_hidden_states or hidden_state_index != -1 + output = self.transformer( + x=x, + attention_mask=attention_mask, + output_hidden_states=output_hidden_states, + output_attentions=False, + ) + return select_hidden_state_embeddings( + output.last_hidden_state, + output.hidden_states, + hidden_state_index=hidden_state_index, + store_all_hidden_states=store_all_hidden_states, + ) + + def forward( + self, + input_ids: torch.Tensor | None = None, + attention_mask: torch.Tensor | None = None, + sequence_id: torch.Tensor | None = None, + inputs_embeds: torch.Tensor | None = None, + labels: torch.Tensor | None = None, + output_attentions: bool | None = None, + output_hidden_states: bool | None = None, + output_s_max: bool | None = False, + return_dict: bool | None = None, + ) -> ESMplusplusSequenceClassifierOutput | tuple[torch.Tensor, ...]: + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + pooling_mask = attention_mask + if pooling_mask is None: + if sequence_id is not None: + pooling_mask = ( + sequence_id if sequence_id.dtype == torch.bool else sequence_id.ne(-1) + ) + elif input_ids is not None: + pooling_mask = input_ids.ne(self.config.pad_token_id) + else: + if inputs_embeds is None: + raise ValueError("You have to specify either input_ids or inputs_embeds") + pooling_mask = torch.ones( + inputs_embeds.shape[:2], + dtype=torch.bool, + device=inputs_embeds.device, + ) + + output = super().forward( + input_ids=input_ids, + attention_mask=attention_mask, + sequence_id=sequence_id, + inputs_embeds=inputs_embeds, + labels=None, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + output_s_max=output_s_max, + return_dict=True, + compute_logits=False, + ) + + last_hidden_state = output.last_hidden_state + features = self.pooler(last_hidden_state, pooling_mask) + logits = self.classifier(features) + + loss = None + if labels is not None: + labels = labels.to(logits.device) + if self.config.problem_type is None: + if self.num_labels == 1: + self.config.problem_type = "regression" + elif self.num_labels > 1 and ( + labels.dtype == torch.long or labels.dtype == torch.int + ): + self.config.problem_type = "single_label_classification" + else: + self.config.problem_type = "multi_label_classification" + + if self.config.problem_type == "regression": + if self.num_labels == 1: + loss = self.mse(logits.flatten(), labels.flatten()) + else: + loss = self.mse(logits, labels) + elif self.config.problem_type == "single_label_classification": + loss = self.ce(logits.view(-1, self.num_labels), labels.view(-1)) + elif self.config.problem_type == "multi_label_classification": + loss = self.bce(logits, labels) + + result = ESMplusplusSequenceClassifierOutput( + loss=loss, + logits=logits, + hidden_states=output.hidden_states, + attentions=output.attentions, + s_max=output.s_max, + ) + return result if return_dict else result.to_tuple() + + +class ESMplusplusForTokenClassification(ESMplusplusForMaskedLM, EmbeddingMixin): + """ + ESM++ model for token classification. + Extends the base ESM++ model with a token classification head. + """ + + def __init__(self, config: ESMplusplusConfig, **kwargs): + ESMplusplusForMaskedLM.__init__(self, config, **kwargs) + self.config = config + self.num_labels = config.num_labels + self.classifier = RegressionHead( + config.hidden_size, config.num_labels, config.hidden_size * 4 + ) + # Large intermediate projections help with sequence classification tasks (*4) + self.loss_fct = nn.CrossEntropyLoss() + self.init_weights() + + def _embed( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor | None = None, + hidden_state_index: int = -1, + store_all_hidden_states: bool = False, + ) -> torch.Tensor: + x = self.embed(input_ids) + output_hidden_states = store_all_hidden_states or hidden_state_index != -1 + output = self.transformer( + x, + attention_mask, + output_hidden_states=output_hidden_states, + output_attentions=False, + ) + return select_hidden_state_embeddings( + output.last_hidden_state, + output.hidden_states, + hidden_state_index=hidden_state_index, + store_all_hidden_states=store_all_hidden_states, + ) + + def forward( + self, + input_ids: torch.Tensor | None = None, + attention_mask: torch.Tensor | None = None, + sequence_id: torch.Tensor | None = None, + inputs_embeds: torch.Tensor | None = None, + labels: torch.Tensor | None = None, + output_attentions: bool | None = None, + output_hidden_states: bool | None = None, + output_s_max: bool | None = False, + return_dict: bool | None = None, + ) -> ESMplusplusTokenClassifierOutput | tuple[torch.Tensor, ...]: + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + output = super().forward( + input_ids=input_ids, + attention_mask=attention_mask, + sequence_id=sequence_id, + inputs_embeds=inputs_embeds, + labels=None, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + output_s_max=output_s_max, + return_dict=True, + compute_logits=False, + ) + + last_hidden_state = output.last_hidden_state + logits = self.classifier(last_hidden_state) + loss = None + if labels is not None: + labels = labels.to(logits.device) + loss = self.loss_fct(logits.view(-1, self.num_labels), labels.view(-1)) + + result = ESMplusplusTokenClassifierOutput( + loss=loss, + logits=logits, + hidden_states=output.hidden_states, + attentions=output.attentions, + s_max=output.s_max, + ) + return result if return_dict else result.to_tuple() + + +### Tokenization +SEQUENCE_VOCAB = [ + "", + "", + "", + "", + "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", + ".", + "-", + "|", + "", +] + + +def _build_sequence_tokenizer_backend( + *, + unk_token: str, + cls_token: str, + pad_token: str, + mask_token: str, + eos_token: str, + chain_break_token: str, +) -> Tokenizer: + """Build the fixed ESMC character vocabulary and boundary-token policy.""" + vocabulary = dict(zip(SEQUENCE_VOCAB, range(len(SEQUENCE_VOCAB)), strict=True)) + backend = Tokenizer(BPE(vocabulary, merges=[], unk_token=unk_token)) + backend.add_special_tokens([cls_token, pad_token, mask_token, eos_token, chain_break_token]) + backend.post_processor = TemplateProcessing( + single=" $A ", + pair=":0 $A:0 :0 $B:1 :1", + special_tokens=[ + ("", backend.token_to_id("")), + ("", backend.token_to_id("")), + ], + ) + return backend + + +class EsmSequenceTokenizer(PreTrainedTokenizerFast): + model_input_names: ClassVar[list[str]] = ["input_ids", "attention_mask"] + + def __init__( + self, + unk_token="", + cls_token="", + pad_token="", + mask_token="", + eos_token="", + chain_break_token="|", + **kwargs, + ): + backend = _build_sequence_tokenizer_backend( + unk_token=unk_token, + cls_token=cls_token, + pad_token=pad_token, + mask_token=mask_token, + eos_token=eos_token, + chain_break_token=chain_break_token, + ) + self.cb_token = chain_break_token + super().__init__( + tokenizer_object=backend, + unk_token=unk_token, + cls_token=cls_token, + pad_token=pad_token, + mask_token=mask_token, + eos_token=eos_token, + additional_special_tokens=[chain_break_token], + **kwargs, + ) + + # These are a footgun, we never use the `bos` token anywhere so we're just overriding it here. + @property + def bos_token(self): + return self.cls_token + + @property + def bos_token_id(self): + return self.cls_token_id + + @property + def chain_break_token(self): + return self.cb_token + + @property + def chain_break_token_id(self): + return self.convert_tokens_to_ids(self.chain_break_token) + + @property + def all_token_ids(self): + return list(range(self.vocab_size)) + + @property + def special_token_ids(self): + return self.all_special_ids diff --git a/fastplms/models/esmfold2/__init__.py b/fastplms/models/esmfold2/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5aefbd1825ce1d0ca46809393c68becfa88fa97d --- /dev/null +++ b/fastplms/models/esmfold2/__init__.py @@ -0,0 +1,39 @@ +"""ESMFold2 public classes, imported lazily to keep optional extras isolated.""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .configuration_esmfold2 import ESMFold2Config as ESMFold2Config + from .modeling_esmfold2 import ESMFold2Model as ESMFold2Model + from .modeling_esmfold2 import ESMFold2Output as ESMFold2Output + from .modeling_esmfold2_experimental import ( + ESMFold2ExperimentalModel as ESMFold2ExperimentalModel, + ) + from .reproducibility import seed_context as seed_context + +_EXPORT_MODULES = { + "ESMFold2Config": ".configuration_esmfold2", + "ESMFold2ExperimentalModel": ".modeling_esmfold2_experimental", + "ESMFold2Model": ".modeling_esmfold2", + "ESMFold2Output": ".modeling_esmfold2", + "seed_context": ".reproducibility", +} + + +def __getattr__(name: str) -> Any: + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(module_name, __name__), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_EXPORT_MODULES)) + + +__all__ = list(_EXPORT_MODULES) diff --git a/fastplms/models/esmfold2/attention.py b/fastplms/models/esmfold2/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..5823fc67550fd0fdbb1bcb7d5f0aa143d1a78c79 --- /dev/null +++ b/fastplms/models/esmfold2/attention.py @@ -0,0 +1,48 @@ +"""Transformers-compatible attention selection for ESMFold2's ESMC backbone.""" + +from __future__ import annotations + +from collections.abc import Mapping + +from ...attention import FastPLMsAttentionMixin, get_attn_implementation + + +class ESMFold2AttentionMixin(FastPLMsAttentionMixin): + """Route the outer Transformers attention API into the loaded ESMC model.""" + + _supports_attention_backend = True + _supports_sdpa = True + _supports_flex_attn = True + _supports_flash_attn_2 = False + _supports_flash_attn_3 = False + _fastplms_attention_implementations = ( + "eager", + "sdpa", + "flex_attention", + ) + + def __init__(self, config, *args, **kwargs) -> None: + super().__init__(config, *args, **kwargs) + config.esmc_attn_backend = get_attn_implementation(config) + + def set_attn_implementation( + self, + attn_implementation: str | Mapping[str, str], + allow_all_kernels: bool = False, + ) -> None: + """Set one canonical backend on ESMFold2 and its loaded ESMC model.""" + + if allow_all_kernels: + raise ValueError( + "ESMFold2 accepts only its declared built-in attention backends; " + "external attention kernels are not supported." + ) + super().set_attn_implementation(attn_implementation) + resolved = get_attn_implementation(self.config) + self.config.esmc_attn_backend = resolved + esmc = getattr(self, "_esmc", None) + if esmc is not None: + esmc.set_attn_implementation(resolved) + + +__all__ = ["ESMFold2AttentionMixin"] diff --git a/fastplms/models/esmfold2/configuration_esmfold2.py b/fastplms/models/esmfold2/configuration_esmfold2.py new file mode 100644 index 0000000000000000000000000000000000000000..b2bbd6292a80a7083186847ed0fefe66d4cc4765 --- /dev/null +++ b/fastplms/models/esmfold2/configuration_esmfold2.py @@ -0,0 +1,306 @@ +# Copyright 2026 Biohub. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configuration schema for release and experimental ESMFold2 checkpoints.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, TypeVar, cast + +from transformers.configuration_utils import PretrainedConfig + +_ESMC_ATTENTION_IMPLEMENTATIONS = frozenset({"eager", "flex_attention", "sdpa"}) +_ESMC_PRECISIONS = frozenset({"auto", "bf16", "fp32", "fp8"}) + + +def _esmc_backbone_checkpoint_ids() -> tuple[str, str]: + """Return the manifest-pinned official and FastPLMs ESMC repositories.""" + + from fastplms.registry import RegistryError, get_model_registry + + registry = get_model_registry() + family = registry.families["esmfold2"] + if family.backbone_model is None: + raise RegistryError("families.esmfold2 must declare backbone_model.") + backbone = registry[family.backbone_model] + return backbone.official.repo_id, backbone.fast.repo_id + + +def normalize_esmc_id(esmc_id: str) -> str: + """Resolve an official ESMC identifier to its FastPLMs checkpoint mirror.""" + + official_repo, fast_repo = _esmc_backbone_checkpoint_ids() + return fast_repo if esmc_id == official_repo else esmc_id + + +def normalize_esmc_attention_implementation( + implementation: str | dict[str, str] | None, +) -> str | None: + """Validate the ESMC backend and translate the historical ``flex`` name.""" + + if isinstance(implementation, dict): + if tuple(implementation) != ("",): + raise ValueError( + "ESMFold2 has one ESMC attention backbone; use a string or {'': implementation}." + ) + implementation = implementation[""] + canonical = "flex_attention" if implementation == "flex" else implementation + if canonical is not None and canonical not in _ESMC_ATTENTION_IMPLEMENTATIONS: + expected = sorted(_ESMC_ATTENTION_IMPLEMENTATIONS) + raise ValueError( + f"Unsupported ESMFold2 attention implementation {canonical!r}; " + f"expected one of {expected}." + ) + return canonical + + +NestedConfig = TypeVar("NestedConfig") + + +def _nested_config(value: Any, config_type: type[NestedConfig]) -> NestedConfig: + if isinstance(value, config_type): + return value + return config_type(**value) if isinstance(value, dict) else config_type() + + +def _coerce_nested_field( + value: NestedConfig | dict[str, Any], config_type: type[NestedConfig] +) -> NestedConfig: + """Convert serialized nested dictionaries while retaining supplied objects.""" + + return config_type(**value) if isinstance(value, dict) else value + + +@dataclass +class AtomAttentionConfig: + """Sliding-window atom attention and three-dimensional RoPE settings.""" + + d_atom: int = field(default=128) + d_token: int = field(default=768) + n_blocks: int = field(default=3) + n_heads: int = field(default=4) + swa_window_size: int = field(default=128) + expansion_ratio: int = field(default=2) + spatial_rope_base_frequency: float = field(default=20.0) + n_spatial_rope_pairs_per_axis: int = field(default=2) + n_uid_rope_pairs: int = field(default=10) + uid_rope_base_frequency: float = field(default=10000.0) + + +@dataclass +class DiffusionModuleConfig: + """Dimensions and depth of the coordinate diffusion network.""" + + sigma_data: float = field(default=16.0) + c_atom: int = field(default=128) + c_token: int = field(default=768) + c_z: int = field(default=256) + c_s_inputs: int = field(default=451) + fourier_dim: int = field(default=256) + relpos_r_max: int = field(default=32) + relpos_s_max: int = field(default=2) + atom_num_blocks: int = field(default=3) + atom_num_heads: int = field(default=4) + token_num_blocks: int = field(default=12) + token_num_heads: int = field(default=16) + transition_multiplier: int = field(default=2) + + +@dataclass +class FoldingTrunkConfig: + """Iterative pair/single trunk dimensions.""" + + n_layers: int = field(default=24) + n_heads: int = field(default=8) + dropout: float = field(default=0.0) + + +@dataclass +class InputsEmbedderConfig: + """Input feature width and atom encoder settings.""" + + d_inputs: int = field(default=451) + atom_encoder: AtomAttentionConfig = field(default_factory=AtomAttentionConfig) + + def __post_init__(self) -> None: + self.atom_encoder = _coerce_nested_field(self.atom_encoder, AtomAttentionConfig) + + +@dataclass +class DiffusionStructureHeadConfig: + """Training and inference schedules for coordinate denoising.""" + + diffusion_module: DiffusionModuleConfig = field(default_factory=DiffusionModuleConfig) + distogram_bins: int = field(default=128) + train_noise_log_mean: float = field(default=-1.2) + train_noise_log_std: float = field(default=1.5) + gamma_0: float = field(default=0.605) + gamma_min: float = field(default=1.107) + noise_scale: float = field(default=0.0) + step_scale: float = field(default=1.0) + inference_s_max: float = field(default=160.0) + inference_s_min: float = field(default=4e-4) + inference_p: float = field(default=8.0) + inference_num_steps: int = field(default=68) + + def __post_init__(self) -> None: + self.diffusion_module = _coerce_nested_field(self.diffusion_module, DiffusionModuleConfig) + + +@dataclass +class ConfidenceHeadConfig: + """Confidence-bin definitions and the compact confidence trunk.""" + + enabled: bool = field(default=True) + num_plddt_bins: int = field(default=50) + num_pde_bins: int = field(default=64) + num_pae_bins: int = field(default=64) + min_dist: float = field(default=2.0) + max_dist: float = field(default=52.0) + distogram_bins: int = field(default=128) + folding_trunk: FoldingTrunkConfig = field( + default_factory=lambda: FoldingTrunkConfig(n_layers=4) + ) + + def __post_init__(self) -> None: + self.folding_trunk = _coerce_nested_field(self.folding_trunk, FoldingTrunkConfig) + + +@dataclass +class MSAEncoderConfig: + """Optional multiple-sequence-alignment encoder settings.""" + + enabled: bool = field(default=False) + d_msa: int = field(default=128) + d_hidden: int = field(default=32) + n_layers: int = field(default=4) + n_heads_msa: int = field(default=8) + msa_head_width: int = field(default=32) + + +@dataclass +class LMEncoderConfig: + """Release-model pair encoder derived from language-model states.""" + + enabled: bool = field(default=True) + n_layers: int = field(default=4) + lm_dropout: float = field(default=0.25) + per_loop_lm_dropout: bool = field(default=True) + + +@dataclass +class ParcaeConfig: + """Release-model diffusion-loop scheduler settings.""" + + enabled: bool = field(default=True) + poisson_mean: float = field(default=3.0) + min_steps: int = field(default=1) + max_steps: int | None = field(default=6) + coda_n_layers: int = field(default=2) + + +_SCALAR_DEFAULTS: tuple[tuple[str, Any], ...] = ( + ("d_single", 384), + ("d_pair", 256), + ("n_relative_residx_bins", 32), + ("n_relative_chain_bins", 2), + ("num_loops", 10), + ("num_diffusion_samples", 8), + ("disable_msa_features", False), + ("lm_dropout", 0.0), + ("force_lm_dropout_during_inference", False), + ("lm_mask_pct", 0.0), + ("lm_d_model", 2560), + ("lm_num_layers", 80), +) +_NESTED_CONFIGS = ( + ("inputs", InputsEmbedderConfig), + ("folding_trunk", FoldingTrunkConfig), + ("structure_head", DiffusionStructureHeadConfig), + ("confidence_head", ConfidenceHeadConfig), + ("msa_encoder", MSAEncoderConfig), + ("parcae", ParcaeConfig), + ("lm_encoder", LMEncoderConfig), +) + + +class ESMFold2Config(PretrainedConfig): + """Serializable ESMFold2 architecture, runtime, and precision settings.""" + + model_type = "esmfold2" + has_no_defaults_at_init = True + + def __init__(self, **kwargs: Any) -> None: + legacy_backend = normalize_esmc_attention_implementation(kwargs.get("esmc_attn_backend")) + requested_backend = normalize_esmc_attention_implementation( + kwargs.get("attn_implementation") + ) + resolved_backend = requested_backend or legacy_backend + kwargs["attn_implementation"] = resolved_backend + super().__init__(**kwargs) + + self.type = kwargs.get("type", "release") + if self.type not in {"experimental", "release"}: + raise ValueError( + f"ESMFold2Config.type must be 'release' or 'experimental', got {self.type!r}" + ) + + for name, default in _SCALAR_DEFAULTS: + setattr(self, name, kwargs.get(name, default)) + + _official_esmc_repo, default_esmc_repo = _esmc_backbone_checkpoint_ids() + self.esmc_id = normalize_esmc_id(kwargs.get("esmc_id", default_esmc_repo)) + self.esmc_attn_backend = resolved_backend + self.esmc_precision = str(kwargs.get("esmc_precision", "auto")) + if self.esmc_precision not in _ESMC_PRECISIONS: + raise ValueError( + "esmc_precision must be 'auto', 'bf16', 'fp32', or 'fp8', " + f"got {self.esmc_precision!r}." + ) + + for name, config_type in _NESTED_CONFIGS: + setattr(self, name, _nested_config(kwargs.get(name), config_type)) + if not isinstance(self.msa_encoder.enabled, bool): + raise TypeError("msa_encoder.enabled must be a boolean.") + declared_msa_conditioning = kwargs.get("msa_conditioning") + if "msa_conditioning" in kwargs and not isinstance(declared_msa_conditioning, bool): + raise TypeError("msa_conditioning must be a boolean when provided.") + self.msa_conditioning = ( + self.msa_encoder.enabled + if "msa_conditioning" not in kwargs + else declared_msa_conditioning + ) + if self.msa_conditioning != self.msa_encoder.enabled: + raise ValueError( + "msa_conditioning must match msa_encoder.enabled; received " + f"{self.msa_conditioning!r} and {self.msa_encoder.enabled!r}." + ) + self.msa_encoder_overwrite = bool(kwargs.get("msa_encoder_overwrite", True)) + + def to_dict(self) -> dict[str, Any]: + output = cast(dict[str, Any], super().to_dict()) + for name, _config_type in _NESTED_CONFIGS: + output[name] = asdict(getattr(self, name)) + return output + + +__all__ = [ + "ESMFold2Config", + "LMEncoderConfig", + "MSAEncoderConfig", + "ParcaeConfig", + "normalize_esmc_attention_implementation", + "normalize_esmc_id", +] diff --git a/fastplms/models/esmfold2/embedding.py b/fastplms/models/esmfold2/embedding.py new file mode 100644 index 0000000000000000000000000000000000000000..ed31b7d8cfc395545f7206747fd8465f5c1f2e4a --- /dev/null +++ b/fastplms/models/esmfold2/embedding.py @@ -0,0 +1,105 @@ +"""ESMFold2 integration for the shared FastPLMs embedding API.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import torch +from torch import Tensor + +from ...embeddings import EmbeddingBatch, EmbeddingResult, embed_dataset +from .esmfold2_constants_esm3 import SEQUENCE_PAD_TOKEN, SEQUENCE_VOCAB + +_TOKEN_TO_ID = {token: index for index, token in enumerate(SEQUENCE_VOCAB)} +_VALID_RESIDUES = frozenset(SEQUENCE_VOCAB[4:31]) - {".", "-", "|"} + + +def _encode_single_chain(sequence: str) -> list[int]: + normalized = sequence.upper() + if not normalized: + raise ValueError("ESMFold2 dataset embedding requires at least one protein residue.") + invalid = sorted(set(normalized) - _VALID_RESIDUES) + if invalid: + raise ValueError( + "ESMFold2 dataset embedding accepts one ungapped protein chain; " + f"invalid symbols: {invalid}." + ) + return [_TOKEN_TO_ID[residue] for residue in normalized] + + +class ESMFold2EmbeddingMixin: + """Learned ESMC sequence summaries for ESMFold2 models.""" + + embedding_unsupported_pooling = frozenset({"cls", "parti"}) + embedding_layer = "all_81_esmc_states" + embedding_projection = "esmfold2_learned_sequence_summary" + embedding_token_policy: ClassVar[dict[str, object]] = { + "unit": "residue", + "normalization": "uppercase", + "include": ["single-chain protein residues"], + "exclude": [ + "BOS", + "EOS", + "padding", + "chain delimiters", + "non-protein tokens", + ], + } + + def project_esmc_hidden_states( + self, + hidden_states: Tensor, + residue_mask: Tensor | None = None, + ) -> Tensor: + """Project H from ``(b, l, 81, 2560)`` to Z with shape ``(b, l, 256)``.""" + + if hidden_states.ndim != 4 or hidden_states.shape[-2] != 81: + raise ValueError( + "ESMFold2 projection requires the official ordered 81-state " + "ESMC tensor H with shape (b, l, 81, d_model)." + ) + return self.language_model.project_sequence(hidden_states, residue_mask) + + def _embedding_batch(self, sequences: list[str], **kwargs: Any) -> EmbeddingBatch: + if kwargs: + raise TypeError(f"Unexpected ESMFold2 embedding options: {', '.join(sorted(kwargs))}.") + if self._esmc is None: + raise RuntimeError("ESMFold2 embeddings require load_esmc=True.") + encoded = [_encode_single_chain(sequence) for sequence in sequences] + sequence_length = max(map(len, encoded)) + b = len(encoded) + device = self.device + input_ids = torch.full( + (b, sequence_length), + SEQUENCE_PAD_TOKEN, + dtype=torch.long, + device=device, + ) + residue_mask = torch.zeros((b, sequence_length), dtype=torch.bool, device=device) + for batch_index, token_ids in enumerate(encoded): + length = len(token_ids) + input_ids[batch_index, :length] = torch.tensor( + token_ids, dtype=torch.long, device=device + ) + residue_mask[batch_index, :length] = True + + residue_index = torch.arange(sequence_length, device=device).expand(b, -1) + asym_id = torch.zeros_like(input_ids) + mol_type = torch.zeros_like(input_ids) + hidden_states = self._compute_lm_hidden_states( + input_ids, + asym_id, + residue_index, + mol_type, + residue_mask, + ) + projected = self.project_esmc_hidden_states(hidden_states, residue_mask) + return EmbeddingBatch(X=projected, residue_mask=residue_mask) + + def embed_dataset(self, inputs: Any, **kwargs: Any) -> EmbeddingResult: + """Embed single-chain proteins using the learned 256-wide ESMFold2 summary.""" + + return embed_dataset(self, inputs, **kwargs) + + +__all__ = ["ESMFold2EmbeddingMixin"] diff --git a/fastplms/models/esmfold2/esmfold2_affine3d.py b/fastplms/models/esmfold2/esmfold2_affine3d.py new file mode 100644 index 0000000000000000000000000000000000000000..17c47f2fbbbd90bf8006b58f0b0a537499b4fd23 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_affine3d.py @@ -0,0 +1,605 @@ +"""Differentiable rigid rotations and affine transforms for ESMFold2.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Self + +import torch +from torch.nn import functional as F + +from .esmfold2_misc import fp32_autocast_context + + +def _index_tuple(index: Any) -> tuple[Any, ...]: + if isinstance(index, int) or index is None: + return (index,) + return tuple(index) + + +def _sqrt_subgradient(values: torch.Tensor) -> torch.Tensor: + """Square root with a zero subgradient for non-positive inputs.""" + + result = torch.zeros_like(values) + positive = values > 0 + result[positive] = torch.sqrt(values[positive]) + return result + + +def _quat_invert(quaternion: torch.Tensor) -> torch.Tensor: + conjugate_sign = torch.tensor([1, -1, -1, -1], device=quaternion.device) + return quaternion * conjugate_sign + + +def _quat_mult(left: torch.Tensor, right: torch.Tensor) -> torch.Tensor: + """Hamilton product for real-first quaternion tensors.""" + + aw, ax, ay, az = torch.unbind(left, -1) + bw, bx, by, bz = torch.unbind(right, -1) + return torch.stack( + ( + aw * bw - ax * bx - ay * by - az * bz, + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + ), + -1, + ) + + +def _quat_rotation( + quaternion: torch.Tensor, + points: torch.Tensor, +) -> torch.Tensor: + """Rotate points using normalized real-first quaternions.""" + + aw, ax, ay, az = torch.unbind(quaternion, -1) + bx, by, bz = torch.unbind(points, -1) + product = torch.stack( + ( + -ax * bx - ay * by - az * bz, + aw * bx + ay * bz - az * by, + aw * by - ax * bz + az * bx, + aw * bz + ax * by - ay * bx, + ), + -1, + ) + return _quat_mult(product, _quat_invert(quaternion))[..., 1:] + + +def _graham_schmidt( + x_axis: torch.Tensor, + xy_plane: torch.Tensor, + eps: float = 1e-12, +) -> torch.Tensor: + """Construct a right-handed orthonormal frame from two directions.""" + + with fp32_autocast_context(x_axis.device.type): + e1 = xy_plane + denominator = torch.sqrt((x_axis**2).sum(dim=-1, keepdim=True) + eps) + x_axis = x_axis / denominator + projection = (x_axis * e1).sum(dim=-1, keepdim=True) + e1 = e1 - x_axis * projection + denominator = torch.sqrt((e1**2).sum(dim=-1, keepdim=True) + eps) + e1 = e1 / denominator + e2 = torch.cross(x_axis, e1, dim=-1) + return torch.stack([x_axis, e1, e2], dim=-1) + + +class Rotation: + """Common interface for matrix-backed and quaternion-backed rotations.""" + + @classmethod + def identity(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ... + + @classmethod + def random(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ... + + def __getitem__(self, idx: Any) -> Self: ... + + @property + def tensor(self) -> torch.Tensor: ... + + @property + def shape(self) -> torch.Size: ... + + def as_matrix(self) -> RotationMatrix: ... + + def as_quat(self, normalize: bool = False) -> RotationQuat: ... + + def compose(self, other: Self) -> Self: ... + + def convert_compose(self, other: Self) -> Self: ... + + def apply(self, points: torch.Tensor) -> torch.Tensor: ... + + def invert(self) -> Self: ... + + @property + def dtype(self) -> torch.dtype: + return self.tensor.dtype + + @property + def device(self) -> torch.device: + return self.tensor.device + + @property + def requires_grad(self) -> bool: + return self.tensor.requires_grad + + @classmethod + def _from_tensor(cls, tensor: torch.Tensor) -> Self: + return cls(tensor) # type: ignore[call-arg] + + def to(self, **kwargs) -> Self: + return self._from_tensor(self.tensor.to(**kwargs)) + + def detach(self, *args, **kwargs) -> Self: + return self._from_tensor(self.tensor.detach(**kwargs)) + + def tensor_apply(self, func) -> Self: + transformed = [func(component) for component in self.tensor.unbind(dim=-1)] + return self._from_tensor(torch.stack(transformed, dim=-1)) + + +class RotationQuat(Rotation): + """A rotation represented by a real-first quaternion.""" + + def __init__(self, quats: torch.Tensor, normalized: bool = False): + if not isinstance(quats, torch.Tensor): + raise TypeError("quats must be a Torch tensor.") + if quats.ndim == 0 or quats.shape[-1] != 4: + raise ValueError( + f"quats must have trailing dimension 4, got shape {tuple(quats.shape)}." + ) + if not isinstance(normalized, bool): + raise TypeError("normalized must be a boolean.") + self._normalized = normalized + if normalized: + quats = F.normalize(quats.to(torch.float32), dim=-1) + self._quats = quats.where(quats[..., :1] >= 0, -quats) + else: + self._quats = quats.to(torch.float32) + + @property + def tensor(self) -> torch.Tensor: + return self._quats + + @property + def shape(self) -> torch.Size: + return self._quats.shape[:-1] + + @classmethod + def identity(cls, shape, **tensor_kwargs) -> RotationQuat: + quaternions = torch.ones((*shape, 4), **tensor_kwargs) + selector = torch.tensor([1, 0, 0, 0], device=quaternions.device) + return cls(quaternions * selector) + + @classmethod + def random(cls, shape, **tensor_kwargs) -> RotationQuat: + return cls(torch.randn((*shape, 4), **tensor_kwargs), normalized=True) + + def __getitem__(self, idx: Any) -> RotationQuat: + indices = _index_tuple(idx) + return RotationQuat(self._quats[(*indices, slice(None))]) + + def normalized(self) -> RotationQuat: + if self._normalized: + return self + return RotationQuat(self._quats, normalized=True) + + def as_quat(self, normalize: bool = False) -> RotationQuat: + return self + + def as_matrix(self) -> RotationMatrix: + quaternion = self.normalized().tensor + r, i, j, k = torch.unbind(quaternion, -1) + scale = 2.0 / torch.linalg.norm(quaternion, dim=-1) + elements = torch.stack( + ( + 1 - scale * (j * j + k * k), + scale * (i * j - k * r), + scale * (i * k + j * r), + scale * (i * j + k * r), + 1 - scale * (i * i + k * k), + scale * (j * k - i * r), + scale * (i * k - j * r), + scale * (j * k + i * r), + 1 - scale * (i * i + j * j), + ), + -1, + ) + return RotationMatrix(elements.reshape((*quaternion.shape[:-1], 3, 3))) + + def compose(self, other: RotationQuat) -> RotationQuat: + with fp32_autocast_context(self.device.type): + return RotationQuat(_quat_mult(self._quats, other._quats)) + + def convert_compose(self, other: Rotation) -> RotationQuat: + return self.compose(other.as_quat()) + + def apply(self, points: torch.Tensor) -> torch.Tensor: + return _quat_rotation(self.normalized()._quats, points) + + def invert(self) -> RotationQuat: + return RotationQuat(_quat_invert(self._quats)) + + +class RotationMatrix(Rotation): + """A rotation represented by a dense FP32 matrix.""" + + def __init__(self, rots: torch.Tensor): + if not isinstance(rots, torch.Tensor): + raise TypeError("rots must be a Torch tensor.") + if rots.ndim > 0 and rots.shape[-1] == 9: + rots = rots.unflatten(-1, (3, 3)) + if rots.ndim < 2 or rots.shape[-2:] != (3, 3): + raise ValueError( + "rots must have trailing shape (3, 3) or flattened width 9, got " + f"shape {tuple(rots.shape)}." + ) + self._rots = rots.to(torch.float32) + + @property + def tensor(self) -> torch.Tensor: + return self._rots.flatten(-2) + + @property + def shape(self) -> torch.Size: + return self._rots.shape[:-2] + + @classmethod + def identity(cls, shape, **tensor_kwargs) -> RotationMatrix: + matrix = torch.eye(3, **tensor_kwargs) + matrix = matrix.view(*(1 for _ in shape), 3, 3) + return cls(matrix.expand(*shape, -1, -1)) + + @classmethod + def random(cls, shape, **tensor_kwargs) -> RotationMatrix: + return RotationQuat.random(shape, **tensor_kwargs).as_matrix() + + @staticmethod + def from_graham_schmidt( + x_axis: torch.Tensor, + xy_plane: torch.Tensor, + eps: float = 1e-12, + ) -> RotationMatrix: + return RotationMatrix(_graham_schmidt(x_axis, xy_plane, eps)) + + def __getitem__(self, idx: Any) -> RotationMatrix: + indices = _index_tuple(idx) + return RotationMatrix(self._rots[(*indices, slice(None), slice(None))]) + + def as_matrix(self) -> RotationMatrix: + return self + + def to_3x3(self) -> torch.Tensor: + return self._rots + + def as_quat(self, normalize: bool = False) -> RotationQuat: + m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind( + self._rots.flatten(-2), + dim=-1, + ) + q_abs = _sqrt_subgradient( + torch.stack( + ( + 1.0 + m00 + m11 + m22, + 1.0 + m00 - m11 - m22, + 1.0 - m00 + m11 - m22, + 1.0 - m00 - m11 + m22, + ), + dim=-1, + ) + ) + products = torch.stack( + ( + q_abs[..., 0] ** 2, + m21 - m12, + m02 - m20, + m10 - m01, + m21 - m12, + q_abs[..., 1] ** 2, + m10 + m01, + m02 + m20, + m02 - m20, + m10 + m01, + q_abs[..., 2] ** 2, + m12 + m21, + m10 - m01, + m20 + m02, + m21 + m12, + q_abs[..., 3] ** 2, + ), + dim=-1, + ).unflatten(-1, (4, 4)) + floor = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device) + candidates = products / (2.0 * q_abs[..., None].max(floor)) + best = torch.zeros_like(q_abs, dtype=torch.bool) + best.scatter_(-1, q_abs.argmax(dim=-1, keepdim=True), True) + quaternion = candidates[best, :].reshape(q_abs.shape) + return RotationQuat(quaternion) + + def compose(self, other: RotationMatrix) -> RotationMatrix: + with fp32_autocast_context(self.device.type): + return RotationMatrix(self._rots @ other._rots) + + def convert_compose(self, other: Rotation) -> RotationMatrix: + return self.compose(other.as_matrix()) + + def apply(self, points: torch.Tensor) -> torch.Tensor: + with fp32_autocast_context(self.device.type): + if self._rots.shape[-3] == 1: + return points @ self._rots.transpose(-1, -2).squeeze(-3) + return torch.einsum("...ij,...j", self._rots, points) + + def invert(self) -> RotationMatrix: + return RotationMatrix(self._rots.transpose(-1, -2)) + + +@dataclass(frozen=True) +class Affine3D: + """A rigid transform with translation and rotation components.""" + + trans: torch.Tensor + rot: Rotation + + def __post_init__(self) -> None: + if not isinstance(self.trans, torch.Tensor): + raise TypeError("trans must be a Torch tensor.") + if not isinstance(self.rot, Rotation): + raise TypeError("rot must implement the ESMFold2 Rotation interface.") + if self.trans.ndim == 0 or self.trans.shape[-1] != 3: + raise ValueError( + "trans must have trailing dimension 3, got " + f"shape {tuple(self.trans.shape)}." + ) + if self.trans.shape[:-1] != self.rot.shape: + raise ValueError( + "translation and rotation batch shapes must match, got " + f"{tuple(self.trans.shape[:-1])} and {tuple(self.rot.shape)}." + ) + + @property + def shape(self) -> torch.Size: + return self.trans.shape[:-1] + + @property + def dtype(self) -> torch.dtype: + return self.trans.dtype + + @property + def device(self) -> torch.device: + return self.trans.device + + @property + def requires_grad(self) -> bool: + return self.trans.requires_grad + + @property + def tensor(self) -> torch.Tensor: + return torch.cat((self.rot.tensor, self.trans), dim=-1) + + @staticmethod + def identity( + shape_or_affine: tuple[int, ...] | Affine3D, + rotation_type: type[Rotation] = RotationMatrix, + **tensor_kwargs, + ) -> Affine3D: + if isinstance(shape_or_affine, Affine3D): + kwargs = { + "dtype": shape_or_affine.dtype, + "device": shape_or_affine.device, + } + kwargs.update(tensor_kwargs) + shape = shape_or_affine.shape + rotation_type = type(shape_or_affine.rot) + else: + kwargs = tensor_kwargs + shape = shape_or_affine + return Affine3D( + torch.zeros((*shape, 3), **kwargs), + rotation_type.identity(shape, **kwargs), + ) + + @staticmethod + def random( + shape: tuple[int, ...], + std: float = 1, + rotation_type: type[Rotation] = RotationMatrix, + **tensor_kwargs, + ) -> Affine3D: + translation = torch.randn((*shape, 3), **tensor_kwargs).mul(std) + rotation = rotation_type.random(shape, **tensor_kwargs) + return Affine3D(trans=translation, rot=rotation) + + @staticmethod + def from_tensor(tensor: torch.Tensor) -> Affine3D: + if not isinstance(tensor, torch.Tensor): + raise TypeError("tensor must be a Torch tensor.") + if tensor.ndim == 0: + raise ValueError("tensor must have at least one dimension.") + width = tensor.shape[-1] + if width == 4: + if tensor.ndim < 2 or tensor.shape[-2] not in (3, 4): + raise ValueError( + "matrix-form affine tensors must have trailing shape (3, 4) or " + f"(4, 4), got {tuple(tensor.shape)}." + ) + translation = tensor[..., :3, 3] + rotation: Rotation = RotationMatrix(tensor[..., :3, :3]) + elif width == 6: + translation = tensor[..., -3:] + rotation = RotationQuat(F.pad(tensor[..., :3], (1, 0), value=1)) + elif width == 7: + translation = tensor[..., -3:] + rotation = RotationQuat(tensor[..., :4]) + elif width == 12: + translation = tensor[..., -3:] + rotation = RotationMatrix(tensor[..., :-3].unflatten(-1, (3, 3))) + else: + raise RuntimeError( + f"Cannot detect rotation format from {tensor.shape[-1] - 3}-d flat vector" + ) + return Affine3D(translation, rotation) + + @staticmethod + def from_tensor_pair( + translation: torch.Tensor, + rotation: torch.Tensor, + ) -> Affine3D: + return Affine3D(translation, RotationMatrix(rotation)) + + @staticmethod + def from_graham_schmidt( + neg_x_axis: torch.Tensor, + origin: torch.Tensor, + xy_plane: torch.Tensor, + eps: float = 1e-10, + ) -> Affine3D: + x_axis = origin - neg_x_axis + plane_direction = xy_plane - origin + rotation = RotationMatrix.from_graham_schmidt( + x_axis, + plane_direction, + eps, + ) + return Affine3D(trans=origin, rot=rotation) + + @staticmethod + def cat(affines: list[Affine3D], dim: int = 0) -> Affine3D: + if not affines: + raise ValueError("affines must contain at least one transform.") + if any(not isinstance(affine, Affine3D) for affine in affines): + raise TypeError("affines must contain only Affine3D instances.") + if dim < 0: + dim = len(affines[0].shape) + dim + return Affine3D.from_tensor(torch.cat([affine.tensor for affine in affines], dim=dim)) + + def __getitem__(self, idx: Any) -> Affine3D: + indices = _index_tuple(idx) + translation = self.trans[(*indices, slice(None))] + return Affine3D(trans=translation, rot=self.rot[idx]) + + def to(self, **kwargs) -> Affine3D: + return Affine3D(self.trans.to(**kwargs), self.rot.to(**kwargs)) + + def detach(self, *args, **kwargs) -> Affine3D: + return Affine3D( + self.trans.detach(**kwargs), + self.rot.detach(**kwargs), + ) + + def tensor_apply(self, func) -> Affine3D: + components = [func(value) for value in self.tensor.unbind(dim=-1)] + return Affine3D.from_tensor(torch.stack(components, dim=-1)) + + def as_matrix(self) -> Affine3D: + return Affine3D(trans=self.trans, rot=self.rot.as_matrix()) + + def as_quat(self, normalize: bool = False) -> Affine3D: + return Affine3D( + trans=self.trans, + rot=self.rot.as_quat(normalize), + ) + + def compose( + self, + other: Affine3D, + autoconvert: bool = False, + ) -> Affine3D: + compose_rotation = self.rot.convert_compose if autoconvert else self.rot.compose + rotation = compose_rotation(other.rot) + translation = self.rot.apply(other.trans) + self.trans + return Affine3D(trans=translation, rot=rotation) + + def compose_rotation( + self, + other: Rotation, + autoconvert: bool = False, + ) -> Affine3D: + compose = self.rot.convert_compose if autoconvert else self.rot.compose + return Affine3D(trans=self.trans, rot=compose(other)) + + def scale(self, value: torch.Tensor | float) -> Affine3D: + return Affine3D(self.trans * value, self.rot) + + def mask(self, mask: torch.Tensor, with_zero: bool = False) -> Affine3D: + if with_zero: + masked = torch.zeros_like(self.tensor).where( + mask[..., None], + self.tensor, + ) + return Affine3D.from_tensor(masked) + identity = self.identity( + self.shape, + rotation_type=type(self.rot), + device=self.device, + dtype=self.dtype, + ).tensor + return Affine3D.from_tensor(identity.where(mask[..., None], self.tensor)) + + def apply(self, points: torch.Tensor) -> torch.Tensor: + return self.rot.apply(points) + self.trans + + def invert(self) -> Affine3D: + rotation = self.rot.invert() + return Affine3D(trans=-rotation.apply(self.trans), rot=rotation) + + +def build_affine3d_from_coordinates( + coords: torch.Tensor, +) -> tuple[Affine3D, torch.Tensor]: + """Build residue frames from X with shape (b, l, 3, 3).""" + + if not isinstance(coords, torch.Tensor): + raise TypeError("coords must be a Torch tensor.") + if coords.ndim != 4 or coords.shape[-2:] != (3, 3): + raise ValueError( + "coords must have shape (batch, length, 3, 3), got " + f"{tuple(coords.shape)}." + ) + + maximum_distance = 1e6 + coord_mask = torch.all( + torch.all( + torch.isfinite(coords) & (coords < maximum_distance), + dim=-1, + ), + dim=-1, + ) + + def backbone_affine(positions: torch.Tensor) -> Affine3D: + n, ca, c = positions.unbind(dim=-2) + return Affine3D.from_graham_schmidt(c, ca, n) + + coords = coords.clone().float() + coords[~coord_mask] = 0 + average = coords.masked_fill(~coord_mask[..., None, None], 0).sum(1) / ( + coord_mask.sum(-1)[..., None, None] + 1e-8 + ) + average_affine = backbone_affine(average.float()).as_matrix() + + b, length, _, _ = coords.shape + rotation = average_affine.rot.tensor[..., None, :].expand(b, length, 9) + translation = average_affine.trans[..., None, :].expand(b, length, 3) + identity = RotationMatrix.identity( + (b, length), + dtype=torch.float32, + device=coords.device, + requires_grad=False, + ) + rotation = rotation.where( + coord_mask.any(-1)[..., None, None], + identity.tensor, + ) + missing_frame = Affine3D(translation, RotationMatrix(rotation)) + + residue_frame = backbone_affine(coords.float()) + residue_frame = Affine3D.from_tensor( + residue_frame.tensor.where( + coord_mask[..., None], + missing_frame.tensor, + ) + ) + return residue_frame, coord_mask diff --git a/fastplms/models/esmfold2/esmfold2_aligner.py b/fastplms/models/esmfold2/esmfold2_aligner.py new file mode 100644 index 0000000000000000000000000000000000000000..a23b9d6d6b727ba020cef08f9208281e65b02393 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_aligner.py @@ -0,0 +1,87 @@ +"""Rigid alignment for structure dataclasses.""" + +from __future__ import annotations + +from dataclasses import Field, replace +from typing import Any, ClassVar, Protocol, TypeVar + +import numpy as np +import torch +from torch import Tensor + +from .esmfold2_protein_structure import compute_affine_and_rmsd + + +class Alignable(Protocol): + """Minimum structure interface accepted by :class:`Aligner`.""" + + __dataclass_fields__: ClassVar[dict[str, Field[Any]]] + + @property + def atom37_positions(self) -> np.ndarray: ... + + @property + def atom37_mask(self) -> np.ndarray: ... + + def __len__(self) -> int: ... + + +AlignableT = TypeVar("AlignableT", bound=Alignable) + + +def _coordinate_batch(structure: Alignable) -> Tensor: + return torch.as_tensor(structure.atom37_positions, dtype=torch.double).unsqueeze(0) + + +def _shared_atom_mask(mobile: Alignable, target: Alignable, backbone_only: bool) -> Tensor: + shared = np.asarray(mobile.atom37_mask, dtype=bool) & np.asarray( + target.atom37_mask, + dtype=bool, + ) + if backbone_only: + shared = shared.copy() + shared[:, 3:] = False + return torch.from_numpy(shared).unsqueeze(0) + + +class Aligner: + """Fit a mobile structure onto a target with masked Kabsch alignment.""" + + def __init__( + self, + mobile: Alignable, + target: Alignable, + only_use_backbone: bool = False, + use_reflection: bool = False, + ) -> None: + if len(mobile) != len(target): + raise AssertionError("mobile and target must contain the same residue count") + + mobile_coordinates = _coordinate_batch(mobile) + target_coordinates = _coordinate_batch(target) + if use_reflection: + target_coordinates = -target_coordinates + atom_mask = _shared_atom_mask(mobile, target, only_use_backbone) + self._affine3D, rmsd = compute_affine_and_rmsd( + mobile_coordinates, + target_coordinates, + atom_exists_mask=atom_mask, + ) + self._rmsd = rmsd.item() + + @property + def rmsd(self) -> float: + return self._rmsd + + def apply(self, mobile: AlignableT) -> AlignableT: + """Return a dataclass copy with all present atom coordinates aligned.""" + + present = np.asarray(mobile.atom37_mask, dtype=bool) + packed = torch.as_tensor( + mobile.atom37_positions[present], + dtype=torch.float32, + ).unsqueeze(0) + aligned = self._affine3D.apply(packed).squeeze(0).cpu().numpy() + atom37_positions = np.full_like(mobile.atom37_positions, np.nan) + atom37_positions[present] = aligned + return replace(mobile, atom37_positions=atom37_positions) diff --git a/fastplms/models/esmfold2/esmfold2_atom_indexer.py b/fastplms/models/esmfold2/esmfold2_atom_indexer.py new file mode 100644 index 0000000000000000000000000000000000000000..676cbde76427c6c64672c0fe4e991457cdf57d70 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_atom_indexer.py @@ -0,0 +1,30 @@ +"""Name-based views into an atom-axis property.""" + +from __future__ import annotations + +from operator import attrgetter +from typing import Any + +import numpy as np + +from .esmfold2_protein_structure import index_by_atom_name + + +class AtomIndexer: + """Select named atoms from one property of a structure-like object. + + The wrapper intentionally remains small because ``ProteinChain.atom37`` and + related public properties expose it directly. + """ + + __slots__ = ("_get_property", "dim", "property", "structure") + + def __init__(self, structure: Any, property: str, dim: int): + self.structure = structure + self.property = property + self.dim = dim + self._get_property = attrgetter(property) + + def __getitem__(self, atom_names: str | list[str]) -> np.ndarray: + values = self._get_property(self.structure) + return index_by_atom_name(values, atom_names, dim=self.dim) diff --git a/fastplms/models/esmfold2/esmfold2_conformers.py b/fastplms/models/esmfold2/esmfold2_conformers.py new file mode 100644 index 0000000000000000000000000000000000000000..ddd0e9b55e4175aa0d144c7330ecfcd1d3d902d4 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_conformers.py @@ -0,0 +1,402 @@ +"""Lazy access to Chemical Component Dictionary conformers. + +The feature pipeline depends on atom names, formal charges, bonds, leaving-atom +flags, and one preferred reference conformer. Asset resolution is explicit at +``load_ccd`` time; importing this module performs no download or file access. +""" + +from __future__ import annotations + +import os +import pickle +import stat +import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from hashlib import file_digest +from pathlib import Path +from typing import Any, BinaryIO + +import numpy as np +from huggingface_hub import hf_hub_download +from huggingface_hub.constants import HF_HUB_CACHE + +from fastplms.registry import RuntimeAsset, get_model_registry + +from .esmfold2_constants import RES_TYPE_TO_CCD + +_CCD_ENVIRONMENT_VARIABLE = "ESMCFOLD_CCD_PATH" +_CCD_ASSET_ID = "esmfold2_ccd" + + +@dataclass(frozen=True) +class _ResolvedAsset: + path: Path + trusted_hub_cache_root: Path | None = None + + +def _asset_contract() -> RuntimeAsset: + """Return the manifest-owned identity of the trusted CCD pickle.""" + + try: + asset = get_model_registry().runtime_assets[_CCD_ASSET_ID] + except KeyError as error: + raise RuntimeError( + f"The package manifest does not declare runtime asset {_CCD_ASSET_ID!r}." + ) from error + if asset.trust_kind != "hash_pinned_pickle": + raise RuntimeError( + f"Runtime asset {_CCD_ASSET_ID!r} must use the hash_pinned_pickle trust policy." + ) + return asset + + +@contextmanager +def _open_verified_asset( + asset_path: Path, + contract: RuntimeAsset, + *, + trusted_hub_cache_root: Path | None = None, +) -> Iterator[BinaryIO]: + """Yield a private snapshot containing exactly the verified pickle bytes.""" + + try: + path_state = asset_path.lstat() + except FileNotFoundError as error: + raise FileNotFoundError(f"CCD asset does not exist: {asset_path}") from error + opened_path = asset_path + if stat.S_ISLNK(path_state.st_mode): + if trusted_hub_cache_root is None: + raise ValueError(f"CCD asset must not be a symlink: {asset_path}") + opened_path = _resolve_trusted_hub_snapshot_link( + asset_path, + contract, + trusted_hub_cache_root, + ) + path_state = opened_path.lstat() + if not stat.S_ISREG(path_state.st_mode): + raise ValueError(f"CCD asset must be a regular file: {asset_path}") + + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor: int | None = None + try: + descriptor = os.open(opened_path, flags) + opened_state = os.fstat(descriptor) + if not stat.S_ISREG(opened_state.st_mode): + raise ValueError(f"CCD asset must be a regular file: {asset_path}") + if (path_state.st_dev, path_state.st_ino) != ( + opened_state.st_dev, + opened_state.st_ino, + ): + raise ValueError(f"CCD asset changed while it was being opened: {asset_path}") + + source = os.fdopen(descriptor, "rb") + descriptor = None + with source, tempfile.TemporaryFile(mode="w+b") as snapshot: + actual_size = opened_state.st_size + if actual_size != contract.size: + raise ValueError( + "CCD asset size mismatch: " + f"expected {contract.size} bytes, received {actual_size}." + ) + # Copy into a loader-owned OS temporary file. Hashing and + # deserialization then consume the same immutable snapshot, so a + # path replacement or in-place source write cannot substitute + # unverified pickle bytes after validation. + remaining = contract.size + while remaining: + chunk = source.read(min(1024 * 1024, remaining)) + if not chunk: + break + snapshot.write(chunk) + remaining -= len(chunk) + copied_size = snapshot.tell() + extra_byte = source.read(1) + if remaining or extra_byte: + observed_size = copied_size if remaining else copied_size + len(extra_byte) + raise ValueError( + "CCD asset size changed while it was being copied: " + f"expected {contract.size} bytes, received at least {observed_size}." + ) + snapshot.flush() + snapshot.seek(0) + actual_hash = file_digest(snapshot, "sha256").hexdigest() + if actual_hash != contract.sha256: + raise ValueError( + "CCD asset SHA256 mismatch; refusing to cross the " + "trusted-pickle boundary." + ) + snapshot.seek(0) + yield snapshot + finally: + if descriptor is not None: + os.close(descriptor) + + +def _resolve_trusted_hub_snapshot_link( + asset_path: Path, + contract: RuntimeAsset, + cache_root: Path, +) -> Path: + """Resolve only the immutable Hub snapshot link declared by the manifest.""" + + root = cache_root.expanduser().resolve(strict=True) + if len(contract.revision) != 40 or any( + character not in "0123456789abcdef" for character in contract.revision.lower() + ): + raise ValueError("CCD Hub asset revision must be an immutable 40-character commit.") + relative_asset = Path(contract.path) + if relative_asset.is_absolute() or ".." in relative_asset.parts: + raise ValueError(f"CCD Hub asset path is unsafe: {contract.path!r}") + repository_cache = root / f"models--{contract.repository.replace('/', '--')}" + try: + repository_cache.resolve(strict=True).relative_to(root) + except (FileNotFoundError, ValueError) as error: + raise ValueError( + f"CCD Hub repository cache escapes the effective Hub cache root: {repository_cache}" + ) from error + snapshot_root = repository_cache / "snapshots" / contract.revision + expected_path = snapshot_root / relative_asset + lexical_path = Path(os.path.abspath(asset_path)) + if lexical_path != Path(os.path.abspath(expected_path)): + raise ValueError( + "CCD Hub symlink is not the manifest-owned immutable snapshot path: " + f"{asset_path}" + ) + + try: + asset_path.parent.resolve(strict=True).relative_to(root) + except (FileNotFoundError, ValueError) as error: + raise ValueError( + f"CCD Hub snapshot path escapes the effective Hub cache root: {asset_path}" + ) from error + + resolved = asset_path.resolve(strict=True) + blob_root = (repository_cache / "blobs").resolve(strict=True) + try: + blob_root.relative_to(root) + resolved.relative_to(blob_root) + except ValueError as error: + raise ValueError( + f"CCD Hub snapshot link escapes its repository blob cache: {asset_path}" + ) from error + if not resolved.is_file() or resolved.is_symlink(): + raise ValueError(f"CCD Hub snapshot target must be a regular file: {resolved}") + return resolved + + +class _ChemicalComponentStore: + def __init__(self) -> None: + self.molecules: dict[str, Any] | None = None + self.conformers: dict[str, dict[str, np.ndarray]] = {} + self.atoms: dict[str, list[tuple[str, str, int]]] = {} + self.bonds: dict[str, list[tuple[str, str]]] = {} + self.leaving_atoms: dict[str, set[str]] = {} + self.standard_positions: dict[tuple[int, str], np.ndarray | None] = {} + self.ligand_positions: dict[tuple[str, str], np.ndarray | None] = {} + + def load(self, cache_dir: Path | str | None = None) -> dict[str, Any]: + if self.molecules is not None: + return self.molecules + contract = _asset_contract() + resolved = self._resolve_asset_location(cache_dir, contract) + asset = resolved.path + try: + # SECURITY: the private snapshot is both hash-validated and + # deserialized, closing path-replacement and in-place-write races. + with _open_verified_asset( + asset, + contract, + trusted_hub_cache_root=resolved.trusted_hub_cache_root, + ) as handle: + loaded = pickle.load(handle) + except FileNotFoundError: + raise + except Exception as error: + raise ValueError(f"Could not read the CCD asset at {asset}: {error}") from error + if loaded is not None and not isinstance(loaded, dict): + raise TypeError("The CCD asset must contain a component dictionary.") + self.molecules = loaded or {} + return self.molecules + + @staticmethod + def _resolve_asset(cache_dir: Path | str | None) -> Path: + contract = _asset_contract() + return _ChemicalComponentStore._resolve_asset_location(cache_dir, contract).path + + @staticmethod + def _resolve_asset_location( + cache_dir: Path | str | None, + contract: RuntimeAsset, + ) -> _ResolvedAsset: + configured = os.environ.get(_CCD_ENVIRONMENT_VARIABLE) + if configured: + asset = Path(configured).expanduser() + elif cache_dir is not None: + asset = Path(cache_dir).expanduser() / contract.path + else: + try: + asset = Path( + hf_hub_download( + repo_id=contract.repository, + filename=contract.path, + revision=contract.revision, + ) + ) + except Exception as error: + raise FileNotFoundError( + "Could not resolve the ESMFold2 CCD asset. Set " + f"{_CCD_ENVIRONMENT_VARIABLE} or populate the Hugging Face cache." + ) from error + return _ResolvedAsset( + path=asset, + trusted_hub_cache_root=Path(HF_HUB_CACHE), + ) + return _ResolvedAsset(path=asset) + + def _component_with_conformer(self, component_id: str): + molecule = self.load().get(component_id) + if molecule is None or molecule.GetNumConformers() == 0: + return None, None + + conformers = list(molecule.GetConformers()) + priority = {"Computed": 0, "Ideal": 1} + selected_index = min( + range(len(conformers)), + key=lambda index: priority.get(conformers[index].GetPropsAsDict().get("name"), 2), + ) + + from rdkit import Chem + + heavy_molecule = Chem.RemoveHs(molecule, sanitize=False) + if heavy_molecule.GetNumConformers() == 0: + return None, None + conformer_index = min(selected_index, heavy_molecule.GetNumConformers() - 1) + return heavy_molecule, heavy_molecule.GetConformer(conformer_index) + + def conformer(self, component_id: str) -> dict[str, np.ndarray] | None: + if component_id not in self.conformers: + molecule, conformer = self._component_with_conformer(component_id) + positions: dict[str, np.ndarray] = {} + if molecule is not None and conformer is not None: + for atom in molecule.GetAtoms(): + atom_name = atom.GetPropsAsDict().get("name") + if not isinstance(atom_name, str) or not atom_name: + continue + point = conformer.GetAtomPosition(atom.GetIdx()) + positions[atom_name] = np.asarray((point.x, point.y, point.z), dtype=np.float32) + self.conformers[component_id] = positions + result = self.conformers[component_id] + return result or None + + def atom_records(self, component_id: str) -> list[tuple[str, str, int]] | None: + if component_id not in self.atoms: + molecule, _conformer = self._component_with_conformer(component_id) + records: list[tuple[str, str, int]] = [] + if molecule is not None: + for atom in molecule.GetAtoms(): + atom_name = atom.GetPropsAsDict().get("name") + if isinstance(atom_name, str) and atom_name: + records.append((atom_name, atom.GetSymbol(), atom.GetFormalCharge())) + self.atoms[component_id] = records + result = self.atoms[component_id] + return result or None + + def bond_records(self, component_id: str) -> list[tuple[str, str]] | None: + if component_id not in self.bonds: + molecule, _conformer = self._component_with_conformer(component_id) + records: list[tuple[str, str]] = [] + if molecule is not None: + names = { + atom.GetIdx(): atom.GetPropsAsDict().get("name") for atom in molecule.GetAtoms() + } + for bond in molecule.GetBonds(): + first = names.get(bond.GetBeginAtomIdx()) + second = names.get(bond.GetEndAtomIdx()) + if isinstance(first, str) and first and isinstance(second, str) and second: + records.append((first, second)) + self.bonds[component_id] = records + result = self.bonds[component_id] + return result or None + + def component_leaving_atoms(self, component_id: str) -> set[str]: + if component_id not in self.leaving_atoms: + molecule = self.load().get(component_id) + names: set[str] = set() + if molecule is not None: + for atom in molecule.GetAtoms(): + if atom.HasProp("leaving_atom") and atom.GetProp("leaving_atom") == "1": + name = atom.GetProp("name") if atom.HasProp("name") else "" + if name: + names.add(name) + self.leaving_atoms[component_id] = names + return self.leaving_atoms[component_id] + + +_STORE = _ChemicalComponentStore() + + +def load_ccd(cache_dir: Path | str | None = None) -> dict[str, Any]: + """Load and cache the CCD asset, resolving it only when called.""" + + return _STORE.load(cache_dir) + + +def get_ccd_conformer(component_id: str) -> dict[str, np.ndarray] | None: + """Return the preferred heavy-atom conformer by atom name.""" + + return _STORE.conformer(component_id) + + +def get_idealized_atom_pos(res_type: int, atom_name: str) -> np.ndarray | None: + """Return one standard-residue atom position from the preferred conformer.""" + + key = (res_type, atom_name) + if key not in _STORE.standard_positions: + component_id = RES_TYPE_TO_CCD.get(res_type) + conformer = _STORE.conformer(component_id) if component_id is not None else None + _STORE.standard_positions[key] = None if conformer is None else conformer.get(atom_name) + return _STORE.standard_positions[key] + + +def get_ligand_idealized_atom_pos(residue_name: str, atom_name: str) -> np.ndarray | None: + """Return one ligand atom position from the preferred conformer.""" + + key = (residue_name, atom_name) + if key not in _STORE.ligand_positions: + conformer = _STORE.conformer(residue_name) + _STORE.ligand_positions[key] = None if conformer is None else conformer.get(atom_name) + return _STORE.ligand_positions[key] + + +def get_ligand_ccd_atoms_with_charges( + component_id: str, +) -> list[tuple[str, str, int]] | None: + """Return heavy-atom name, element, and formal-charge records.""" + + return _STORE.atom_records(component_id) + + +def get_ligand_ccd_bonds(component_id: str) -> list[tuple[str, str]] | None: + """Return bonds as component atom-name pairs.""" + + return _STORE.bond_records(component_id) + + +def get_ccd_leaving_atoms(component_id: str) -> set[str]: + """Return atoms removed when a CCD component is polymerized.""" + + return _STORE.component_leaving_atoms(component_id) + + +__all__ = [ + "get_ccd_conformer", + "get_ccd_leaving_atoms", + "get_idealized_atom_pos", + "get_ligand_ccd_atoms_with_charges", + "get_ligand_ccd_bonds", + "get_ligand_idealized_atom_pos", + "load_ccd", +] diff --git a/fastplms/models/esmfold2/esmfold2_constants.py b/fastplms/models/esmfold2/esmfold2_constants.py new file mode 100644 index 0000000000000000000000000000000000000000..df0709620fda4f5cdf91b69f6eff2d3cd61e9b19 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_constants.py @@ -0,0 +1,156 @@ +"""Declarative molecular schema for ESMFold2 feature preparation. + +The package manifest owns the upstream revision and license provenance. This +module expresses the corresponding checkpoint-facing integer schema as compact +ordered records, then derives lookup tables from those records. The generated +tables are validated at import without reading files, downloading assets, or +mutating process state. +""" + +from __future__ import annotations + +SCHEMA_PROVENANCE = { + "manifest_family": "esmfold2", + "contract": "biohub_esmfold2_input_v1", +} + + +def _words(value: str) -> list[str]: + return value.split() + + +MOL_TYPE_PROTEIN = 0 +MOL_TYPE_DNA = 1 +MOL_TYPE_RNA = 2 +MOL_TYPE_NONPOLYMER = 3 + +# The record order is part of the checkpoint input contract. Residue indices +# start at two because zero and one are reserved by the model feature schema. +_PROTEIN_SCHEMA = tuple( + tuple(record.split(":")) + for record in ( + "ALA:A:N CA C O CB", + "ARG:R:N CA C O CB CG CD NE CZ NH1 NH2", + "ASN:N:N CA C O CB CG OD1 ND2", + "ASP:D:N CA C O CB CG OD1 OD2", + "CYS:C:N CA C O CB SG", + "GLN:Q:N CA C O CB CG CD OE1 NE2", + "GLU:E:N CA C O CB CG CD OE1 OE2", + "GLY:G:N CA C O", + "HIS:H:N CA C O CB CG ND1 CD2 CE1 NE2", + "ILE:I:N CA C O CB CG1 CG2 CD1", + "LEU:L:N CA C O CB CG CD1 CD2", + "LYS:K:N CA C O CB CG CD CE NZ", + "MET:M:N CA C O CB CG SD CE", + "PHE:F:N CA C O CB CG CD1 CD2 CE1 CE2 CZ", + "PRO:P:N CA C O CB CG CD", + "SER:S:N CA C O CB OG", + "THR:T:N CA C O CB OG1 CG2", + "TRP:W:N CA C O CB CG CD1 CD2 NE1 CE2 CE3 CZ2 CZ3 CH2", + "TYR:Y:N CA C O CB CG CD1 CD2 CE1 CE2 CZ OH", + "VAL:V:N CA C O CB CG1 CG2", + ) +) + +PROTEIN_RESIDUE_TO_RES_TYPE = { + residue: index for index, (residue, _letter, _atoms) in enumerate(_PROTEIN_SCHEMA, 2) +} +PROTEIN_RESIDUE_TO_RES_TYPE["MSE"] = PROTEIN_RESIDUE_TO_RES_TYPE["MET"] +PROTEIN_UNK_RES_TYPE = 22 + +RNA_RESIDUE_TO_RES_TYPE = dict(zip("AGCU", range(23, 27), strict=True)) +RNA_UNK_RES_TYPE = 27 +DNA_RESIDUE_TO_RES_TYPE = dict(zip(("DA", "DG", "DC", "DT"), range(28, 32), strict=True)) +DNA_UNK_RES_TYPE = 32 +GAP_RES_TYPE = DNA_UNK_RES_TYPE + +PROTEIN_3TO1 = {residue: letter for residue, letter, _atoms in _PROTEIN_SCHEMA} +PROTEIN_3TO1["MSE"] = "M" +PROTEIN_1TO3 = {letter: residue for residue, letter, _atoms in _PROTEIN_SCHEMA} +PROTEIN_1TO3["X"] = "UNK" +DNA_1TO3 = dict(zip("ATCG", ("DA", "DT", "DC", "DG"), strict=True)) +RNA_1TO3 = {letter: letter for letter in "AUCG"} + +_ESM_RESIDUE_ORDER = "LAGVSERTIDPKQNFYM HWC".replace(" ", "") +ESM_PROTEIN_VOCAB = {residue: token_id for token_id, residue in enumerate(_ESM_RESIDUE_ORDER, 4)} +ESM_PROTEIN_VOCAB["X"] = 3 +DNA_RNA_LIGAND_INPUT_ID = 24 +MSA_PAD_TOKEN_ID = 0 +MSA_GAP_TOKEN_ID = 1 + +RES_TYPE_TO_CCD = { + **{ + index: residue for residue, index in PROTEIN_RESIDUE_TO_RES_TYPE.items() if residue != "MSE" + }, + 22: "UNK", + **dict(zip(range(23, 28), ("A", "G", "C", "U", "N"), strict=True)), + **dict(zip(range(28, 33), ("DA", "DG", "DC", "DT", "DN"), strict=True)), +} + +_CHARGE_SCHEMA = _words( + "LYS:NZ:1 ARG:NH2:1 HIS:ND1:1 PO4:O2:-1 PO4:O3:-1 PO4:O4:-1 " + "SO4:O3:-1 SO4:O4:-1 MG:MG:2 ZN:ZN:2 CA:CA:2 FE2:FE:2 MN:MN:2 " + "CO:CO:2 NCO:CO:3 CU:CU:2 NI:NI:2 K:K:1 NA:NA:1 CD:CD:2 CL:CL:-1 " + "ACT:OXT:-1 NAD:O2N:-1 NAD:N1N:1 NAP:O2N:-1 NAP:N1N:1 IMD:N3:1 " + "SAM:SD:1 FE:FE:3 A1BH3:N3:1" +) +CHARGED_ATOMS = { + (component, atom): int(charge) + for component, atom, charge in (record.split(":") for record in _CHARGE_SCHEMA) +} + +_PERIODIC_SYMBOLS = _words( + "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 " + "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 " + "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 " + "FR RA AC TH PA U" +) +ELEMENT_TO_ATOMIC_NUM = { + symbol: atomic_number + for atomic_number, symbol in enumerate(_PERIODIC_SYMBOLS, 1) + if symbol != "HE" +} +ELEMENT_NUMBER_TO_SYMBOL = { + atomic_number: symbol for symbol, atomic_number in ELEMENT_TO_ATOMIC_NUM.items() +} + +PROTEIN_HEAVY_ATOMS = { + residue: atom_string.split() for residue, _letter, atom_string in _PROTEIN_SCHEMA +} +PROTEIN_HEAVY_ATOMS["MSE"] = PROTEIN_HEAVY_ATOMS["MET"].copy() +PROTEIN_HEAVY_ATOMS["UNK"] = _words("N CA C O") + +DNA_BACKBONE_ATOMS = _words("P OP1 OP2 O5' C5' C4' O4' C3' O3' C2' C1'") +RNA_BACKBONE_ATOMS = _words("P OP1 OP2 O5' C5' C4' O4' C3' O3' C2' O2' C1'") +_NUCLEOBASE_ATOMS = { + "A": _words("N9 C8 N7 C5 C6 N6 N1 C2 N3 C4"), + "G": _words("N9 C8 N7 C5 C6 O6 N1 C2 N2 N3 C4"), + "C": _words("N1 C2 O2 N3 C4 N4 C5 C6"), + "U": _words("N1 C2 O2 N3 C4 O4 C5 C6"), + "T": _words("N1 C2 O2 N3 C4 O4 C5 C7 C6"), +} +DNA_HEAVY_ATOMS = { + "DA": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["A"], + "DG": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["G"], + "DC": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["C"], + "DT": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["T"], +} +RNA_HEAVY_ATOMS = {residue: RNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS[residue] for residue in "AGCU"} + + +def _validate_schema() -> None: + if sorted(set(PROTEIN_RESIDUE_TO_RES_TYPE.values())) != list(range(2, 22)): + raise RuntimeError("Protein residue indices must cover the checkpoint interval 2..21.") + if len(_ESM_RESIDUE_ORDER) != 20 or len(set(_ESM_RESIDUE_ORDER)) != 20: + raise RuntimeError("The ESM residue vocabulary must contain 20 canonical residues.") + if RES_TYPE_TO_CCD[14] != "MET" or PROTEIN_RESIDUE_TO_RES_TYPE["MSE"] != 14: + raise RuntimeError("Selenomethionine must share the methionine residue index.") + if ELEMENT_TO_ATOMIC_NUM.get("U") != 92 or 2 in ELEMENT_NUMBER_TO_SYMBOL: + raise RuntimeError("The element schema must preserve the training-time atomic-number map.") + if set(DNA_HEAVY_ATOMS) != {"DA", "DG", "DC", "DT"}: + raise RuntimeError("The DNA atom schema is incomplete.") + + +_validate_schema() + +__all__ = [name for name in globals() if name.isupper()] diff --git a/fastplms/models/esmfold2/esmfold2_constants_esm3.py b/fastplms/models/esmfold2/esmfold2_constants_esm3.py new file mode 100644 index 0000000000000000000000000000000000000000..4c044111bb9e5f18d11b207a3b9d2c0a75f83fc5 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_constants_esm3.py @@ -0,0 +1,98 @@ +"""Token schemas needed by the ESMC encoder inside ESMFold2. + +The values implement the published Biohub ESM sequence-token contract pinned by +``models.toml``. They are generated from ordered schemas so token position and +special-token relationships are explicit and independently testable. This +module performs no downloads and resolves no model assets at import time. +""" + +from __future__ import annotations + +from types import MappingProxyType + + +def _words(value: str) -> list[str]: + return value.split() + + +SEQUENCE_VOCAB = _words( + " 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 . - | " +) + +_sequence_token_ids = MappingProxyType({token: index for index, token in enumerate(SEQUENCE_VOCAB)}) +SEQUENCE_BOS_TOKEN = _sequence_token_ids[""] +SEQUENCE_PAD_TOKEN = _sequence_token_ids[""] +SEQUENCE_EOS_TOKEN = _sequence_token_ids[""] +SEQUENCE_CHAINBREAK_TOKEN = _sequence_token_ids["|"] +SEQUENCE_MASK_TOKEN = _sequence_token_ids[""] +SEQUENCE_STANDARD_AA_MIN_TOKEN = _sequence_token_ids["L"] +SEQUENCE_STANDARD_AA_MAX_TOKEN = _sequence_token_ids["X"] + +VQVAE_CODEBOOK_SIZE = 4096 +VQVAE_SPECIAL_TOKENS = { + name: VQVAE_CODEBOOK_SIZE + offset + for offset, name in enumerate(("MASK", "EOS", "BOS", "PAD", "CHAINBREAK")) +} +VQVAE_DIRECTION_LOSS_BINS = 16 +VQVAE_PAE_BINS = 64 +VQVAE_MAX_PAE_BIN = 31.0 +VQVAE_PLDDT_BINS = 50 + +STRUCTURE_MASK_TOKEN = VQVAE_SPECIAL_TOKENS["MASK"] +STRUCTURE_EOS_TOKEN = VQVAE_SPECIAL_TOKENS["EOS"] +STRUCTURE_BOS_TOKEN = VQVAE_SPECIAL_TOKENS["BOS"] +STRUCTURE_PAD_TOKEN = VQVAE_SPECIAL_TOKENS["PAD"] +STRUCTURE_CHAINBREAK_TOKEN = VQVAE_SPECIAL_TOKENS["CHAINBREAK"] +STRUCTURE_UNDEFINED_TOKEN = 955 + +SASA_PAD_TOKEN = 0 +SS8_PAD_TOKEN = 0 +INTERPRO_PAD_TOKEN = 0 +RESIDUE_PAD_TOKEN = 0 + +CHAIN_BREAK_STR = "|" +SEQUENCE_BOS_STR = "" +SEQUENCE_EOS_STR = "" +MASK_STR_SHORT = "_" +SEQUENCE_MASK_STR = "" +SASA_MASK_STR = "" +SS8_MASK_STR = "" + +SSE_8CLASS_VOCAB = "GHITEBSC" +SSE_3CLASS_VOCAB = "HEC" +SSE_8CLASS_TO_3CLASS_MAP = dict(zip(SSE_8CLASS_VOCAB, "HHHCEECC", strict=True)) + +SASA_DISCRETIZATION_BOUNDARIES = [ + 0.8, + 4.0, + 9.6, + 16.4, + 24.5, + 32.9, + 42.0, + 51.5, + 61.2, + 70.9, + 81.6, + 93.3, + 107.2, + 125.4, + 151.4, +] +MAX_RESIDUE_ANNOTATIONS = 16 +TFIDF_VECTOR_SIZE = 58_641 +FUNCTION_TOKENS_DEPTH = 8 + + +def _validate_schema() -> None: + if len(SEQUENCE_VOCAB) != len(set(SEQUENCE_VOCAB)): + raise RuntimeError("The ESM sequence vocabulary contains duplicate tokens.") + if SEQUENCE_STANDARD_AA_MAX_TOKEN - SEQUENCE_STANDARD_AA_MIN_TOKEN != 20: + raise RuntimeError("The canonical residue interval must contain 20 tokens.") + if tuple(VQVAE_SPECIAL_TOKENS.values()) != tuple(range(4096, 4101)): + raise RuntimeError("The structure special-token interval is not contiguous.") + + +_validate_schema() + +__all__ = [name for name in globals() if name.isupper()] diff --git a/fastplms/models/esmfold2/esmfold2_input_builder.py b/fastplms/models/esmfold2/esmfold2_input_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..ca51bc2c91fd4c1e4139f7bcca71898e38e23065 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_input_builder.py @@ -0,0 +1,244 @@ +"""Typed, JSON-safe inputs for ESMFold2 feature preparation.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, TypeAlias + +import numpy as np + +from .esmfold2_msa import MSA + +MSAInput: TypeAlias = MSA | None + + +@dataclass +class Modification: + """A zero-indexed residue substitution using a CCD component.""" + + position: int + ccd: str + smiles: str | None = None + + +@dataclass +class ProteinInput: + id: str | list[str] + sequence: str + modifications: list[Modification] | None = None + msa: MSAInput = None + + +@dataclass +class RNAInput: + id: str | list[str] + sequence: str + modifications: list[Modification] | None = None + + +@dataclass +class DNAInput: + id: str | list[str] + sequence: str + modifications: list[Modification] | None = None + + +@dataclass +class LigandInput: + id: str | list[str] + smiles: str | None = None + ccd: list[str] | None = None + + +@dataclass +class DistogramConditioning: + chain_id: str + distogram: np.ndarray + + +@dataclass +class PocketConditioning: + binder_chain_id: str + contacts: list[tuple[str, int]] + + +@dataclass +class CovalentBond: + chain_id1: str + res_idx1: int + atom_idx1: int + chain_id2: str + res_idx2: int + atom_idx2: int + + +SequenceInput: TypeAlias = ProteinInput | RNAInput | DNAInput | LigandInput + + +@dataclass +class StructurePredictionInput: + sequences: Sequence[SequenceInput] + pocket: PocketConditioning | None = None + distogram_conditioning: list[DistogramConditioning] | None = None + covalent_bonds: list[CovalentBond] | None = None + + +_CHAIN_TYPE = { + ProteinInput: "protein", + RNAInput: "rna", + DNAInput: "dna", +} + + +def _serialize_modifications( + modifications: list[Modification] | None, +) -> list[dict[str, Any]] | None: + if not modifications: + return None + return [{"position": item.position, "ccd": item.ccd} for item in modifications] + + +def _serialize_chain(chain: SequenceInput) -> dict[str, Any]: + if isinstance(chain, LigandInput): + return { + "smiles": chain.smiles, + "id": chain.id, + "ccd": chain.ccd, + "type": "ligand", + } + + chain_type = _CHAIN_TYPE.get(type(chain)) + if chain_type is None: + raise ValueError(f"Unsupported sequence input type: {type(chain)}") + serialized: dict[str, Any] = { + "sequence": chain.sequence, + "id": chain.id, + "type": chain_type, + } + if modifications := _serialize_modifications(chain.modifications): + serialized["modifications"] = modifications + if isinstance(chain, ProteinInput): + if chain.msa is not None and not isinstance(chain.msa, MSA): + raise AttributeError(f"MSA must be None or MSA. Got {chain.msa} instead.") + serialized["msa"] = None if chain.msa is None else {"sequences": chain.msa.sequences} + return serialized + + +def serialize_structure_prediction_input( + structure_input: StructurePredictionInput, +) -> dict[str, Any]: + """Convert an input object to a JSON-safe mapping.""" + + serialized: dict[str, Any] = { + "sequences": [_serialize_chain(chain) for chain in structure_input.sequences] + } + if structure_input.covalent_bonds is not None: + serialized["covalent_bonds"] = [ + vars(bond).copy() for bond in structure_input.covalent_bonds + ] + if structure_input.pocket is not None: + serialized["pocket"] = { + "binder_chain_id": structure_input.pocket.binder_chain_id, + "contacts": structure_input.pocket.contacts, + } + if structure_input.distogram_conditioning is not None: + serialized["distogram_conditioning"] = [ + {"chain_id": item.chain_id, "distogram": item.distogram.tolist()} + for item in structure_input.distogram_conditioning + ] + return serialized + + +def _deserialize_modifications(chain: dict[str, Any]) -> list[Modification] | None: + raw = chain.get("modifications") + if not raw: + return None + return [Modification(position=item["position"], ccd=item["ccd"]) for item in raw] + + +def _deserialize_msa(chain: dict[str, Any]) -> MSAInput: + raw = chain.get("msa") + if raw is None: + return None + if not isinstance(raw, dict) or not isinstance(raw.get("sequences"), list): + raise ValueError(f"Unexpected MSA value: {raw!r}") + return MSA.from_sequences(raw["sequences"]) + + +def _deserialize_chain(chain: dict[str, Any]) -> SequenceInput: + chain_type = chain.get("type") + common = {"id": chain["id"]} + if chain_type == "protein": + return ProteinInput( + **common, + sequence=chain["sequence"], + modifications=_deserialize_modifications(chain), + msa=_deserialize_msa(chain), + ) + if chain_type == "rna": + return RNAInput( + **common, + sequence=chain["sequence"], + modifications=_deserialize_modifications(chain), + ) + if chain_type == "dna": + return DNAInput( + **common, + sequence=chain["sequence"], + modifications=_deserialize_modifications(chain), + ) + if chain_type == "ligand": + return LigandInput(**common, smiles=chain.get("smiles"), ccd=chain.get("ccd")) + raise ValueError(f"Unsupported sequence type: {chain_type!r}") + + +def deserialize_structure_prediction_input(data: dict[str, Any]) -> StructurePredictionInput: + """Reconstruct the typed input represented by a serialized mapping.""" + + pocket_data = data.get("pocket") + pocket = None + if pocket_data is not None: + pocket = PocketConditioning( + binder_chain_id=pocket_data["binder_chain_id"], + contacts=[tuple(contact) for contact in pocket_data["contacts"]], + ) + + distogram_data = data.get("distogram_conditioning") + distograms = None + if distogram_data is not None: + distograms = [ + DistogramConditioning( + chain_id=item["chain_id"], distogram=np.asarray(item["distogram"]) + ) + for item in distogram_data + ] + + bond_data = data.get("covalent_bonds") + bonds = None + if bond_data is not None: + bonds = [CovalentBond(**item) for item in bond_data] + + return StructurePredictionInput( + sequences=[_deserialize_chain(chain) for chain in data["sequences"]], + pocket=pocket, + distogram_conditioning=distograms, + covalent_bonds=bonds, + ) + + +__all__ = [ + "CovalentBond", + "DNAInput", + "DistogramConditioning", + "LigandInput", + "MSAInput", + "Modification", + "PocketConditioning", + "ProteinInput", + "RNAInput", + "SequenceInput", + "StructurePredictionInput", + "deserialize_structure_prediction_input", + "serialize_structure_prediction_input", +] diff --git a/fastplms/models/esmfold2/esmfold2_metrics.py b/fastplms/models/esmfold2/esmfold2_metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..fcd665167967a7524b7de15bff5afcc9e4c63bf6 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_metrics.py @@ -0,0 +1,235 @@ +"""Contact, lDDT, RMSD, and GDT-TS metrics for structure validation.""" + +from __future__ import annotations + +import numpy as np +import torch +import torch.nn.functional as F +from torch import Tensor +from torch.amp import autocast # type: ignore + +from . import esmfold2_residue_constants as residue_constants +from .esmfold2_misc import binpack, unbinpack +from .esmfold2_protein_structure import ( + compute_alignment_tensors, + compute_gdt_ts_no_alignment, + compute_rmsd_no_alignment, +) + + +def _distance_matrix(positions: Tensor, eps: float) -> Tensor: + displacement = positions[..., None, :] - positions[..., None, :, :] + return torch.sqrt(eps + torch.sum(displacement**2, dim=-1)) + + +def compute_lddt_from_dmat( + dmat_pred: Tensor, + dmat_true: Tensor, + pairwise_mask: Tensor, + cutoff: float | Tensor = 15.0, + eps: float = 1e-10, + per_residue: bool = True, +) -> Tensor: + """Score distance matrices ``D_pred`` and ``D_true`` with shape (..., l, l).""" + + sequence_length = dmat_true.size(-1) + identity = torch.eye(sequence_length, device=dmat_true.device) + scored_pairs = (dmat_true < cutoff) * pairwise_mask * (1.0 - identity) + absolute_error = torch.abs(dmat_true - dmat_pred) + score = ( + (absolute_error < 0.5).type(absolute_error.dtype) + + (absolute_error < 1.0).type(absolute_error.dtype) + + (absolute_error < 2.0).type(absolute_error.dtype) + + (absolute_error < 4.0).type(absolute_error.dtype) + ) * 0.25 + dimensions = (-1,) if per_residue else (-2, -1) + normalization = 1.0 / (eps + scored_pairs.sum(dim=dimensions)) + return normalization * (eps + (scored_pairs * score).sum(dim=dimensions)) + + +def compute_lddt( + all_atom_pred_pos: Tensor, + all_atom_positions: Tensor, + all_atom_mask: Tensor, + pairwise_all_atom_mask: Tensor | None = None, + cutoff: float | Tensor = 15.0, + eps: float = 1e-10, + per_residue: bool = True, + sequence_id: Tensor | None = None, +) -> Tensor: + """Compute lDDT from coordinate tensors and atom masks.""" + + expanded_mask = all_atom_mask[..., None] + true_distances = _distance_matrix(all_atom_positions, eps) + predicted_distances = _distance_matrix(all_atom_pred_pos, eps) + pair_mask = expanded_mask * expanded_mask.transpose(-2, -1) + if pairwise_all_atom_mask is not None: + pair_mask = pair_mask * pairwise_all_atom_mask + if sequence_id is not None: + same_sequence = sequence_id[..., None] == sequence_id[..., None, :] + pair_mask = pair_mask * same_sequence.type_as(pair_mask) + return compute_lddt_from_dmat( + predicted_distances, + true_distances, + pair_mask, + cutoff=cutoff, + eps=eps, + per_residue=per_residue, + ) + + +def compute_lddt_ca( + all_atom_pred_pos: Tensor, + all_atom_positions: Tensor, + all_atom_mask: Tensor, + cutoff: float = 15.0, + eps: float = 1e-10, + per_residue: bool = True, + sequence_id: Tensor | None = None, +) -> Tensor: + """Compute lDDT using only C-alpha coordinates.""" + + ca_index = residue_constants.atom_order["CA"] + predicted_ca = ( + all_atom_pred_pos if all_atom_pred_pos.dim() == 3 else all_atom_pred_pos[..., ca_index, :] + ) + return compute_lddt( + predicted_ca, + all_atom_positions[..., ca_index, :], + all_atom_mask[..., ca_index], + cutoff=cutoff, + eps=eps, + per_residue=per_residue, + sequence_id=sequence_id, + ) + + +@torch.no_grad() +@autocast("cuda", enabled=False) +def compute_rmsd( + mobile: Tensor, + target: Tensor, + atom_exists_mask: Tensor | None = None, + sequence_id: Tensor | None = None, + reduction: str = "batch", +) -> Tensor: + """Align ``X`` to ``Y`` and compute RMSD.""" + + centered_mobile, _, centered_target, _, rotation, counts = compute_alignment_tensors( + mobile, + target, + atom_exists_mask, + sequence_id, + ) + rmsd = compute_rmsd_no_alignment( + torch.matmul(centered_mobile, rotation), + centered_target, + counts, + reduction=reduction, + ) + if reduction == "per_residue" and sequence_id is not None: + return binpack(rmsd, sequence_id, pad_value=0) + return rmsd + + +def compute_gdt_ts( + mobile: Tensor, + target: Tensor, + atom_exists_mask: Tensor | None = None, + sequence_id: Tensor | None = None, + reduction: str = "per_sample", +) -> Tensor: + """Align ``X`` to ``Y`` and compute GDT-TS.""" + + if atom_exists_mask is None: + atom_exists_mask = torch.isfinite(target).all(dim=-1) + centered_mobile, _, centered_target, _, rotation, _ = compute_alignment_tensors( + mobile, + target, + atom_exists_mask, + sequence_id, + ) + if sequence_id is not None: + atom_exists_mask = unbinpack(atom_exists_mask, sequence_id, pad_value=False) + return compute_gdt_ts_no_alignment( + torch.matmul(centered_mobile, rotation), + centered_target, + atom_exists_mask, + reduction, + ) + + +def _batched_contacts(predictions: Tensor, targets: Tensor) -> tuple[Tensor, Tensor]: + if predictions.dim() == 2: + predictions = predictions.unsqueeze(0) + if targets.dim() == 2: + targets = targets.unsqueeze(0) + if predictions.size() != targets.size(): + raise ValueError( + f"Size mismatch. Received predictions of size {predictions.size()}, " + f"targets of size {targets.size()}" + ) + return predictions, targets + + +def _valid_contact_mask( + targets: Tensor, + src_lengths: Tensor, + minsep: int, + maxsep: int | None, +) -> Tensor: + sequence_length = targets.shape[-1] + positions = torch.arange(sequence_length, device=targets.device) + separation = (positions.unsqueeze(0) - positions.unsqueeze(1)).unsqueeze(0) + valid = (separation >= minsep) & (targets >= 0) + if maxsep is not None: + valid &= separation < maxsep + within_length = positions.unsqueeze(0) < src_lengths.unsqueeze(1) + return valid & within_length.unsqueeze(1) & within_length.unsqueeze(2) + + +def contact_precision( + predictions: Tensor, + targets: Tensor, + src_lengths: Tensor | None = None, + minsep: int = 6, + maxsep: int | None = None, + override_length: int | None = None, +) -> dict[str, Tensor]: + """Compute P@L, P@L/5, and binned area for contact probabilities.""" + + predictions, targets = _batched_contacts(predictions, targets) + batch_size, sequence_length, _ = predictions.shape + if src_lengths is None: + src_lengths = torch.full( + (batch_size,), + sequence_length, + dtype=torch.long, + device=predictions.device, + ) + valid = _valid_contact_mask(targets, src_lengths, minsep, maxsep) + masked_predictions = predictions.masked_fill(~valid, float("-inf")) + row_index, column_index = np.triu_indices(sequence_length, minsep) + upper_predictions = masked_predictions[:, row_index, column_index] + upper_targets = targets[:, row_index, column_index] + + topk = sequence_length if override_length is None else max(sequence_length, override_length) + ranked_indices = upper_predictions.argsort(dim=-1, descending=True)[:, :topk] + batch_indices = torch.arange(batch_size, device=ranked_indices.device).unsqueeze(1) + ranked_targets = upper_targets[batch_indices, ranked_indices] + if ranked_targets.size(1) < topk: + ranked_targets = F.pad(ranked_targets, [0, topk - ranked_targets.size(1)]) + cumulative_contacts = ranked_targets.type_as(predictions).cumsum(dim=-1) + + gather_lengths = src_lengths.unsqueeze(1) + if override_length is not None: + gather_lengths = override_length * torch.ones_like(gather_lengths) + fractions = torch.arange(0.1, 1.1, 0.1, device=predictions.device).unsqueeze(0) + gather_indices = (fractions * gather_lengths).type(torch.long).sub(1).clamp_min(0) + cumulative_bins = cumulative_contacts.gather(1, gather_indices) + precisions = cumulative_bins / (gather_indices + 1).type_as(cumulative_bins) + return { + "AUC": precisions.mean(dim=-1), + "P@L": precisions[:, 9], + "P@L5": precisions[:, 1], + } diff --git a/fastplms/models/esmfold2/esmfold2_misc.py b/fastplms/models/esmfold2/esmfold2_misc.py new file mode 100644 index 0000000000000000000000000000000000000000..022edaf3916ab22b21363018e3cbf54963440c0e --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_misc.py @@ -0,0 +1,400 @@ +"""Small tensor, sequence, and annotation utilities used by ESMFold2. + +The helpers in this module are deliberately free of model state. Importing the +module therefore performs no device selection, compilation, or remote access. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Generator, Iterable, Sequence +from contextlib import AbstractContextManager, nullcontext +from dataclasses import is_dataclass +from io import BytesIO +from typing import Any, Protocol, TypeVar, runtime_checkable +from warnings import warn + +import numpy as np +import torch +import zstandard + +from .esmfold2_constants_esm3 import CHAIN_BREAK_STR +from .esmfold2_utils_types import FunctionAnnotation + +MAX_SUPPORTED_DISTANCE = 1e6 + +TSequence = TypeVar("TSequence", bound=Sequence) + + +@runtime_checkable +class Concatable(Protocol): + """Protocol for sequence-like records with a class-level concatenator.""" + + @classmethod + def concat(cls, objs: list[Concatable]) -> Concatable: ... + + +def fp32_autocast_context( + device_type: str, +) -> AbstractContextManager[Any]: # type: ignore + """Return a context that keeps numerically sensitive work in FP32.""" + + if device_type == "mps": + return nullcontext() + if device_type == "cpu": + return torch.amp.autocast(device_type, enabled=False) # type: ignore + if device_type == "cuda": + return torch.amp.autocast(device_type, dtype=torch.float32) # type: ignore + raise ValueError(f"Unsupported device type: {device_type}") + + +def maybe_tensor(value, convert_none_to_nan: bool = False) -> torch.Tensor | None: + """Convert an optional array-like value to a tensor.""" + + if value is None: + return None + if isinstance(value, torch.Tensor): + return value + if isinstance(value, list) and all(isinstance(element, torch.Tensor) for element in value): + return torch.stack(value) + if convert_none_to_nan: + value = np.asarray(value, dtype=np.float32) + value = np.where(value is None, np.nan, value) + return torch.tensor(value) + + +def maybe_list(value, convert_nan_to_none: bool = False) -> list | None: + """Convert an optional tensor or NumPy array to nested Python lists.""" + + if value is None: + return None + if not convert_nan_to_none: + return value.tolist() + if isinstance(value, torch.Tensor): + nan_mask = torch.isnan(value).cpu().numpy() + array = value.cpu().numpy().astype(object) + elif isinstance(value, np.ndarray): + nan_mask = np.isnan(value) + array = value.astype(object) + else: + raise TypeError("maybe_list can only work with torch.tensor or np.ndarray.") + array[nan_mask] = None + return array.tolist() + + +def replace_inf(data): + """Replace infinite array values by the ESM API sentinel value.""" + + if data is None: + return None + array = np.asarray(data, dtype=np.float32) + return np.where(np.isinf(array), 1000, array).tolist() + + +def slice_python_object_as_numpy( + obj: TSequence, + idx: int | list[int] | slice | np.ndarray, +) -> TSequence: + """Apply NumPy-style scalar, mask, or index-array slicing to Python data.""" + + normalized_idx: list[int] | slice | np.ndarray = ( + [int(idx)] if np.isscalar(idx) else idx # type: ignore[arg-type] + ) + + if isinstance(normalized_idx, np.ndarray) and normalized_idx.dtype == bool: + selected = [obj[position] for position in np.flatnonzero(normalized_idx)] + elif isinstance(normalized_idx, slice): + selected = obj[normalized_idx] + else: + selected = [obj[position] for position in normalized_idx] + + if isinstance(obj, str) and isinstance(selected, list): + return "".join(selected) # type: ignore[return-value] + return obj.__class__(selected) # type: ignore[call-arg,return-value] + + +def slice_any_object( + obj: TSequence, + idx: int | list[int] | slice | np.ndarray, +) -> TSequence: + """Slice tensors, arrays, dataclasses, and ordinary Python sequences.""" + + if isinstance(obj, (np.ndarray, torch.Tensor)) or is_dataclass(obj): + return obj[idx] # type: ignore[index,return-value] + return slice_python_object_as_numpy(obj, idx) + + +def join_lists( + lists: Sequence[Sequence[Any]], + separator: Sequence[Any] | None = None, +) -> list[Any]: + """Join lists, inserting all elements of ``separator`` between inputs.""" + + if len(lists) == 0: + return [] + joined = list(lists[0]) + for values in lists[1:]: + if separator: + joined.extend(separator) + joined.extend(values) + return joined + + +def iterate_with_intermediate( + lists: Iterable, + intermediate, +) -> Generator[Any, None, None]: + """Yield an intermediate value between consecutive input values.""" + + iterator = iter(lists) + yield next(iterator) + for value in iterator: + yield intermediate + yield value + + +def concat_objects(objs: Sequence[Any], separator: Any | None = None): + """Concatenate one supported homogeneous collection.""" + + if not objs: + raise ValueError("objs must contain at least one value.") + first = objs[0] + if isinstance(first, Concatable): + return first.__class__.concat(objs) + if isinstance(first, str): + if not isinstance(separator, str): + raise TypeError("separator must be a string when joining strings.") + return separator.join(objs) + if isinstance(first, list): + return join_lists(objs, None if separator is None else [separator]) + if isinstance(first, np.ndarray): + pieces = ( + objs + if separator is None + else list(iterate_with_intermediate(objs, np.array([separator]))) + ) + return np.concatenate(pieces) + if isinstance(first, torch.Tensor): + pieces = ( + objs + if separator is None + else list(iterate_with_intermediate(objs, torch.tensor([separator]))) + ) + return torch.cat(pieces) # type: ignore[arg-type] + raise TypeError(type(first)) + + +def rbf(values, v_min, v_max, n_bins=16): + """Encode values against evenly spaced radial basis centers.""" + + centers = torch.linspace( + v_min, + v_max, + n_bins, + dtype=values.dtype, + device=values.device, + ) + centers = centers.reshape((1,) * values.ndim + (-1,)) + standardized = (values.unsqueeze(-1) - centers) / ((v_max - v_min) / n_bins) + return torch.exp(-(standardized**2)) + + +def batched_gather(data, inds, dim=0, no_batch_dims=0): + """Gather along one data dimension while retaining leading batch axes.""" + + batch_indices = [] + index_rank = len(inds.shape) + for axis, size in enumerate(data.shape[:no_batch_dims]): + shape = (1,) * axis + (-1,) + (1,) * (index_rank - axis - 1) + batch_indices.append(torch.arange(size).view(*shape)) + tail = [slice(None)] * (len(data.shape) - no_batch_dims) + tail[dim - no_batch_dims if dim >= 0 else dim] = inds + return data[tuple(batch_indices + tail)] + + +def node_gather(s: torch.Tensor, edges: torch.Tensor) -> torch.Tensor: + """Gather node features for each row of an edge-index tensor.""" + + return batched_gather( + s.unsqueeze(-3), + edges, + -2, + no_batch_dims=len(s.shape) - 1, + ) + + +def knn_graph( + coords: torch.Tensor, + coord_mask: torch.Tensor, + padding_mask: torch.Tensor, + sequence_id: torch.Tensor, + *, + no_knn: int, +): + """Build nearest-neighbor edges, using sequence distance for missing geometry.""" + + length = coords.shape[-2] + coords = coords.nan_to_num() + missing_pair = ~(coord_mask[..., None, :] & coord_mask[..., :, None]) + excluded_pair = padding_mask[..., None, :] | padding_mask[..., :, None] + if sequence_id is not None: + excluded_pair |= sequence_id.unsqueeze(1) != sequence_id.unsqueeze(2) + + distances = (coords.unsqueeze(-2) - coords.unsqueeze(-3)).norm(dim=-1) + residue_index = torch.arange(length, device=coords.device) + sequence_distance = (residue_index.unsqueeze(-1) - residue_index.unsqueeze(-2)).abs() + if not (distances[~missing_pair] < MAX_SUPPORTED_DISTANCE).all(): + raise ValueError( + "Coordinate pairwise distances exceed max supported distance " + f"({MAX_SUPPORTED_DISTANCE}). " + ) + + rank_distance = sequence_distance.to(distances.dtype).mul(1e2).add(MAX_SUPPORTED_DISTANCE) + rank_distance = rank_distance.where(missing_pair, distances) + rank_distance = rank_distance.masked_fill(excluded_pair, torch.inf) + sorted_distance, sorted_edge = rank_distance.sort(dim=-1, descending=False) + width = min(no_knn, length) + return sorted_edge[..., :width], sorted_distance[..., :width].isfinite() + + +def stack_variable_length_tensors( + sequences: Sequence[torch.Tensor], + constant_value: int | float = 0, + dtype: torch.dtype | None = None, +) -> torch.Tensor: + """Pad arbitrary tensor dimensions to their maxima, then stack.""" + + output_shape = [ + len(sequences), + *np.max([sequence.shape for sequence in sequences], axis=0).tolist(), + ] + output = torch.full( + output_shape, + constant_value, + dtype=sequences[0].dtype if dtype is None else dtype, + device=sequences[0].device, + ) + for destination, source in zip(output, sequences, strict=True): + destination[tuple(slice(size) for size in source.shape)] = source + return output + + +def binpack( + tensor: torch.Tensor, + sequence_id: torch.Tensor | None, + pad_value: int | float, +): + """Scatter a sequence-major tensor into the packed layout described by IDs.""" + + if sequence_id is None: + return tensor + sequence_counts = sequence_id.max(dim=-1).values + 1 + output = torch.full( + sequence_id.shape + tensor.shape[2:], + fill_value=pad_value, + dtype=tensor.dtype, + device=tensor.device, + ) + source_index = 0 + for batch_index, (batch_ids, count) in enumerate( + zip(sequence_id, sequence_counts, strict=True) + ): + for seqid in range(count): + selection = batch_ids == seqid + output[batch_index, selection] = tensor[source_index, : selection.sum()] + source_index += 1 + return output + + +def unbinpack( + tensor: torch.Tensor, + sequence_id: torch.Tensor | None, + pad_value: int | float, +): + """Restore sequence-major rows from a packed tensor and its sequence IDs.""" + + if sequence_id is None: + return tensor + rows = [] + sequence_counts = sequence_id.max(dim=-1).values + 1 + for batch_index, (batch_ids, count) in enumerate( + zip(sequence_id, sequence_counts, strict=True) + ): + for seqid in range(count): + rows.append(tensor[batch_index, batch_ids == seqid]) + return stack_variable_length_tensors(rows, pad_value) + + +def merge_ranges( + ranges: list[range], + merge_gap_max: int | None = None, +) -> list[range]: + """Merge overlapping or sufficiently close ranges in positional order.""" + + maximum_gap = 0 if merge_gap_max is None else merge_gap_max + if not isinstance(maximum_gap, int) or isinstance(maximum_gap, bool): + raise TypeError("merge_gap_max must be an integer or None.") + if maximum_gap < 0: + raise ValueError(f"merge_gap_max must be non-negative, got {maximum_gap}.") + merged: list[range] = [] + for current in sorted(ranges, key=lambda item: item.start): + if not merged or merged[-1].stop + maximum_gap < current.start: + merged.append(current) + continue + previous = merged[-1] + merged[-1] = range(previous.start, max(previous.stop, current.stop)) + return merged + + +def merge_annotations( + annotations: list[FunctionAnnotation], + merge_gap_max: int | None = None, +) -> list[FunctionAnnotation]: + """Merge overlapping annotations independently for each label.""" + + grouped: dict[str, list[range]] = defaultdict(list) + for annotation in annotations: + grouped[annotation.label].append(range(annotation.start, annotation.end + 1)) + result = [] + for label, spans in grouped.items(): + result.extend( + FunctionAnnotation(label=label, start=span.start, end=span.stop - 1) + for span in merge_ranges(spans, merge_gap_max=merge_gap_max) + ) + return result + + +def get_chainbreak_boundaries_from_sequence( + sequence: Sequence[str], +) -> np.ndarray: + """Return half-open chain intervals split by chain-break tokens.""" + + boundaries = [0] + final_index = len(sequence) - 1 + for index, residue in enumerate(sequence): + if residue != CHAIN_BREAK_STR: + continue + if index == final_index: + raise ValueError( + "Encountered chain break token at end of sequence, this is unexpected." + ) + if index == final_index - 1: + warn( + "Encountered chain break token at penultimate position, this is unexpected.", + stacklevel=2, + ) + boundaries.extend((index, index + 1)) + boundaries.append(len(sequence)) + assert len(boundaries) % 2 == 0 + return np.asarray(boundaries).reshape(-1, 2) + + +def deserialize_tensors(data: bytes) -> Any: + """Decompress a tensor-only Torch payload onto CPU.""" + + decompressed = zstandard.ZstdDecompressor().decompress(data) + return torch.load( + BytesIO(decompressed), + map_location="cpu", + weights_only=True, + ) diff --git a/fastplms/models/esmfold2/esmfold2_mmcif_parsing.py b/fastplms/models/esmfold2/esmfold2_mmcif_parsing.py new file mode 100644 index 0000000000000000000000000000000000000000..caa4a04d76bf48212f6476008d360640ec6202f4 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_mmcif_parsing.py @@ -0,0 +1,469 @@ +"""Biotite-backed mmCIF parsing used by ESMFold2 structure records.""" + +from __future__ import annotations + +import functools +import io +import os +from contextlib import suppress +from dataclasses import dataclass +from datetime import datetime + +import biotite.structure as bs +import biotite.structure.io.pdbx as pdbx +import numpy as np +from biotite.structure.io.pdbx import CIFColumn, CIFData, CIFFile + +from . import esmfold2_residue_constants as residue_constants + +PathOrBuffer = str | os.PathLike | io.StringIO + +PLDDT_B_FACTOR_SCALE = 100.0 +_MMCIF_COLUMN_DECIMALS = { + "Cartn_x": 3, + "Cartn_y": 3, + "Cartn_z": 3, + "B_iso_or_equiv": 2, +} +_NONPOLYMER_ENTITY_TYPES = frozenset({"NON-POLYMER", "WATER", "BRANCHED"}) + + +class NoProteinError(Exception): + """Raised internally when an mmCIF block contains no model-one atoms.""" + + +@dataclass +class Residue: + residue_number: int | None = None + insertion_code: str = "" + hetflag: bool = False + + +@dataclass +class MmcifHeader: + release_date: datetime | None = None + resolution: float | None = None + structure_method: str = "UNKNOWN" + + +def round_mmcif_columns(cif_file: CIFFile) -> None: + """Round coordinate and confidence columns in place for stable exports.""" + + if "atom_site" not in cif_file.block: + return + atom_site = cif_file.block["atom_site"] + for name, decimals in _MMCIF_COLUMN_DECIMALS.items(): + if name not in atom_site: + continue + original = atom_site[name] + values = original.as_array(np.float64) + strings = np.asarray( + [f"{value:.{decimals}f}" for value in values], + dtype=np.str_, + ) + atom_site[name] = CIFColumn( + data=CIFData(array=strings, dtype=np.str_), + mask=original.mask, + ) + + +def _clean_chain_list(value: str) -> list[str]: + return [chain.strip() for chain in value.split(",") if chain.strip()] + + +def _empty_residue() -> Residue: + return Residue(residue_number=None, insertion_code="", hetflag=False) + + +def _header_from_block( + block, + header: MmcifHeader | None = None, +) -> MmcifHeader: + header = MmcifHeader() if header is None else header + try: + if "pdbx_database_status" in block: + category = block["pdbx_database_status"] + if "recvd_initial_deposition_date" in category: + value = category["recvd_initial_deposition_date"].as_item() + if value and value != "?": + with suppress(ValueError): + header.release_date = datetime.strptime(value, "%Y-%m-%d") + if "refine" in block: + category = block["refine"] + if "ls_d_res_high" in category: + value = category["ls_d_res_high"].as_item() + if value and value != "?": + with suppress(ValueError): + header.resolution = float(value) + if "exptl" in block: + category = block["exptl"] + if "method" in category: + value = category["method"].as_item() + if value and value != "?": + header.structure_method = value.upper() + except Exception: + pass + return header + + +def _entities_from_block( + block, + entities: dict[int, list[str]] | None = None, +) -> dict[int, list[str]]: + entities = {} if entities is None else entities + if "entity" in block: + category = block["entity"] + ids = category["id"].as_array(str) + types = category["type"].as_array(str) + for entity_id, _ in zip(ids, types, strict=False): + entities[int(entity_id)] = [] + if "entity_poly" in block: + category = block["entity_poly"] + ids = category["entity_id"].as_array(str) + chain_lists = category["pdbx_strand_id"].as_array(str) + for raw_id, raw_chains in zip(ids, chain_lists, strict=False): + entity_id = int(raw_id) + if entity_id in entities: + entities[entity_id] = _clean_chain_list(raw_chains) + if "struct_asym" in block: + category = block["struct_asym"] + asym_ids = category["id"].as_array(str) + entity_ids = category["entity_id"].as_array(str) + for asym_id, raw_id in zip(asym_ids, entity_ids, strict=False): + entity_id = int(raw_id) + if entity_id in entities and not entities[entity_id]: + entities[entity_id].append(asym_id) + return entities + + +def _polymer_sequences(block) -> dict[str, str]: + sequences: dict[str, str] = {} + if "entity_poly" not in block: + return sequences + category = block["entity_poly"] + entity_ids = category["entity_id"].as_array(str) + raw_sequences = category["pdbx_seq_one_letter_code_can"].as_array(str) + chain_lists = category["pdbx_strand_id"].as_array(str) + for _, raw_sequence, raw_chains in zip( + entity_ids, + raw_sequences, + chain_lists, + strict=False, + ): + sequence = "".join(raw_sequence.split()) + for chain_id in _clean_chain_list(raw_chains): + sequences[chain_id] = sequence + return sequences + + +def _scheme_columns(category): + asym_ids = category["asym_id"].as_array(str) + insertion_codes = ( + category["pdb_ins_code"].as_array(str) + if "pdb_ins_code" in category + else [""] * len(asym_ids) + ) + hetflags = category["hetflag"].as_array(str) if "hetflag" in category else ["N"] * len(asym_ids) + author_chains = ( + category["pdb_strand_id"].as_array(str) if "pdb_strand_id" in category else asym_ids + ) + return ( + asym_ids, + category["seq_id"].as_array(str), + category["auth_seq_num"].as_array(str), + insertion_codes, + hetflags, + author_chains, + ) + + +def _scheme_residue_map(category): + ( + asym_ids, + sequence_positions, + author_numbers, + insertion_codes, + hetflags, + author_chains, + ) = _scheme_columns(category) + asym_to_author = { + asym_id: author_id for asym_id, author_id in zip(asym_ids, author_chains, strict=False) + } + per_chain: dict[str, dict[int, Residue]] = {} + for asym_id, raw_position, raw_number, raw_code, raw_hetflag in zip( + asym_ids, + sequence_positions, + author_numbers, + insertion_codes, + hetflags, + strict=False, + ): + residues = per_chain.setdefault(asym_id, {}) + try: + position = int(raw_position) - 1 + residue_number = int(raw_number) if raw_number != "?" else None + except ValueError: + continue + if residue_number is None: + insertion_code = "" + else: + insertion_code = "" if raw_code in (".", "?") else raw_code + residues[position] = Residue( + residue_number=residue_number, + insertion_code=insertion_code, + hetflag=raw_hetflag.upper() == "Y", + ) + return per_chain, asym_to_author + + +def _renumber_duplicate_residues( + per_chain: dict[str, dict[int, Residue]], +) -> None: + for residues in per_chain.values(): + positions_by_number: dict[int, list[int]] = {} + for position, residue in residues.items(): + if residue.residue_number is not None: + positions_by_number.setdefault(residue.residue_number, []).append(position) + for number, positions in positions_by_number.items(): + if len(positions) <= 1: + continue + positions.sort() + for offset, position in enumerate(positions): + previous = residues[position] + residues[position] = Residue( + residue_number=number + offset, + insertion_code=previous.insertion_code, + hetflag=previous.hetflag, + ) + + +def _ordered_scheme_mapping( + per_chain: dict[str, dict[int, Residue]], + asym_to_author: dict[str, str], + chain_sequences: dict[str, str], +) -> dict[str, dict[int, Residue]]: + result: dict[str, dict[int, Residue]] = {} + for asym_id, residues in per_chain.items(): + author_chain = asym_to_author.get(asym_id, asym_id) + if author_chain in chain_sequences: + result[author_chain] = { + position: residues.get(position, _empty_residue()) + for position in range(len(chain_sequences[author_chain])) + } + elif residues: + result[author_chain] = { + index: residues[position] for index, position in enumerate(sorted(residues)) + } + return result + + +def _complete_polymer_mappings( + mappings: dict[str, dict[int, Residue]], + chain_sequences: dict[str, str], +) -> None: + for chain_id, sequence in chain_sequences.items(): + mapping = mappings.setdefault(chain_id, {}) + for position in range(len(sequence)): + if position not in mapping: + mapping[position] = _empty_residue() + + +def _add_structure_fallbacks( + mappings: dict[str, dict[int, Residue]], + structure: bs.AtomArray, +) -> None: + if not ( + structure + and hasattr(structure, "chain_id") + and structure.chain_id is not None + and hasattr(structure.chain_id, "__iter__") + ): + return + for chain_id in set(structure.chain_id): + if chain_id in mappings: + continue + chain = structure[structure.chain_id == chain_id] + if not ( + hasattr(chain, "res_id") + and chain.res_id is not None + and hasattr(chain.res_id, "__iter__") + ): + continue + residue_ids = sorted(set(chain.res_id)) + mappings[chain_id] = { + index: Residue( + residue_number=residue_id, + insertion_code="", + hetflag=False, + ) + for index, residue_id in enumerate(residue_ids) + } + + +def _nonpolymer_entity_ids(block) -> set[str]: + result = set() + if "entity" not in block: + return result + category = block["entity"] + ids = category["id"].as_array(str) + types = category["type"].as_array(str) + for entity_id, entity_type in zip(ids, types, strict=False): + if entity_type.upper() in _NONPOLYMER_ENTITY_TYPES: + result.add(entity_id) + return result + + +def _nonpolymer_component_map(block, entity_ids: set[str]) -> dict[str, str]: + result = {} + if "pdbx_entity_nonpoly" not in block: + return result + category = block["pdbx_entity_nonpoly"] + ids = category["entity_id"].as_array(str) + components = category["comp_id"].as_array(str) + for entity_id, component in zip(ids, components, strict=False): + if entity_id in entity_ids: + result[entity_id] = component + return result + + +class MmcifWrapper: + """Parsed model-one structure, metadata, sequences, and residue mappings.""" + + def __init__(self, id: str | None = None): + self.id = id or "" + self.raw: pdbx.CIFFile | None = None + self.structure: bs.AtomArray + self.header = MmcifHeader() + self.entities: dict[int, list[str]] = {} + self.chain_to_seqres: dict[str, str] = {} + self.seqres_to_structure: dict[str, dict[int, Residue]] = {} + + @classmethod + def read(cls, path: PathOrBuffer, id: str | None = None) -> MmcifWrapper: + wrapper = cls(id=id) + wrapper._load(path) + return wrapper + + def _load(self, path: PathOrBuffer, fileid: str | None = None) -> None: + self.raw = pdbx.CIFFile.read(path) + self._parse_structure() + self._parse_header() + self._parse_entities() + self._parse_sequences() + + def _parse_structure(self) -> None: + try: + structure = pdbx.get_structure(self.raw, model=1) + if structure is None or not isinstance(structure, bs.AtomArray): + raise NoProteinError("No structure found in mmCIF file") + if len(structure) == 0: + raise NoProteinError("Empty structure in mmCIF file") + self.structure = structure + except Exception as error: + raise ValueError(f"Failed to parse structure: {error}") from error + + def _parse_header(self) -> None: + if self.raw: + self.header = _header_from_block(self.raw.block, self.header) + + def _parse_entities(self) -> None: + if not self.raw: + return + try: + self.entities = _entities_from_block(self.raw.block, self.entities) + except Exception: + if ( + self.structure + and hasattr(self.structure, "chain_id") + and self.structure.chain_id is not None + and hasattr(self.structure.chain_id, "__iter__") + ): + self.entities = {1: list(set(self.structure.chain_id))} + + def _parse_sequences(self) -> None: + if not self.raw: + return + block = self.raw.block + self.chain_to_seqres.update(_polymer_sequences(block)) + if "pdbx_poly_seq_scheme" in block: + per_chain, asym_to_author = _scheme_residue_map(block["pdbx_poly_seq_scheme"]) + _renumber_duplicate_residues(per_chain) + self.seqres_to_structure.update( + _ordered_scheme_mapping( + per_chain, + asym_to_author, + self.chain_to_seqres, + ) + ) + _complete_polymer_mappings( + self.seqres_to_structure, + self.chain_to_seqres, + ) + _add_structure_fallbacks(self.seqres_to_structure, self.structure) + + def _parse_nonpoly_from_mmcif(self) -> dict[tuple, bs.AtomArray]: + assert self.raw is not None + block = self.raw.block + entity_ids = _nonpolymer_entity_ids(block) + _nonpolymer_component_map(block, entity_ids) + groups: dict[tuple[str, str], list[int]] = {} + if "atom_site" in block: + category = block["atom_site"] + chain_ids = category["label_asym_id"].as_array(str) + atom_entity_ids = category["label_entity_id"].as_array(str) + component_ids = category["label_comp_id"].as_array(str) + for index, (chain_id, entity_id, component_id) in enumerate( + zip(chain_ids, atom_entity_ids, component_ids, strict=False) + ): + if entity_id in entity_ids: + groups.setdefault((component_id, chain_id), []).append(index) + + coordinates = {} + for component_id, chain_id in groups: + selection = (self.structure.chain_id == chain_id) & ( + self.structure.res_name == component_id + ) + if not selection.any(): + continue + atoms = self.structure[selection] + if isinstance(atoms, (bs.AtomArray, bs.AtomArrayStack)) and len(atoms) > 0: + coordinates[(component_id, chain_id)] = atoms + return coordinates + + def _parse_nonpoly_fallback(self) -> dict[tuple, bs.AtomArray]: + result = {} + if not (self.structure and hasattr(self.structure, "chain_id")): + return result + standard_residues = set(residue_constants.resnames[:-1]) + standard_residues.update({"A", "C", "G", "T", "U"}) + if self.structure.chain_id is None: + return result + for chain_id in set(self.structure.chain_id): + chain = self.structure[self.structure.chain_id == chain_id] + if not ( + hasattr(chain, "res_name") + and chain.res_name is not None + and hasattr(chain.res_name, "__iter__") + ): + continue + for residue_name in set(chain.res_name): + if residue_name in standard_residues: + continue + selection = (chain.chain_id == chain_id) & (chain.res_name == residue_name) + if selection.any() and isinstance( + chain, + (bs.AtomArray, bs.AtomArrayStack), + ): + result[(residue_name, chain_id)] = chain[selection] + return result + + @functools.cached_property + def non_polymer_coords(self) -> dict[tuple, bs.AtomArray]: + """Map each non-polymer component and chain to its atoms.""" + + if not self.structure or not self.raw: + return {} + try: + return self._parse_nonpoly_from_mmcif() + except Exception: + return self._parse_nonpoly_fallback() diff --git a/fastplms/models/esmfold2/esmfold2_molecular_complex.py b/fastplms/models/esmfold2/esmfold2_molecular_complex.py new file mode 100644 index 0000000000000000000000000000000000000000..3c8921534302e524a9cc3e11242a6c54f16a53e5 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_molecular_complex.py @@ -0,0 +1,1016 @@ +"""Flat molecular-complex records used by the ESMFold2 public API. + +The folding model operates on tokens and a single atom table. This module owns +that representation, its protein-only bridge, mmCIF I/O, structure metrics, and +the compact wire format. It deliberately has no dependency on the upstream +Biohub package; the pinned submodule is used only by differential tests. +""" + +from __future__ import annotations + +import io +import os +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from subprocess import check_output +from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING, Any + +import biotite.structure as bs +import biotite.structure.io.pdbx as pdbx +import brotli +import msgpack +import numpy as np +import torch +from biotite.structure.io.pdbx import ( + CIFCategory, + CIFColumn, + CIFData, + CIFFile, + set_structure, +) + +from . import esmfold2_residue_constants as residue_constants +from .esmfold2_metrics import compute_lddt, compute_rmsd +from .esmfold2_mmcif_parsing import PLDDT_B_FACTOR_SCALE, round_mmcif_columns +from .esmfold2_protein_complex import ProteinComplex, ProteinComplexMetadata + + +@dataclass +class MolecularComplexResult: + """One folded complex and the optional model outputs associated with it.""" + + complex: MolecularComplex + plddt: torch.Tensor | None = None + ptm: float | None = None + iptm: float | None = None + pae: torch.Tensor | None = None + distogram: torch.Tensor | None = None + pair_chains_iptm: torch.Tensor | None = None + output_embedding_sequence: torch.Tensor | None = None + output_embedding_pair_pooled: torch.Tensor | None = None + residue_index: torch.Tensor | None = None + entity_id: torch.Tensor | None = None + sae_features: np.ndarray | None = None # X has shape (l, n_features). + ttt_metrics: dict[str, Any] | None = None + + +@dataclass +class MolecularComplexMetadata: + """Entity and chain labels carried with a molecular complex.""" + + entity_lookup: dict[int, str] + chain_lookup: dict[int, str] + assembly_composition: dict[str, list[str]] | None = None + + +@dataclass +class Molecule: + """The atom slice represented by one model token.""" + + token: str + token_idx: int + atom_positions: np.ndarray # P has shape (n_atoms, 3). + atom_elements: np.ndarray # E has shape (n_atoms,). + atom_names: np.ndarray | None = None # N has shape (n_atoms,) when present. + atom_hetero: np.ndarray | None = None # M has shape (n_atoms,) when present. + residue_type: int = 0 + molecule_type: int = 0 + confidence: float = 0.0 + + +_NUCLEOTIDE_NAMES = frozenset({"A", "T", "G", "C", "U", "DA", "DT", "DG", "DC"}) +_SERIALIZED_ARRAYS = frozenset( + { + "atom_positions", + "atom_elements", + "atom_names", + "atom_hetero", + "token_to_atoms", + "chain_id", + "entity_id", + "sym_id", + "plddt", + } +) + + +def _assert_table_lengths(complex_value: MolecularComplex) -> None: + """Check that token and atom annotations align with their tables.""" + if not isinstance(complex_value.sequence, list) or any( + not isinstance(token, str) for token in complex_value.sequence + ): + raise TypeError("sequence must be a list of token strings.") + n_tokens = len(complex_value.sequence) + if not isinstance(complex_value.atom_positions, np.ndarray): + raise TypeError("atom_positions must be a NumPy array.") + if complex_value.atom_positions.ndim != 2 or complex_value.atom_positions.shape[1:] != ( + 3, + ): + raise ValueError( + "atom_positions must have shape (n_atoms, 3), got " + f"{complex_value.atom_positions.shape}." + ) + if not np.issubdtype(complex_value.atom_positions.dtype, np.number): + raise TypeError("atom_positions must use a numeric dtype.") + n_atoms = len(complex_value.atom_positions) + if not isinstance(complex_value.atom_elements, np.ndarray): + raise TypeError("atom_elements must be a NumPy array.") + if complex_value.atom_elements.shape != (n_atoms,): + raise ValueError( + f"atom_elements shape {complex_value.atom_elements.shape} != {n_atoms} atoms" + ) + token_tables = { + "token_to_atoms": complex_value.token_to_atoms, + "chain_id": complex_value.chain_id, + "plddt": complex_value.plddt, + } + if complex_value.entity_id is not None: + token_tables["entity_id"] = complex_value.entity_id + if complex_value.sym_id is not None: + token_tables["sym_id"] = complex_value.sym_id + for label, values in token_tables.items(): + if not isinstance(values, np.ndarray): + raise TypeError(f"{label} must be a NumPy array, got {type(values).__name__}.") + if values.ndim == 0 or values.shape[0] != n_tokens: + raise ValueError(f"{label} shape {values.shape} != {n_tokens} tokens") + if complex_value.token_to_atoms.shape != (n_tokens, 2): + raise ValueError( + "token_to_atoms must have shape " + f"({n_tokens}, 2), got {complex_value.token_to_atoms.shape}." + ) + if not np.issubdtype(complex_value.token_to_atoms.dtype, np.integer): + raise TypeError("token_to_atoms must use an integer dtype.") + if complex_value.chain_id.shape != (n_tokens,): + raise ValueError(f"chain_id must have shape ({n_tokens},).") + for label, values in ( + ("chain_id", complex_value.chain_id), + ("entity_id", complex_value.entity_id), + ("sym_id", complex_value.sym_id), + ): + if values is not None and values.shape != (n_tokens,): + raise ValueError(f"{label} must have shape ({n_tokens},).") + if values is not None and not np.issubdtype(values.dtype, np.integer): + raise TypeError(f"{label} must use an integer dtype.") + if complex_value.plddt.shape != (n_tokens,): + raise ValueError(f"plddt must have shape ({n_tokens},).") + if not np.issubdtype(complex_value.plddt.dtype, np.number): + raise TypeError("plddt must use a numeric dtype.") + if n_tokens: + starts = complex_value.token_to_atoms[:, 0] + stops = complex_value.token_to_atoms[:, 1] + if np.any(starts < 0) or np.any(stops < starts) or np.any(stops > n_atoms): + raise ValueError("token_to_atoms contains an invalid or out-of-bounds atom span.") + for label, values in ( + ("atom_names", complex_value.atom_names), + ("atom_hetero", complex_value.atom_hetero), + ): + if values is not None and not isinstance(values, np.ndarray): + raise TypeError(f"{label} must be a NumPy array, got {type(values).__name__}.") + if isinstance(values, np.ndarray) and values.shape != (n_atoms,): + raise ValueError(f"{label} shape {values.shape} != {n_atoms} atoms") + + +def _flat_protein_atoms( + protein: ProteinComplex, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Flatten the populated atom37 entries of a protein complex.""" + positions: list[np.ndarray] = [] + elements: list[str] = [] + names: list[str] = [] + hetero: list[bool] = [] + spans: list[tuple[int, int]] = [] + + for sequence_index, residue in enumerate(protein.sequence): + if residue == "|": + continue + start = len(positions) + mask = protein.atom37_mask[sequence_index] + residue_positions = protein.atom37_positions[sequence_index] + for atom_index in np.flatnonzero(mask): + atom_name = residue_constants.atom_types[int(atom_index)] + positions.append(residue_positions[atom_index]) + elements.append(atom_name[0] if atom_name else "C") + names.append(atom_name) + hetero.append(False) + spans.append((start, len(positions))) + + return ( + np.asarray(positions, dtype=np.float32), + np.asarray(elements, dtype=object), + np.asarray(names, dtype=object), + np.asarray(hetero, dtype=bool), + np.asarray(spans, dtype=np.int32), + ) + + +def _protein_sequence_and_indices( + complex_value: MolecularComplex, +) -> tuple[list[int], str, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + protein_indices = [ + index + for index, token in enumerate(complex_value.sequence) + if token in residue_constants.restype_3to1 + ] + if not protein_indices: + raise ValueError("No protein tokens found in MolecularComplex") + + chain_ids = complex_value.chain_id[protein_indices] + entity_ids = ( + chain_ids + if complex_value.entity_id is None + else complex_value.entity_id[protein_indices] + ) + sym_ids = ( + np.zeros_like(chain_ids) + if complex_value.sym_id is None + else complex_value.sym_id[protein_indices] + ) + confidences = complex_value.plddt[protein_indices] + sequence: list[str] = [] + previous_instance: Any = None + preserve_instances = complex_value.sym_id is not None + for index, chain_id, sym_id in zip( + protein_indices, chain_ids, sym_ids, strict=True + ): + instance = (int(chain_id), int(sym_id)) if preserve_instances else int(chain_id) + if previous_instance is not None and instance != previous_instance: + sequence.append("|") + sequence.append(residue_constants.restype_3to1[complex_value.sequence[index]]) + previous_instance = instance + return protein_indices, "".join(sequence), chain_ids, entity_ids, sym_ids, confidences + + +def _protein_entity_metadata_value(value: int | str) -> int | str: + """Restore the numeric entity labels used by ProteinComplex metadata.""" + if isinstance(value, str): + try: + return int(value) + except ValueError: + pass + return value + + +def _atom37_from_flat( + complex_value: MolecularComplex, protein_indices: list[int] +) -> tuple[np.ndarray, np.ndarray]: + n_residues = len(protein_indices) + positions = np.full((n_residues, 37, 3), np.nan, dtype=np.float32) + mask = np.zeros((n_residues, 37), dtype=bool) + if complex_value.atom_names is None: + return positions, mask + + for residue_index, token_index in enumerate(protein_indices): + start, stop = complex_value.token_to_atoms[token_index] + seen: set[str] = set() + for atom_name, atom_position in zip( + complex_value.atom_names[start:stop], + complex_value.atom_positions[start:stop], + strict=True, + ): + normalized = str(atom_name).upper().strip() + if normalized in seen: + continue + seen.add(normalized) + atom37_index = residue_constants.atom_order.get(normalized) + if atom37_index is not None: + positions[residue_index, atom37_index] = atom_position + mask[residue_index, atom37_index] = True + return positions, mask + + +def _expand_protein_rows( + sequence: str, + protein_chain_ids: np.ndarray, + protein_entity_ids: np.ndarray, + protein_sym_ids: np.ndarray, + confidences: np.ndarray, + compact_positions: np.ndarray, + compact_mask: np.ndarray, +) -> dict[str, np.ndarray]: + """Insert empty rows at chain separators in a protein representation.""" + n_positions = len(sequence) + expanded = { + "chain_id": np.full(n_positions, -1, dtype=np.int64), + "entity_id": np.full(n_positions, -1, dtype=np.int64), + "sym_id": np.zeros(n_positions, dtype=np.int64), + "residue_index": np.zeros(n_positions, dtype=np.int64), + "insertion_code": np.asarray([""] * n_positions, dtype=object), + "confidence": np.zeros(n_positions, dtype=np.float32), + "atom37_positions": np.full((n_positions, 37, 3), np.nan, dtype=np.float32), + "atom37_mask": np.zeros((n_positions, 37), dtype=bool), + } + residue_number = 0 + compact_index = 0 + for sequence_index, residue in enumerate(sequence): + if residue == "|": + residue_number = 0 + continue + chain_id = protein_chain_ids[compact_index] + residue_number += 1 + expanded["chain_id"][sequence_index] = chain_id + expanded["entity_id"][sequence_index] = protein_entity_ids[compact_index] + expanded["sym_id"][sequence_index] = protein_sym_ids[compact_index] + expanded["residue_index"][sequence_index] = residue_number + expanded["confidence"][sequence_index] = confidences[compact_index] + expanded["atom37_positions"][sequence_index] = compact_positions[compact_index] + expanded["atom37_mask"][sequence_index] = compact_mask[compact_index] + compact_index += 1 + return expanded + + +def _read_cif(source: str) -> CIFFile: + if os.path.exists(source): + return pdbx.CIFFile.read(source) + return pdbx.CIFFile.read(io.StringIO(source)) + + +def _read_structure(cif_file: CIFFile) -> Any: + try: + return pdbx.get_structure(cif_file, model=1, extra_fields=["b_factor"]) + except (KeyError, ValueError): + try: + return pdbx.get_structure(cif_file) + except Exception: + return pdbx.get_structure(cif_file, model=None) + + +def _column_array(category: Any, name: str) -> np.ndarray: + column = category[name] + if hasattr(column, "as_array"): + return column.as_array(str) + return np.asarray(list(column), dtype=str) + + +def _label_asym_ids(cif_file: CIFFile, n_structure_atoms: int) -> list[str] | None: + """Return label-asym identifiers after applying Biohub's atom filters.""" + block = cif_file.block + if "atom_site" not in block or "label_asym_id" not in block["atom_site"]: + return None + atom_site = block["atom_site"] + labels = _column_array(atom_site, "label_asym_id") + keep = np.ones(len(labels), dtype=bool) + if "pdbx_PDB_model_num" in atom_site: + keep &= _column_array(atom_site, "pdbx_PDB_model_num") == "1" + if "label_alt_id" in atom_site: + keep &= np.isin(_column_array(atom_site, "label_alt_id"), [".", "?", "", "A"]) + filtered = labels[keep] + return filtered.tolist() if len(filtered) == n_structure_atoms else None + + +def _entity_metadata(cif_file: CIFFile) -> dict[Any, Any]: + result: dict[Any, Any] = {} + try: + category = cif_file.block["entity"] + if "id" not in category or "type" not in category: + return result + for entity_id, entity_type in zip(category["id"], category["type"], strict=False): + result[entity_id] = entity_type + except Exception: + return {} + return result + + +def _group_structure_atoms( + structure: Any, labels: list[str] | None +) -> dict[str, dict[tuple[int, str], dict[str, Any]]]: + grouped: dict[str, dict[tuple[int, str], dict[str, Any]]] = {} + for atom_index, atom in enumerate(structure): + chain = labels[atom_index] if labels is not None else atom.chain_id + residues = grouped.setdefault(chain, {}) + key = (atom.res_id, atom.res_name) + record = residues.setdefault( + key, + {"atoms": [], "res_name": atom.res_name, "is_hetero": atom.hetero}, + ) + record["atoms"].append(atom) + return grouped + + +def _flatten_structure_groups( + grouped: dict[str, dict[tuple[int, str], dict[str, Any]]], +) -> tuple[ + list[str], + list[np.ndarray], + list[str], + list[str], + list[bool], + list[tuple[int, int]], + list[float], + list[int], + dict[str, int], +]: + tokens: list[str] = [] + positions: list[np.ndarray] = [] + elements: list[str] = [] + names: list[str] = [] + hetero: list[bool] = [] + spans: list[tuple[int, int]] = [] + confidences: list[float] = [] + token_chains: list[int] = [] + chain_numbers = {chain: index for index, chain in enumerate(sorted(grouped))} + + for chain in sorted(grouped): + for residue_key in sorted(grouped[chain]): + record = grouped[chain][residue_key] + if record["res_name"] == "HOH": + continue + atoms = record["atoms"] + tokens.append(record["res_name"]) + token_chains.append(chain_numbers[chain]) + start = len(positions) + positions.extend(atom.coord for atom in atoms) + elements.extend(atom.element for atom in atoms) + names.extend(atom.atom_name for atom in atoms) + hetero.extend(atom.hetero for atom in atoms) + spans.append((start, len(positions))) + b_factor = getattr(atoms[0], "b_factor", 50.0) if atoms else 50.0 + confidences.append(min(b_factor / PLDDT_B_FACTOR_SCALE, 1.0)) + return ( + tokens, + positions, + elements, + names, + hetero, + spans, + confidences, + token_chains, + chain_numbers, + ) + + +def _chain_entity_maps( + complex_value: MolecularComplex, +) -> tuple[dict[str, list[str]], dict[str, int], dict[int, tuple[str, ...]]]: + chains: dict[str, list[str]] = {} + for token_index, numeric_chain in enumerate(complex_value.chain_id): + numeric = int(numeric_chain) + label = complex_value.metadata.chain_lookup.get(numeric, chr(65 + numeric)) + chains.setdefault(label, []).append(complex_value.sequence[token_index]) + + sequence_entities: dict[tuple[str, ...], int] = {} + chain_entities: dict[str, int] = {} + entity_sequences: dict[int, tuple[str, ...]] = {} + for label, sequence in chains.items(): + key = tuple(sequence) + entity_id = sequence_entities.get(key) + if entity_id is None: + entity_id = len(sequence_entities) + 1 + sequence_entities[key] = entity_id + entity_sequences[entity_id] = key + chain_entities[label] = entity_id + return chains, chain_entities, entity_sequences + + +def _cif_column(values: list[str]) -> CIFColumn: + return CIFColumn(data=CIFData(array=np.asarray(values), dtype=np.str_)) + + +def _add_entity_categories( + cif_file: CIFFile, + complex_value: MolecularComplex, + entity_sequences: dict[int, tuple[str, ...]], +) -> None: + ids: list[str] = [] + types: list[str] = [] + descriptions: list[str] = [] + for entity_id in sorted(entity_sequences): + sequence = entity_sequences[entity_id] + protein = any(token in residue_constants.restype_3to1 for token in sequence) + nucleic = any(token in _NUCLEOTIDE_NAMES for token in sequence) + ids.append(str(entity_id)) + types.append("polymer" if protein or nucleic else "non-polymer") + if protein: + descriptions.append(f"Polymer entity {entity_id} (protein)") + elif nucleic: + descriptions.append(f"Polymer entity {entity_id} (nucleic acid)") + else: + descriptions.append(f"Non-polymer entity {entity_id}") + + if ids: + cif_file.block["entity"] = CIFCategory( + name="entity", + columns={ + "id": _cif_column(ids), + "type": _cif_column(types), + "pdbx_description": _cif_column(descriptions), + }, + ) + + _, chain_entities, _ = _chain_entity_maps(complex_value) + if chain_entities: + labels = sorted(chain_entities) + cif_file.block["struct_asym"] = CIFCategory( + name="struct_asym", + columns={ + "id": _cif_column(labels), + "entity_id": _cif_column([str(chain_entities[label]) for label in labels]), + }, + ) + + entity_chains: dict[int, list[str]] = {} + for chain, entity_id in chain_entities.items(): + entity_chains.setdefault(entity_id, []).append(chain) + polymer_rows: list[tuple[str, str, str, str]] = [] + residue_rows: list[tuple[str, str, str, str]] = [] + for entity_id in sorted(entity_sequences): + sequence = entity_sequences[entity_id] + protein = any(token in residue_constants.restype_3to1 for token in sequence) + nucleic = any(token in _NUCLEOTIDE_NAMES for token in sequence) + if not (protein or nucleic): + continue + if protein: + polymer_type = "polypeptide(L)" + canonical = "".join( + residue_constants.restype_3to1.get(token, "(X)") for token in sequence + ) + else: + polymer_type = ( + "polyribonucleotide" + if "U" in sequence + else ( + "polydeoxyribonucleotide" + if any(token in {"DA", "DT", "DG", "DC"} for token in sequence) + else "polyribonucleotide" + ) + ) + nucleotide_letters = {"DA": "A", "DT": "T", "DG": "G", "DC": "C"} + canonical = "".join(nucleotide_letters.get(token, token) for token in sequence) + strand_ids = ",".join(sorted(entity_chains.get(entity_id, []))) or "?" + polymer_rows.append((str(entity_id), polymer_type, strand_ids, canonical)) + residue_rows.extend( + (str(entity_id), str(number), token, "n") + for number, token in enumerate(sequence, start=1) + ) + + if polymer_rows: + columns = list(zip(*polymer_rows, strict=True)) + cif_file.block["entity_poly"] = CIFCategory( + name="entity_poly", + columns={ + "entity_id": _cif_column(list(columns[0])), + "type": _cif_column(list(columns[1])), + "pdbx_strand_id": _cif_column(list(columns[2])), + "pdbx_seq_one_letter_code_can": _cif_column(list(columns[3])), + }, + ) + if residue_rows: + columns = list(zip(*residue_rows, strict=True)) + cif_file.block["entity_poly_seq"] = CIFCategory( + name="entity_poly_seq", + columns={ + "entity_id": _cif_column(list(columns[0])), + "num": _cif_column(list(columns[1])), + "mon_id": _cif_column(list(columns[2])), + "hetero": _cif_column(list(columns[3])), + }, + ) + + +def _fallback_atom_names(token: str, count: int) -> list[str]: + if token in residue_constants.restype_3to1: + names = list(residue_constants.residue_atoms.get(token, ["N", "CA", "C", "O"]))[:count] + names.extend(f"X{index + 1}" for index in range(len(names), count)) + return names + return [f"C{index + 1}" for index in range(count)] + + +def _as_atom_array(complex_value: MolecularComplex, chain_entities: dict[str, int]) -> bs.AtomArray: + n_atoms = len(complex_value.atom_positions) + atom_array = bs.AtomArray(length=n_atoms) + atom_array.coord = complex_value.atom_positions + residue_ids = np.zeros(n_atoms, dtype=np.int32) + chain_labels = np.empty(n_atoms, dtype=object) + residue_names = np.empty(n_atoms, dtype=object) + hetero = np.zeros(n_atoms, dtype=bool) + b_factors = np.zeros(n_atoms, dtype=np.float32) + atom_names = np.empty(n_atoms, dtype=object) + entity_ids = np.zeros(n_atoms, dtype=np.int32) + next_residue: dict[Any, int] = {} + + for token_index, (start, stop) in enumerate(complex_value.token_to_atoms): + token = complex_value.sequence[token_index] + numeric_chain = complex_value.chain_id[token_index] + numeric = int(numeric_chain) + chain = complex_value.metadata.chain_lookup.get(numeric, chr(65 + numeric)) + residue_id = next_residue.get(numeric_chain, 0) + 1 + next_residue[numeric_chain] = residue_id + count = int(stop - start) + names = ( + list(complex_value.atom_names[start:stop]) + if complex_value.atom_names is not None + else _fallback_atom_names(token, count) + ) + residue_ids[start:stop] = residue_id + chain_labels[start:stop] = chain + residue_names[start:stop] = token + hetero[start:stop] = ( + complex_value.atom_hetero[start:stop] + if complex_value.atom_hetero is not None + else token not in residue_constants.restype_3to1 + ) + b_factors[start:stop] = complex_value.plddt[token_index] * PLDDT_B_FACTOR_SCALE + atom_names[start:stop] = names + entity_ids[start:stop] = chain_entities.get(chain, 1) + + atom_array.res_id = residue_ids + atom_array.chain_id = np.asarray(chain_labels, dtype="U16") + atom_array.res_name = np.asarray(residue_names, dtype="U8") + atom_array.hetero = hetero + atom_array.atom_name = np.asarray(atom_names, dtype="U4") + atom_array.add_annotation("b_factor", dtype=float) + atom_array.b_factor = b_factors + atom_array.add_annotation("occupancy", dtype=float) + atom_array.occupancy = np.ones(n_atoms, dtype=np.float32) + atom_array.add_annotation("entity_id", dtype=int) + atom_array.entity_id = entity_ids + if complex_value.atom_elements is not None and len(complex_value.atom_elements) == n_atoms: + atom_array.element = np.asarray(complex_value.atom_elements, dtype="U4") + else: + atom_array.element = bs.infer_elements(atom_array) + return atom_array + + +def _repair_label_entity_ids(cif_file: CIFFile, chain_entities: dict[str, int]) -> None: + if "atom_site" not in cif_file.block: + return + atom_site = cif_file.block["atom_site"] + if "label_asym_id" not in atom_site or "label_entity_id" not in atom_site: + return + labels = _column_array(atom_site, "label_asym_id").tolist() + if labels: + atom_site["label_entity_id"] = _cif_column( + [str(chain_entities.get(label, 1)) for label in labels] + ) + + +def _centroid_tensors( + mobile: MolecularComplex, + target: MolecularComplex, + *, + retain_missing: bool, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + if len(mobile) != len(target): + raise ValueError( + f"Complexes must have the same number of tokens: {len(mobile)} vs {len(target)}" + ) + mobile_centers: list[np.ndarray] = [] + target_centers: list[np.ndarray] = [] + valid: list[bool] = [] + for token_index in range(len(mobile)): + mobile_start, mobile_stop = mobile.token_to_atoms[token_index] + target_start, target_stop = target.token_to_atoms[token_index] + mobile_atoms = mobile.atom_positions[mobile_start:mobile_stop] + target_atoms = target.atom_positions[target_start:target_stop] + present = len(mobile_atoms) > 0 and len(target_atoms) > 0 + if not present and not retain_missing: + continue + if present: + mobile_centers.append(mobile_atoms.mean(axis=0)) + target_centers.append(target_atoms.mean(axis=0)) + else: + mobile_centers.append(np.full(3, np.nan)) + target_centers.append(np.full(3, np.nan)) + valid.append(present) + if not any(valid): + metric = "LDDT" if retain_missing else "RMSD" + raise ValueError(f"No valid atoms found for {metric} computation") + return ( + torch.from_numpy(np.stack(mobile_centers)).unsqueeze(0), + torch.from_numpy(np.stack(target_centers)).unsqueeze(0), + torch.as_tensor(valid, dtype=torch.bool).unsqueeze(0), + ) + + +@dataclass(frozen=True) +class MolecularComplex: + """A token sequence backed by one contiguous atom table. + + P stores atom coordinates with shape (n_atoms, 3). Token span ``i`` is + ``P[token_to_atoms[i, 0]:token_to_atoms[i, 1]]``. ``chain_id`` identifies + the author chain, while optional ``entity_id`` and ``sym_id`` distinguish + biological entities and repeated chain instances. + """ + + id: str + sequence: list[str] + atom_positions: np.ndarray # P has shape (n_atoms, 3). + atom_elements: np.ndarray # E has shape (n_atoms,). + token_to_atoms: np.ndarray # I has shape (n_tokens, 2). + chain_id: np.ndarray # C has shape (n_tokens,). + plddt: np.ndarray # S has shape (n_tokens,). + metadata: MolecularComplexMetadata + atom_names: np.ndarray | None = None # N has shape (n_atoms,) when present. + atom_hetero: np.ndarray | None = None # M has shape (n_atoms,) when present. + # These token-aligned IDs are optional for compatibility with older blobs. + # ProteinComplex adapters populate them so homomers and repeated author-chain + # labels survive a MolecularComplex round trip. + entity_id: np.ndarray | None = None + sym_id: np.ndarray | None = None + + def __post_init__(self) -> None: + _assert_table_lengths(self) + + def __len__(self) -> int: + return len(self.sequence) + + def __getitem__(self, idx: int) -> Molecule: + if idx < 0 or idx >= len(self): + raise IndexError(f"Token index {idx} out of range for {len(self)} tokens") + start, stop = self.token_to_atoms[idx] + return Molecule( + token=self.sequence[idx], + token_idx=idx, + atom_positions=self.atom_positions[start:stop], + atom_elements=self.atom_elements[start:stop], + atom_names=None if self.atom_names is None else self.atom_names[start:stop], + atom_hetero=(None if self.atom_hetero is None else self.atom_hetero[start:stop]), + residue_type=0, + molecule_type=0, + confidence=self.plddt[idx], + ) + + @property + def atom_coordinates(self) -> np.ndarray: + """Return P, the flat atom-coordinate table with shape (n_atoms, 3).""" + return self.atom_positions + + @classmethod + def from_protein_complex(cls, pc: ProteinComplex) -> MolecularComplex: + positions, elements, names, hetero, spans = _flat_protein_atoms(pc) + residue_positions = [index for index, value in enumerate(pc.sequence) if value != "|"] + metadata = MolecularComplexMetadata( + entity_lookup={key: str(value) for key, value in pc.metadata.entity_lookup.items()}, + chain_lookup=dict(pc.metadata.chain_lookup), + assembly_composition=pc.metadata.assembly_composition, + ) + return cls( + id=pc.id, + sequence=[ + residue_constants.restype_1to3.get(pc.sequence[index], "UNK") + for index in residue_positions + ], + atom_positions=positions, + atom_elements=elements, + token_to_atoms=spans, + chain_id=np.asarray(pc.chain_id[residue_positions], dtype=np.int64), + plddt=np.asarray(pc.confidence[residue_positions], dtype=np.float32), + metadata=metadata, + atom_names=names, + atom_hetero=hetero, + entity_id=np.asarray(pc.entity_id[residue_positions], dtype=np.int64), + sym_id=np.asarray(pc.sym_id[residue_positions], dtype=np.int64), + ) + + def to_protein_complex(self) -> ProteinComplex: + ( + protein_indices, + sequence, + chain_ids, + entity_ids, + sym_ids, + confidences, + ) = _protein_sequence_and_indices(self) + compact_positions, compact_mask = _atom37_from_flat(self, protein_indices) + arrays = _expand_protein_rows( + sequence, + chain_ids, + entity_ids, + sym_ids, + confidences, + compact_positions, + compact_mask, + ) + unique_chains = np.unique(chain_ids) + unique_entities = np.unique(entity_ids) + metadata = ProteinComplexMetadata( + entity_lookup={ + int(entity): _protein_entity_metadata_value( + self.metadata.entity_lookup.get(int(entity), int(entity)) + ) + for entity in unique_entities + }, + chain_lookup={ + int(chain): self.metadata.chain_lookup.get(int(chain), chr(65 + int(chain))) + for chain in unique_chains + }, + assembly_composition=self.metadata.assembly_composition, + ) + return ProteinComplex( + id=self.id, + sequence=sequence, + entity_id=arrays["entity_id"], + chain_id=arrays["chain_id"], + sym_id=arrays["sym_id"], + residue_index=arrays["residue_index"], + insertion_code=arrays["insertion_code"], + atom37_positions=arrays["atom37_positions"], + atom37_mask=arrays["atom37_mask"], + confidence=arrays["confidence"], + metadata=metadata, + ) + + @classmethod + def from_mmcif(cls, inp: str, id: str | None = None) -> MolecularComplex: + cif_file = _read_cif(inp) + structure = _read_structure(cif_file) + if TYPE_CHECKING: + structure: Any = structure + labels = _label_asym_ids(cif_file, len(structure)) + grouped = _group_structure_atoms(structure, labels) + ( + tokens, + positions, + elements, + names, + hetero, + spans, + confidences, + token_chains, + chain_numbers, + ) = _flatten_structure_groups(grouped) + n_tokens = len(tokens) + if positions: + position_array = np.asarray(positions, dtype=np.float32) + element_array = np.asarray(elements, dtype=object) + name_array = np.asarray(names, dtype=object) + hetero_array = np.asarray(hetero, dtype=bool) + span_array = np.asarray(spans, dtype=np.int32) + chain_array = np.asarray(token_chains, dtype=np.int64) + else: + position_array = np.zeros((0, 3), dtype=np.float32) + element_array = np.zeros(0, dtype=object) + name_array = np.zeros(0, dtype=object) + hetero_array = np.zeros(0, dtype=bool) + span_array = np.zeros((n_tokens, 2), dtype=np.int32) + chain_array = ( + np.asarray(token_chains, dtype=np.int64) + if token_chains + else np.zeros(n_tokens, dtype=np.int64) + ) + complex_id = id or (Path(inp).stem if os.path.exists(inp) else "complex_from_string") + return cls( + id=complex_id, + sequence=tokens, + atom_positions=position_array, + atom_elements=element_array, + token_to_atoms=span_array, + chain_id=chain_array, + plddt=np.asarray(confidences, dtype=np.float32), + metadata=MolecularComplexMetadata( + entity_lookup=_entity_metadata(cif_file), + chain_lookup={number: chain for chain, number in chain_numbers.items()}, + assembly_composition=None, + ), + atom_names=name_array, + atom_hetero=hetero_array, + ) + + def _get_entity_mapping( + self, + ) -> tuple[dict[str, list[str]], dict[str, int], dict[int, tuple[str, ...]]]: + return _chain_entity_maps(self) + + def _add_entity_information( + self, cif_file: CIFFile, entity_sequences: dict[int, tuple[str, ...]] + ) -> None: + _add_entity_categories(cif_file, self, entity_sequences) + + def to_mmcif(self) -> str: + _, chain_entities, entity_sequences = _chain_entity_maps(self) + atom_array = _as_atom_array(self, chain_entities) + cif_file = CIFFile() + set_structure(cif_file, atom_array, data_block=self.id) + _repair_label_entity_ids(cif_file, chain_entities) + _add_entity_categories(cif_file, self, entity_sequences) + round_mmcif_columns(cif_file) + output = io.StringIO() + cif_file.write(output) + return output.getvalue() + + def dockq(self, native: MolecularComplex) -> Any: + try: + mobile = self.to_protein_complex().normalize_chain_ids_for_pdb() + target = native.to_protein_complex().normalize_chain_ids_for_pdb() + except ValueError as error: + raise ValueError( + f"Cannot convert MolecularComplex to ProteinComplex for DockQ: {error}" + ) from None + try: + return mobile.dockq(target) + except Exception: + return self._compute_dockq_manual(native) + + def _compute_dockq_manual(self, native: MolecularComplex) -> Any: + try: + mobile = self.to_protein_complex().normalize_chain_ids_for_pdb() + target = native.to_protein_complex().normalize_chain_ids_for_pdb() + except ValueError as error: + raise ValueError( + f"Cannot convert MolecularComplex to ProteinComplex for DockQ: {error}" + ) from None + with TemporaryDirectory() as directory: + mobile_path = Path(directory) / "self.pdb" + target_path = Path(directory) / "native.pdb" + mobile.to_pdb(mobile_path) + target.to_pdb(target_path) + try: + raw_output = check_output(["DockQ", str(mobile_path), str(target_path)]) + output = raw_output.decode() + score: float | None = None + for line in output.split("\n"): + if "Total DockQ" in line: + match = re.search(r"Total DockQ.*: ([\d.]+)", line) + if match: + score = float(match.group(1)) + break + if score is None: + for line in output.split("\n"): + if line.startswith("DockQ") and ":" in line: + try: + score = float(line.split(":")[1].strip()) + break + except (ValueError, IndexError): + continue + if score is None: + raise ValueError("Could not parse DockQ score from output") + return {"total_dockq": score, "raw_output": output, "aligned": self} + except FileNotFoundError: + raise RuntimeError( + "DockQ is not installed. Please install DockQ to use this method." + ) from None + except Exception as error: + raise RuntimeError(f"DockQ computation failed: {error}") from error + + def rmsd(self, target: MolecularComplex, **kwargs: Any) -> float: + mobile, reference, mask = _centroid_tensors(self, target, retain_missing=False) + value = compute_rmsd( + mobile=mobile, + target=reference, + atom_exists_mask=mask, + reduction="batch", + **kwargs, + ) + return float(value) + + def lddt_ca(self, target: MolecularComplex, **kwargs: Any) -> float: + mobile, reference, mask = _centroid_tensors(self, target, retain_missing=True) + value = compute_lddt( + all_atom_pred_pos=mobile, + all_atom_positions=reference, + all_atom_mask=mask, + per_residue=False, + **kwargs, + ) + return float(value) + + def state_dict(self) -> dict[str, Any]: + state = dict(vars(self)) + for optional_identity in ("entity_id", "sym_id"): + if state[optional_identity] is None: + state.pop(optional_identity) + for key, value in tuple(state.items()): + if isinstance(value, MolecularComplexMetadata): + state[key] = asdict(value) + elif isinstance(value, np.ndarray): + if value.dtype == np.int64: + value = value.astype(np.int32) + elif value.dtype in (np.dtype(np.float64), np.dtype(np.float32)): + value = value.astype(np.float16) + state[key] = value.tolist() + return state + + def to_blob(self) -> bytes: + return brotli.compress(msgpack.dumps(self.state_dict()), quality=5) + + @classmethod + def from_state_dict(cls, dct: dict[str, Any]) -> MolecularComplex: + dct = dict(dct) + for key, value in tuple(dct.items()): + if isinstance(value, list) and key in _SERIALIZED_ARRAYS: + dct[key] = np.asarray(value) + for key, value in tuple(dct.items()): + if not isinstance(value, np.ndarray): + continue + if key in {"atom_positions", "plddt"}: + dct[key] = value.astype(np.float32) + elif key == "token_to_atoms": + dct[key] = value.astype(np.int32) + elif key in {"chain_id", "entity_id", "sym_id"}: + dct[key] = value.astype(np.int64) + dct["metadata"] = MolecularComplexMetadata(**dct["metadata"]) + if "chain_id" not in dct: + dct["chain_id"] = np.zeros(len(dct["sequence"]), dtype=np.int64) + return cls(**dct) + + @classmethod + def from_blob(cls, input: Path | str | io.BytesIO | bytes) -> MolecularComplex: + if isinstance(input, (Path, str)): + payload = Path(input).read_bytes() + elif isinstance(input, io.BytesIO): + payload = input.getvalue() + else: + payload = input + state = msgpack.loads(brotli.decompress(payload), strict_map_key=False) + return cls.from_state_dict(state) diff --git a/fastplms/models/esmfold2/esmfold2_msa.py b/fastplms/models/esmfold2/esmfold2_msa.py new file mode 100644 index 0000000000000000000000000000000000000000..56c625556d3096e5ac060e1f4571577043d9eb15 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_msa.py @@ -0,0 +1,577 @@ +"""Multiple-sequence-alignment value objects and lossless encodings.""" + +from __future__ import annotations + +import dataclasses +import string +from collections.abc import Sequence +from dataclasses import dataclass +from functools import cached_property +from itertools import islice +from typing import Any + +import numpy as np +from Bio import SeqIO +from scipy.spatial.distance import cdist + +from .esmfold2_misc import slice_any_object +from .esmfold2_msa_filter_sequences import greedy_select_indices, hhfilter +from .esmfold2_parsing import FastaEntry, read_sequences, write_sequences +from .esmfold2_sequential_dataclass import SequentialDataclass +from .esmfold2_system import PathOrBuffer + +_A3M_INSERTION_DELETE_TABLE = str.maketrans( + dict.fromkeys(string.ascii_lowercase + ".") +) +_SERIALIZATION_VERSION = 1 +_UINT32_BYTES = 4 + + +def is_a3m_insertion(character: str) -> bool: + """Return whether a character is an A3M insertion marker.""" + + return character == "." or character.islower() + + +def remove_insertions_from_sequence(sequence: str) -> str: + """Remove lowercase residues and dot insertion markers from an A3M row.""" + + return sequence.translate(_A3M_INSERTION_DELETE_TABLE) + + +def a3m_deletion_counts(sequence: str) -> np.ndarray: + """Count insertions preceding each A3M match column.""" + + codes = np.frombuffer(sequence.encode("ascii"), dtype=np.uint8) + lowercase = (codes >= ord("a")) & (codes <= ord("z")) + insertion_mask = lowercase | (codes == ord(".")) + prefix_counts = np.concatenate(([0], np.cumsum(insertion_mask))) + match_positions = np.flatnonzero(~insertion_mask) + return np.diff(prefix_counts[match_positions], prepend=0) + + +def _parse_full_payload(data: bytes) -> tuple[np.ndarray, list[str]]: + version = int.from_bytes(data[:1], "little") + if version != _SERIALIZATION_VERSION: + raise ValueError(f"Unsupported version: {version}") + seqlen = int.from_bytes(data[1:5], "little") + depth = int.from_bytes(data[5:9], "little") + body = data[9:] + split = seqlen * depth + array = np.frombuffer(body[:split], dtype="|S1").reshape(depth, seqlen) + headers = [header for header in body[split:].decode().split("\n") if header] + if not headers and depth > 0: + headers = [""] * depth + return array, headers + + +def _parse_sequence_payload(data: bytes) -> np.ndarray: + seqlen = int.from_bytes(data[:_UINT32_BYTES], "little") + return np.frombuffer(data[_UINT32_BYTES:], dtype="|S1").reshape(-1, seqlen) + + +def _full_payload(array: np.ndarray, headers: Sequence[str]) -> bytes: + depth, seqlen = array.shape + prefix = b"".join( + ( + _SERIALIZATION_VERSION.to_bytes(1, "little"), + seqlen.to_bytes(_UINT32_BYTES, "little"), + depth.to_bytes(_UINT32_BYTES, "little"), + ) + ) + return prefix + array.tobytes() + "\n".join(headers).encode() + + +def _sequence_payload(array: np.ndarray) -> bytes: + return array.shape[1].to_bytes(_UINT32_BYTES, "little") + array.tobytes() + + +def _random_row_indices(depth: int, count: int) -> np.ndarray: + sampled = np.random.choice(depth - 1, count - 1, replace=False) + 1 + return np.sort(np.append(0, sampled)) + + +@dataclass(frozen=True) +class FastMSA(SequentialDataclass): + """An MSA stored as a two-dimensional NumPy byte array.""" + + array: np.ndarray + headers: list[str] | None = None + + def __post_init__(self) -> None: + if not isinstance(self.array, np.ndarray): + raise TypeError("FastMSA array must be a NumPy array.") + if self.array.ndim != 2 or self.array.shape[0] == 0 or self.array.shape[1] == 0: + raise ValueError( + f"FastMSA array must have non-empty shape (depth, length), got {self.array.shape}." + ) + if self.headers is not None and len(self.headers) != self.depth: + raise ValueError("Number of headers must match depth.") + + @property + def depth(self) -> int: + return self.array.shape[0] + + @property + def seqlen(self) -> int: + return self.array.shape[1] + + def __len__(self) -> int: + return self.seqlen + + @classmethod + def from_bytes(cls, data: bytes) -> FastMSA: + array, headers = _parse_full_payload(data) + return cls(array, headers) + + @classmethod + def from_sequence_bytes(cls, data: bytes) -> FastMSA: + return cls(_parse_sequence_payload(data)) + + def __getitem__( + self, + indices: int | list[int] | slice | np.ndarray, + ) -> FastMSA: + column_indices = [indices] if isinstance(indices, int) else indices + return dataclasses.replace(self, array=self.array[:, column_indices]) + + def select_sequences( + self, + indices: Sequence[int] | np.ndarray, + ) -> FastMSA: + headers = None + if self.headers is not None: + headers = [self.headers[index] for index in indices] + return dataclasses.replace( + self, + array=self.array[indices], + headers=headers, + ) + + def select_random_sequences(self, num_seqs: int) -> FastMSA: + if num_seqs >= self.depth: + return self + return self.select_sequences(_random_row_indices(self.depth, num_seqs)) + + def pad_to_depth(self, depth: int) -> FastMSA: + if depth < self.depth: + raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}") + if depth == self.depth: + return self + row_count = depth - self.depth + pad_value = ord("-") if self.array.dtype == np.uint8 else b"-" + array = np.pad( + self.array, + ((0, row_count), (0, 0)), + constant_values=pad_value, + ) + headers = None if self.headers is None else self.headers + [""] * row_count + return dataclasses.replace(self, array=array, headers=headers) + + @classmethod + def concat( + cls, + msas: Sequence[FastMSA], + join_token: str | None = None, + allow_depth_mismatch: bool = False, + ) -> FastMSA: + if not msas: + raise ValueError("Cannot concatenate an empty list of MSAs") + if join_token not in (None, ""): + raise NotImplementedError("join_token is not supported for FastMSA") + depths = [msa.depth for msa in msas] + if len(set(depths)) != 1: + if not allow_depth_mismatch: + raise ValueError("Depth mismatch in concatenating MSAs") + maximum_depth = max(depths) + msas = [msa.pad_to_depth(maximum_depth) for msa in msas] + header_columns = ( + msa.headers if msa.headers is not None else [""] * msa.depth for msa in msas + ) + headers = [ + "|".join(str(header) for header in row) for row in zip(*header_columns, strict=False) + ] + return cls( + np.concatenate([msa.array for msa in msas], axis=1), + headers, + ) + + @classmethod + def stack( + cls, + msas: Sequence[FastMSA], + remove_query_from_later_msas: bool = True, + ) -> FastMSA: + if not msas: + raise ValueError("Cannot stack an empty list of MSAs") + arrays: list[np.ndarray] = [] + headers: list[str] | None = [] if any(msa.headers is not None for msa in msas) else None + for index, msa in enumerate(msas): + start = 1 if index > 0 and remove_query_from_later_msas else 0 + arrays.append(msa.array[start:]) + if headers is not None: + source_headers = msa.headers or [""] * msa.depth + headers.extend(source_headers[start:]) + return cls(np.concatenate(arrays, axis=0), headers) + + def to_msa(self) -> MSA: + headers = self.headers + if headers is None: + headers = [f"seq{index}" for index in range(self.depth)] + entries = [ + FastaEntry(header, b"".join(row).decode()) + for header, row in zip(headers, self.array, strict=False) + ] + return MSA(entries) + + +@dataclass(frozen=True) +class MSA(SequentialDataclass): + """An ordered set of aligned protein sequences and optional A3M metadata.""" + + entries: list[FastaEntry] + deletions: np.ndarray | None = dataclasses.field(default=None, compare=False) + + def __post_init__(self) -> None: + if not isinstance(self.entries, list): + raise TypeError("MSA entries must be a list of FastaEntry rows.") + if not self.entries: + raise ValueError("MSA requires at least one aligned sequence.") + if any(not isinstance(entry, FastaEntry) for entry in self.entries): + raise TypeError("Every MSA entry must be a FastaEntry.") + expected_length = len(self.entries[0].sequence) + if expected_length == 0: + raise ValueError("MSA sequences must be non-empty.") + for row, entry in enumerate(self.entries[1:], start=1): + if len(entry.sequence) != expected_length: + raise ValueError( + "MSA row length mismatch: " + f"row 0 has {expected_length} columns, row {row} has " + f"{len(entry.sequence)}." + ) + deletions = self.deletions + if deletions is not None and not isinstance(deletions, np.ndarray): + raise TypeError("MSA deletions must be a NumPy array when provided.") + if isinstance(deletions, np.ndarray) and deletions.shape != ( + len(self.entries), + expected_length, + ): + raise ValueError( + "MSA deletion matrix must have shape " + f"({len(self.entries)}, {expected_length}), got {deletions.shape}." + ) + + @cached_property + def sequences(self) -> list[str]: + return [entry.sequence for entry in self.entries] + + @cached_property + def headers(self) -> list[str]: + return [entry.header for entry in self.entries] + + @property + def depth(self) -> int: + return len(self.entries) + + @property + def seqlen(self) -> int: + return len(self.entries[0].sequence) + + @property + def query(self) -> str: + return self.entries[0].sequence + + @cached_property + def array(self) -> np.ndarray: + return np.array([list(sequence) for sequence in self.sequences], dtype="|S1") + + @cached_property + def seqid(self) -> np.ndarray: + byte_array = self.array.view(np.uint8) + return (1 - cdist(byte_array[0][None], byte_array, "hamming"))[0] + + def __len__(self) -> int: + return self.seqlen + + def __repr__(self) -> str: + return f"MSA({self.entries[0].header}: Depth={self.depth}, Length={self.seqlen})" + + @classmethod + def from_a3m( + cls, + path: PathOrBuffer, + remove_insertions: bool = True, + max_sequences: int | None = None, + ) -> MSA: + entries = [] + deletion_rows = [] + for header, raw_sequence in islice(read_sequences(path), max_sequences): + if remove_insertions: + deletion_rows.append(a3m_deletion_counts(raw_sequence)) + sequence = ( + remove_insertions_from_sequence(raw_sequence) if remove_insertions else raw_sequence + ) + if entries: + expected_length = len(entries[0].sequence) + if len(sequence) != expected_length: + raise ValueError( + "Sequence length mismatch. " + f"Expected: {expected_length}, Received: {len(sequence)}" + ) + entries.append(FastaEntry(header, sequence)) + deletions = None + if remove_insertions and deletion_rows: + deletions = np.stack(deletion_rows).astype(np.float32) + return cls(entries, deletions=deletions) + + @classmethod + def from_stockholm( + cls, + path: PathOrBuffer, + remove_insertions: bool = True, + max_sequences: int | None = None, + ) -> MSA: + entries = [] + for record in islice(SeqIO.parse(path, "stockholm"), max_sequences): + sequence = str(record.seq) + if entries: + expected_length = len(entries[0].sequence) + if len(sequence) != expected_length: + raise ValueError( + "Sequence length mismatch. " + f"Expected: {expected_length}, Received: {len(sequence)}" + ) + entries.append(FastaEntry(f"{record.id} {record.description}", sequence)) + msa = cls(entries) + if remove_insertions: + msa = msa.select_positions( + [index for index, residue in enumerate(msa.query) if residue != "-"] + ) + return msa + + @classmethod + def from_sequences( + cls, + sequences: list[str], + remove_insertions: bool = False, + ) -> MSA: + transform = ( + remove_insertions_from_sequence if remove_insertions else lambda sequence: sequence + ) + return cls([FastaEntry("", transform(sequence)) for sequence in sequences]) + + @classmethod + def from_bytes(cls, data: bytes) -> MSA: + array, headers = _parse_full_payload(data) + return cls( + [ + FastaEntry(header, b"".join(row).decode()) + for header, row in zip(headers, array, strict=False) + ] + ) + + @classmethod + def from_sequence_bytes(cls, data: bytes) -> MSA: + array = _parse_sequence_payload(data) + return cls([FastaEntry("", b"".join(row).decode()) for row in array]) + + @classmethod + def from_state_dict(cls, dct: dict[str, Any]) -> MSA: + deletions = dct.get("deletions") + return cls( + [FastaEntry("", sequence) for sequence in dct["sequences"]], + deletions=(None if deletions is None else np.asarray(deletions, dtype=np.float32)), + ) + + def to_a3m(self, path: PathOrBuffer) -> None: + write_sequences(self.entries, path) + + def to_fast_msa(self) -> FastMSA: + return FastMSA(self.array, self.headers) + + def to_bytes(self) -> bytes: + return _full_payload(self.array, self.headers) + + def to_sequence_bytes(self) -> bytes: + """Serialize aligned sequences without their headers.""" + + return _sequence_payload(self.array) + + def state_dict(self, json_serializable: bool = False) -> dict[str, Any]: + result: dict[str, Any] = {"sequences": self.sequences} + if self.deletions is not None: + result["deletions"] = self.deletions.tolist() if json_serializable else self.deletions + return result + + def _aligned_deletions(self) -> np.ndarray | None: + if self.deletions is None: + return None + if self.deletions.shape != (self.depth, self.seqlen): + return None + return self.deletions + + def _select_deletion_columns(self, indices) -> np.ndarray | None: + if self.deletions is None or self.deletions.shape[1] != self.seqlen: + return None + return self.deletions[:, indices] + + def select_sequences( + self, + indices: Sequence[int] | np.ndarray, + ) -> MSA: + deletions = None if self.deletions is None else self.deletions[np.asarray(indices)] + return dataclasses.replace( + self, + entries=[self.entries[index] for index in indices], + deletions=deletions, + ) + + def select_positions( + self, + indices: Sequence[int] | np.ndarray, + ) -> MSA: + entries = [ + FastaEntry( + entry.header, + "".join(entry.sequence[index] for index in indices), + ) + for entry in self.entries + ] + return dataclasses.replace( + self, + entries=entries, + deletions=self._select_deletion_columns(indices), + ) + + def __getitem__( + self, + indices: int | list[int] | slice | np.ndarray, + ) -> MSA: + column_indices = [indices] if isinstance(indices, int) else indices + entries = [ + FastaEntry( + entry.header, + slice_any_object(entry.sequence, column_indices), + ) + for entry in self.entries + ] + return dataclasses.replace( + self, + entries=entries, + deletions=self._select_deletion_columns(column_indices), + ) + + def greedy_select(self, num_seqs: int, mode: str = "max") -> MSA: + if mode not in ("max", "min"): + raise ValueError(f"Unsupported MSA selection mode: {mode!r}.") + if self.depth <= num_seqs: + return self + return self.select_sequences(greedy_select_indices(self.array, num_seqs, mode)) + + def hhfilter( + self, + seqid: int = 90, + diff: int = 0, + cov: int = 0, + qid: int = 0, + qsc: float = -20.0, + binary: str = "hhfilter", + ) -> MSA: + indices = hhfilter( + self.sequences, + seqid=seqid, + diff=diff, + cov=cov, + qid=qid, + qsc=qsc, + binary=binary, + ) + return self.select_sequences(indices) + + def select_random_sequences(self, num_seqs: int) -> MSA: + if num_seqs >= self.depth: + return self + return self.select_sequences(_random_row_indices(self.depth, num_seqs)) + + def select_diverse_sequences(self, num_seqs: int) -> MSA: + if num_seqs >= self.depth: + return self + filtered = self.hhfilter(diff=num_seqs) + if num_seqs < filtered.depth: + filtered = filtered.select_random_sequences(num_seqs) + return filtered + + def pad_to_depth(self, depth: int) -> MSA: + if depth < self.depth: + raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}") + if depth == self.depth: + return self + count = depth - self.depth + extra = [FastaEntry("", "-" * self.seqlen) for _ in range(count)] + deletions = self._aligned_deletions() + if deletions is not None: + zero_rows = np.zeros((count, self.seqlen), dtype=deletions.dtype) + deletions = np.concatenate((deletions, zero_rows), axis=0) + return dataclasses.replace( + self, + entries=self.entries + extra, + deletions=deletions, + ) + + @classmethod + def stack( + cls, + msas: Sequence[MSA], + remove_query_from_later_msas: bool = True, + ) -> MSA: + entries = [] + deletion_arrays = [] + for index, msa in enumerate(msas): + start = 1 if index > 0 and remove_query_from_later_msas else 0 + entries.extend(msa.entries[start:]) + aligned = msa._aligned_deletions() + if aligned is not None: + deletion_arrays.append(aligned[start:]) + deletions = None + if ( + len(deletion_arrays) == len(msas) + and len({array.shape[1] for array in deletion_arrays}) == 1 + ): + deletions = np.concatenate(deletion_arrays, axis=0) + return cls(entries=entries, deletions=deletions) + + @classmethod + def concat( + cls, + msas: Sequence[MSA], + join_token: str | None = "|", + allow_depth_mismatch: bool = False, + ) -> MSA: + if not msas: + raise ValueError("Cannot concatenate an empty list of MSAs") + depths = [msa.depth for msa in msas] + if len(set(depths)) != 1: + if not allow_depth_mismatch: + raise ValueError("Depth mismatch in concatenating MSAs") + maximum_depth = max(depths) + msas = [msa.pad_to_depth(maximum_depth) for msa in msas] + headers = [ + "|".join(str(header) for header in row) + for row in zip(*(msa.headers for msa in msas), strict=False) + ] + separator = "" if join_token is None else join_token + sequences = [ + separator.join(row) for row in zip(*(msa.sequences for msa in msas), strict=False) + ] + deletions = None + if separator == "": + arrays = [msa._aligned_deletions() for msa in msas] + if all(array is not None for array in arrays): + deletions = np.concatenate(arrays, axis=1) # type: ignore[arg-type] + return cls( + [ + FastaEntry(header, sequence) + for header, sequence in zip(headers, sequences, strict=False) + ], + deletions=deletions, + ) diff --git a/fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py b/fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py new file mode 100644 index 0000000000000000000000000000000000000000..bc13db7bee2398d291265e6f5a2e95752b2be61e --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py @@ -0,0 +1,111 @@ +"""Sequence selection for multiple-sequence alignments.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import numpy as np + +from .esmfold2_system import run_subprocess_with_errorcheck + + +def _byte_matrix(array: np.ndarray) -> np.ndarray: + """Return a two-dimensional byte view used for Hamming comparisons.""" + + matrix = np.asarray(array).view(np.uint8) + return matrix.reshape(matrix.shape[0], -1) + + +def _hamming_to_all(query: np.ndarray, sequences: np.ndarray) -> np.ndarray: + return np.not_equal(sequences, query).mean(axis=1, dtype=np.float64) + + +def greedy_select_indices(array: np.ndarray, num_seqs: int, mode: str = "max") -> list[int]: + """Select MSA rows by greedy mean Hamming distance from the query row. + + Row zero is always retained. At each step the selector chooses the remaining + row with greatest distance for ``mode="max"`` or least distance for + ``mode="min"``. Returned indices follow source order. + """ + + if not isinstance(array, np.ndarray): + raise TypeError("array must be a NumPy array") + if array.ndim != 2 or array.shape[0] == 0 or array.shape[1] == 0: + raise ValueError( + f"array must have non-empty shape (depth, length), got {array.shape}" + ) + if isinstance(num_seqs, bool) or not isinstance(num_seqs, int): + raise TypeError("num_seqs must be an integer") + if num_seqs <= 0: + raise ValueError("num_seqs must be greater than zero") + if not isinstance(mode, str): + raise TypeError("mode must be a string") + if mode not in {"max", "min"}: + raise ValueError(f"unsupported selection mode: {mode}") + depth = array.shape[0] + if depth <= num_seqs: + return list(range(depth)) + + sequences = _byte_matrix(array) + selected = [0] + available = np.ones(depth, dtype=bool) + available[0] = False + distance_sum = _hamming_to_all(sequences[0], sequences) + choose = np.argmax if mode == "max" else np.argmin + + while len(selected) < num_seqs: + candidates = np.flatnonzero(available) + candidate_scores = distance_sum[candidates] / len(selected) + next_index = int(candidates[int(choose(candidate_scores))]) + selected.append(next_index) + available[next_index] = False + distance_sum += _hamming_to_all(sequences[next_index], sequences) + return sorted(selected) + + +def _temporary_root() -> str | None: + shared_memory = Path("/dev/shm") + return os.fspath(shared_memory) if shared_memory.is_dir() else None + + +def hhfilter( + sequences: list[str], + seqid: int = 90, + diff: int = 0, + cov: int = 0, + qid: int = 0, + qsc: float = -20.0, + binary: str = "hhfilter", +) -> list[int]: + """Run HH-suite filtering and return source indices from its FASTA headers.""" + + with tempfile.TemporaryDirectory(dir=_temporary_root()) as directory: + work = Path(directory) + source_path = work / "input.fasta" + result_path = work / "output.fasta" + records = (f">{index}\n{sequence}" for index, sequence in enumerate(sequences)) + source_path.write_text("\n".join(records), encoding="utf-8") + command = [ + binary, + "-i", + os.fspath(source_path), + "-M", + "a3m", + "-o", + os.fspath(result_path), + "-id", + str(seqid), + "-diff", + str(diff), + "-cov", + str(cov), + "-qid", + str(qid), + "-qsc", + str(qsc), + ] + run_subprocess_with_errorcheck(command, capture_output=True) + headers = result_path.read_text(encoding="utf-8").splitlines() + return [int(line[1:].strip()) for line in headers if line.startswith(">")] diff --git a/fastplms/models/esmfold2/esmfold2_normalize_coordinates.py b/fastplms/models/esmfold2/esmfold2_normalize_coordinates.py new file mode 100644 index 0000000000000000000000000000000000000000..45e065247f9b1ac048c697d9b10c0a14605ee8e9 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_normalize_coordinates.py @@ -0,0 +1,67 @@ +"""Rigid-frame normalization for atom37 coordinates.""" + +from __future__ import annotations + +from typing import TypeVar + +import numpy as np +import torch +from torch import Tensor + +from . import esmfold2_residue_constants as residue_constants +from .esmfold2_affine3d import Affine3D + +ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor) + + +def atom3_to_backbone_frames(bb_positions: Tensor) -> Affine3D: + """Construct a frame from N, C-alpha, and C positions in ``X``.""" + + n_position, ca_position, c_position = bb_positions.unbind(dim=-2) + return Affine3D.from_graham_schmidt(c_position, ca_position, n_position) + + +def index_by_atom_name( + atom37: ArrayOrTensor, + atom_names: str | list[str], + dim: int = -2, +) -> ArrayOrTensor: + """Select one or more named atoms along an atom37 axis.""" + + single_atom = isinstance(atom_names, str) + names = [atom_names] if single_atom else atom_names + indices = [residue_constants.atom_order[name] for name in names] + axis = dim % atom37.ndim + if isinstance(atom37, Tensor): + index = torch.tensor(indices, dtype=torch.long, device=atom37.device) + selected = torch.index_select(atom37, axis, index) + else: + selected = np.take(atom37, indices, axis=axis) + return selected.squeeze(axis) if single_atom else selected # type: ignore[return-value] + + +def get_protein_normalization_frame(coords: Tensor) -> Affine3D: + """Build one frame from backbone coordinates ``X`` with shape (l, 37, 3).""" + + backbone = index_by_atom_name(coords, ["N", "CA", "C"], dim=-2) + residue_is_valid = torch.isfinite(backbone).all(dim=-1).all(dim=-1) + weights = residue_is_valid[..., None, None] + coordinate_sum = backbone.masked_fill(~weights, 0).sum(dim=-3) + count = residue_is_valid.sum(dim=-1)[..., None, None] + mean_backbone = coordinate_sum / (count + 1e-8) + return atom3_to_backbone_frames(mean_backbone.float()) + + +def apply_frame_to_coords(coords: Tensor, frame: Affine3D) -> Tensor: + """Express atom coordinates ``X`` in the inverse of ``frame``.""" + + transformed = frame[..., None, None].invert().apply(coords) + frame_is_valid = frame.trans.norm(dim=-1) > 0 + normalized = torch.where(frame_is_valid[..., None, None, None], transformed, coords) + return normalized.masked_fill(torch.isinf(coords), torch.inf) + + +def normalize_coordinates(coords: Tensor) -> Tensor: + """Normalize ``X`` with shape (..., l, 37, 3) to its backbone frame.""" + + return apply_frame_to_coords(coords, get_protein_normalization_frame(coords)) diff --git a/fastplms/models/esmfold2/esmfold2_output.py b/fastplms/models/esmfold2/esmfold2_output.py new file mode 100644 index 0000000000000000000000000000000000000000..97c846a73358dc5ab25506037ec77f6786441ef4 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_output.py @@ -0,0 +1,201 @@ +"""Convert ESMFold2 coordinate tensors into molecular-complex records.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from itertools import groupby +from typing import Any + +import numpy as np +import torch + +from .esmfold2_constants import ELEMENT_NUMBER_TO_SYMBOL, MOL_TYPE_NONPOLYMER +from .esmfold2_molecular_complex import MolecularComplex, MolecularComplexMetadata + + +def get_element_symbol(atomic_number: int) -> str: + """Map a training-time atomic number to a chemical symbol.""" + + return ELEMENT_NUMBER_TO_SYMBOL.get(atomic_number, "X") + + +def _decode_atom_name(encoded_name: Any) -> str: + values = encoded_name.tolist() if hasattr(encoded_name, "tolist") else encoded_name + return "".join(chr(int(value) + 32) for value in values if int(value)).strip() + + +@dataclass +class _ComplexRecords: + sequence: list[str] = field(default_factory=list) + chain_ids: list[int] = field(default_factory=list) + token_to_atoms: list[list[int]] = field(default_factory=list) + confidence: list[float] = field(default_factory=list) + positions: list[list[float]] = field(default_factory=list) + elements: list[str] = field(default_factory=list) + atom_names: list[str] = field(default_factory=list) + atom_hetero: list[bool] = field(default_factory=list) + chain_lookup: dict[int, str] = field(default_factory=dict) + entity_lookup: dict[int, str] = field(default_factory=dict) + + def add_token( + self, + *, + residue_name: str, + asym_id: int, + plddt: float, + atoms: Iterable[tuple[list[float], str, str]], + hetero: bool, + ) -> None: + atom_start = len(self.positions) + for position, element, atom_name in atoms: + self.positions.append(position) + self.elements.append(element) + self.atom_names.append(atom_name) + self.atom_hetero.append(hetero) + self.sequence.append(residue_name) + self.chain_ids.append(asym_id) + self.confidence.append(plddt) + self.token_to_atoms.append([atom_start, len(self.positions)]) + + def build(self, complex_id: str) -> MolecularComplex: + return MolecularComplex( + id=complex_id, + sequence=self.sequence, + atom_positions=np.asarray(self.positions, dtype=np.float32).reshape(-1, 3), + atom_elements=np.asarray(self.elements, dtype=object), + token_to_atoms=np.asarray(self.token_to_atoms, dtype=np.int32).reshape(-1, 2), + chain_id=np.asarray(self.chain_ids, dtype=np.int64), + plddt=np.asarray(self.confidence, dtype=np.float32), + atom_names=np.asarray(self.atom_names, dtype=object), + atom_hetero=np.asarray(self.atom_hetero, dtype=bool), + metadata=MolecularComplexMetadata( + entity_lookup=self.entity_lookup, + chain_lookup=self.chain_lookup, + assembly_composition=None, + ), + ) + + +def build_molecular_complex_from_features( + coords: torch.Tensor, + plddt: torch.Tensor, + atom_mask: torch.Tensor, + ref_element: torch.Tensor, + ref_atom_name_chars: torch.Tensor, + chain_infos: list[Any], + complex_id: str, +) -> MolecularComplex: + """Decode model features into one complex without intermediate structure files. + + Protein, DNA, and RNA tokens are grouped by residue index. Ligand atom + tokens are collapsed into one non-polymer residue per chain. + """ + + M = atom_mask.bool().cpu().numpy() + X = coords.float().cpu().numpy() + atom_names = ref_atom_name_chars.cpu().numpy() + elements = ref_element.cpu().numpy() + confidence = plddt.float().cpu().numpy() + records = _ComplexRecords() + + def decode_atoms(tokens: Iterable[Any]): + for token in tokens: + for atom_index in range(token.atom_start, token.atom_start + token.atom_count): + if M[atom_index]: + yield ( + X[atom_index].tolist(), + get_element_symbol(int(elements[atom_index])), + _decode_atom_name(atom_names[atom_index]), + ) + + for chain in chain_infos: + is_nonpolymer = chain.mol_type == MOL_TYPE_NONPOLYMER + records.chain_lookup[chain.asym_id] = chain.chain_id + records.entity_lookup[chain.entity_id] = "non-polymer" if is_nonpolymer else "polymer" + + if is_nonpolymer: + mean_confidence = ( + float(np.mean([confidence[token.token_index] for token in chain.tokens])) + if chain.tokens + else 0.0 + ) + records.add_token( + residue_name=chain.tokens[0].residue_name if chain.tokens else "LIG", + asym_id=chain.asym_id, + plddt=mean_confidence, + atoms=decode_atoms(chain.tokens), + hetero=True, + ) + continue + + residue_groups = groupby(chain.tokens, key=lambda token: token.residue_index) + for _residue_index, group in residue_groups: + residue_tokens = list(group) + records.add_token( + residue_name=residue_tokens[0].residue_name, + asym_id=chain.asym_id, + plddt=float(np.mean([confidence[token.token_index] for token in residue_tokens])), + atoms=decode_atoms(residue_tokens), + hetero=False, + ) + + return records.build(complex_id) + + +def build_molecular_complex( + structure: Any, + coords: torch.Tensor, + plddt: torch.Tensor, + complex_id: str, +) -> MolecularComplex: + """Decode coordinates using the atom and residue arrays of a prepared structure.""" + + records = _ComplexRecords() + coordinate_index = 0 + confidence_index = 0 + + for chain in structure.chains: + asym_id = int(chain["asym_id"]) + mol_type = int(chain["mol_type"]) + is_nonpolymer = mol_type == MOL_TYPE_NONPOLYMER + records.chain_lookup[asym_id] = str(chain["name"]) + records.entity_lookup[int(chain["entity_id"])] = ( + "non-polymer" if is_nonpolymer else "polymer" + ) + + residue_start = int(chain["res_idx"]) + residue_stop = residue_start + int(chain["res_num"]) + for residue in structure.residues[residue_start:residue_stop]: + atom_start = int(residue["atom_idx"]) + atom_stop = atom_start + int(residue["atom_num"]) + decoded_atoms: list[tuple[list[float], str, str]] = [] + for atom in structure.atoms[atom_start:atom_stop]: + if not atom["is_present"]: + continue + decoded_atoms.append( + ( + coords[coordinate_index].tolist(), + get_element_symbol(int(atom["element"].item())), + _decode_atom_name(atom["name"]), + ) + ) + coordinate_index += 1 + + records.add_token( + residue_name=str(residue["name"]), + asym_id=asym_id, + plddt=float(plddt[confidence_index].item()), + atoms=decoded_atoms, + hetero=is_nonpolymer, + ) + confidence_index += 1 + + return records.build(complex_id) + + +__all__ = [ + "build_molecular_complex", + "build_molecular_complex_from_features", + "get_element_symbol", +] diff --git a/fastplms/models/esmfold2/esmfold2_paired_msa.py b/fastplms/models/esmfold2/esmfold2_paired_msa.py new file mode 100644 index 0000000000000000000000000000000000000000..44f050b37c83ae70cf70b532380eb11c94a608a3 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_paired_msa.py @@ -0,0 +1,282 @@ +"""Construct taxonomy-paired MSA features for multichain folding.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +import numpy as np + +from .esmfold2_constants import ( + MSA_GAP_TOKEN_ID, + PROTEIN_3TO1, + PROTEIN_RESIDUE_TO_RES_TYPE, + PROTEIN_UNK_RES_TYPE, +) +from .esmfold2_msa import MSA + +_TAXONOMY_PATTERN = re.compile(r"key=(-?\d+)") + + +def protein_letter_to_res_type() -> dict[str, int]: + """Return the one-letter residue vocabulary used by the MSA encoder.""" + + vocabulary = { + one_letter: PROTEIN_RESIDUE_TO_RES_TYPE[three_letter] + for three_letter, one_letter in PROTEIN_3TO1.items() + if three_letter in PROTEIN_RESIDUE_TO_RES_TYPE + } + vocabulary.update({"-": MSA_GAP_TOKEN_ID, "X": PROTEIN_UNK_RES_TYPE}) + return vocabulary + + +def _taxonomy_from_header(header: str) -> int: + match = _TAXONOMY_PATTERN.search(header) if header else None + return int(match.group(1)) if match is not None else -1 + + +def _emitted_length(sequence: str) -> int: + return sum(character != "." and not character.islower() for character in sequence) + + +def _decode_a3m_row( + sequence: str, + sequence_length: int, + vocabulary: dict[str, int], +) -> tuple[np.ndarray, np.ndarray]: + residues = np.full(sequence_length, MSA_GAP_TOKEN_ID, dtype=np.int64) + deletions = np.zeros(sequence_length, dtype=np.float32) + column = 0 + insertion_count = 0 + for character in sequence: + if character == "." or character.islower(): + insertion_count += 1 + continue + if column == sequence_length: + break + residues[column] = ( + MSA_GAP_TOKEN_ID + if character == "-" + else vocabulary.get(character.upper(), PROTEIN_UNK_RES_TYPE) + ) + if insertion_count: + deletions[column] = float(insertion_count) + insertion_count = 0 + column += 1 + return residues, deletions + + +def msa_to_res_type_and_deletions( + msa: MSA, + letter_to_res_type: dict[str, int], +) -> tuple[np.ndarray, np.ndarray]: + """Decode an A3M alignment into arrays ``X`` and ``D`` with shape (m, l).""" + + sequence_length = _emitted_length(msa.entries[0].sequence) + residue_rows: list[np.ndarray] = [] + deletion_rows: list[np.ndarray] = [] + for entry in msa.entries: + residues, deletions = _decode_a3m_row( + entry.sequence, + sequence_length, + letter_to_res_type, + ) + residue_rows.append(residues) + deletion_rows.append(deletions) + return np.stack(residue_rows), np.stack(deletion_rows) + + +@dataclass(frozen=True) +class _ChainAlignment: + residues: np.ndarray + deletions: np.ndarray + taxonomies: list[int] + + +def _chain_alignment( + msa: MSA | None, + query_res_types: np.ndarray, + vocabulary: dict[str, int], +) -> _ChainAlignment: + if msa is None or msa.depth == 0: + return _ChainAlignment( + residues=query_res_types[None, :], + deletions=np.zeros((1, query_res_types.shape[0]), dtype=np.float32), + taxonomies=[-1], + ) + residues, deletions = msa_to_res_type_and_deletions(msa, vocabulary) + taxonomies = [_taxonomy_from_header(entry.header) for entry in msa.entries] + return _ChainAlignment(residues, deletions, taxonomies) + + +def _taxonomy_groups( + chain_ids: list[int], + alignments: dict[int, _ChainAlignment], +) -> dict[int, list[tuple[int, int]]]: + groups: dict[int, list[tuple[int, int]]] = {} + for chain_id in chain_ids: + for row, taxonomy in enumerate(alignments[chain_id].taxonomies): + if row and taxonomy != -1: + groups.setdefault(taxonomy, []).append((chain_id, row)) + return {taxonomy: rows for taxonomy, rows in groups.items() if len(rows) > 1} + + +def _available_rows( + chain_ids: list[int], + alignments: dict[int, _ChainAlignment], + groups: dict[int, list[tuple[int, int]]], +) -> dict[int, list[int]]: + used = {row for group in groups.values() for row in group} + return { + chain_id: [ + row + for row in range(1, len(alignments[chain_id].taxonomies)) + if (chain_id, row) not in used + ] + for chain_id in chain_ids + } + + +def _append_taxonomy_rows( + rows: list[dict[int, int]], + paired_flags: list[dict[int, int]], + chain_ids: list[int], + groups: dict[int, list[tuple[int, int]]], + available: dict[int, list[int]], + max_pairs: int, +) -> None: + ordered_groups = sorted( + groups.values(), + key=lambda group: len({chain_id for chain_id, _row in group}), + reverse=True, + ) + for group in ordered_groups: + rows_by_chain: dict[int, list[int]] = {} + for chain_id, row in group: + rows_by_chain.setdefault(chain_id, []).append(row) + for occurrence in range(max(map(len, rows_by_chain.values()))): + selected: dict[int, int] = {} + flags: dict[int, int] = {} + for chain_id, candidates in rows_by_chain.items(): + selected[chain_id] = candidates[occurrence % len(candidates)] + flags[chain_id] = 1 + for chain_id in chain_ids: + if chain_id not in selected: + flags[chain_id] = 0 + selected[chain_id] = available[chain_id].pop(0) if available[chain_id] else -1 + rows.append(selected) + paired_flags.append(flags) + if len(rows) >= max_pairs: + break + if len(rows) >= max_pairs: + break + + +def _append_unpaired_rows( + rows: list[dict[int, int]], + paired_flags: list[dict[int, int]], + chain_ids: list[int], + available: dict[int, list[int]], + max_total: int, +) -> None: + max_remaining = max((len(indices) for indices in available.values()), default=0) + for _ in range(min(max_total - len(rows), max_remaining)): + rows.append( + { + chain_id: available[chain_id].pop(0) if available[chain_id] else -1 + for chain_id in chain_ids + } + ) + paired_flags.append({chain_id: 0 for chain_id in chain_ids}) + if len(rows) >= max_total: + break + + +def _pairing_plan( + chain_ids: list[int], + alignments: dict[int, _ChainAlignment], + max_pairs: int, + max_total: int, + max_seqs: int, +) -> tuple[list[dict[int, int]], list[dict[int, int]]]: + groups = _taxonomy_groups(chain_ids, alignments) + available = _available_rows(chain_ids, alignments, groups) + rows = [{chain_id: 0 for chain_id in chain_ids}] + flags = [{chain_id: 1 for chain_id in chain_ids}] + _append_taxonomy_rows(rows, flags, chain_ids, groups, available, max_pairs) + _append_unpaired_rows(rows, flags, chain_ids, available, max_total) + return rows[:max_seqs], flags[:max_seqs] + + +def _project_alignment_rows( + chain_ids: list[int], + alignments: dict[int, _ChainAlignment], + rows: list[dict[int, int]], + flags: list[dict[int, int]], + token_asym_ids: np.ndarray, + token_res_ids: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + m, t = len(rows), len(token_asym_ids) + residues = np.full((m, t), MSA_GAP_TOKEN_ID, dtype=np.int64) + deletions = np.zeros((m, t), dtype=np.float32) + paired_mask = np.zeros((m, t), dtype=np.float32) + for chain_id in chain_ids: + alignment = alignments[chain_id] + selected_rows = np.asarray([row[chain_id] for row in rows], dtype=np.int64) + chain_flags = np.asarray([row[chain_id] for row in flags], dtype=np.float32) + token_mask = token_asym_ids == chain_id + if not token_mask.any(): + continue + columns = np.minimum(token_res_ids[token_mask], alignment.residues.shape[1] - 1) + valid_rows = selected_rows >= 0 + if valid_rows.any(): + output_rows = np.flatnonzero(valid_rows) + output_columns = np.flatnonzero(token_mask) + residues[np.ix_(output_rows, output_columns)] = alignment.residues[ + selected_rows[valid_rows] + ][:, columns] + deletions[np.ix_(output_rows, output_columns)] = alignment.deletions[ + selected_rows[valid_rows] + ][:, columns] + paired_mask[:, token_mask] = chain_flags[:, None] + return residues, deletions, paired_mask + + +def construct_paired_msa( + chain_msas: dict[int, MSA | None], + chain_query_res_types: dict[int, np.ndarray], + token_asym_ids: np.ndarray, + token_res_ids: np.ndarray, + letter_to_res_type: dict[str, int] | None = None, + *, + max_pairs: int = 8192, + max_total: int = 16384, + max_seqs: int = 16384, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return residue, deletion, and pairing arrays with shape (m, t).""" + + vocabulary = protein_letter_to_res_type() if letter_to_res_type is None else letter_to_res_type + chain_ids = sorted(chain_msas) + alignments = { + chain_id: _chain_alignment( + chain_msas[chain_id], + chain_query_res_types[chain_id], + vocabulary, + ) + for chain_id in chain_ids + } + rows, flags = _pairing_plan( + chain_ids, + alignments, + max_pairs, + max_total, + max_seqs, + ) + return _project_alignment_rows( + chain_ids, + alignments, + rows, + flags, + token_asym_ids, + token_res_ids, + ) diff --git a/fastplms/models/esmfold2/esmfold2_parsing.py b/fastplms/models/esmfold2/esmfold2_parsing.py new file mode 100644 index 0000000000000000000000000000000000000000..f906d1ff2804c726daf283d6b323f44b43d2a662 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_parsing.py @@ -0,0 +1,126 @@ +"""FASTA parsing and writing with explicit stream ownership.""" + +from __future__ import annotations + +import gzip +import io +from collections.abc import Generator, Iterable +from contextlib import nullcontext +from pathlib import Path +from typing import NamedTuple, TextIO + +from .esmfold2_utils_types import PathOrBuffer + + +class FastaEntry(NamedTuple): + """One FASTA record in source order.""" + + header: str + sequence: str + + +def parse_fasta(text: str) -> Generator[FastaEntry, None, None]: + """Yield records from FASTA text without normalizing sequence symbols.""" + + header: str | None = None + sequence_lines: list[str] = [] + found_record = False + + for line in text.splitlines(): + if not line or line.startswith("#"): + continue + if line.startswith(">"): + if header is not None: + found_record = True + yield FastaEntry(header, "".join(sequence_lines)) + header = line[1:].strip() + sequence_lines.clear() + elif header is not None: + sequence_lines.append(line) + + if header is not None: + found_record = True + yield FastaEntry(header, "".join(sequence_lines)) + if not found_record: + raise ValueError("Found no sequences in input") + + +def _open_reader(source: PathOrBuffer): + if isinstance(source, io.TextIOBase): + return nullcontext(source) + path = Path(source) + if path.suffix.lower() == ".gz": + return gzip.open(path, mode="rt", encoding="utf-8") + return path.open(mode="r", encoding="utf-8") + + +def read_sequences(source: PathOrBuffer) -> Generator[FastaEntry, None, None]: + """Read FASTA records while leaving caller-owned streams open.""" + + with _open_reader(source) as handle: + yield from parse_fasta(handle.read()) + + +def read_first_sequence(source: PathOrBuffer) -> FastaEntry: + """Return the first FASTA record from a path or text stream.""" + + return next(read_sequences(source)) + + +def count_fasta_sequences(path: str | Path) -> int: + """Count FASTA headers without parsing sequence bodies.""" + + source = Path(path) + if not source.exists(): + return 0 + with source.open(encoding="utf-8") as handle: + return sum(line.startswith(">") for line in handle) + + +def append_fasta_sequence(header: str, sequence: str, path: str | Path) -> None: + """Append one record, inserting a separator if the file lacks a final newline.""" + + destination = Path(path) + destination.parent.mkdir(parents=True, exist_ok=True) + needs_separator = ( + destination.exists() + and destination.stat().st_size > 0 + and destination.read_bytes()[-1:] != b"\n" + ) + with destination.open(mode="a", encoding="utf-8") as handle: + if needs_separator: + handle.write("\n") + handle.write(f">{header}\n{sequence}\n") + + +def _open_writer(destination: PathOrBuffer): + if isinstance(destination, io.TextIOBase): + return nullcontext(destination) + path = Path(destination) + path.parent.mkdir(parents=True, exist_ok=True) + return path.open(mode="w", encoding="utf-8") + + +def write_sequences(sequences: Iterable[tuple[str, str]], destination: PathOrBuffer) -> None: + """Write records with one blank-line-free separator between entries.""" + + with _open_writer(destination) as handle: + _write_records(handle, sequences) + + +def _write_records(handle: TextIO, sequences: Iterable[tuple[str, str]]) -> None: + for index, (header, sequence) in enumerate(sequences): + if index: + handle.write("\n") + handle.write(f">{header}\n{sequence}") + + +__all__ = [ + "FastaEntry", + "append_fasta_sequence", + "count_fasta_sequences", + "parse_fasta", + "read_first_sequence", + "read_sequences", + "write_sequences", +] diff --git a/fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py b/fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py new file mode 100644 index 0000000000000000000000000000000000000000..d6ab3e74beb4ae86d22fb7d879126f5b23a1096e --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py @@ -0,0 +1,127 @@ +"""Predicted-aligned-error scores and training loss.""" + +from __future__ import annotations + +import torch +import torch.nn.functional as F +from torch import Tensor + +from .esmfold2_affine3d import Affine3D + +_CPU_DEVICE = torch.device("cpu") + + +def _compute_pae_masks(mask: Tensor) -> Tensor: + residue_mask = mask.bool() + return residue_mask.unsqueeze(-1) & residue_mask.unsqueeze(-2) + + +def _pae_bins( + max_bin: float = 31, + num_bins: int = 64, + device: torch.device = _CPU_DEVICE, +) -> Tensor: + """Return the representative distance for each PAE probability bin.""" + + boundaries = torch.linspace(0, max_bin, steps=num_bins - 1, device=device) + width = max_bin / (num_bins - 2) + centers = boundaries + width / 2 + overflow_center = centers[-1:] + width + return torch.cat((centers, overflow_center)) + + +def _masked_probabilities(logits: Tensor, pair_mask: Tensor) -> Tensor: + masked_logits = logits.masked_fill( + ~pair_mask.unsqueeze(-1), + torch.finfo(logits.dtype).min, + ) + return masked_logits.softmax(dim=-1) + + +def masked_mean( + mask: Tensor, + value: Tensor, + dim: int | tuple[int, ...] | None = None, + eps: float = 1e-10, +) -> Tensor: + """Average values over true entries of a broadcast-compatible mask.""" + + weights = mask.expand_as(value) + weighted_sum = torch.sum(weights * value, dim=dim) + weight_sum = torch.sum(weights, dim=dim) + return weighted_sum / (weight_sum + eps) + + +def compute_predicted_aligned_error( + logits: Tensor, + aa_mask: Tensor, + sequence_id: Tensor | None = None, + max_bin: float = 31, +) -> Tensor: + """Convert PAE logits ``X`` with shape (..., l, l, n) to distances.""" + + del sequence_id + pair_mask = _compute_pae_masks(aa_mask) + probabilities = _masked_probabilities(logits, pair_mask) + centers = _pae_bins(max_bin, logits.shape[-1], logits.device) + return torch.sum(probabilities * centers, dim=-1) + + +@torch.no_grad() +def compute_tm(logits: Tensor, aa_mask: Tensor, max_bin: float = 31.0) -> Tensor: + """Estimate TM score from pairwise PAE logits.""" + + pair_mask = _compute_pae_masks(aa_mask) + sequence_lengths = aa_mask.sum(dim=-1, keepdim=True) + centers = _pae_bins(max_bin, logits.shape[-1], logits.device) + distance_scale = 1.24 * (sequence_lengths.clamp_min(19) - 15) ** (1 / 3) - 1.8 + tm_weights = 1.0 / (1 + (centers / distance_scale.unsqueeze(-1)) ** 2) + probabilities = _masked_probabilities(logits, pair_mask) + score_per_pair = torch.sum(probabilities * tm_weights.unsqueeze(-2), dim=-1) + score_per_anchor = masked_mean(pair_mask, score_per_pair, dim=-1) + return score_per_anchor.max(dim=-1).values + + +def _local_coordinates(frames: Affine3D) -> Tensor: + origins = frames.trans[..., None, :, :] + return frames.invert()[..., None].apply(origins) + + +def tm_loss( + logits: Tensor, + pred_affine: Tensor, + targ_affine: Tensor, + targ_mask: Tensor, + tm_mask: Tensor | None = None, + sequence_id: Tensor | None = None, + max_bin: float = 31, +) -> Tensor: + """Cross-entropy loss for discretized aligned-position errors.""" + + del sequence_id + predicted_frames = Affine3D.from_tensor(pred_affine) + target_frames = Affine3D.from_tensor(targ_affine) + with torch.no_grad(): + squared_error = ( + (_local_coordinates(predicted_frames) - _local_coordinates(target_frames)) + .square() + .sum(dim=-1) + ) + boundaries = torch.linspace( + 0, + max_bin, + logits.shape[-1] - 1, + device=logits.device, + ).square() + target_bins = (squared_error[..., None] > boundaries).sum(dim=-1).long() + + cross_entropy = F.cross_entropy( + logits.movedim(3, 1), + target_bins, + reduction="none", + ) + pair_mask = _compute_pae_masks(targ_mask) + loss_per_sample = masked_mean(pair_mask, cross_entropy, dim=(-1, -2)) + if tm_mask is None: + return loss_per_sample.mean() + return masked_mean(tm_mask, loss_per_sample) diff --git a/fastplms/models/esmfold2/esmfold2_prepare_input.py b/fastplms/models/esmfold2/esmfold2_prepare_input.py new file mode 100644 index 0000000000000000000000000000000000000000..870c45754d5e3d41d1f0db90ffb56eec2a5eb3fd --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_prepare_input.py @@ -0,0 +1,1130 @@ +"""Translate typed sequence inputs into the tensors consumed by ESMFold2. + +The conversion has four explicit stages: entity and chain assignment, residue +tokenization, structural feature construction, and atom-table padding. Keeping +those stages separate makes the biological indexing rules testable without +loading model weights. +""" + +from __future__ import annotations + +import math +import warnings +from collections import defaultdict +from contextlib import suppress +from dataclasses import dataclass, field +from itertools import combinations +from typing import Any + +import numpy as np +import torch + +from .esmfold2_conformers import ( + get_ccd_leaving_atoms, + get_idealized_atom_pos, + get_ligand_ccd_atoms_with_charges, + get_ligand_ccd_bonds, + get_ligand_idealized_atom_pos, +) +from .esmfold2_constants import ( + CHARGED_ATOMS, + DNA_1TO3, + DNA_BACKBONE_ATOMS, + DNA_HEAVY_ATOMS, + DNA_RESIDUE_TO_RES_TYPE, + DNA_RNA_LIGAND_INPUT_ID, + DNA_UNK_RES_TYPE, + ELEMENT_TO_ATOMIC_NUM, + ESM_PROTEIN_VOCAB, + MOL_TYPE_DNA, + MOL_TYPE_NONPOLYMER, + MOL_TYPE_PROTEIN, + MOL_TYPE_RNA, + MSA_GAP_TOKEN_ID, + PROTEIN_1TO3, + PROTEIN_3TO1, + PROTEIN_HEAVY_ATOMS, + PROTEIN_RESIDUE_TO_RES_TYPE, + PROTEIN_UNK_RES_TYPE, + RNA_1TO3, + RNA_BACKBONE_ATOMS, + RNA_HEAVY_ATOMS, + RNA_RESIDUE_TO_RES_TYPE, + RNA_UNK_RES_TYPE, +) +from .esmfold2_types import ( + MSA, + DNAInput, + LigandInput, + Modification, + ProteinInput, + RNAInput, + StructurePredictionInput, +) + +_ZERO_POS = np.zeros(3, dtype=np.float32) +_ENCODE_ATOM_NAME_CACHE: dict[str, list[int]] = {} +_ELEMENT_ATOMIC_NUM_CACHE: dict[str, int] = {} +_TWO_LETTER_ELEMENTS = frozenset({"FE", "ZN", "MG", "MN", "CO", "NI", "CU", "SE", "BR"}) + + +@dataclass +class AtomInfo: + """One row in the unpadded atom table.""" + + name: str + element: str + charge: int + ref_pos: np.ndarray # R has shape (3,). + pos: np.ndarray # X has shape (3,). + token_index: int = -1 + atom_index: int = -1 + space_uid: int = -1 + is_valid: bool = True + + +@dataclass +class TokenInfo: + """Biological and atom-span annotations for one model token.""" + + token_index: int + residue_index: int + residue_name: str + mol_type: int + res_type: int + input_id: int + asym_id: int + sym_id: int + entity_id: int + atom_start: int + atom_count: int + + +@dataclass +class ChainInfo: + """One input chain after entity and symmetry assignment.""" + + chain_id: str + asym_id: int + entity_id: int + sym_id: int + mol_type: int + tokens: list[TokenInfo] = field(default_factory=list) + ligand_bonds: list[tuple[str, str]] = field(default_factory=list) + + +@dataclass +class _TokenizationState: + """Mutable cursor shared by residue tokenizers.""" + + token_index: int + atom_index: int + space_uid: int + tokens: list[TokenInfo] = field(default_factory=list) + atoms: list[AtomInfo] = field(default_factory=list) + + def _append_atom( + self, + name: str, + element: str, + charge: int, + ref_pos: np.ndarray | None, + ) -> None: + self.atoms.append( + AtomInfo( + name=name, + element=element, + charge=charge, + ref_pos=(ref_pos.copy() if ref_pos is not None else _ZERO_POS.copy()), + pos=_ZERO_POS.copy(), + token_index=self.token_index, + atom_index=self.atom_index, + space_uid=self.space_uid, + ) + ) + self.atom_index += 1 + + def _append_token( + self, + *, + residue_index: int, + residue_name: str, + mol_type: int, + res_type: int, + input_id: int, + asym_id: int, + sym_id: int, + entity_id: int, + atom_start: int, + atom_count: int, + ) -> None: + self.tokens.append( + TokenInfo( + token_index=self.token_index, + residue_index=residue_index, + residue_name=residue_name, + mol_type=mol_type, + res_type=res_type, + input_id=input_id, + asym_id=asym_id, + sym_id=sym_id, + entity_id=entity_id, + atom_start=atom_start, + atom_count=atom_count, + ) + ) + self.token_index += 1 + + def add_residue_token( + self, + atom_specs: list[tuple[str, str, int, np.ndarray | None]], + **token_fields: Any, + ) -> None: + start = self.atom_index + for atom_spec in atom_specs: + self._append_atom(*atom_spec) + self._append_token( + atom_start=start, + atom_count=len(atom_specs), + **token_fields, + ) + self.space_uid += 1 + + def add_atom_tokens( + self, + atom_specs: list[tuple[str, str, int, np.ndarray | None]], + **token_fields: Any, + ) -> None: + for atom_spec in atom_specs: + start = self.atom_index + self._append_atom(*atom_spec) + self._append_token(atom_start=start, atom_count=1, **token_fields) + self.space_uid += 1 + + +def encode_atom_name(name: str) -> list[int]: + """Encode a four-character atom name with the model's ASCII offset.""" + cached = _ENCODE_ATOM_NAME_CACHE.get(name) + if cached is None: + cached = [0 if char == " " else ord(char) - 32 for char in name.ljust(4)[:4]] + _ENCODE_ATOM_NAME_CACHE[name] = cached + return cached + + +def get_element_atomic_num(element: str) -> int: + """Map an element symbol to the model's atomic-number vocabulary.""" + cached = _ELEMENT_ATOMIC_NUM_CACHE.get(element) + if cached is None: + cached = ELEMENT_TO_ATOMIC_NUM.get(element.upper(), 0) + _ELEMENT_ATOMIC_NUM_CACHE[element] = cached + return cached + + +def _infer_element(atom_name: str) -> str: + normalized = atom_name.strip() + if not normalized: + return "C" + if normalized[0].isdigit(): + return normalized[1] if len(normalized) > 1 else "H" + if len(normalized) == 2 and normalized in _TWO_LETTER_ELEMENTS: + return normalized + return normalized[0] + + +def _compute_res_type(name: str, mol_type: int) -> int: + if mol_type == MOL_TYPE_PROTEIN: + return PROTEIN_RESIDUE_TO_RES_TYPE.get(name, PROTEIN_UNK_RES_TYPE) + if mol_type == MOL_TYPE_DNA: + return DNA_RESIDUE_TO_RES_TYPE.get( + name, RNA_RESIDUE_TO_RES_TYPE.get(name, DNA_UNK_RES_TYPE) + ) + if mol_type == MOL_TYPE_RNA: + return RNA_RESIDUE_TO_RES_TYPE.get( + name, DNA_RESIDUE_TO_RES_TYPE.get(name, RNA_UNK_RES_TYPE) + ) + return PROTEIN_UNK_RES_TYPE + + +def _compute_esm_input_id(name: str, mol_type: int) -> int: + if mol_type != MOL_TYPE_PROTEIN: + return DNA_RNA_LIGAND_INPUT_ID + letter = PROTEIN_3TO1.get(name) + return ( + DNA_RNA_LIGAND_INPUT_ID + if letter is None + else ESM_PROTEIN_VOCAB.get(letter, ESM_PROTEIN_VOCAB["X"]) + ) + + +def _apply_modifications(residues: list[str], modifications: list[Modification] | None) -> set[int]: + changed: set[int] = set() + for modification in modifications or (): + residues[modification.position] = modification.ccd + changed.add(modification.position) + return changed + + +def _ideal_atom_specs( + residue_name: str, + residue_type: int, + atom_names: list[str], + *, + charges: bool = True, +) -> list[tuple[str, str, int, np.ndarray | None]]: + return [ + ( + atom_name, + _infer_element(atom_name), + CHARGED_ATOMS.get((residue_name, atom_name), 0) if charges else 0, + get_idealized_atom_pos(residue_type, atom_name), + ) + for atom_name in atom_names + ] + + +def _ccd_atom_specs( + residue_name: str, + atoms: list[tuple[str, str, int]], + excluded: set[str], + *, + force_zero: bool = False, +) -> list[tuple[str, str, int, np.ndarray | None]]: + return [ + ( + atom_name, + element, + charge, + None if force_zero else get_ligand_idealized_atom_pos(residue_name, atom_name), + ) + for atom_name, element, charge in atoms + if atom_name not in excluded + ] + + +def tokenize_protein( + sequence: str, + modifications: list[Modification] | None, + entity_id: int, + asym_id: int, + sym_id: int, + token_offset: int, + atom_offset: int, + space_uid_offset: int, +) -> tuple[list[TokenInfo], list[AtomInfo]]: + """Tokenize protein residues, atom-tokenizing modified CCD components.""" + residues = [PROTEIN_1TO3.get(letter, "UNK") for letter in sequence] + modified = _apply_modifications(residues, modifications) + state = _TokenizationState(token_offset, atom_offset, space_uid_offset) + + for residue_index, residue_name in enumerate(residues): + canonical_name = "MET" if residue_name == "MSE" else residue_name + common_fields = { + "residue_index": residue_index, + "mol_type": MOL_TYPE_PROTEIN, + "asym_id": asym_id, + "sym_id": sym_id, + "entity_id": entity_id, + } + if residue_index not in modified and canonical_name in PROTEIN_HEAVY_ATOMS: + residue_type = _compute_res_type(canonical_name, MOL_TYPE_PROTEIN) + state.add_residue_token( + _ideal_atom_specs( + canonical_name, + residue_type, + PROTEIN_HEAVY_ATOMS[canonical_name], + ), + residue_name=canonical_name, + res_type=residue_type, + input_id=_compute_esm_input_id(canonical_name, MOL_TYPE_PROTEIN), + **common_fields, + ) + continue + + ccd_atoms = get_ligand_ccd_atoms_with_charges(residue_name) + if ccd_atoms is None: + ccd_atoms = [ + (_infer_element(name), _infer_element(name), 0) for name in ("N", "CA", "C", "O") + ] + excluded = ( + set() if residue_index == len(residues) - 1 else get_ccd_leaving_atoms(residue_name) + ) + retained = [atom for atom in ccd_atoms if atom[0] not in excluded] + state.add_atom_tokens( + _ccd_atom_specs( + residue_name, + retained, + set(), + force_zero=len(retained) == 1, + ), + residue_name=residue_name, + res_type=PROTEIN_UNK_RES_TYPE, + input_id=DNA_RNA_LIGAND_INPUT_ID, + **common_fields, + ) + return state.tokens, state.atoms + + +def tokenize_nucleotide( + sequence: str, + modifications: list[Modification] | None, + mol_type: int, + entity_id: int, + asym_id: int, + sym_id: int, + token_offset: int, + atom_offset: int, + space_uid_offset: int, +) -> tuple[list[TokenInfo], list[AtomInfo]]: + """Tokenize DNA or RNA, retaining backbone atoms for unknown bases.""" + dna = mol_type == MOL_TYPE_DNA + letter_map = DNA_1TO3 if dna else RNA_1TO3 + heavy_atoms = DNA_HEAVY_ATOMS if dna else RNA_HEAVY_ATOMS + backbone_atoms = DNA_BACKBONE_ATOMS if dna else RNA_BACKBONE_ATOMS + unknown_type = DNA_UNK_RES_TYPE if dna else RNA_UNK_RES_TYPE + residues = [letter_map.get(letter, "UNK") for letter in sequence] + modified = _apply_modifications(residues, modifications) + state = _TokenizationState(token_offset, atom_offset, space_uid_offset) + + for residue_index, residue_name in enumerate(residues): + common_fields = { + "residue_index": residue_index, + "residue_name": residue_name, + "mol_type": mol_type, + "asym_id": asym_id, + "sym_id": sym_id, + "entity_id": entity_id, + "input_id": DNA_RNA_LIGAND_INPUT_ID, + } + if residue_index not in modified and residue_name in heavy_atoms: + residue_type = _compute_res_type(residue_name, mol_type) + state.add_residue_token( + _ideal_atom_specs(residue_name, residue_type, heavy_atoms[residue_name]), + res_type=residue_type, + **common_fields, + ) + continue + if residue_index not in modified and residue_name == "UNK": + state.add_residue_token( + [(atom_name, _infer_element(atom_name), 0, None) for atom_name in backbone_atoms], + res_type=unknown_type, + **common_fields, + ) + continue + + ccd_atoms = get_ligand_ccd_atoms_with_charges(residue_name) + if ccd_atoms is None: + ccd_atoms = [(_infer_element(name), _infer_element(name), 0) for name in backbone_atoms] + excluded = ( + set() if residue_index == len(residues) - 1 else get_ccd_leaving_atoms(residue_name) + ) + state.add_atom_tokens( + _ccd_atom_specs(residue_name, ccd_atoms, excluded), + res_type=PROTEIN_UNK_RES_TYPE, + **common_fields, + ) + return state.tokens, state.atoms + + +def tokenize_ligand_ccd( + ccd_codes: list[str], + entity_id: int, + asym_id: int, + sym_id: int, + token_offset: int, + atom_offset: int, + space_uid_offset: int, + has_covalent_bond: bool, +) -> tuple[list[TokenInfo], list[AtomInfo]]: + """Tokenize CCD ligands with one model token per retained atom.""" + state = _TokenizationState(token_offset, atom_offset, space_uid_offset) + for residue_index, code in enumerate(ccd_codes): + ccd_atoms = get_ligand_ccd_atoms_with_charges(code) + if ccd_atoms is None: + raise ValueError(f"CCD component {code} not found") + excluded = get_ccd_leaving_atoms(code) if has_covalent_bond else set() + state.add_atom_tokens( + _ccd_atom_specs(code, ccd_atoms, excluded), + residue_index=residue_index, + residue_name=code, + mol_type=MOL_TYPE_NONPOLYMER, + res_type=PROTEIN_UNK_RES_TYPE, + input_id=DNA_RNA_LIGAND_INPUT_ID, + asym_id=asym_id, + sym_id=sym_id, + entity_id=entity_id, + ) + return state.tokens, state.atoms + + +def tokenize_ligand_smiles( + smiles: str, + entity_id: int, + asym_id: int, + sym_id: int, + token_offset: int, + atom_offset: int, + space_uid_offset: int, + seed: int | None = None, +) -> tuple[list[TokenInfo], list[AtomInfo], list[tuple[str, str]]]: + """Generate a conformer and tokenize each heavy atom of a SMILES ligand.""" + from rdkit import Chem + from rdkit.Chem import AllChem + + molecule = Chem.MolFromSmiles(smiles) + if molecule is None: + raise ValueError(f"Failed to parse SMILES: {smiles}") + molecule = Chem.AddHs(molecule) + canonical_order = AllChem.CanonicalRankAtoms(molecule) # type: ignore[attr-defined] + for atom, canonical_index in zip(molecule.GetAtoms(), canonical_order, strict=True): + name = atom.GetSymbol().upper() + str(canonical_index + 1) + if len(name) > 4: + raise ValueError(f"SMILES {smiles} has atom name longer than 4 chars: {name}") + atom.SetProp("name", name) + + options = AllChem.ETKDGv3() # type: ignore[attr-defined] + options.clearConfs = False + if seed is not None: + options.randomSeed = seed + conformer_id = AllChem.EmbedMolecule(molecule, options) # type: ignore[attr-defined] + if conformer_id == -1: + options.useRandomCoords = True + conformer_id = AllChem.EmbedMolecule(molecule, options) # type: ignore[attr-defined] + if conformer_id != -1: + with suppress(RuntimeError, ValueError): + AllChem.UFFOptimizeMolecule( # type: ignore[attr-defined] + molecule, confId=conformer_id, maxIters=1000 + ) + + molecule = Chem.RemoveHs(molecule) + if molecule.GetNumConformers() == 0: + raise ValueError(f"Failed to generate conformer for SMILES: {smiles}") + conformer = molecule.GetConformer(0) + atom_specs: list[tuple[str, str, int, np.ndarray | None]] = [] + for atom in molecule.GetAtoms(): + position = conformer.GetAtomPosition(atom.GetIdx()) + atom_specs.append( + ( + atom.GetProp("name"), + atom.GetSymbol(), + atom.GetFormalCharge(), + np.asarray([position.x, position.y, position.z], dtype=np.float32), + ) + ) + state = _TokenizationState(token_offset, atom_offset, space_uid_offset) + state.add_atom_tokens( + atom_specs, + residue_index=0, + residue_name="LIG", + mol_type=MOL_TYPE_NONPOLYMER, + res_type=PROTEIN_UNK_RES_TYPE, + input_id=DNA_RNA_LIGAND_INPUT_ID, + asym_id=asym_id, + sym_id=sym_id, + entity_id=entity_id, + ) + bonds = [ + ( + bond.GetBeginAtom().GetProp("name"), + bond.GetEndAtom().GetProp("name"), + ) + for bond in molecule.GetBonds() + ] + return state.tokens, state.atoms, bonds + + +def _get_sequence_key(item: Any) -> str: + if isinstance(item, ProteinInput): + return f"PROTEIN:{item.sequence}" + if isinstance(item, DNAInput): + return f"DNA:{item.sequence}" + if isinstance(item, RNAInput): + return f"RNA:{item.sequence}" + if isinstance(item, LigandInput): + return f"LIGAND_CCD:{','.join(item.ccd)}" if item.ccd else f"LIGAND_SMILES:{item.smiles}" + raise ValueError(f"Unknown input type: {type(item)}") + + +def _tokenize_chain( + item: Any, + chain_id: str, + *, + entity_id: int, + asym_id: int, + sym_id: int, + token_offset: int, + atom_offset: int, + space_uid_offset: int, + covalent_chains: set[str], + seed: int | None, +) -> tuple[list[TokenInfo], list[AtomInfo], list[tuple[str, str]]]: + common = { + "entity_id": entity_id, + "asym_id": asym_id, + "sym_id": sym_id, + "token_offset": token_offset, + "atom_offset": atom_offset, + "space_uid_offset": space_uid_offset, + } + if isinstance(item, ProteinInput): + if item.msa is None: + warnings.warn( + f"No MSA provided for {item.id}, using single sequence mode", + stacklevel=2, + ) + tokens, atoms = tokenize_protein(item.sequence, item.modifications, **common) + return tokens, atoms, [] + if isinstance(item, (DNAInput, RNAInput)): + mol_type = MOL_TYPE_DNA if isinstance(item, DNAInput) else MOL_TYPE_RNA + tokens, atoms = tokenize_nucleotide( + item.sequence, item.modifications, mol_type=mol_type, **common + ) + return tokens, atoms, [] + if not isinstance(item, LigandInput): + raise ValueError(f"Unknown input type: {type(item)}") + if item.ccd is not None: + if item.smiles is not None: + warnings.warn("Both ccd and smiles provided, using ccd", stacklevel=2) + tokens, atoms = tokenize_ligand_ccd( + item.ccd, + has_covalent_bond=chain_id in covalent_chains, + **common, + ) + return tokens, atoms, [] + if item.smiles is not None: + return tokenize_ligand_smiles(item.smiles, seed=seed, **common) + raise ValueError("LigandInput must have either ccd or smiles") + + +def build_chains_from_input( + input: StructurePredictionInput, seed: int | None = None +) -> tuple[list[ChainInfo], list[TokenInfo], list[AtomInfo]]: + """Assign entities and symmetry copies, then tokenize every input chain.""" + chains: list[ChainInfo] = [] + tokens: list[TokenInfo] = [] + atoms: list[AtomInfo] = [] + entity_for_sequence: dict[str, int] = {} + next_symmetry: dict[int, int] = {} + covalent_chains = { + chain_id + for bond in input.covalent_bonds or () + for chain_id in (bond.chain_id1, bond.chain_id2) + } + space_uid_offset = 0 + + for item in input.sequences: + key = _get_sequence_key(item) + entity_id = entity_for_sequence.setdefault(key, len(entity_for_sequence)) + chain_ids = [item.id] if isinstance(item.id, str) else item.id + for chain_id in chain_ids: + sym_id = next_symmetry.get(entity_id, 0) + next_symmetry[entity_id] = sym_id + 1 + asym_id = len(chains) + new_tokens, new_atoms, ligand_bonds = _tokenize_chain( + item, + chain_id, + entity_id=entity_id, + asym_id=asym_id, + sym_id=sym_id, + token_offset=len(tokens), + atom_offset=len(atoms), + space_uid_offset=space_uid_offset, + covalent_chains=covalent_chains, + seed=seed, + ) + chains.append( + ChainInfo( + chain_id=chain_id, + asym_id=asym_id, + entity_id=entity_id, + sym_id=sym_id, + mol_type=(new_tokens[0].mol_type if new_tokens else MOL_TYPE_PROTEIN), + tokens=new_tokens, + ligand_bonds=ligand_bonds, + ) + ) + tokens.extend(new_tokens) + atoms.extend(new_atoms) + space_uid_offset += len({atom.space_uid for atom in new_atoms}) + return chains, tokens, atoms + + +def _atom_indices_by_name(atoms: list[AtomInfo]) -> dict[int, dict[str, int]]: + result: dict[int, dict[str, int]] = defaultdict(dict) + for atom in atoms: + if atom.is_valid: + result[atom.token_index][atom.name] = atom.atom_index + return result + + +def _ligand_frames( + tokens: list[TokenInfo], + atoms: list[AtomInfo], + atom_indices: dict[int, dict[str, int]], +) -> dict[int, tuple[int, int, int]]: + atom_for_token: dict[int, int] = {} + tokens_by_residue: dict[tuple[int, int], list[int]] = defaultdict(list) + for token in tokens: + if token.mol_type != MOL_TYPE_NONPOLYMER: + continue + named_atoms = atom_indices.get(token.token_index) + if named_atoms: + atom_for_token[token.token_index] = next(iter(named_atoms.values())) + tokens_by_residue[(token.asym_id, token.residue_index)].append(token.token_index) + + frames: dict[int, tuple[int, int, int]] = {} + for residue_tokens in tokens_by_residue.values(): + residue_atoms = [ + atom_for_token[token] for token in residue_tokens if token in atom_for_token + ] + if len(residue_atoms) < 3: + for token in residue_tokens: + if token in atom_for_token: + atom_index = atom_for_token[token] + frames[token] = (atom_index, atom_index, atom_index) + continue + R = np.asarray([atoms[index].ref_pos for index in residue_atoms]) + distances = np.sqrt(((R[:, None] - R[None]) ** 2).sum(-1)) + nearest = np.argsort(distances, axis=1) + local = np.column_stack((nearest[:, 1], nearest[:, 0], nearest[:, 2])) + local_index = {atom_index: index for index, atom_index in enumerate(residue_atoms)} + for token in residue_tokens: + atom_index = atom_for_token.get(token) + if atom_index is None: + continue + selected = local[local_index[atom_index]] + frames[token] = tuple(residue_atoms[int(index)] for index in selected) + return frames + + +def _frame_for_token( + token: TokenInfo, + named_atoms: dict[str, int], + ligand_frames: dict[int, tuple[int, int, int]], +) -> tuple[int, int, int]: + fallback = next(iter(named_atoms.values()), 0) + if token.mol_type == MOL_TYPE_PROTEIN: + return ( + (fallback, fallback, fallback) + if token.res_type == PROTEIN_UNK_RES_TYPE + else ( + named_atoms.get("N", 0), + named_atoms.get("CA", 0), + named_atoms.get("C", 0), + ) + ) + if token.mol_type in (MOL_TYPE_DNA, MOL_TYPE_RNA): + return ( + (fallback, fallback, fallback) + if token.res_type == PROTEIN_UNK_RES_TYPE + else ( + named_atoms.get("C1'", 0), + named_atoms.get("C3'", 0), + named_atoms.get("C4'", 0), + ) + ) + if token.mol_type == MOL_TYPE_NONPOLYMER: + return ligand_frames.get(token.token_index, (fallback, fallback, fallback)) + return fallback, fallback, fallback + + +def _resolved_frames( + frames: np.ndarray, tokens: list[TokenInfo], atoms: list[AtomInfo] +) -> np.ndarray: + if not tokens: + return np.zeros(0, dtype=bool) + X = ( + np.asarray([atom.pos for atom in atoms], dtype=np.float32) + if atoms + else np.zeros((0, 3), dtype=np.float32) + ) + valid_atoms = ( + np.asarray([atom.is_valid for atom in atoms], dtype=bool) + if atoms + else np.zeros(0, dtype=bool) + ) + resolved_atoms = valid_atoms & np.any(X != 0, axis=1) + origin = X[frames[:, 1]] + left = X[frames[:, 0]] - origin + right = X[frames[:, 2]] - origin + left_norm = np.linalg.norm(left, axis=1) + right_norm = np.linalg.norm(right, axis=1) + valid_norms = (left_norm >= 1e-6) & (right_norm >= 1e-6) + cosine = np.zeros(len(tokens), dtype=np.float32) + if np.any(valid_norms): + cosine[valid_norms] = np.sum(left[valid_norms] * right[valid_norms], axis=1) / ( + left_norm[valid_norms] * right_norm[valid_norms] + ) + angle = np.degrees(np.arccos(np.abs(np.clip(cosine, -1, 1)))) + all_resolved = resolved_atoms[frames].all(axis=1) + repeated = (frames[:, 0] == frames[:, 1]) & (frames[:, 1] == frames[:, 2]) + return all_resolved & ~repeated & valid_norms & (angle >= 25) + + +def compute_frame_indices( + tokens: list[TokenInfo], atoms: list[AtomInfo] +) -> tuple[np.ndarray, np.ndarray]: + """Return frame atom indices F with shape (l, 3) and validity M with shape (l,).""" + named_atoms = _atom_indices_by_name(atoms) + ligand_frames = _ligand_frames(tokens, atoms, named_atoms) + frames = np.asarray( + [ + _frame_for_token(token, named_atoms.get(token.token_index, {}), ligand_frames) + for token in tokens + ], + dtype=np.int64, + ) + return frames, _resolved_frames(frames, tokens, atoms) + + +def _atom_tokenized_residues( + tokens: list[TokenInfo], atoms: list[AtomInfo] +) -> dict[tuple[int, int], list[tuple[str, int]]]: + grouped: dict[tuple[int, int], list[tuple[str, int]]] = defaultdict(list) + for atom in atoms: + if not atom.is_valid or atom.token_index >= len(tokens): + continue + token = tokens[atom.token_index] + if token.mol_type == MOL_TYPE_NONPOLYMER or token.res_type == PROTEIN_UNK_RES_TYPE: + grouped[(token.asym_id, token.residue_index)].append((atom.name, atom.token_index)) + return grouped + + +def _backbone_token( + residue_tokens: list[TokenInfo], atom_name: str, atoms: list[AtomInfo] +) -> int | None: + if len(residue_tokens) == 1 and residue_tokens[0].res_type != PROTEIN_UNK_RES_TYPE: + return residue_tokens[0].token_index + for token in residue_tokens: + for atom_index in range(token.atom_start, token.atom_start + token.atom_count): + if atom_index < len(atoms) and atoms[atom_index].name == atom_name: + return token.token_index + return residue_tokens[0].token_index if residue_tokens else None + + +def compute_token_bonds( + tokens: list[TokenInfo], + atoms: list[AtomInfo], + input: StructurePredictionInput, + chains: list[ChainInfo], +) -> torch.Tensor: + """Build the symmetric token-bond matrix M with shape (l, l, 1).""" + edges: set[tuple[int, int]] = set() + + def connect(left: int | None, right: int | None) -> None: + if left is not None and right is not None and left != right: + edges.add((min(left, right), max(left, right))) + + explicit_bonds = { + (chain.asym_id, 0): chain.ligand_bonds for chain in chains if chain.ligand_bonds + } + for residue_key, atom_list in _atom_tokenized_residues(tokens, atoms).items(): + if not atom_list: + continue + residue_name = tokens[atom_list[0][1]].residue_name + token_for_name = {name: token_index for name, token_index in atom_list} + bonds = explicit_bonds.get(residue_key) + if bonds is None: + bonds = get_ligand_ccd_bonds(residue_name) + if bonds: + for left_name, right_name in bonds: + if left_name in token_for_name and right_name in token_for_name: + connect(token_for_name[left_name], token_for_name[right_name]) + else: + for left, right in combinations([token_index for _, token_index in atom_list], 2): + connect(left, right) + + if input.covalent_bonds: + chain_for_id = {chain.chain_id: chain for chain in chains} + residue_atoms: dict[tuple[int, int], list[AtomInfo]] = defaultdict(list) + for atom in atoms: + if atom.is_valid and atom.token_index < len(tokens): + token = tokens[atom.token_index] + residue_atoms[(token.asym_id, token.residue_index)].append(atom) + for bond in input.covalent_bonds: + left_chain = chain_for_id.get(bond.chain_id1) + right_chain = chain_for_id.get(bond.chain_id2) + if left_chain is None or right_chain is None: + continue + left_atoms = residue_atoms.get((left_chain.asym_id, bond.res_idx1), []) + right_atoms = residue_atoms.get((right_chain.asym_id, bond.res_idx2), []) + if bond.atom_idx1 < len(left_atoms) and bond.atom_idx2 < len(right_atoms): + connect( + left_atoms[bond.atom_idx1].token_index, + right_atoms[bond.atom_idx2].token_index, + ) + + protein_residues: dict[tuple[int, int], list[TokenInfo]] = defaultdict(list) + for token in tokens: + if token.mol_type == MOL_TYPE_PROTEIN: + protein_residues[(token.asym_id, token.residue_index)].append(token) + for (asym_id, residue_index), residue_tokens in protein_residues.items(): + if not any(token.res_type == PROTEIN_UNK_RES_TYPE for token in residue_tokens): + continue + previous = protein_residues.get((asym_id, residue_index - 1)) + following = protein_residues.get((asym_id, residue_index + 1)) + if previous: + connect( + _backbone_token(previous, "C", atoms), + _backbone_token(residue_tokens, "N", atoms), + ) + if following: + connect( + _backbone_token(residue_tokens, "C", atoms), + _backbone_token(following, "N", atoms), + ) + + matrix = torch.zeros(len(tokens), len(tokens), 1, dtype=torch.float32) + for left, right in edges: + matrix[left, right, 0] = 1.0 + matrix[right, left, 0] = 1.0 + return matrix + + +def compute_representative_atoms(tokens: list[TokenInfo], atoms: list[AtomInfo]) -> torch.Tensor: + """Choose one distogram atom per token and return indices I with shape (l,).""" + named_atoms = _atom_indices_by_name(atoms) + representatives = torch.zeros(len(tokens), dtype=torch.int64) + for token in tokens: + names = named_atoms.get(token.token_index, {}) + fallback = next(iter(names.values()), 0) + if token.mol_type == MOL_TYPE_PROTEIN: + representative = names.get("CB", names.get("CA", fallback)) + elif token.mol_type in (MOL_TYPE_DNA, MOL_TYPE_RNA): + if token.res_type in (27, 32): + representative = names.get("C1'", fallback) + elif token.res_type in (23, 24, 28, 29): + representative = names.get("C4", names.get("C1'", fallback)) + else: + representative = names.get("C2", names.get("C1'", fallback)) + else: + representative = fallback + representatives[token.token_index] = representative + return representatives + + +def _msa_assignments( + input: StructurePredictionInput, chains: list[ChainInfo] +) -> dict[int, MSA | None]: + chain_msas: dict[int, MSA | None] = {} + chain_index = 0 + for item in input.sequences: + chain_ids = [item.id] if isinstance(item.id, str) else list(item.id) + for _ in chain_ids: + chain = chains[chain_index] + if isinstance(item, ProteinInput): + chain_msas[chain.asym_id] = ( + MSA.from_sequences([item.sequence]) if item.msa is None else item.msa + ) + else: + chain_msas[chain.asym_id] = None + chain_index += 1 + return chain_msas + + +def compute_msa_features( + input: StructurePredictionInput, + chains: list[ChainInfo], + tokens: list[TokenInfo], + max_seqs: int = 16384, +) -> dict[str, torch.Tensor]: + """Pair per-chain MSAs and return row features with shape (m, l).""" + from .esmfold2_paired_msa import ( + construct_paired_msa, + protein_letter_to_res_type, + ) + + chain_msas = _msa_assignments(input, chains) + query_types = { + chain.asym_id: np.asarray( + [token.res_type for token in tokens if token.asym_id == chain.asym_id], + dtype=np.int64, + ) + for chain in chains + } + msa_residues, deletion_counts, _ = construct_paired_msa( + chain_msas, + query_types, + np.asarray([token.asym_id for token in tokens], dtype=np.int64), + np.asarray([token.residue_index for token in tokens], dtype=np.int64), + letter_to_res_type=protein_letter_to_res_type(), + max_seqs=max_seqs, + ) + for token in tokens: + if chain_msas.get(token.asym_id) is None: + msa_residues[:, token.token_index] = MSA_GAP_TOKEN_ID + msa_residues[0, token.token_index] = token.res_type + if msa_residues.shape[0] == 0: + msa_residues = np.full((1, len(tokens)), MSA_GAP_TOKEN_ID, dtype=np.int64) + deletion_counts = np.zeros((1, len(tokens)), dtype=np.float32) + + msa = torch.from_numpy(msa_residues) + deletion_count = torch.from_numpy(deletion_counts) + deletion_value = (np.pi / 2) * torch.arctan(deletion_count / 3) + return { + "msa": msa, + "deletion_value": deletion_value, + "has_deletion": deletion_count > 0, + "deletion_mean": deletion_value.mean(dim=0), + "msa_attention_mask": torch.ones_like(msa, dtype=torch.bool), + } + + +def compute_distogram_conditioning( + input: StructurePredictionInput, + chains: list[ChainInfo], + tokens: list[TokenInfo], + disto_center: torch.Tensor, + min_dist: float = 2.0, + max_dist: float = 22.0, + num_bins: int = 64, +) -> tuple[torch.Tensor, torch.Tensor]: + """Bin user distances into D and return D plus its Boolean mask M.""" + del disto_center + n_tokens = len(tokens) + bins = torch.zeros((n_tokens, n_tokens), dtype=torch.long) + mask = torch.zeros((n_tokens, n_tokens), dtype=torch.bool) + if not input.distogram_conditioning: + return bins, mask + asym_for_chain = {chain.chain_id: chain.asym_id for chain in chains} + tokens_for_asym: dict[int, list[int]] = defaultdict(list) + for token in tokens: + tokens_for_asym[token.asym_id].append(token.token_index) + boundaries = torch.linspace(min_dist, max_dist, num_bins + 1) + + for conditioning in input.distogram_conditioning: + asym_id = asym_for_chain.get(conditioning.chain_id) + if asym_id is None: + continue + indices = tokens_for_asym[asym_id] + distances = torch.as_tensor(conditioning.distogram, dtype=torch.float32) + expected_shape = (len(indices), len(indices)) + if distances.shape != expected_shape: + raise ValueError( + f"Distogram shape {distances.shape} doesn't match chain length {len(indices)}" + ) + selected = torch.bucketize(distances, boundaries[:-1]).sub(1).clamp(0, num_bins - 1) + token_indices_tensor = torch.as_tensor(indices, dtype=torch.long) + bins[token_indices_tensor[:, None], token_indices_tensor[None, :]] = selected + mask[token_indices_tensor[:, None], token_indices_tensor[None, :]] = True + return bins, mask + + +def _padded_atoms(atoms: list[AtomInfo]) -> list[AtomInfo]: + target = math.ceil(len(atoms) / 32) * 32 if atoms else 32 + padding = [ + AtomInfo( + name="", + element="", + charge=0, + ref_pos=_ZERO_POS.copy(), + pos=_ZERO_POS.copy(), + token_index=0, + atom_index=index, + space_uid=0, + is_valid=False, + ) + for index in range(len(atoms), target) + ] + return [*atoms, *padding] + + +def _token_tensors(tokens: list[TokenInfo]) -> dict[str, torch.Tensor]: + fields = { + "token_index": "token_index", + "residue_index": "residue_index", + "asym_id": "asym_id", + "sym_id": "sym_id", + "entity_id": "entity_id", + "mol_type": "mol_type", + "res_type": "res_type", + "input_ids": "input_id", + } + return { + output_name: torch.from_numpy( + np.asarray([getattr(token, attribute) for token in tokens], dtype=np.int64) + ) + for output_name, attribute in fields.items() + } + + +def _atom_tensors(atoms: list[AtomInfo]) -> dict[str, torch.Tensor]: + n_atoms = len(atoms) + ref_pos = np.zeros((n_atoms, 3), dtype=np.float32) + ref_element = np.zeros(n_atoms, dtype=np.int64) + ref_charge = np.zeros(n_atoms, dtype=np.int8) + ref_name = np.zeros((n_atoms, 4), dtype=np.int64) + ref_space = np.zeros(n_atoms, dtype=np.int64) + atom_mask = np.zeros(n_atoms, dtype=np.bool_) + atom_to_token = np.zeros(n_atoms, dtype=np.int64) + positions = np.zeros((n_atoms, 3), dtype=np.float64) + valid = np.zeros(n_atoms, dtype=np.bool_) + for index, atom in enumerate(atoms): + if atom.ref_pos is not None: + ref_pos[index] = atom.ref_pos + ref_charge[index] = atom.charge + ref_space[index] = atom.space_uid if atom.space_uid >= 0 else atom.token_index + atom_mask[index] = atom.is_valid + valid[index] = atom.is_valid + positions[index] = atom.pos + if atom.is_valid: + ref_element[index] = get_element_atomic_num(atom.element) + ref_name[index] = encode_atom_name(atom.name) + atom_to_token[index] = atom.token_index + + resolved = valid & np.any(positions != 0, axis=1) + X = torch.from_numpy(positions) + resolved_mask = torch.from_numpy(resolved) + valid_mask = torch.from_numpy(valid) + if resolved_mask.any(): + X = X - X[resolved_mask].mean(dim=0, keepdim=True) + X[~valid_mask] = 0.0 + return { + "ref_pos": torch.from_numpy(ref_pos), + "ref_element": torch.from_numpy(ref_element), + "ref_charge": torch.from_numpy(ref_charge), + "ref_atom_name_chars": torch.from_numpy(ref_name), + "ref_space_uid": torch.from_numpy(ref_space), + "gt_coords": X.float().unsqueeze(0), + "atom_attention_mask": torch.from_numpy(atom_mask), + "atom_to_token": torch.from_numpy(atom_to_token), + "is_resolved": torch.tensor(resolved, dtype=torch.bool), + } + + +def build_feature_tensors( + chains: list[ChainInfo], + tokens: list[TokenInfo], + atoms: list[AtomInfo], + input: StructurePredictionInput, +) -> dict[str, torch.Tensor]: + """Assemble the complete unbatched ESMFold2 feature dictionary.""" + token_features = _token_tensors(tokens) + atom_features = _atom_tensors(_padded_atoms(atoms)) + frames, _ = compute_frame_indices(tokens, atoms) + msa_features = compute_msa_features(input, chains, tokens) + distogram, distogram_mask = compute_distogram_conditioning( + input, + chains, + tokens, + torch.zeros(len(tokens), 3, dtype=torch.float32), + ) + return { + **token_features, + "token_bonds": compute_token_bonds(tokens, atoms, input, chains), + "token_attention_mask": torch.ones(len(tokens), dtype=torch.bool), + "pocket_feature": torch.zeros(len(tokens), dtype=torch.long), + **atom_features, + "distogram_atom_idx": compute_representative_atoms(tokens, atoms), + "frames_idx": torch.from_numpy(frames).to(torch.int64), + "disto_cond": distogram, + "disto_cond_mask": distogram_mask, + **msa_features, + } + + +def prepare_esmfold2_input( + input: StructurePredictionInput, seed: int | None = None +) -> tuple[dict[str, torch.Tensor], list[ChainInfo]]: + """Convert one typed request to model features and output-chain metadata.""" + chains, tokens, atoms = build_chains_from_input(input, seed) + return build_feature_tensors(chains, tokens, atoms, input), chains diff --git a/fastplms/models/esmfold2/esmfold2_processor.py b/fastplms/models/esmfold2/esmfold2_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..d11f3986177e732c352e8ea7881c0c1f7eaebfb1 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_processor.py @@ -0,0 +1,332 @@ +"""Input preparation and output decoding for ESMFold2 inference.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from torch import Tensor + +from .esmfold2_conformers import load_ccd +from .esmfold2_molecular_complex import MolecularComplexResult +from .esmfold2_output import build_molecular_complex_from_features +from .esmfold2_prepare_input import ChainInfo, prepare_esmfold2_input +from .esmfold2_types import MSA, Modification, ProteinInput, StructurePredictionInput +from .modeling_esmfold2_common import MSA_CONDITIONING_INPUT_NAMES +from .reproducibility import seed_context + +# Backward-compatible private alias for the pinned parity helpers. New callers +# should import ``seed_context`` from the public ``fastplms.models.esmfold2`` +# package instead of reaching into implementation modules. +_seed_context = seed_context + + +@dataclass(frozen=True) +class _SplitProteinState: + ids: dict[str, list[str]] + modifications: dict[str, list[Modification]] + msas: dict[str, MSA | None] + + +@dataclass(frozen=True) +class _PendingProtein: + source: ProteinInput + sequence: str + state: _SplitProteinState + + +def _chain_starts(chains: list[str]) -> list[int]: + starts: list[int] = [] + position = 0 + for chain in chains: + starts.append(position) + position += len(chain) + 1 + return starts + + +def _split_modifications( + item: ProteinInput, + chains: list[str], + starts: list[int], +) -> dict[str, list[Modification]]: + grouped: dict[str, list[Modification]] = {} + if item.modifications is None: + return grouped + for chain, start in zip(chains, starts, strict=True): + end = start + len(chain) + adjusted = [ + Modification(position=modification.position - start, ccd=modification.ccd) + for modification in item.modifications + if start <= modification.position < end + ] + grouped.setdefault(chain, []).extend(adjusted) + return grouped + + +def _split_msas( + item: ProteinInput, + chains: list[str], + starts: list[int], +) -> dict[str, MSA | None]: + grouped: dict[str, MSA | None] = {} + if item.msa is None: + return grouped + for chain, start in zip(chains, starts, strict=True): + if chain not in grouped: + grouped[chain] = item.msa.select_positions(np.arange(start, start + len(chain))) + return grouped + + +def _split_protein(item: ProteinInput) -> tuple[list[_PendingProtein], _SplitProteinState]: + chains = ":".join(item.sequence.split("|")).split(":") + starts = _chain_starts(chains) + base_id = item.id[0] if isinstance(item.id, list) else item.id + ids: dict[str, list[str]] = {} + for index, chain in enumerate(chains): + chain_ids = ids.setdefault(chain, []) + chain_ids.append(f"{base_id}_{index}") + state = _SplitProteinState( + ids=ids, + modifications=_split_modifications(item, chains, starts), + msas=_split_msas(item, chains, starts), + ) + pending = [ + _PendingProtein(item, chain, state) + for chain, chain_ids in ids.items() + if chain_ids + ] + return pending, state + + +def _resolve_pending(pending: _PendingProtein) -> ProteinInput: + item = pending.source + sequence = pending.sequence + state = pending.state + return ProteinInput( + id=state.ids[sequence], + sequence=sequence, + msa=state.msas.get(sequence) if item.msa else None, + modifications=(state.modifications.get(sequence) if item.modifications else None), + ) + + +def clean_esmfold2_input(input: StructurePredictionInput) -> StructurePredictionInput: + """Expand chain delimiters and group repeated protein sequences by entity.""" + + if input.pocket is not None: + raise NotImplementedError( + "ESMFold2 pocket conditioning is present in the upstream input schema but " + "the published ESMFold2 feature pipeline drops it. FastPLMs refuses this " + "input instead of silently emitting an all-zero pocket feature." + ) + + cleaned: list[Any] = [] + for item in input.sequences: + if not isinstance(item, ProteinInput): + cleaned.append(item) + continue + sequence = ":".join(item.sequence.split("|")) + if ":" not in sequence: + cleaned.append(item) + continue + if input.covalent_bonds is not None: + raise ValueError( + "Covalent bonds are not supported when using chainbreaks. " + "Chains must be separated into multiple ProteinInput objects." + ) + pending, _state = _split_protein(item) + cleaned.extend(pending) + + resolved = [ + _resolve_pending(item) if isinstance(item, _PendingProtein) else item + for item in cleaned + ] + return StructurePredictionInput( + sequences=resolved, + pocket=input.pocket, + distogram_conditioning=input.distogram_conditioning, + covalent_bonds=input.covalent_bonds, + ) + + +def _batch_features( + features: dict[str, Any], + device: torch.device | str | None, +) -> dict[str, Any]: + return { + name: (value[None].to(device) if device is not None else value[None]) + if isinstance(value, Tensor) + else value + for name, value in features.items() + } + + +def _sampler_overrides( + noise_scale: float | None, + step_scale: float | None, + max_inference_sigma: int | None, +) -> dict[str, Any]: + values = { + "noise_scale": noise_scale, + "step_scale": step_scale, + "max_inference_sigma": max_inference_sigma, + } + return {name: value for name, value in values.items() if value is not None} + + +class ESMFold2InputBuilder: + """Prepare public input objects, run folding, and decode model tensors.""" + + def __init__(self, ccd_cache: Path | None = None) -> None: + load_ccd(ccd_cache) + + def prepare_input( + self, + input: StructurePredictionInput, + seed: int | None = None, + device: torch.device | str | None = None, + ) -> tuple[dict[str, Any], list[ChainInfo]]: + cleaned = clean_esmfold2_input(input) + with seed_context(seed): + features, chain_infos = prepare_esmfold2_input(cleaned, seed=seed) + return _batch_features(features, device), chain_infos + + def prepare_model_input( + self, + model: Any, + input: StructurePredictionInput, + seed: int | None = None, + device: torch.device | str | None = None, + ) -> tuple[dict[str, Any], list[ChainInfo]]: + """Prepare features while enforcing the checkpoint's MSA contract.""" + + msa_conditioning = getattr(model.config, "msa_conditioning", None) + if not isinstance(msa_conditioning, bool): + raise RuntimeError("The ESMFold2 config has no Boolean msa_conditioning contract.") + if not msa_conditioning: + explicit_msa_ids = [ + item.id + for item in input.sequences + if isinstance(item, ProteinInput) and item.msa is not None + ] + if explicit_msa_ids: + raise ValueError( + "This ESMFold2 checkpoint was trained without MSA conditioning and " + f"rejects explicit MSAs for protein inputs {explicit_msa_ids!r}." + ) + features, chain_infos = self.prepare_input(input, seed=seed, device=device) + if not msa_conditioning: + for name in MSA_CONDITIONING_INPUT_NAMES: + features.pop(name, None) + return features, chain_infos + + def __call__( + self, + input: StructurePredictionInput, + seed: int | None = None, + device: torch.device | str | None = None, + ) -> tuple[dict[str, Any], list[ChainInfo]]: + return self.prepare_input(input, seed=seed, device=device) + + def _decode_sample( + self, + output: Mapping[str, Tensor], + features: dict[str, Tensor], + chain_infos: list[ChainInfo], + sample: int, + complex_id: str, + ) -> MolecularComplexResult: + plddt = output["plddt"][sample] + molecular_complex = build_molecular_complex_from_features( + coords=output["sample_atom_coords"][sample], + plddt=plddt, + atom_mask=features["atom_attention_mask"][0], + ref_element=features["ref_element"][0], + ref_atom_name_chars=features["ref_atom_name_chars"][0], + chain_infos=chain_infos, + complex_id=complex_id, + ) + + def sample_tensor(name: str) -> Tensor | None: + value = output.get(name) + return None if value is None else value[sample].detach().cpu() + + def shared_tensor(name: str) -> Tensor | None: + value = output.get(name) + return None if value is None else value[0].detach().cpu() + + ptm = output.get("ptm") + iptm = output.get("iptm") + return MolecularComplexResult( + complex=molecular_complex, + plddt=plddt.detach().cpu(), + ptm=float(ptm[sample].item()) if ptm is not None else None, + iptm=float(iptm[sample].item()) if iptm is not None else None, + pae=sample_tensor("pae"), + distogram=shared_tensor("distogram_logits"), + pair_chains_iptm=sample_tensor("pair_chains_iptm"), + residue_index=shared_tensor("residue_index"), + entity_id=shared_tensor("entity_id"), + ) + + def decode( + self, + output: Mapping[str, Tensor], + features: dict[str, Tensor], + chain_infos: list[ChainInfo], + *, + num_diffusion_samples: int = 1, + complex_id: str = "pred", + ) -> MolecularComplexResult | list[MolecularComplexResult]: + results = [ + self._decode_sample(output, features, chain_infos, sample, complex_id) + for sample in range(output["sample_atom_coords"].shape[0]) + ] + return results[0] if num_diffusion_samples == 1 and len(results) == 1 else results + + def fold( + self, + model: Any, + input: StructurePredictionInput, + *, + num_loops: int = 3, + num_sampling_steps: int = 200, + num_diffusion_samples: int = 1, + seed: int | None = None, + noise_scale: float | None = None, + step_scale: float | None = None, + max_inference_sigma: int | None = None, + early_exit: bool = False, + complex_id: str = "pred", + ) -> MolecularComplexResult | list[MolecularComplexResult]: + features, chain_infos = self.prepare_model_input( + model, + input, + seed=seed, + device=model.device, + ) + overrides = _sampler_overrides(noise_scale, step_scale, max_inference_sigma) + with torch.no_grad(), seed_context(seed): + output = model( + **features, + num_loops=num_loops, + num_sampling_steps=num_sampling_steps, + num_diffusion_samples=num_diffusion_samples, + early_exit=early_exit, + return_dict=True, + **overrides, + ) + return self.decode( + output, + features, + chain_infos, + num_diffusion_samples=num_diffusion_samples, + complex_id=complex_id, + ) + + +__all__ = ["ESMFold2InputBuilder", "clean_esmfold2_input", "seed_context"] diff --git a/fastplms/models/esmfold2/esmfold2_protein_chain.py b/fastplms/models/esmfold2/esmfold2_protein_chain.py new file mode 100644 index 0000000000000000000000000000000000000000..acaaaf585e354204bb99916f9d2b2f515c58ea29 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_protein_chain.py @@ -0,0 +1,1450 @@ +"""Protein-chain data, geometry, and serialization for ESMFold2.""" + +from __future__ import annotations + +import io +import warnings +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass, replace +from functools import cached_property +from pathlib import Path +from typing import Any + +import biotite.structure as bs +import brotli +import msgpack +import msgpack_numpy +import numpy as np +import torch +from biotite.database import rcsb +from biotite.structure.io.pdb import PDBFile +from biotite.structure.io.pdbx import CIFCategory, CIFColumn, CIFData, CIFFile +from biotite.structure.io.pdbx import set_structure as set_structure_pdbx +from scipy.spatial import ConvexHull, KDTree +from scipy.spatial.distance import cdist, pdist, squareform + +from . import esmfold2_residue_constants as residue_constants +from .esmfold2_affine3d import Affine3D +from .esmfold2_aligner import Aligner +from .esmfold2_atom_indexer import AtomIndexer +from .esmfold2_metrics import compute_gdt_ts, compute_lddt_ca +from .esmfold2_misc import slice_python_object_as_numpy +from .esmfold2_mmcif_parsing import ( + PLDDT_B_FACTOR_SCALE, + MmcifWrapper, + Residue, + round_mmcif_columns, +) +from .esmfold2_normalize_coordinates import ( + apply_frame_to_coords, + get_protein_normalization_frame, +) +from .esmfold2_protein_structure import index_by_atom_name +from .esmfold2_utils_types import PathOrBuffer + +CHAIN_ID_CONST = "A" + + +def _str_key_to_int_key(values: dict, ignore_keys: list[str] | None = None) -> dict: + """Restore integer dictionary keys after JSON-compatible serialization.""" + ignored = frozenset(ignore_keys or ()) + restored = {} + for key, value in values.items(): + if isinstance(value, dict) and key not in ignored: + value = _str_key_to_int_key(value, ignore_keys=ignore_keys) + restored_key = int(key) if isinstance(key, str) and key.isdigit() else key + restored[restored_key] = value + return restored + + +def _num_non_null_residues(seqres_to_structure_chain: Mapping[int, Residue]) -> int: + return sum(residue.residue_number is not None for residue in seqres_to_structure_chain.values()) + + +def infer_cb( + C, + N, + Ca, + bond_length: float = 1.522, + bond_angle: float = 1.927, + dihedral: float = -2.143, +): + """Infer C-beta coordinates from C, N, and C-alpha coordinates.""" + + def normalize(X: np.ndarray) -> np.ndarray: + return X / np.sqrt(np.square(X).sum(-1, keepdims=True) + 1e-8) + + with np.errstate(invalid="ignore"): + n_to_ca = N - Ca + n_to_c = N - C + axis = normalize(n_to_ca) + normal = normalize(np.cross(n_to_c, axis)) + basis = (axis, np.cross(normal, axis), normal) + offsets = ( + bond_length * np.cos(bond_angle), + bond_length * np.sin(bond_angle) * np.cos(dihedral), + -bond_length * np.sin(bond_angle) * np.sin(dihedral), + ) + return Ca + sum(vector * offset for vector, offset in zip(basis, offsets, strict=False)) + + +def chain_to_ndarray( + atom_array: bs.AtomArray, mmcif: MmcifWrapper, chain_id: str, is_predicted=False +): + if not isinstance(atom_array, bs.AtomArray): + raise TypeError("atom_array must be a biotite AtomArray.") + if not isinstance(mmcif, MmcifWrapper): + raise TypeError("mmcif must be an MmcifWrapper.") + if not isinstance(chain_id, str) or not chain_id: + raise ValueError("chain_id must be a non-empty string.") + if chain_id not in mmcif.chain_to_seqres or chain_id not in mmcif.seqres_to_structure: + raise ValueError(f"mmCIF data does not contain sequence mappings for chain {chain_id!r}.") + entity_id = None + for entity, chains in mmcif.entities.items(): + if chain_id in chains: + entity_id = entity + num_res = len(mmcif.chain_to_seqres[chain_id]) + sequence = mmcif.chain_to_seqres[chain_id] + + atom_positions = np.full([num_res, residue_constants.atom_type_num, 3], np.nan) + atom_mask = np.full([num_res, residue_constants.atom_type_num], False, dtype=bool) + residue_index = np.full([num_res], -1, dtype=np.int64) + insertion_code = np.full([num_res], "", dtype=" ProteinChain: + """Return a ProteinChain object from an pdb file. NOTE: prefer mmcif for rcsb PDB files. + This function is mostly to interface with old PDB files and predicted structures - + it will not fill out the entity id correctly + + Args: + path: PDB path or text buffer. + id: Optional structure identifier. + is_predicted (bool): If True, reads b factor as the confidence readout. Default: False. + chain_id: Author chain identifier. ``"detect"`` selects the first chain. + """ + + if id is not None: + file_id = id + else: + match path: + case Path() | str(): + file_id = Path(path).with_suffix("").name + case _: + file_id = "null" + + atom_array = PDBFile.read(path).get_structure(model=1, extra_fields=["b_factor"]) + if len(atom_array) == 0: + raise ValueError("PDB contains no atoms.") + if chain_id == "detect": + chain_id = atom_array.chain_id[0] + atom_array = atom_array[ + bs.filter_amino_acids(atom_array) + & ~atom_array.hetero + & (atom_array.chain_id == chain_id) + ] + if len(atom_array) == 0: + raise ValueError(f"PDB contains no amino-acid atoms for chain {chain_id!r}.") + + entity_id = 1 # Not supplied in PDBfiles + + sequence = "".join( + residue_constants.restype_3to1.get(monomer[0].res_name, "X") + for monomer in bs.residue_iter(atom_array) + ) + num_res = len(sequence) + + atom_positions = np.full( + [num_res, residue_constants.atom_type_num, 3], np.nan, dtype=np.float32 + ) + atom_mask = np.full([num_res, residue_constants.atom_type_num], False, dtype=bool) + residue_index = np.full([num_res], -1, dtype=np.int64) + insertion_code = np.full([num_res], "", dtype=" ProteinChain: + return cls( + id=data["id"], + chain_id=data["chain_id"], + entity_id=data["entity_id"], + sequence=data["sequence"], + residue_index=data["residue_index"], + insertion_code=np.asarray(data["insertion_code"]), + atom37_positions=data["atom37_positions"], + atom37_mask=data["atom37_mask"].astype(bool), + confidence=data["confidence"], + mmcif=None, + ) + + @classmethod + def from_rcsb( + cls, + pdb_id: str, + chain_id: str | None = None, + entity_id: int | None = None, + keep_source: bool = False, + ) -> ProteinChain: + f: io.StringIO = rcsb.fetch(pdb_id, "cif") # type: ignore + return cls.from_mmcif( + f, + id=pdb_id, + chain_id=chain_id, + entity_id=entity_id, + keep_source=keep_source, + is_predicted=False, + ) + + @classmethod + def from_atomarray( + cls, atom_array: bs.AtomArray, id: str | None = None, is_predicted: bool = False + ) -> ProteinChain: + """A simple converter from bs.AtomArray -> ProteinChain. + Uses PDB file format as intermediate.""" + atom_array = atom_array.copy() + atom_array.box = None # remove surrounding box, from_pdb won't handle this + pdb_file = PDBFile() # pyright: ignore + pdb_file.set_structure(atom_array) + + buf = io.StringIO() + pdb_file.write(buf) + buf.seek(0) + return cls.from_pdb(buf, id=id, is_predicted=is_predicted) + + # Object invariants and atom views + def __post_init__(self): + if not isinstance(self.id, str): + raise TypeError("id must be a string.") + if not isinstance(self.sequence, str): + raise TypeError("sequence must be a string.") + if not isinstance(self.chain_id, str) or not self.chain_id: + raise ValueError("chain_id must be a non-empty string.") + if self.entity_id is not None and ( + not isinstance(self.entity_id, int) or isinstance(self.entity_id, bool) + ): + raise TypeError("entity_id must be an integer or None.") + sequence_length = len(self.sequence) + aligned = { + "atom37_positions": self.atom37_positions, + "atom37_mask": self.atom37_mask, + "residue_index": self.residue_index, + "insertion_code": self.insertion_code, + "confidence": self.confidence, + } + for name, values in aligned.items(): + if not isinstance(values, np.ndarray): + raise TypeError(f"{name} must be a NumPy array, got {type(values).__name__}.") + if values.ndim == 0 or values.shape[0] != sequence_length: + raise ValueError( + f"{name} shape {values.shape} does not align with " + f"sequence length {sequence_length}." + ) + if self.atom37_positions.shape != (sequence_length, 37, 3): + raise ValueError( + "atom37_positions must have shape " + f"({sequence_length}, 37, 3), got {self.atom37_positions.shape}." + ) + if self.atom37_mask.shape != (sequence_length, 37): + raise ValueError( + "atom37_mask must have shape " + f"({sequence_length}, 37), got {self.atom37_mask.shape}." + ) + if self.atom37_mask.dtype != bool: + raise TypeError(f"atom37_mask must have Boolean dtype, got {self.atom37_mask.dtype}.") + if not np.issubdtype(self.atom37_positions.dtype, np.number): + raise TypeError("atom37_positions must use a numeric dtype.") + if not np.issubdtype(self.residue_index.dtype, np.integer): + raise TypeError("residue_index must use an integer dtype.") + if self.insertion_code.dtype.kind not in {"U", "S", "O"}: + raise TypeError("insertion_code must use a string-compatible dtype.") + if any(not isinstance(value, str) for value in self.insertion_code.tolist()): + raise TypeError("insertion_code must contain only strings.") + for name, values in ( + ("residue_index", self.residue_index), + ("insertion_code", self.insertion_code), + ("confidence", self.confidence), + ): + if values.shape != (sequence_length,): + raise ValueError( + f"{name} must have shape ({sequence_length},), got {values.shape}." + ) + if not np.issubdtype(self.confidence.dtype, np.number): + raise TypeError("confidence must use a numeric dtype.") + atom37_confidence = self.atom37_confidence + if atom37_confidence is not None and not isinstance(atom37_confidence, np.ndarray): + raise TypeError("atom37_confidence must be a NumPy array when provided.") + if ( + isinstance(atom37_confidence, np.ndarray) + and atom37_confidence.shape != self.atom37_mask.shape + ): + raise ValueError( + "atom37_confidence shape must match atom37_mask: " + f"{atom37_confidence.shape} != {self.atom37_mask.shape}." + ) + if isinstance(atom37_confidence, np.ndarray) and not np.issubdtype( + atom37_confidence.dtype, np.number + ): + raise TypeError("atom37_confidence must use a numeric dtype.") + + @cached_property + def atoms(self) -> AtomIndexer: + return AtomIndexer(self, property="atom37_positions", dim=-2) + + @cached_property + def atom_mask(self) -> AtomIndexer: + return AtomIndexer(self, property="atom37_mask", dim=-1) + + @cached_property + def atom_array(self) -> bs.AtomArray: + atoms = [] + for res_idx_i, ( + res_name, + res_idx, + ins_code, + positions, + mask, + conf, + ) in enumerate( + zip( + self.sequence, + self.residue_index, + self.insertion_code, + self.atom37_positions, + self.atom37_mask.astype(bool), + self.confidence, + strict=False, + ) + ): + for i, pos in zip(np.where(mask)[0], positions[mask], strict=False): + b_factor = ( + self.atom37_confidence[res_idx_i, i] + if self.atom37_confidence is not None + else conf + ) + atom = bs.Atom( + coord=pos, + chain_id="A" if self.chain_id is None else self.chain_id, + res_id=res_idx, + ins_code=ins_code, + res_name=residue_constants.restype_1to3.get(res_name, "UNK"), + hetero=False, + atom_name=residue_constants.atom_types[i], + element=residue_constants.atom_types[i][0], + b_factor=float(b_factor) * PLDDT_B_FACTOR_SCALE, + occupancy=1.0, + ) + atoms.append(atom) + return bs.array(atoms) + + # Coordinate transformations and dataset adapters + def get_normalization_frame(self) -> Affine3D: + """Given a set of coordinates, compute a single frame. + The frame is built from the mean N, C-alpha, and C coordinates with + Gram-Schmidt orthogonalization. Its origin is the mean C-alpha position. + + Returns: + Affine3D: [] tensor of Affine3D frame + """ + coords = torch.from_numpy(self.atom37_positions) + frame = get_protein_normalization_frame(coords) + + return frame + + def apply_frame(self, frame: Affine3D) -> ProteinChain: + """Given a frame, apply the frame to the protein's coordinates. + + Args: + frame (Affine3D): [] tensor of Affine3D frame + + Returns: + ProteinChain: Transformed protein chain + """ + coords = torch.from_numpy(self.atom37_positions).to(frame.trans.dtype) + coords = apply_frame_to_coords(coords, frame) + atom37_positions = coords.numpy() + return replace(self, atom37_positions=atom37_positions) + + def normalize_coordinates(self) -> ProteinChain: + """Normalize the coordinates of the protein chain.""" + return self.apply_frame(self.get_normalization_frame()) + + def infer_oxygen(self) -> ProteinChain: + """Oxygen position is fixed given N, CA, C atoms. Infer it if not provided.""" + O_missing_indices = np.argwhere(~np.isfinite(self.atoms["O"]).all(axis=1)).squeeze() + + O_vector = torch.tensor([0.6240, -1.0613, 0.0103], dtype=torch.float32) + N, CA, C = torch.from_numpy(self.atoms[["N", "CA", "C"]]).float().unbind(dim=1) + N = torch.roll(N, -3) + N[..., -1, :] = torch.nan + + # Get the frame defined by the CA-C-N atom + frames = Affine3D.from_graham_schmidt(CA, C, N) + oxygen_coordinates = frames.apply(O_vector) + atom37_positions = self.atom37_positions.copy() + atom37_mask = self.atom37_mask.copy() + + atom37_positions[O_missing_indices, residue_constants.atom_order["O"]] = oxygen_coordinates[ + O_missing_indices + ].numpy() + atom37_mask[O_missing_indices, residue_constants.atom_order["O"]] = ~np.isnan( + atom37_positions[O_missing_indices, residue_constants.atom_order["O"]] + ).any(-1) + new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask) + return new_chain + + @cached_property + def inferred_cbeta(self) -> np.ndarray: + """Infer cbeta positions based on N, C, CA.""" + N, CA, C = np.moveaxis(self.atoms[["N", "CA", "C"]], 1, 0) + # See usage in trDesign codebase. + # https://github.com/gjoni/trDesign/blob/f2d5930b472e77bfacc2f437b3966e7a708a8d37/02-GD/utils.py#L140 + CB = infer_cb(C, N, CA, 1.522, 1.927, -2.143) + return CB + + def infer_cbeta(self, infer_cbeta_for_glycine: bool = False) -> ProteinChain: + """Return a new chain with inferred CB atoms at all residues except GLY. + + Args: + infer_cbeta_for_glycine (bool): If True, infers a beta carbon for glycine + residues, even though that residue doesn't have one. Default off. + + NOTE(rverkuil): The reason for having this switch in the first place + is that sometimes we want a (inferred) CB coordinate for every residue, + for example for making a pairwise distance matrix, or doing an RMSD + calculation between two designs for a given structural template, w/ + CB atoms. + """ + atom37_positions = self.atom37_positions.copy() + atom37_mask = self.atom37_mask.copy() + + inferred_cbeta_positions = self.inferred_cbeta + if not infer_cbeta_for_glycine: + inferred_cbeta_positions[np.array(list(self.sequence)) == "G", :] = np.nan + + atom37_positions[:, residue_constants.atom_order["CB"]] = inferred_cbeta_positions + atom37_mask[:, residue_constants.atom_order["CB"]] = ~np.isnan( + atom37_positions[:, residue_constants.atom_order["CB"]] + ).any(-1) + new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask) + return new_chain + + @cached_property + def pdist_CA(self) -> np.ndarray: + CA = self.atoms["CA"] + pdist_CA = squareform(pdist(CA)) + return pdist_CA + + @cached_property + def pdist_CB(self) -> np.ndarray: + pdist_CB = squareform(pdist(self.inferred_cbeta)) + return pdist_CB + + @classmethod + def as_complex(cls, chains: Sequence[ProteinChain]): + raise RuntimeError( + ".as_complex() has been deprecated in favor of .concat(). " + ".concat() will eventually be deprecated in favor of ProteinComplex..." + ) + + @classmethod + def concat(cls, chains: Sequence[ProteinChain], use_chainbreak: bool = True): + if not chains: + raise ValueError("chains must contain at least one ProteinChain.") + if any(not isinstance(chain, ProteinChain) for chain in chains): + raise TypeError("chains must contain only ProteinChain instances.") + sep_tokens = { + "residue_index": np.array([-1]), + "insertion_code": np.array([""]), + "atom37_positions": np.full([1, 37, 3], np.inf), + "atom37_mask": np.zeros([1, 37], dtype=bool), + "confidence": np.array([0]), + } + + def join_arrays(arrays: Sequence[np.ndarray], sep: np.ndarray): + if use_chainbreak: + full_array = [] + for array in arrays: + full_array.append(array) + full_array.append(sep) + full_array = full_array[:-1] + return np.concatenate(full_array, 0) + else: + return np.concatenate(arrays, 0) + + array_args: dict[str, np.ndarray] = { + name: join_arrays([getattr(chain, name) for chain in chains], sep) + for name, sep in sep_tokens.items() + } + + chain_break = residue_constants.CHAIN_BREAK_TOKEN if use_chainbreak else "" + return cls( + id=chains[0].id, + sequence=chain_break.join(chain.sequence for chain in chains), + chain_id="A", + entity_id=None, + mmcif=None, + **array_args, + ) + + def find_nonpolymer_contacts(self): + if self.mmcif is None: + raise ValueError( + "find_nonpolymer_contacts requires a chain loaded with keep_source=True." + ) + nonpolymer_and_chain_id_to_array = self.mmcif.non_polymer_coords + + results = [] + for ( + nonpolymer, + _, + ), nonpolymer_array in nonpolymer_and_chain_id_to_array.items(): + if nonpolymer_array.coord is None: + raise ValueError( + f"Non-polymer {nonpolymer.comp_id!r} has no coordinate table." + ) + chain_coords = self.atom37_positions[self.atom37_mask] + distance = cdist(nonpolymer_array.coord, chain_coords) + + is_contact = distance < 5 + if not is_contact.any(): + continue + contacting_atoms = np.where(is_contact.any(0))[0] + chain_index = np.where(self.atom37_mask)[0] + contacting_residues = np.unique(chain_index[contacting_atoms]) + + result = { + "ligand": nonpolymer.name, + "ligand_id": nonpolymer.comp_id, + "contacting_residues": contacting_residues.tolist(), + } + results.append(result) + return results + + def select_residue_indices( + self, indices: list[int | str], ignore_x_mismatch: bool = False + ) -> ProteinChain: + numeric_indices = [idx if isinstance(idx, int) else int(idx[1:]) for idx in indices] + mask = np.isin(self.residue_index, numeric_indices) + new = self[mask] + mismatches = [] + for aa, idx in zip(new.sequence, indices, strict=False): + if isinstance(idx, int): + continue + if aa == "X" and ignore_x_mismatch: + continue + if aa != idx[0]: + mismatches.append((aa, idx)) + if mismatches: + mismatch_str = "; ".join( + f"Position {idx[1:]}, Expected: {idx[0]}, Received: {aa}" for aa, idx in mismatches + ) + raise RuntimeError(mismatch_str) + + return new + + def to_structure_encoder_inputs( + self, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Convert protein chain to structure encoder inputs. + + Returns: + tuple: (coordinates, plddt, residue_index) where: + - coordinates: X with shape (1, l, 37, 3), containing atom positions + - plddt: P with shape (1, l), containing confidence scores + - residue_index: R with shape (1, l), containing residue indices + """ + # Convert to tensors and add batch dimension + coordinates = ( + torch.from_numpy(self.atom37_positions).float().unsqueeze(0) + ) # X has shape (1, l, 37, 3). + plddt = torch.from_numpy(self.confidence).float().unsqueeze(0) # P: (1, l) + residue_index = ( + torch.from_numpy(self.residue_index).long().unsqueeze(0) + ) # R has shape (1, l). + + return coordinates, plddt, residue_index + + # Sequence access, interchange, and compact storage + def __getitem__(self, idx: int | list[int] | slice | np.ndarray | torch.Tensor): + if isinstance(idx, int): + idx = [idx] + if isinstance(idx, torch.Tensor): + idx = idx.cpu().numpy() + + sequence = slice_python_object_as_numpy(self.sequence, idx) + return replace( + self, + sequence=sequence, + residue_index=self.residue_index[..., idx], + insertion_code=self.insertion_code[..., idx], + atom37_positions=self.atom37_positions[..., idx, :, :], + atom37_mask=self.atom37_mask[..., idx, :], + confidence=self.confidence[..., idx], + atom37_confidence=self.atom37_confidence[..., idx, :] + if self.atom37_confidence is not None + else None, + ) + + def __len__(self): + return len(self.sequence) + + def cbeta_contacts(self, distance_threshold: float = 8.0) -> np.ndarray: + distance = self.pdist_CB + contacts = (distance < distance_threshold).astype(np.int64) + contacts[np.isnan(distance)] = -1 + np.fill_diagonal(contacts, -1) + return contacts + + def to_pdb(self, path: PathOrBuffer, include_insertions: bool = True): + """Dssp works better w/o insertions.""" + f = PDBFile() + if not include_insertions: + f.set_structure(self.atom_array_no_insertions) + else: + f.set_structure(self.atom_array) + f.write(path) + + def to_pdb_string(self, include_insertions: bool = True) -> str: + buf = io.StringIO() + self.to_pdb(buf, include_insertions=include_insertions) + buf.seek(0) + return buf.read() + + def to_mmcif(self, path: PathOrBuffer): + f = CIFFile() + set_structure_pdbx(f, self.atom_array, data_block=self.id) + + # incantations molstar needs to render pLDDT / confidence onto + # the structure with "alphafold-view" + f.block["ma_qa_metric"] = CIFCategory( + name="ma_qa_metric", + columns={ + "id": CIFColumn(data=CIFData(array=np.array([1, 2]), dtype=np.int64)), + "mode": CIFColumn(data=CIFData(array=np.array(["global", "local"]), dtype=np.str_)), + "name": CIFColumn(data=CIFData(array=np.array(["pLDDT", "pLDDT"]), dtype=np.str_)), + }, + ) + + # table is a duplicate of data already in the atom array, but + # needed by molstar to render pLDDT / confidence + resid_pldd_table = { + # hard coded to as we currently only support single chain structures + "label_asym_id": CIFColumn( + data=CIFData(array=[CHAIN_ID_CONST] * len(self.residue_index), dtype=np.str_) + ), + "label_comp_id": CIFColumn( + data=CIFData( + array=[residue_constants.restype_1to3.get(c, "UNK") for c in self.sequence], + dtype=np.str_, + ) + ), + "label_seq_id": CIFColumn(data=CIFData(array=self.residue_index, dtype=np.int64)), + "ordinal_id": CIFColumn(data=CIFData(array=self.residue_index, dtype=np.int64)), + # hard coded to show these are all local plDDT values + "metric_id": CIFColumn( + data=CIFData(array=["2"] * len(self.residue_index), dtype=np.str_) + ), + "metric_value": CIFColumn( + data=CIFData( + array=self.confidence * PLDDT_B_FACTOR_SCALE, + dtype=np.float32, + ) + ), + # hard coded to show there are the initial version, there are no revisions + "model_id": CIFColumn( + data=CIFData(array=["1"] * len(self.residue_index), dtype=np.str_) + ), + } + f.block["ma_qa_metric_local"] = CIFCategory( + name="ma_qa_metric_local", columns=resid_pldd_table + ) + round_mmcif_columns(f) + f.write(path) + + def to_mmcif_string(self) -> str: + buf = io.StringIO() + self.to_mmcif(buf) + buf.seek(0) + return buf.read() + + def state_dict(self, backbone_only=False, json_serializable=False): + """This state dict is optimized for storage, so it turns things to fp16 whenever + possible. Note that we also only support int32 residue indices, I'm hoping we don't + need more than 2**32 residues...""" + dct = {k: v for k, v in asdict(self).items() if k not in ["mmcif"]} + if backbone_only: + dct["atom37_mask"][:, 3:] = False + dct["atom37_positions"] = dct["atom37_positions"][dct["atom37_mask"]] + if dct.get("atom37_confidence") is not None: + dct["atom37_confidence"] = dct["atom37_confidence"][dct["atom37_mask"]] + else: + dct.pop("atom37_confidence", None) + + for k, v in dct.items(): + if isinstance(v, np.ndarray): + match v.dtype: + case np.int64: + dct[k] = v.astype(np.int32) + case np.float64 | np.float32: + dct[k] = v.astype(np.float16) + case _: + pass + if json_serializable: + dct[k] = v.tolist() + return dct + + def to_blob(self, backbone_only=False) -> bytes: + payload = msgpack.dumps(self.state_dict(backbone_only), default=msgpack_numpy.encode) + return brotli.compress(payload, quality=5) + + @classmethod + def from_open_source(cls, pc: ProteinChain): + return cls(**vars(pc)) + + @classmethod + def from_state_dict(cls, dct): + # Note: assembly_composition is *supposed* to have string keys. + dct = _str_key_to_int_key(dct, ignore_keys=["assembly_composition"]) + + for k, v in dct.items(): + if isinstance(v, list): + dct[k] = np.array(v) + + atom37 = np.full((*dct["atom37_mask"].shape, 3), np.nan) + atom37[dct["atom37_mask"]] = dct["atom37_positions"] + dct["atom37_positions"] = atom37 + if "atom37_confidence" in dct: + atom37_conf = np.full(dct["atom37_mask"].shape, np.nan, dtype=np.float32) + atom37_conf[dct["atom37_mask"]] = dct["atom37_confidence"] + dct["atom37_confidence"] = atom37_conf + dct = { + k: ( + v.astype(np.float32) + if k in ["atom37_positions", "confidence", "atom37_confidence"] + else v + ) + for k, v in dct.items() + if not (k == "atom37_confidence" and v is None) + } + return cls(**dct, mmcif=None) + + @classmethod + def from_blob(cls, input: Path | str | io.BytesIO | bytes): + """NOTE(@zlin): blob + sparse coding + brotli + fp16 reduces memory + of chains from 52G/1M chains to 20G/1M chains, I think this is a good first + shot at compressing and dumping chains to disk. I'm sure there's better ways.""" + match input: + case Path() | str(): + bytes = Path(input).read_bytes() + case io.BytesIO(): + bytes = input.getvalue() + case _: + bytes = input + state = msgpack.loads(brotli.decompress(bytes), object_hook=msgpack_numpy.decode) + return cls.from_state_dict(state) + + # Surface and structural comparison metrics + def sasa(self, by_residue: bool = True): + arr = self.atom_array_no_insertions + if len(arr) == 0: + raise ValueError("SASA requires at least one resolved atom.") + sasa_per_atom = bs.sasa(arr) # type: ignore + if by_residue: + # Sum per-atom SASA into residue "bins", with np.bincount. + if arr.res_id is None: + raise RuntimeError("Biotite AtomArray is missing residue identifiers.") + # Residue IDs are one-indexed, so discard the unused zero bin. + # NOTE(aderry): We compute only for residues with coordinates, return NaN otherwise. + num_trailing_residues = len(self) - arr.res_id.max() + sasa_per_residue = np.concatenate( + [ + np.bincount(arr.res_id, weights=sasa_per_atom)[1:], + np.zeros(num_trailing_residues), + ] + ) + sasa_per_residue[~self.atom37_mask.any(-1)] = np.nan + if len(sasa_per_residue) != len(self): + raise RuntimeError("Residue SASA output does not align with the protein chain.") + return sasa_per_residue + return sasa_per_atom + + def sap_score(self, aggregation: str = "atom") -> np.ndarray: + """Compute per-atom spatial aggregation propensity (SAP). + + Residue aggregation averages resolved atoms and omits unresolved residues. + Protein aggregation sums positive atom scores, following Lauer et al. 2011. + """ + sap_radius = 5.0 + arr = self.atom_array_no_insertions + if len(arr) == 0: + raise ValueError("SAP requires at least one resolved atom.") + + for name in ("res_id", "res_name", "atom_name", "coord"): + if getattr(arr, name) is None: + raise RuntimeError(f"Biotite AtomArray is missing required {name!r} data.") + + # compute SASA and residue-specific properties + sasa_per_atom = self.sasa(by_residue=False) + resid_to_resname = dict(zip(arr.res_id, arr.res_name, strict=False)) + + max_side_chain_asa = np.full(len(self), np.nan) + res_hydrophobicity = np.full(len(self), np.nan) + resolved_res_mask = self.atom37_mask.any(-1) + num_trailing_residues = len(self) - arr.res_id.max() + + max_side_chain_asa[resolved_res_mask] = np.array( + [residue_constants.side_chain_asa[resid_to_resname[i]] for i in np.unique(arr.res_id)] + ) + res_hydrophobicity[resolved_res_mask] = np.array( + [residue_constants.hydrophobicity[resid_to_resname[i]] for i in np.unique(arr.res_id)] + ) + + # compute SAP score + is_side_chain = ~bs.filter_peptide_backbone(arr) + sasa_per_atom[is_side_chain] = 0 + kdtree = KDTree(arr.coord) + neighbors = kdtree.query_ball_tree(kdtree, sap_radius, p=2.0) + sap_by_atom = np.zeros_like(sasa_per_atom) + for i, nn_list in enumerate(neighbors): + saa_nn = np.zeros_like(sasa_per_atom) + saa_nn[nn_list] = sasa_per_atom[nn_list] + sasa_within_r = np.concatenate( + [ + np.bincount(arr.res_id, weights=saa_nn)[1:], + np.zeros(num_trailing_residues), + ] + ) + sap = np.nansum((sasa_within_r / max_side_chain_asa) * res_hydrophobicity) + sap_by_atom[i] = sap + + match aggregation: + case "atom": + return sap_by_atom + case "residue": + sap_by_residue = np.concatenate( + [ + np.bincount(arr.res_id, weights=sap_by_atom)[1:], + np.zeros(num_trailing_residues), + ] + ) / ( + np.concatenate([np.bincount(arr.res_id)[1:], np.zeros(num_trailing_residues)]) + + 1e-8 + ) + sap_by_residue[~resolved_res_mask] = np.nan + if len(sap_by_residue) != len(self): + raise RuntimeError("Residue SAP output does not align with the protein chain.") + return sap_by_residue + case "protein": + return sum(sap_by_atom[sap_by_atom > 0]) # pyright: ignore[reportReturnType] + case _: + raise ValueError( + f"Invalid aggregation method: {aggregation}. Must be one of " + "'atom', 'residue', or 'protein'" + ) + + def globularity(self) -> float: + # Computes globularity using total volumes divided by MVEE. + # We make the simplifying approximation that atoms never overlap. + # The globularity is only computed where structure exists. + # Besides the approximation above, this is inspired by: + + # https://www.mdpi.com/2073-4352/11/12/1539 + # The non-overlapping-atom approximation can produce globularity above one. + mask = self.atom37_mask.any(-1) + points = self.atom37_positions[self.atom37_mask] + sequence = [aa for aa, m in zip(self.sequence, mask, strict=False) if m] # type: ignore + A, _ = self._mvee(points, tol=1e-3) + mvee_volume = (4 * np.pi) / (3 * np.sqrt(np.linalg.det(A))) + volume = sum(residue_constants.amino_acid_volumes[x] for x in sequence) + ratio = volume / mvee_volume + + # The paper compares the ellipsoidal profile with scalar t, a measurement + # of elongation. We want a single number, so we multiply by 1/(2t), so + # that value is normalized between 0-1 + eigenvalues = np.linalg.eigvals(A) + R = 1 / np.sqrt(eigenvalues) + # ellipsoid radii length triangle inequality coefficient + t = max(R[0] / (R[1] + R[2]), R[1] / (R[0] + R[2]), R[2] / (R[0] + R[1])) + elongation_metric = 1 / max(t, 1) + return ratio * elongation_metric + + @staticmethod + def _mvee(P: np.ndarray, tol, max_iter=10000): + # Finds minimum volume enclosing ellipsoid of a set of points. + # Returns A, c where the ellipse is defined as: + # (x-c).T @ A @ (x-c) = 1 + hull = ConvexHull(P) + P = P[hull.vertices] + P = P.T + + # Data points + d, n = P.shape + Q = np.zeros((d + 1, n)) + Q[:d, :] = P[:d, :n] + Q[d, :] = np.ones((1, n)) + + # Initializations + count = 1 + err = 1.0 + u = np.full((n, 1), 1 / n) # First iteration. + + # Khachiyan Algorithm + for _ in range(max_iter): + X = Q.dot(np.diag(u.squeeze())) @ Q.T + M = np.diag(Q.T @ np.linalg.inv(X) @ Q) + maximum, j = np.max(M), np.argmax(M) + step_size = (maximum - d - 1) / ((d + 1) * (maximum - 1)) + new_u = (1 - step_size) * u + new_u[j] += step_size + count += 1 + err = np.linalg.norm(new_u - u) + u = new_u + if err < tol: + break + else: + raise ValueError("MVEE did not converge") + + d = P.shape[0] # Fixed: use P.shape[0] instead of P.shape + U = np.diag(u.squeeze()) + + # The A matrix for the ellipse + A = (1 / d) * np.linalg.inv(P @ U @ P.T - (P @ u) @ (P @ u).T) + + # Center of the ellipse + c = P @ u + + return A, c + + def radius_of_gyration(self): + arr = self.atom_array_no_insertions + return bs.gyration_radius(arr) + + def align( + self, + target: ProteinChain, + mobile_inds: list[int] | np.ndarray | None = None, + target_inds: list[int] | np.ndarray | None = None, + only_use_backbone: bool = False, + ): + """ + Aligns the current protein to the provided target. + + Args: + target (ProteinChain): The target protein to align to. + mobile_inds: Mobile atom indices, not residue indices. + target_inds: Target atom indices, not residue indices. + only_use_backbone (bool, optional): If True, only align the backbone atoms. + """ + aligner = Aligner( + self if mobile_inds is None else self[mobile_inds], + target if target_inds is None else target[target_inds], + only_use_backbone, + ) + + return aligner.apply(self) + + def rmsd( + self, + target: ProteinChain, + also_check_reflection: bool = False, + mobile_inds: list[int] | np.ndarray | None = None, + target_inds: list[int] | np.ndarray | None = None, + only_compute_backbone_rmsd: bool = False, + ): + """ + Compute the RMSD between this protein chain and another. + + Args: + target (ProteinChain): The target (other) protein chain to compare to. + also_check_reflection: Compare the reflected mobile coordinates too. + mobile_inds: Mobile atom indices, not residue indices. + target_inds: Target atom indices, not residue indices. + only_compute_backbone_rmsd: Restrict the score to backbone atoms. + """ + if isinstance(target, bs.AtomArray): + raise ValueError( + "Support for bs.AtomArray removed, use ProteinChain.from_atomarry for ProteinChain." + ) + aligner = Aligner( + self if mobile_inds is None else self[mobile_inds], + target if target_inds is None else target[target_inds], + only_compute_backbone_rmsd, + ) + avg_rmsd = aligner.rmsd + + if not also_check_reflection: + return avg_rmsd + + aligner = Aligner( + self if mobile_inds is None else self[mobile_inds], + target if target_inds is None else target[target_inds], + only_compute_backbone_rmsd, + use_reflection=True, + ) + avg_rmsd_neg = aligner.rmsd + + return min(avg_rmsd, avg_rmsd_neg) + + def lddt_ca( + self, + native: ProteinChain, + mobile_inds: list[int] | np.ndarray | None = None, + target_inds: list[int] | np.ndarray | None = None, + **kwargs, + ) -> float | np.ndarray: + """Compute the LDDT between this protein chain and another. NOTE: LDDT IS NOT SYMMETRIC. + The call should always be prediction.lddt_ca(native). + + Arguments: + native (ProteinChain): The ground truth protein chain + mobile_inds: Mobile atom indices, not residue indices. + target_inds: Target atom indices, not residue indices. + + Returns: + float | np.ndarray: The LDDT score between the two protein chains, either + a single float or per-residue LDDT scores if `per_residue` is True. + """ + lddt = compute_lddt_ca( + torch.tensor(self.atom37_positions[mobile_inds]).unsqueeze(0), + torch.tensor(native.atom37_positions[target_inds]).unsqueeze(0), + torch.tensor(native.atom37_mask[mobile_inds]).unsqueeze(0), + **kwargs, + ) + return float(lddt) if lddt.numel() == 1 else lddt.numpy().flatten() + + def gdt_ts( + self, + target: ProteinChain, + mobile_inds: list[int] | np.ndarray | None = None, + target_inds: list[int] | np.ndarray | None = None, + **kwargs, + ) -> float | np.ndarray: + """Compute the GDT_TS between this protein chain and another. + + Arguments: + target (ProteinChain): The other protein chain to compare to. + mobile_inds: Mobile atom indices, not residue indices. + target_inds: Target atom indices, not residue indices. + + Returns: + float: The GDT_TS score between the two protein chains. + """ + gdt_ts = compute_gdt_ts( + mobile=torch.tensor( + index_by_atom_name(self.atom37_positions[mobile_inds], "CA"), + dtype=torch.float32, + ).unsqueeze(0), + target=torch.tensor( + index_by_atom_name(target.atom37_positions[target_inds], "CA"), + dtype=torch.float32, + ).unsqueeze(0), + atom_exists_mask=torch.tensor( + index_by_atom_name(self.atom37_mask[mobile_inds], "CA", dim=-1) + & index_by_atom_name(target.atom37_mask[target_inds], "CA", dim=-1) + ).unsqueeze(0), + **kwargs, + ) + return float(gdt_ts) if gdt_ts.numel() == 1 else gdt_ts.numpy().flatten() + + @cached_property + def residue_index_no_insertions(self) -> np.ndarray: + return self.residue_index + np.cumsum(self.insertion_code != "") + + @cached_property + def atom_array_no_insertions(self) -> bs.AtomArray: + atoms = [] + for res_idx, (res_name, positions, mask, conf) in enumerate( + zip( + self.sequence, + self.atom37_positions, + self.atom37_mask.astype(bool), + self.confidence, + strict=False, + ) + ): + for i, pos in zip(np.where(mask)[0], positions[mask], strict=False): + b_factor = ( + self.atom37_confidence[res_idx, i] + if self.atom37_confidence is not None + else conf + ) + atom = bs.Atom( + coord=pos, + # hard coded to as we currently only support single chain structures + chain_id=CHAIN_ID_CONST, + res_id=res_idx + 1, + res_name=residue_constants.restype_1to3.get(res_name, "UNK"), + hetero=False, + atom_name=residue_constants.atom_types[i], + element=residue_constants.atom_types[i][0], + b_factor=float(b_factor) * PLDDT_B_FACTOR_SCALE, + occupancy=1.0, + ) + atoms.append(atom) + return bs.array(atoms) diff --git a/fastplms/models/esmfold2/esmfold2_protein_complex.py b/fastplms/models/esmfold2/esmfold2_protein_complex.py new file mode 100644 index 0000000000000000000000000000000000000000..688ea5ce68efecdcc522e1ec4015d60d12bf782a --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_protein_complex.py @@ -0,0 +1,1240 @@ +"""Protein-complex data, assembly expansion, and geometry for ESMFold2.""" + +from __future__ import annotations + +import io +import itertools +import random +import re +import warnings +from collections.abc import Iterable, Sequence +from dataclasses import asdict, dataclass, replace +from functools import cached_property +from pathlib import Path +from subprocess import check_output +from tempfile import TemporaryDirectory +from typing import Any + +import biotite.structure as bs +import brotli +import msgpack +import msgpack_numpy +import numpy as np +import torch +from biotite.database import rcsb +from biotite.file import InvalidFileError +from biotite.structure.io.pdb import PDBFile +from biotite.structure.io.pdbx import CIFCategory, CIFColumn, CIFData, CIFFile +from biotite.structure.io.pdbx import set_structure as set_structure_pdbx +from biotite.structure.io.pdbx.convert import _get_transformations, get_structure +from biotite.structure.util import matrix_rotate +from scipy.spatial import KDTree + +from . import esmfold2_residue_constants as residue_constants +from .esmfold2_affine3d import Affine3D +from .esmfold2_aligner import Aligner +from .esmfold2_atom_indexer import AtomIndexer +from .esmfold2_metrics import compute_gdt_ts, compute_lddt_ca +from .esmfold2_misc import slice_python_object_as_numpy +from .esmfold2_mmcif_parsing import ( + MmcifWrapper, + NoProteinError, + round_mmcif_columns, +) +from .esmfold2_protein_chain import ( + ProteinChain, + _str_key_to_int_key, + chain_to_ndarray, + index_by_atom_name, + infer_cb, +) +from .esmfold2_utils_types import PathOrBuffer + +SINGLE_LETTER_CHAIN_IDS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + + +def _parse_operation_expression(expression: str) -> list[tuple[str, ...]]: + """Expand an mmCIF operation expression in application order.""" + + def expand_group(group: str) -> list[str]: + operation_ids: list[str] = [] + for term in group.split(","): + if "-" not in term: + operation_ids.append(term) + continue + first, last = (int(value) for value in term.split("-")) + operation_ids.extend(str(value) for value in range(first, last + 1)) + return operation_ids + + groups = [group for group in expression.replace(")", "").split("(") if group] + groups.reverse() + return list(itertools.product(*(expand_group(group) for group in groups))) + + +def _apply_transformations_fast(chains, transformation_dict, operations): + """Return transformed copies of each affected protein chain.""" + transformed_chains = [] + for chain in chains: + for operation in operations: + coordinates = chain.atom37_positions.copy() + for op_step in operation: + transform = transformation_dict[op_step] + coordinates = matrix_rotate(coordinates, transform.rotation) + coordinates += transform.target_translation + transformed_chains.append(replace(chain, atom37_positions=coordinates)) + return transformed_chains + + +@dataclass +class ProteinComplexMetadata: + entity_lookup: dict[int, int | str] + chain_lookup: dict[int, str] + mmcif: MmcifWrapper | None = None + # This is a dictionary that maps assembly ids to the list of unique chains + # in that assembly. Allows for usage of `switch_assembly`. + assembly_composition: dict[str, list[str]] | None = None + + +@dataclass +class DockQSingleScore: + native_chains: tuple[str, str] + DockQ: float + interface_rms: float + ligand_rms: float + fnat: float + fnonnat: float + clashes: float + F1: float + DockQ_F1: float + + +@dataclass +class DockQResult: + total_dockq: float + native_interfaces: int + chain_mapping: dict[str, str] + interfaces: dict[tuple[str, str], DockQSingleScore] + # zip(aligned.chain_iter(), native.chain_iter()) gives you the pairing + # aligned.rmsd(native) should give you a low rmsd irrespective of shuffling + aligned: ProteinComplex + aligned_rmsd: float + + +@dataclass(frozen=True) +class ProteinComplex: + """Dataclass with atom37 representation of an entire protein complex.""" + + id: str + sequence: str + entity_id: np.ndarray # entities map to unique sequences + chain_id: np.ndarray # multiple chains might share an entity id + sym_id: np.ndarray # complexes might be copies of the same chain + residue_index: np.ndarray + insertion_code: np.ndarray + atom37_positions: np.ndarray + atom37_mask: np.ndarray + confidence: np.ndarray + # This metadata is parsed from the MMCIF file. For synthetic data, we do a best effort. + metadata: ProteinComplexMetadata + atom37_confidence: np.ndarray | None = None # P has shape (l, 37). + + # Coordinate completion, concatenation, and comparison + def infer_oxygen(self) -> ProteinComplex: + """Oxygen position is fixed given N, CA, C atoms. Infer it if not provided.""" + O_missing_indices = np.argwhere(~np.isfinite(self.atoms["O"]).all(axis=1)).squeeze() + + O_vector = torch.tensor([0.6240, -1.0613, 0.0103], dtype=torch.float32) + N, CA, C = torch.from_numpy(self.atoms[["N", "CA", "C"]]).float().unbind(dim=1) + N = torch.roll(N, -3) + N[..., -1, :] = torch.nan + + # Get the frame defined by the CA-C-N atom + frames = Affine3D.from_graham_schmidt(CA, C, N) + oxygen_coordinates = frames.apply(O_vector) + atom37_positions = self.atom37_positions.copy() + atom37_mask = self.atom37_mask.copy() + + atom37_positions[O_missing_indices, residue_constants.atom_order["O"]] = oxygen_coordinates[ + O_missing_indices + ].numpy() + atom37_mask[O_missing_indices, residue_constants.atom_order["O"]] = ~np.isnan( + atom37_positions[O_missing_indices, residue_constants.atom_order["O"]] + ).any(-1) + new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask) + return new_chain + + def infer_cbeta(self, infer_cbeta_for_glycine: bool = False) -> ProteinComplex: + """Return a new chain with inferred CB atoms at all residues except GLY. + + Args: + infer_cbeta_for_glycine (bool): If True, infers a beta carbon for glycine + residues, even though that residue doesn't have one. Default off. + + NOTE(rverkuil): The reason for having this switch in the first place + is that sometimes we want a (inferred) CB coordinate for every residue, + for example for making a pairwise distance matrix, or doing an RMSD + calculation between two designs for a given structural template, w/ + CB atoms. + """ + atom37_positions = self.atom37_positions.copy() + atom37_mask = self.atom37_mask.copy() + + N, CA, C = np.moveaxis(self.atoms[["N", "CA", "C"]], 1, 0) + # See usage in trDesign codebase. + # https://github.com/gjoni/trDesign/blob/f2d5930b472e77bfacc2f437b3966e7a708a8d37/02-GD/utils.py#L140 + inferred_cbeta_positions = infer_cb(C, N, CA, 1.522, 1.927, -2.143) + if not infer_cbeta_for_glycine: + inferred_cbeta_positions[np.array(list(self.sequence)) == "G", :] = np.nan + + atom37_positions[:, residue_constants.atom_order["CB"]] = inferred_cbeta_positions + atom37_mask[:, residue_constants.atom_order["CB"]] = ~np.isnan( + atom37_positions[:, residue_constants.atom_order["CB"]] + ).any(-1) + new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask) + return new_chain + + @classmethod + def from_open_source(cls, pc: ProteinComplex): + # TODO(@zeming): deprecated, should delete + return pc + + @classmethod + def concat(cls, objs: list[ProteinComplex]) -> ProteinComplex: + pdb_ids = [obj.id for obj in objs] + if len(set(pdb_ids)) > 1: + raise RuntimeError( + "Concatention of protein complexes across different PDB ids is unsupported" + ) + return ProteinComplex.from_chains( + list(itertools.chain.from_iterable(obj.chain_iter() for obj in objs)) + ) + + def _sanity_check_complexes_are_comparable(self, other: ProteinComplex): + if len(self) != len(other): + raise ValueError("Protein complexes must have the same length") + if len(list(self.chain_iter())) != len(list(other.chain_iter())): + raise ValueError("Protein complexes must have the same number of chains") + + def rmsd( + self, + target: ProteinComplex, + also_check_reflection: bool = False, + mobile_inds: list[int] | np.ndarray | None = None, + target_inds: list[int] | np.ndarray | None = None, + only_compute_backbone_rmsd: bool = False, + compute_chain_assignment: bool = True, + ): + """ + Compute the RMSD between this protein chain and another. + + Args: + target (ProteinComplex): The target (other) protein complex to compare to. + also_check_reflection: Compare the reflected mobile coordinates too. + mobile_inds: Mobile atom indices, not residue indices. + target_inds: Target atom indices, not residue indices. + only_compute_backbone_rmsd: Restrict the score to backbone atoms. + """ + aligned = self.dockq(target).aligned if compute_chain_assignment else self + + aligner = Aligner( + aligned if mobile_inds is None else aligned[mobile_inds], + target if target_inds is None else target[target_inds], + only_compute_backbone_rmsd, + ) + avg_rmsd = aligner.rmsd + + if not also_check_reflection: + return avg_rmsd + + aligner = Aligner( + aligned if mobile_inds is None else aligned[mobile_inds], + target if target_inds is None else target[target_inds], + only_compute_backbone_rmsd, + use_reflection=True, + ) + avg_rmsd_neg = aligner.rmsd + + return min(avg_rmsd, avg_rmsd_neg) + + def lddt_ca( + self, + target: ProteinComplex, + mobile_inds: list[int] | np.ndarray | None = None, + target_inds: list[int] | np.ndarray | None = None, + compute_chain_assignment: bool = True, + **kwargs, + ) -> float | np.ndarray: + """Compute the LDDT between this protein complex and another. + + Arguments: + target (ProteinComplex): The other protein complex to compare to. + mobile_inds: Mobile atom indices, not residue indices. + target_inds: Target atom indices, not residue indices. + + Returns: + float | np.ndarray: The LDDT score between the two protein chains, either + a single float or per-residue LDDT scores if `per_residue` is True. + """ + aligned = self.dockq(target).aligned if compute_chain_assignment else self + lddt = compute_lddt_ca( + torch.tensor(aligned.atom37_positions[mobile_inds]).unsqueeze(0), + torch.tensor(target.atom37_positions[target_inds]).unsqueeze(0), + torch.tensor(aligned.atom37_mask[mobile_inds]).unsqueeze(0), + **kwargs, + ) + return float(lddt) if lddt.numel() == 1 else lddt.numpy().flatten() + + def gdt_ts( + self, + target: ProteinComplex, + mobile_inds: list[int] | np.ndarray | None = None, + target_inds: list[int] | np.ndarray | None = None, + compute_chain_assignment: bool = True, + **kwargs, + ) -> float | np.ndarray: + """Compute the GDT_TS between this protein complex and another. + + Arguments: + target (ProteinComplex): The other protein complex to compare to. + mobile_inds: Mobile atom indices, not residue indices. + target_inds: Target atom indices, not residue indices. + + Returns: + float: The GDT_TS score between the two protein chains. + """ + aligned = self.dockq(target).aligned if compute_chain_assignment else self + gdt_ts = compute_gdt_ts( + mobile=torch.tensor( + index_by_atom_name(aligned.atom37_positions[mobile_inds], "CA"), + dtype=torch.float32, + ).unsqueeze(0), + target=torch.tensor( + index_by_atom_name(target.atom37_positions[target_inds], "CA"), + dtype=torch.float32, + ).unsqueeze(0), + atom_exists_mask=torch.tensor( + index_by_atom_name(aligned.atom37_mask[mobile_inds], "CA", dim=-1) + & index_by_atom_name(target.atom37_mask[target_inds], "CA", dim=-1) + ).unsqueeze(0), + **kwargs, + ) + return float(gdt_ts) if gdt_ts.numel() == 1 else gdt_ts.numpy().flatten() + + def dockq(self, native: ProteinComplex): + # This function uses dockqv2 to compute the DockQ score. Because it does a mapping + # over all possible chains, it's quite slow. Be careful not to use this in an inference loop + # or something that requires fast scoring. It defaults to 8 CPUs. + # + # TODO(@zeming): Because we haven't properly implemented protein complexes for mmcif, + # if your protein has multi-letter or repeated chain IDs, this will fail. Please call + # Normalize chain IDs before DockQ when IDs repeat or use multiple letters. + + try: + pass + except BaseException: + raise RuntimeError("DockQ is not installed. Please update your environment.") from None + self._sanity_check_complexes_are_comparable(native) + + def sanity_check_chain_ids(pc: ProteinComplex): + ids = [] + for i, chain in enumerate(pc.chain_iter()): + if i > len(SINGLE_LETTER_CHAIN_IDS): + raise ValueError("Too many chains to write to PDB file") + if len(chain.chain_id) > 1: + raise ValueError("We only supports single letter chain IDs for DockQ") + ids.append(chain.chain_id) + if len(set(ids)) != len(ids): + raise ValueError(f"Duplicate chain IDs in protein complex: {ids}") + return ids + + sanity_check_chain_ids(self) + sanity_check_chain_ids(native) + + with TemporaryDirectory() as tdir: + dir = Path(tdir) + self.to_pdb(dir / "self.pdb") + native.to_pdb(dir / "native.pdb") + + output = check_output(["DockQ", dir / "self.pdb", dir / "native.pdb"]) + lines = output.decode().split("\n") + + # Remove the header comments + start_index = next(i for i, line in enumerate(lines) if line.startswith("Model")) + lines = lines[start_index:] + + result = {} + interfaces = [] + current_interface: dict = {} + + for line in lines: + line = line.strip() + if not line: + continue + + if line.startswith(("Model :", "Native :")): + pass # Tmp pdb file location, it's useless... + elif line.startswith("Total DockQ"): + total_dockq_match = re.search( + r"Total DockQ over (\d+) native interfaces: ([\d.]+) with " + r"(.*) model:native mapping", + line, + ) + if total_dockq_match: + result["value"] = float(total_dockq_match.group(2)) + result["native interfaces"] = int(total_dockq_match.group(1)) + native_chains, self_chains = total_dockq_match.group(3).split(":") + result["mapping"] = dict(zip(native_chains, self_chains, strict=False)) + else: + raise RuntimeError( + "Failed to parse DockQ output, maybe your DockQ version is wrong?" + ) + elif line.startswith("Native chains:"): + if current_interface: + interfaces.append(current_interface) + current_interface = {"Native chains": line.split(":")[1].strip().split(", ")} + elif line.startswith("Model chains:"): + current_interface["Model chains"] = line.split(":")[1].strip().split(", ") + elif ":" in line: + key, value = line.split(":", 1) + current_interface[key.strip()] = float(value.strip()) + + if current_interface: + interfaces.append(current_interface) + + def parse_dict(d: dict[str, Any]) -> DockQSingleScore: + return DockQSingleScore( + native_chains=tuple(d["Native chains"]), # type: ignore + DockQ=float(d["DockQ"]), + interface_rms=float(d["irms"]), + ligand_rms=float(d["Lrms"]), # Note the capitalization difference + fnat=float(d["fnat"]), + fnonnat=float(d["fnonnat"]), + clashes=float(d["clashes"]), + F1=float(d["F1"]), + DockQ_F1=float(d["DockQ_F1"]), + ) + + inv_mapping = {v: k for k, v in result["mapping"].items()} + + self_chain_map = {c.chain_id: c for c in self.chain_iter()} + realigned = [] + for chain in native.chain_iter(): + realigned.append(self_chain_map[inv_mapping[chain.chain_id]]) + + realigned = ProteinComplex.from_chains(realigned) + aligner = Aligner(realigned, native) + realigned = aligner.apply(realigned) + + result = DockQResult( + total_dockq=result["value"], + native_interfaces=result["native interfaces"], + chain_mapping=result["mapping"], + interfaces={ + (i["Model chains"][0], i["Model chains"][1]): parse_dict(i) for i in interfaces + }, + aligned=realigned, + aligned_rmsd=aligner.rmsd, + ) + + return result + + # Object invariants, slicing, and chain views + def __post_init__(self): + if not isinstance(self.sequence, str): + raise TypeError("sequence must be a string.") + sequence_length = len(self.sequence) + aligned = { + "atom37_positions": self.atom37_positions, + "atom37_mask": self.atom37_mask, + "residue_index": self.residue_index, + "insertion_code": self.insertion_code, + "confidence": self.confidence, + "entity_id": self.entity_id, + "chain_id": self.chain_id, + "sym_id": self.sym_id, + } + for name, values in aligned.items(): + if not isinstance(values, np.ndarray): + raise TypeError(f"{name} must be a NumPy array, got {type(values).__name__}.") + if values.ndim == 0 or values.shape[0] != sequence_length: + raise ValueError( + f"{name} shape {values.shape} does not align with " + f"sequence length {sequence_length}." + ) + if self.atom37_positions.shape != (sequence_length, 37, 3): + raise ValueError( + "atom37_positions must have shape " + f"({sequence_length}, 37, 3), got {self.atom37_positions.shape}." + ) + if self.atom37_mask.shape != (sequence_length, 37): + raise ValueError( + "atom37_mask must have shape " + f"({sequence_length}, 37), got {self.atom37_mask.shape}." + ) + if self.atom37_mask.dtype != bool: + raise TypeError(f"atom37_mask must have Boolean dtype, got {self.atom37_mask.dtype}.") + if not np.issubdtype(self.atom37_positions.dtype, np.number): + raise TypeError("atom37_positions must use a numeric dtype.") + for name, values in ( + ("residue_index", self.residue_index), + ("insertion_code", self.insertion_code), + ("confidence", self.confidence), + ("entity_id", self.entity_id), + ("chain_id", self.chain_id), + ("sym_id", self.sym_id), + ): + if values.shape != (sequence_length,): + raise ValueError( + f"{name} must have shape ({sequence_length},), got {values.shape}." + ) + if not np.issubdtype(self.confidence.dtype, np.number): + raise TypeError("confidence must use a numeric dtype.") + atom37_confidence = self.atom37_confidence + if atom37_confidence is not None and not isinstance(atom37_confidence, np.ndarray): + raise TypeError("atom37_confidence must be a NumPy array when provided.") + if ( + isinstance(atom37_confidence, np.ndarray) + and atom37_confidence.shape != self.atom37_mask.shape + ): + raise ValueError( + "atom37_confidence shape must match atom37_mask: " + f"{atom37_confidence.shape} != {self.atom37_mask.shape}." + ) + + def __getitem__(self, idx: int | list[int] | slice | np.ndarray): + """This function slices protein complexes without consideration of chain breaks + NOTE: When slicing with a boolean mask, it's possible that the output array won't + be the expected length. This is because we do our best to preserve chainbreak tokens. + """ + + if isinstance(idx, int): + idx = [idx] + if isinstance(idx, list): + raise ValueError("ProteinComplex doesn't supports indexing with lists of indices") + + if isinstance(idx, np.ndarray): + is_chainbreak = np.asarray([s == "|" for s in self.sequence]) + idx = idx.astype(bool) | is_chainbreak + + complex = self._unsafe_slice(idx) + if len(complex) == 0: + return complex + + # detect runs of chainbreaks by searching for instances of '||' in complex.sequence + chainbreak_runs = np.asarray( + [complex.sequence[i : i + 2] == "||" for i in range(len(complex.sequence) - 1)] + + [complex.sequence[-1] == "|"] + ) + # We should remove as many chainbreaks as possible from the start of the sequence + for i in range(len(chainbreak_runs)): + if complex.sequence[i] == "|": + chainbreak_runs[i] = True + else: + break + complex = complex._unsafe_slice(~chainbreak_runs) + return complex + + def _unsafe_slice(self, idx: int | list[int] | slice | np.ndarray): + sequence = slice_python_object_as_numpy(self.sequence, idx) + return replace( + self, + sequence=sequence, + entity_id=self.entity_id[..., idx], + chain_id=self.chain_id[..., idx], + sym_id=self.sym_id[..., idx], + residue_index=self.residue_index[..., idx], + insertion_code=self.insertion_code[..., idx], + atom37_positions=self.atom37_positions[..., idx, :, :], + atom37_mask=self.atom37_mask[..., idx, :], + confidence=self.confidence[..., idx], + atom37_confidence=self.atom37_confidence[..., idx, :] + if self.atom37_confidence is not None + else None, + ) + + def __len__(self): + return len(self.sequence) + + @property + def num_chains(self): + return len(self.chain_boundaries) + + @cached_property + def atoms(self) -> AtomIndexer: + return AtomIndexer(self, property="atom37_positions", dim=-2) + + @cached_property + def atom_mask(self) -> AtomIndexer: + return AtomIndexer(self, property="atom37_mask", dim=-1) + + @cached_property + def chain_lengths(self) -> np.ndarray: + return np.diff(self.chain_boundaries, axis=1).flatten() + + @cached_property + def chain_boundaries(self) -> list[tuple[int, int]]: + cb = [-1] + for i, s in enumerate(self.sequence): + if s == "|": + cb.append(i) + cb.append(len(self)) + return [(cb[i] + 1, cb[i + 1]) for i in range(len(cb) - 1)] + + def get_chain_by_index(self, index: int) -> ProteinChain: + try: + start, end = self.chain_boundaries[index] + return self[start:end].as_chain() + except IndexError: + raise IndexError(f"Chain index {index} out of bounds") from None + + def get_chain_by_id( + self, chain_id: str, sample_chain_if_duplicate: bool = True + ) -> ProteinChain: + valid_indices = [ + index + for index, id_of_index in self.metadata.chain_lookup.items() + if id_of_index == chain_id + ] + if not valid_indices: + raise KeyError(f"Chain ID {chain_id} not found") + if sample_chain_if_duplicate: + index_to_return = random.choice(valid_indices) + return self.get_chain_by_index(index_to_return) + else: + if len(valid_indices) > 1: + raise ValueError(f"Multiple chains with chain ID {chain_id} found") + return self.get_chain_by_index(valid_indices[0]) + + def chain_iter(self) -> Iterable[ProteinChain]: + for start, end in self.chain_boundaries: + c = self[start:end] + yield c.as_chain() + + def as_chain(self, force_conversion: bool = False) -> ProteinChain: + """Convert the ProteinComplex to a ProteinChain. + + Args: + force_conversion: Flatten multiple chains to access chain-only utilities. + + """ + if not force_conversion: + if len(np.unique(self.chain_id)) != 1: + raise ValueError( + f"Protein complex {self.id!r} has multiple chains; " + "pass force_conversion=True to flatten it." + ) + if len(np.unique(self.entity_id)) != 1: + raise ValueError( + f"Protein complex {self.id!r} has multiple entities; " + "pass force_conversion=True to flatten it." + ) + if self.chain_id[0] not in self.metadata.chain_lookup: + warnings.warn( + "Chain ID not found in metadata, using 'A' as default", + stacklevel=2, + ) + if self.entity_id[0] not in self.metadata.entity_lookup: + warnings.warn( + "Entity ID not found in metadata, using None as default", + stacklevel=2, + ) + chain_id = self.metadata.chain_lookup.get(self.chain_id[0], "A") + entity_id = self.metadata.entity_lookup.get(self.entity_id[0], None) + else: + chain_id = "A" + entity_id = None + + return ProteinChain( + id=self.id, + sequence=self.sequence, + chain_id=chain_id, + entity_id=entity_id, + atom37_positions=self.atom37_positions, + atom37_mask=self.atom37_mask, + residue_index=self.residue_index, + insertion_code=self.insertion_code, + confidence=self.confidence, + mmcif=self.metadata.mmcif, + atom37_confidence=self.atom37_confidence, + ) + + # Contact topology and mmCIF export + @cached_property + def per_chain_kd_trees(self): + # Iterate over chains, build KDTree for each chain + kdtrees = [] + + CA = self.atoms["CA"] + + for start, end in self.chain_boundaries: + chain_CA = CA[start:end] + chain_CA = chain_CA[np.isfinite(chain_CA).all(axis=-1)] + kdtrees.append(KDTree(chain_CA)) + + return kdtrees + + def chain_adjacency(self, cutoff: float = 8.0) -> np.ndarray: + # Compute adjacency matrix for protein complex + num_chains = self.num_chains + adjacency = np.zeros((num_chains, num_chains), dtype=bool) + for (i, kdtree), (j, kdtree2) in itertools.combinations( + enumerate(self.per_chain_kd_trees), 2 + ): + adj = kdtree.query_ball_tree(kdtree2, cutoff) + any_is_adjacent = any(len(a) > 0 for a in adj) + adjacency[i, j] = any_is_adjacent + adjacency[j, i] = any_is_adjacent + return adjacency + + def chain_adjacency_by_index(self, index: int, cutoff: float = 8.0) -> np.ndarray: + num_chains = len(self.chain_boundaries) + adjacency = np.zeros(num_chains, dtype=bool) + for i, kdtree in enumerate(self.per_chain_kd_trees): + if i == index: + continue + adj = kdtree.query_ball_tree(self.per_chain_kd_trees[index], cutoff) + adjacency[i] = any(len(a) > 0 for a in adj) + return adjacency + + def add_prefix_to_chain_ids(self, prefix: str) -> ProteinComplex: + """Rename all chains in the complex with a given prefix. + + Args: + prefix (str): The prefix to use for the new chain IDs. Each chain will be + named as "{prefix}_{chain_id}". + + Returns: + ProteinComplex: A new protein complex with renamed chains. + """ + new_chains = [] + for chain in self.chain_iter(): + # Create new chain with updated chain_id + new_chain = replace(chain, chain_id=f"{prefix}_{chain.chain_id}") + new_chains.append(new_chain) + return ProteinComplex.from_chains(new_chains) + + def sasa(self, by_residue: bool = True): + chain = self.as_chain(force_conversion=True) + return chain.sasa(by_residue=by_residue) + + def to_mmcif_string(self) -> str: + """Convert the ProteinComplex to mmCIF format. + + Returns: + str: The mmCIF content as a string. + """ + # Convert the ProteinComplex to a biotite AtomArray + # Collect all atoms from all chains + all_atoms = [] + for chain in self.chain_iter(): + chain_atom_array = chain.atom_array + # Convert AtomArray to list of atoms and add to collection + all_atoms.extend(chain_atom_array) + + # Create combined AtomArray from all atoms + if not all_atoms: + raise ValueError("No atoms found in protein complex") + + atom_array = bs.array(all_atoms) + + # Create CIF file + f = CIFFile() + set_structure_pdbx(f, atom_array, data_block=self.id) + + # Add entity information for proper mmCIF structure + self._add_entity_information(f) + round_mmcif_columns(f) + + # Write to string + output = io.StringIO() + f.write(output) + return output.getvalue() + + def _add_entity_information(self, cif_file: CIFFile) -> None: + """Add entity, entity_poly, and struct_asym sections to CIF file.""" + + # Group chains by sequence to create unique entities + entity_map = {} # sequence -> entity_id + chain_to_entity = {} # chain_id -> entity_id + entity_sequences = {} # entity_id -> sequence + entity_id_counter = 1 + + for chain in self.chain_iter(): + sequence = chain.sequence + if sequence not in entity_map: + entity_map[sequence] = entity_id_counter + entity_sequences[entity_id_counter] = sequence + entity_id_counter += 1 + chain_to_entity[chain.chain_id] = entity_map[sequence] + + # Create _entity section + entity_ids = [] + entity_types = [] + entity_descriptions = [] + + for entity_id in sorted(entity_sequences.keys()): + entity_ids.append(str(entity_id)) + entity_types.append("polymer") + entity_descriptions.append(f"Protein chain (entity {entity_id})") + + cif_file.block["entity"] = CIFCategory( + name="entity", + columns={ + "id": CIFColumn(data=CIFData(array=np.array(entity_ids), dtype=np.str_)), + "type": CIFColumn(data=CIFData(array=np.array(entity_types), dtype=np.str_)), + "pdbx_description": CIFColumn( + data=CIFData(array=np.array(entity_descriptions), dtype=np.str_) + ), + }, + ) + + # Create _entity_poly section + poly_entity_ids = [] + poly_types = [] + poly_nstd_linkages = [] + poly_sequences = [] + + for entity_id in sorted(entity_sequences.keys()): + poly_entity_ids.append(str(entity_id)) + poly_types.append("polypeptide(L)") + poly_nstd_linkages.append("no") + poly_sequences.append(entity_sequences[entity_id]) + + cif_file.block["entity_poly"] = CIFCategory( + name="entity_poly", + columns={ + "entity_id": CIFColumn( + data=CIFData(array=np.array(poly_entity_ids), dtype=np.str_) + ), + "type": CIFColumn(data=CIFData(array=np.array(poly_types), dtype=np.str_)), + "nstd_linkage": CIFColumn( + data=CIFData(array=np.array(poly_nstd_linkages), dtype=np.str_) + ), + "pdbx_seq_one_letter_code": CIFColumn( + data=CIFData(array=np.array(poly_sequences), dtype=np.str_) + ), + }, + ) + + # Create _struct_asym section + asym_ids = [] + asym_entity_ids = [] + asym_details = [] + + for chain in self.chain_iter(): + asym_ids.append(chain.chain_id) + asym_entity_ids.append(str(chain_to_entity[chain.chain_id])) + asym_details.append("") + + cif_file.block["struct_asym"] = CIFCategory( + name="struct_asym", + columns={ + "id": CIFColumn(data=CIFData(array=np.array(asym_ids), dtype=np.str_)), + "entity_id": CIFColumn( + data=CIFData(array=np.array(asym_entity_ids), dtype=np.str_) + ), + "details": CIFColumn(data=CIFData(array=np.array(asym_details), dtype=np.str_)), + }, + ) + + # Construction, PDB interchange, and compact storage + @classmethod + def from_pdb( + cls, path: PathOrBuffer, id: str | None = None, is_predicted: bool = False + ) -> ProteinComplex: + atom_array = PDBFile.read(path).get_structure(model=1, extra_fields=["b_factor"]) + + chains = [] + for chain in bs.chain_iter(atom_array): + chain = chain[~chain.hetero] + if len(chain) == 0: + continue + chains.append(ProteinChain.from_atomarray(chain, id, is_predicted)) + return ProteinComplex.from_chains(chains) + + def to_pdb(self, path: PathOrBuffer, include_insertions: bool = True): + atom_array = None + for chain in self.chain_iter(): + carr = chain.atom_array if include_insertions else chain.atom_array_no_insertions + atom_array = carr if atom_array is None else atom_array + carr + f = PDBFile() + f.set_structure(atom_array) + f.write(path) + + def to_pdb_string(self, include_insertions: bool = True) -> str: + buf = io.StringIO() + self.to_pdb(buf, include_insertions=include_insertions) + buf.seek(0) + return buf.read() + + def normalize_chain_ids_for_pdb(self): + # Since PDB files have 1-letter chain IDs and don't support the idea of a symmetric index, + # we can normalize it instead which might be necessary for DockQ and to_pdb. + ids = SINGLE_LETTER_CHAIN_IDS + chains = [] + for i, chain in enumerate(self.chain_iter()): + chain = replace(chain, chain_id=ids[i]) + if i > len(ids): + raise RuntimeError("Too many chains to write to PDB file") + chains.append(chain) + + return ProteinComplex.from_chains(chains) + + def find_assembly_ids_with_chain(self, id: str) -> list[str]: + good_chains = [] + if (comp := self.metadata.assembly_composition) is not None: + for assembly_id, chain_ids in comp.items(): + if id in chain_ids: + good_chains.append(assembly_id) + else: + raise ValueError( + "Cannot switch assemblies on this ProteinComplex; construct it from " + "mmCIF to retain assembly metadata" + ) + return good_chains + + def switch_assembly(self, id: str): + if self.metadata.mmcif is None: + raise ValueError( + "Cannot switch assemblies without retained mmCIF source metadata." + ) + return get_assembly_fast(self.metadata.mmcif, assembly_id=id) + + def state_dict(self, backbone_only=False, json_serializable=False): + """This state dict is optimized for storage, so it turns things to fp16 whenever + possible. Note that we also only support int32 residue indices, I'm hoping we don't + need more than 2**32 residues...""" + dct = {k: v for k, v in vars(self).items()} + if backbone_only: + # Frozen dataclasses do not make their NumPy members immutable. Work on a + # private mask so requesting a compact backbone payload cannot clear the + # caller's side-chain atoms in-place. + atom37_mask = dct["atom37_mask"].copy() + atom37_mask[:, 3:] = False + dct["atom37_mask"] = atom37_mask + dct["atom37_positions"] = dct["atom37_positions"][dct["atom37_mask"]] + if dct.get("atom37_confidence") is not None: + dct["atom37_confidence"] = dct["atom37_confidence"][dct["atom37_mask"]] + else: + dct.pop("atom37_confidence", None) + for k, v in dct.items(): + if isinstance(v, np.ndarray): + match v.dtype: + case np.int64: + dct[k] = v.astype(np.int32) + case np.float64 | np.float32: + dct[k] = v.astype(np.float16) + case _: + pass + if json_serializable: + dct[k] = v.tolist() + elif isinstance(v, ProteinComplexMetadata): + dct[k] = asdict(v) + dct["metadata"]["mmcif"] = None + # These can be populated with non-serializable objects and are not needed for reconstruction + dct.pop("atoms", None) + dct.pop("atom_mask", None) + dct.pop("per_chain_kd_trees", None) + return dct + + def to_blob(self, backbone_only=False) -> bytes: + payload = msgpack.dumps(self.state_dict(backbone_only), default=msgpack_numpy.encode) + return brotli.compress(payload, quality=5) + + @classmethod + def from_state_dict(cls, dct): + # Note: assembly_composition is *supposed* to have string keys. + dct = _str_key_to_int_key(dct, ignore_keys=["assembly_composition"]) + + for k, v in dct.items(): + if isinstance(v, list): + dct[k] = np.array(v) + + atom37 = np.full((*dct["atom37_mask"].shape, 3), np.nan) + atom37[dct["atom37_mask"]] = dct["atom37_positions"] + dct["atom37_positions"] = atom37 + if "atom37_confidence" in dct: + atom37_conf = np.full(dct["atom37_mask"].shape, np.nan, dtype=np.float32) + atom37_conf[dct["atom37_mask"]] = dct["atom37_confidence"] + dct["atom37_confidence"] = atom37_conf + dct = { + k: ( + v.astype(np.float32) + if k in ["atom37_positions", "confidence", "atom37_confidence"] + else v + ) + for k, v in dct.items() + } + if "chain_boundaries" in dct: + del dct["chain_boundaries"] + if "chain_boundaries" in dct["metadata"]: + del dct["metadata"]["chain_boundaries"] + dct["metadata"] = ProteinComplexMetadata(**dct["metadata"]) + return cls(**dct) + + @classmethod + def from_blob(cls, input: Path | str | io.BytesIO | bytes): + """NOTE(@zlin): blob + sparse coding + brotli + fp16 reduces memory + of chains from 52G/1M chains to 20G/1M chains, I think this is a good first + shot at compressing and dumping chains to disk. I'm sure there's better ways.""" + match input: + case Path() | str(): + bytes = Path(input).read_bytes() + case io.BytesIO(): + bytes = input.getvalue() + case _: + bytes = input + state = msgpack.loads( + brotli.decompress(bytes), + object_hook=msgpack_numpy.decode, + strict_map_key=False, + ) + return cls.from_state_dict(state) + + @classmethod + def from_rcsb(cls, pdb_id: str, keep_source: bool = False) -> ProteinComplex: + f: io.StringIO = rcsb.fetch(pdb_id, "cif") # type: ignore + return cls.from_mmcif(f, id=pdb_id, keep_source=keep_source, is_predicted=False) + + @classmethod + def from_mmcif( + cls, + path: PathOrBuffer, + id: str | None = None, + assembly_id: str | None = None, + is_predicted: bool = False, + keep_source: bool = False, + ): + """Return a ProteinComplex object from an mmcif file. + TODO(@zeming): there's actually multiple complexes per file, but for ease of implementation, + we only consider the first defined complex! + + Args: + path: Uncompressed mmCIF path or text buffer. + id: Optional structure identifier. + is_predicted (bool): If True, reads b factor as the confidence readout. Default: False. + chain_id (str, optional): Select a chain corresponding to (author) chain id. + """ + mmcif = MmcifWrapper.read(path, id) + return get_assembly_fast(mmcif, assembly_id=assembly_id) + + @classmethod + def from_chains( + cls, + chains: Sequence[ProteinChain], + mmcif: MmcifWrapper | None = None, + all_assembly_metadata_dictionary: dict[str, list[str]] | None = None, + ): + if not chains: + raise ValueError("Cannot create a ProteinComplex from an empty list of chains") + + # TODO(roshan): Make a proper protein complex class + def join_arrays(arrays: Sequence[np.ndarray], sep: np.ndarray): + full_array = [] + for array in arrays: + full_array.append(array) + full_array.append(sep) + full_array = full_array[:-1] + return np.concatenate(full_array, 0) + + sep_tokens = { + "residue_index": np.array([-1]), + "insertion_code": np.array([""]), + "atom37_positions": np.full([1, 37, 3], np.nan), + "atom37_mask": np.zeros([1, 37], dtype=bool), + "confidence": np.array([0]), + } + + any_has_atom37_conf = any(c.atom37_confidence is not None for c in chains) + if any_has_atom37_conf: + sep_tokens["atom37_confidence"] = np.full([1, 37], np.nan, dtype=np.float32) + + def _get_chain_attr(chain: ProteinChain, name: str) -> np.ndarray: + val = getattr(chain, name) + if val is None and name == "atom37_confidence": + return np.full([len(chain), 37], np.nan, dtype=np.float32) + return val + + array_args: dict[str, np.ndarray] = { + name: join_arrays([_get_chain_attr(chain, name) for chain in chains], sep) + for name, sep in sep_tokens.items() + } + + multimer_arrays = [] + chain2num_max = -1 + chain2num = {} + ent2num_max = -1 + ent2num = {} + total_index = 0 + for i, c in enumerate(chains): + num_res = c.residue_index.shape[0] + if c.chain_id not in chain2num: + chain2num[c.chain_id] = (chain2num_max := chain2num_max + 1) + chain_id_array = np.full([num_res], chain2num[c.chain_id], dtype=np.int64) + + if c.entity_id is None: + entity_num = (ent2num_max := ent2num_max + 1) + else: + if c.entity_id not in ent2num: + ent2num[c.entity_id] = (ent2num_max := ent2num_max + 1) + entity_num = ent2num[c.entity_id] + entity_id_array = np.full([num_res], entity_num, dtype=np.int64) + + sym_id_array = np.full([num_res], i, dtype=np.int64) + + multimer_arrays.append( + { + "chain_id": chain_id_array, + "entity_id": entity_id_array, + "sym_id": sym_id_array, + } + ) + + total_index += num_res + 1 + + sep = np.array([-1]) + update = { + name: join_arrays([dct[name] for dct in multimer_arrays], sep=sep) + for name in ["chain_id", "entity_id", "sym_id"] + } + array_args.update(update) + + metadata = ProteinComplexMetadata( + mmcif=mmcif, + chain_lookup={v: k for k, v in chain2num.items()}, + entity_lookup={v: k for k, v in ent2num.items()}, + assembly_composition=all_assembly_metadata_dictionary, + ) + + return cls( + id=chains[0].id, + sequence=residue_constants.CHAIN_BREAK_TOKEN.join(chain.sequence for chain in chains), + metadata=metadata, + **array_args, + ) + + +# Biological-assembly expansion +def get_assembly_fast( + mmcif: MmcifWrapper, + assembly_id=None, + model=None, + data_block=None, + altloc="first", + use_author_fields=True, +): + pdbx_file = mmcif.raw + if pdbx_file is None: + raise InvalidFileError("No mmCIF data loaded") + assembly_gen_category = pdbx_file.block["pdbx_struct_assembly_gen"] + if assembly_gen_category is None: + raise InvalidFileError("File has no 'pdbx_struct_assembly_gen' category") + + struct_oper_category = pdbx_file.block["pdbx_struct_oper_list"] + if struct_oper_category is None: + raise InvalidFileError("File has no 'pdbx_struct_oper_list' category") + + if assembly_id is None: + assembly_id = assembly_gen_category["assembly_id"].data.array[0] + elif assembly_id not in assembly_gen_category["assembly_id"].data.array: + raise KeyError(f"File has no Assembly ID '{assembly_id}'") + + ### Calculate all possible transformations + transformations = _get_transformations(struct_oper_category) + + ### Get structure according to additional parameters + structure = get_structure( + pdbx_file, model, data_block, altloc, ["label_asym_id"], use_author_fields + )[0] # type: ignore + # TODO(@zeming) This line will remove all non-protein structural elements, + # we should remove this when we want to parse these too. + structure: bs.AtomArray = structure[ + bs.filter_amino_acids(structure) & ~structure.hetero # type: ignore + ] + if len(structure) == 0: + raise NoProteinError + unique_asym_ids = np.unique(structure.label_asym_id) # type: ignore + asym2chain = {} + asym2auth = {} + for asym_id in unique_asym_ids: + sub_structure: bs.AtomArray = structure[structure.label_asym_id == asym_id] # type: ignore + chain_id: str = sub_structure[0].chain_id # type: ignore + ( + sequence, + atom_positions, + atom_mask, + residue_index, + insertion_code, + confidence, + entity_id, + ) = chain_to_ndarray(sub_structure, mmcif, chain_id, False) + + asym2chain[asym_id] = ProteinChain( + id=mmcif.id or "unknown", + sequence=sequence, + chain_id=chain_id, + entity_id=entity_id, + atom37_positions=atom_positions, + atom37_mask=atom_mask, + residue_index=residue_index, + insertion_code=insertion_code, + confidence=confidence, + mmcif=None, + ) + asym2auth[asym_id] = chain_id + + ### Get transformations and apply them to the affected asym IDs + assembly = [] + assembly_id_dict: dict[str, list[str]] = {} + + # Process the target assembly ID + for aid, op_expr, asym_id_expr in zip( + assembly_gen_category["assembly_id"].data.array, + assembly_gen_category["oper_expression"].data.array, + assembly_gen_category["asym_id_list"].data.array, + strict=False, + ): + if aid == assembly_id: + # Parse operations and asym IDs for this specific entry + operations = _parse_operation_expression(op_expr) + asym_ids = asym_id_expr.split(",") + + # Filter affected asym IDs to only protein chains, preserving order + sub_structures = [asym2chain[asym_id] for asym_id in asym_ids if asym_id in asym2chain] + + # Apply transformations + sub_assembly = _apply_transformations_fast(sub_structures, transformations, operations) + assembly.extend(sub_assembly) + + # Build assembly_id_dict for this entry + assembly_id_dict[aid] = assembly_id_dict.get(aid, []) + [ + asym2auth[id_] for id_ in asym_ids if id_ in asym2auth + ] + + if len(assembly) == 0: + raise NoProteinError + return ProteinComplex.from_chains(assembly, mmcif, assembly_id_dict) + + +def protein_chain_to_protein_complex(chain: ProteinChain) -> ProteinComplex: + if "|" not in chain.sequence: + return ProteinComplex.from_chains([chain]) + chain_breaks = np.array(list(chain.sequence)) == "|" + chain_break_inds = np.where(chain_breaks)[0] + chain_break_inds = np.concatenate([[0], chain_break_inds, [len(chain)]]) + chain_break_inds = np.array(list(itertools.pairwise(chain_break_inds))) + complex_chains = [] + for start, end in chain_break_inds: + if start != 0: + start += 1 + complex_chains.append(chain[start:end]) + complex_chains = [ + ProteinChain.from_atom37( + chain.atom37_positions, + sequence=chain.sequence, + chain_id=SINGLE_LETTER_CHAIN_IDS[i], + entity_id=i, + ) + for i, chain in enumerate(complex_chains) + ] + return ProteinComplex.from_chains(complex_chains) diff --git a/fastplms/models/esmfold2/esmfold2_protein_structure.py b/fastplms/models/esmfold2/esmfold2_protein_structure.py new file mode 100644 index 0000000000000000000000000000000000000000..1d41330753a1d2d82194f77004645d162bda3617 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_protein_structure.py @@ -0,0 +1,251 @@ +"""Atom selection, rigid alignment, RMSD, and GDT-TS primitives.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TypeVar + +import numpy as np +import torch +import torch.nn.functional as F +from torch import Tensor +from torch.amp import autocast # type: ignore + +from .esmfold2_affine3d import Affine3D +from .esmfold2_misc import unbinpack +from .esmfold2_normalize_coordinates import index_by_atom_name + +ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor) + + +def _coordinate_operations( + coordinates: ArrayOrTensor, +) -> tuple[Callable[[ArrayOrTensor], ArrayOrTensor], Callable[..., ArrayOrTensor]]: + if isinstance(coordinates, np.ndarray): + + def normalize(X: ArrayOrTensor) -> ArrayOrTensor: + return X / np.linalg.norm(X, axis=-1, keepdims=True) + + return normalize, np.cross + return F.normalize, torch.cross # type: ignore[return-value] + + +def infer_cbeta_from_atom37( + atom37: ArrayOrTensor, + bond_length: float = 1.522, + bond_angle: float = 1.927, + dihedral: float = -2.143, +) -> ArrayOrTensor: + """Infer C-beta coordinates from backbone tensor ``X``. + + The scalar keyword arguments encode the bond length, bond angle, and + dihedral in radians used by the checkpoint's training geometry. + """ + + n_position = index_by_atom_name(atom37, "N", dim=-2) + ca_position = index_by_atom_name(atom37, "CA", dim=-2) + c_position = index_by_atom_name(atom37, "C", dim=-2) + normalize, cross = _coordinate_operations(atom37) + with np.errstate(invalid="ignore"): + n_to_ca = n_position - ca_position + n_to_c = n_position - c_position + unit_n_to_ca = normalize(n_to_ca) + normal = normalize(cross(n_to_c, unit_n_to_ca)) + basis = [unit_n_to_ca, cross(normal, unit_n_to_ca), normal] + coefficients = [ + bond_length * np.cos(bond_angle), + bond_length * np.sin(bond_angle) * np.cos(dihedral), + -bond_length * np.sin(bond_angle) * np.sin(dihedral), + ] + offset = sum( + vector * coefficient for vector, coefficient in zip(basis, coefficients, strict=True) + ) + return ca_position + offset + + +def _unpack_alignment_inputs( + mobile: Tensor, + target: Tensor, + atom_mask: Tensor | None, + sequence_id: Tensor | None, +) -> tuple[Tensor, Tensor, Tensor | None]: + if sequence_id is None: + return mobile, target, atom_mask + unpacked_mobile = unbinpack(mobile, sequence_id, pad_value=torch.nan) + unpacked_target = unbinpack(target, sequence_id, pad_value=torch.nan) + if atom_mask is None: + unpacked_mask = torch.isfinite(unpacked_target).all(dim=-1) + else: + unpacked_mask = unbinpack(atom_mask, sequence_id, pad_value=0) + return unpacked_mobile, unpacked_target, unpacked_mask + + +def _flatten_atom_axes( + mobile: Tensor, + target: Tensor, + atom_mask: Tensor | None, +) -> tuple[Tensor, Tensor, Tensor | None]: + b = mobile.shape[0] + flat_mobile = mobile.view(b, -1, 3) if mobile.dim() == 4 else mobile + flat_target = target.view(b, -1, 3) if target.dim() == 4 else target + flat_mask = atom_mask + if flat_mask is not None and flat_mask.dim() == 3: + flat_mask = flat_mask.view(b, -1) + return flat_mobile, flat_target, flat_mask + + +def _masked_coordinates( + mobile: Tensor, + target: Tensor, + atom_mask: Tensor | None, +) -> tuple[Tensor, Tensor, Tensor]: + if atom_mask is None: + atom_mask = torch.ones( + mobile.shape[:2], + dtype=torch.bool, + device=mobile.device, + ) + return mobile, target, atom_mask + expanded_mask = atom_mask.unsqueeze(-1) + return ( + mobile.masked_fill(~expanded_mask, 0), + target.masked_fill(~expanded_mask, 0), + atom_mask, + ) + + +@torch.no_grad() +@autocast("cuda", enabled=False) +def compute_alignment_tensors( + mobile: Tensor, + target: Tensor, + atom_exists_mask: Tensor | None = None, + sequence_id: Tensor | None = None, +) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor, Tensor]: + """Center and align coordinate tensors ``X`` and ``Y``. + + Inputs have shape (b, n, 3), or (b, l, n_atoms, 3). The returned rotation + tensor ``R`` has shape (b, 3, 3), and atom counts have shape (b, 1). + """ + + mobile, target, atom_exists_mask = _unpack_alignment_inputs( + mobile, + target, + atom_exists_mask, + sequence_id, + ) + if mobile.shape != target.shape: + raise AssertionError("Batch structure shapes do not match!") + mobile, target, atom_exists_mask = _flatten_atom_axes( + mobile, + target, + atom_exists_mask, + ) + mobile, target, atom_exists_mask = _masked_coordinates( + mobile, + target, + atom_exists_mask, + ) + + num_valid_atoms = atom_exists_mask.sum(dim=-1, keepdim=True) + centroid_mobile = mobile.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1) + centroid_target = target.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1) + centroid_mobile[num_valid_atoms == 0] = 0 + centroid_target[num_valid_atoms == 0] = 0 + + expanded_mask = atom_exists_mask.unsqueeze(-1) + centered_mobile = (mobile - centroid_mobile).masked_fill(~expanded_mask, 0) + centered_target = (target - centroid_target).masked_fill(~expanded_mask, 0) + covariance = torch.matmul(centered_mobile.transpose(1, 2), centered_target) + left_vectors, _, right_vectors = torch.svd(covariance) + rotation = torch.matmul(left_vectors, right_vectors.transpose(1, 2)) + return ( + centered_mobile, + centroid_mobile, + centered_target, + centroid_target, + rotation, + num_valid_atoms, + ) + + +def _validate_reduction(reduction: str, allowed: tuple[str, ...]) -> None: + if reduction not in allowed: + raise ValueError("Unrecognized reduction: '{reduction}'") + + +@torch.no_grad() +@autocast("cuda", enabled=False) +def compute_rmsd_no_alignment( + aligned: Tensor, + target: Tensor, + num_valid_atoms: Tensor, + reduction: str = "batch", +) -> Tensor: + """Measure RMSD after alignment using a declared reduction.""" + + _validate_reduction(reduction, ("per_residue", "per_sample", "batch")) + difference = aligned - target + if reduction == "per_residue": + mean_squared_error = difference.square().view(difference.size(0), -1, 9).mean(-1) + else: + mean_squared_error = difference.square().sum(dim=(1, 2)) / num_valid_atoms.squeeze(-1) + rmsd = torch.sqrt(mean_squared_error) + if reduction in {"per_residue", "per_sample"}: + return rmsd + valid_samples = num_valid_atoms.squeeze(-1) > 0 + return rmsd.masked_fill(~valid_samples, 0).sum() / (valid_samples.sum() + 1e-8) + + +@torch.no_grad() +@autocast("cuda", enabled=False) +def compute_affine_and_rmsd( + mobile: Tensor, + target: Tensor, + atom_exists_mask: Tensor | None = None, + sequence_id: Tensor | None = None, +) -> tuple[Affine3D, Tensor]: + """Fit ``X`` onto ``Y`` and return the rigid transform and batch RMSD.""" + + ( + centered_mobile, + centroid_mobile, + centered_target, + centroid_target, + rotation, + num_valid_atoms, + ) = compute_alignment_tensors(mobile, target, atom_exists_mask, sequence_id) + translation = torch.matmul(-centroid_mobile, rotation) + centroid_target + affine = Affine3D.from_tensor_pair( + translation, + rotation.unsqueeze(dim=-3).transpose(-2, -1), + ) + rotated_mobile = torch.matmul(centered_mobile, rotation) + rmsd = compute_rmsd_no_alignment( + rotated_mobile, + centered_target, + num_valid_atoms, + reduction="batch", + ) + return affine, rmsd + + +def compute_gdt_ts_no_alignment( + aligned: Tensor, + target: Tensor, + atom_exists_mask: Tensor, + reduction: str = "batch", +) -> Tensor: + """Compute GDT-TS for already aligned coordinate tensors.""" + + _validate_reduction(reduction, ("per_sample", "batch")) + if atom_exists_mask is None: + atom_exists_mask = torch.isfinite(target).all(dim=-1) + deviation = torch.linalg.vector_norm(aligned - target, dim=-1) + counts = atom_exists_mask.sum(dim=-1) + score_1 = ((deviation < 1) * atom_exists_mask).sum(dim=-1) / counts + score_2 = ((deviation < 2) * atom_exists_mask).sum(dim=-1) / counts + score_4 = ((deviation < 4) * atom_exists_mask).sum(dim=-1) / counts + score_8 = ((deviation < 8) * atom_exists_mask).sum(dim=-1) / counts + score = (score_1 + score_2 + score_4 + score_8) * 0.25 + return score.mean() if reduction == "batch" else score diff --git a/fastplms/models/esmfold2/esmfold2_residue_constants.py b/fastplms/models/esmfold2/esmfold2_residue_constants.py new file mode 100644 index 0000000000000000000000000000000000000000..42655c58c794c2862036bc993a4970989afbbf05 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_residue_constants.py @@ -0,0 +1,1017 @@ +# Copyright 2025 EvolutionaryScale +# Copyright 2021 AlQuraishi Laboratory +# Copyright 2021 DeepMind Technologies Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Canonical amino-acid geometry tables used by ESMFold2. + +The literal chemistry measurements are kept visible for review. Derived masks, +indices, frames, and atom mappings are built locally below and are checked +exactly against the pinned Biohub implementation. +""" + +from __future__ import annotations + +import functools +from collections import defaultdict, namedtuple +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +import numpy as np + +ca_ca = 3.80209737096 +chi_angles_atoms = { + "ALA": [], + "ARG": [ + ["N", "CA", "CB", "CG"], + ["CA", "CB", "CG", "CD"], + ["CB", "CG", "CD", "NE"], + ["CG", "CD", "NE", "CZ"], + ], + "ASN": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "OD1"]], + "ASP": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "OD1"]], + "CYS": [["N", "CA", "CB", "SG"]], + "GLN": [ + ["N", "CA", "CB", "CG"], + ["CA", "CB", "CG", "CD"], + ["CB", "CG", "CD", "OE1"], + ], + "GLU": [ + ["N", "CA", "CB", "CG"], + ["CA", "CB", "CG", "CD"], + ["CB", "CG", "CD", "OE1"], + ], + "GLY": [], + "HIS": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "ND1"]], + "ILE": [["N", "CA", "CB", "CG1"], ["CA", "CB", "CG1", "CD1"]], + "LEU": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]], + "LYS": [ + ["N", "CA", "CB", "CG"], + ["CA", "CB", "CG", "CD"], + ["CB", "CG", "CD", "CE"], + ["CG", "CD", "CE", "NZ"], + ], + "MET": [ + ["N", "CA", "CB", "CG"], + ["CA", "CB", "CG", "SD"], + ["CB", "CG", "SD", "CE"], + ], + "PHE": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]], + "PRO": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD"]], + "SER": [["N", "CA", "CB", "OG"]], + "THR": [["N", "CA", "CB", "OG1"]], + "TRP": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]], + "TYR": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]], + "VAL": [["N", "CA", "CB", "CG1"]], + "UNK": [], +} +chi_angles_mask = [ + [1.0] * len(groups) + [0.0] * (4 - len(groups)) for groups in chi_angles_atoms.values() +] +_PI_PERIODIC_CHI = {"ASP": {1}, "GLU": {2}, "PHE": {1}, "TYR": {1}} +chi_pi_periodic = [ + [1.0 if chi_index in _PI_PERIODIC_CHI.get(residue_name, ()) else 0.0 for chi_index in range(4)] + for residue_name in chi_angles_atoms +] +rigid_group_atom_positions: dict[str, list[list[Any]]] = { + "ALA": [ + ["N", 0, (-0.525, 1.363, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.526, -0.0, -0.0)], + ["CB", 0, (-0.529, -0.774, -1.205)], + ["O", 3, (0.627, 1.062, 0.0)], + ], + "ARG": [ + ["N", 0, (-0.524, 1.362, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.525, -0.0, -0.0)], + ["CB", 0, (-0.524, -0.778, -1.209)], + ["O", 3, (0.626, 1.062, 0.0)], + ["CG", 4, (0.616, 1.39, -0.0)], + ["CD", 5, (0.564, 1.414, 0.0)], + ["NE", 6, (0.539, 1.357, -0.0)], + ["NH1", 7, (0.206, 2.301, 0.0)], + ["NH2", 7, (2.078, 0.978, -0.0)], + ["CZ", 7, (0.758, 1.093, -0.0)], + ], + "ASN": [ + ["N", 0, (-0.536, 1.357, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.526, -0.0, -0.0)], + ["CB", 0, (-0.531, -0.787, -1.2)], + ["O", 3, (0.625, 1.062, 0.0)], + ["CG", 4, (0.584, 1.399, 0.0)], + ["ND2", 5, (0.593, -1.188, 0.001)], + ["OD1", 5, (0.633, 1.059, 0.0)], + ], + "ASP": [ + ["N", 0, (-0.525, 1.362, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.527, 0.0, -0.0)], + ["CB", 0, (-0.526, -0.778, -1.208)], + ["O", 3, (0.626, 1.062, -0.0)], + ["CG", 4, (0.593, 1.398, -0.0)], + ["OD1", 5, (0.61, 1.091, 0.0)], + ["OD2", 5, (0.592, -1.101, -0.003)], + ], + "CYS": [ + ["N", 0, (-0.522, 1.362, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.524, 0.0, 0.0)], + ["CB", 0, (-0.519, -0.773, -1.212)], + ["O", 3, (0.625, 1.062, -0.0)], + ["SG", 4, (0.728, 1.653, 0.0)], + ], + "GLN": [ + ["N", 0, (-0.526, 1.361, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.526, 0.0, 0.0)], + ["CB", 0, (-0.525, -0.779, -1.207)], + ["O", 3, (0.626, 1.062, -0.0)], + ["CG", 4, (0.615, 1.393, 0.0)], + ["CD", 5, (0.587, 1.399, -0.0)], + ["NE2", 6, (0.593, -1.189, -0.001)], + ["OE1", 6, (0.634, 1.06, 0.0)], + ], + "GLU": [ + ["N", 0, (-0.528, 1.361, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.526, -0.0, -0.0)], + ["CB", 0, (-0.526, -0.781, -1.207)], + ["O", 3, (0.626, 1.062, 0.0)], + ["CG", 4, (0.615, 1.392, 0.0)], + ["CD", 5, (0.6, 1.397, 0.0)], + ["OE1", 6, (0.607, 1.095, -0.0)], + ["OE2", 6, (0.589, -1.104, -0.001)], + ], + "GLY": [ + ["N", 0, (-0.572, 1.337, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.517, -0.0, -0.0)], + ["O", 3, (0.626, 1.062, -0.0)], + ], + "HIS": [ + ["N", 0, (-0.527, 1.36, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.525, 0.0, 0.0)], + ["CB", 0, (-0.525, -0.778, -1.208)], + ["O", 3, (0.625, 1.063, 0.0)], + ["CG", 4, (0.6, 1.37, -0.0)], + ["CD2", 5, (0.889, -1.021, 0.003)], + ["ND1", 5, (0.744, 1.16, -0.0)], + ["CE1", 5, (2.03, 0.851, 0.002)], + ["NE2", 5, (2.145, -0.466, 0.004)], + ], + "ILE": [ + ["N", 0, (-0.493, 1.373, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.527, -0.0, -0.0)], + ["CB", 0, (-0.536, -0.793, -1.213)], + ["O", 3, (0.627, 1.062, -0.0)], + ["CG1", 4, (0.534, 1.437, -0.0)], + ["CG2", 4, (0.54, -0.785, -1.199)], + ["CD1", 5, (0.619, 1.391, 0.0)], + ], + "LEU": [ + ["N", 0, (-0.52, 1.363, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.525, -0.0, -0.0)], + ["CB", 0, (-0.522, -0.773, -1.214)], + ["O", 3, (0.625, 1.063, -0.0)], + ["CG", 4, (0.678, 1.371, 0.0)], + ["CD1", 5, (0.53, 1.43, -0.0)], + ["CD2", 5, (0.535, -0.774, 1.2)], + ], + "LYS": [ + ["N", 0, (-0.526, 1.362, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.526, 0.0, 0.0)], + ["CB", 0, (-0.524, -0.778, -1.208)], + ["O", 3, (0.626, 1.062, -0.0)], + ["CG", 4, (0.619, 1.39, 0.0)], + ["CD", 5, (0.559, 1.417, 0.0)], + ["CE", 6, (0.56, 1.416, 0.0)], + ["NZ", 7, (0.554, 1.387, 0.0)], + ], + "MET": [ + ["N", 0, (-0.521, 1.364, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.525, 0.0, 0.0)], + ["CB", 0, (-0.523, -0.776, -1.21)], + ["O", 3, (0.625, 1.062, -0.0)], + ["CG", 4, (0.613, 1.391, -0.0)], + ["SD", 5, (0.703, 1.695, 0.0)], + ["CE", 6, (0.32, 1.786, -0.0)], + ], + "PHE": [ + ["N", 0, (-0.518, 1.363, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.524, 0.0, -0.0)], + ["CB", 0, (-0.525, -0.776, -1.212)], + ["O", 3, (0.626, 1.062, -0.0)], + ["CG", 4, (0.607, 1.377, 0.0)], + ["CD1", 5, (0.709, 1.195, -0.0)], + ["CD2", 5, (0.706, -1.196, 0.0)], + ["CE1", 5, (2.102, 1.198, -0.0)], + ["CE2", 5, (2.098, -1.201, -0.0)], + ["CZ", 5, (2.794, -0.003, -0.001)], + ], + "PRO": [ + ["N", 0, (-0.566, 1.351, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.527, -0.0, 0.0)], + ["CB", 0, (-0.546, -0.611, -1.293)], + ["O", 3, (0.621, 1.066, 0.0)], + ["CG", 4, (0.382, 1.445, 0.0)], + ["CD", 5, (0.477, 1.424, 0.0)], + ], + "SER": [ + ["N", 0, (-0.529, 1.36, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.525, -0.0, -0.0)], + ["CB", 0, (-0.518, -0.777, -1.211)], + ["O", 3, (0.626, 1.062, -0.0)], + ["OG", 4, (0.503, 1.325, 0.0)], + ], + "THR": [ + ["N", 0, (-0.517, 1.364, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.526, 0.0, -0.0)], + ["CB", 0, (-0.516, -0.793, -1.215)], + ["O", 3, (0.626, 1.062, 0.0)], + ["CG2", 4, (0.55, -0.718, -1.228)], + ["OG1", 4, (0.472, 1.353, 0.0)], + ], + "TRP": [ + ["N", 0, (-0.521, 1.363, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.525, -0.0, 0.0)], + ["CB", 0, (-0.523, -0.776, -1.212)], + ["O", 3, (0.627, 1.062, 0.0)], + ["CG", 4, (0.609, 1.37, -0.0)], + ["CD1", 5, (0.824, 1.091, 0.0)], + ["CD2", 5, (0.854, -1.148, -0.005)], + ["CE2", 5, (2.186, -0.678, -0.007)], + ["CE3", 5, (0.622, -2.53, -0.007)], + ["NE1", 5, (2.14, 0.69, -0.004)], + ["CH2", 5, (3.028, -2.89, -0.013)], + ["CZ2", 5, (3.283, -1.543, -0.011)], + ["CZ3", 5, (1.715, -3.389, -0.011)], + ], + "TYR": [ + ["N", 0, (-0.522, 1.362, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.524, -0.0, -0.0)], + ["CB", 0, (-0.522, -0.776, -1.213)], + ["O", 3, (0.627, 1.062, -0.0)], + ["CG", 4, (0.607, 1.382, -0.0)], + ["CD1", 5, (0.716, 1.195, -0.0)], + ["CD2", 5, (0.713, -1.194, -0.001)], + ["CE1", 5, (2.107, 1.2, -0.002)], + ["CE2", 5, (2.104, -1.201, -0.003)], + ["OH", 5, (4.168, -0.002, -0.005)], + ["CZ", 5, (2.791, -0.001, -0.003)], + ], + "VAL": [ + ["N", 0, (-0.494, 1.373, -0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.527, -0.0, -0.0)], + ["CB", 0, (-0.533, -0.795, -1.213)], + ["O", 3, (0.627, 1.062, -0.0)], + ["CG1", 4, (0.54, 1.429, -0.0)], + ["CG2", 4, (0.533, -0.776, 1.203)], + ], + "UNK": [ + ["N", 0, (-0.525, 1.363, 0.0)], + ["CA", 0, (0.0, 0.0, 0.0)], + ["C", 0, (1.526, -0.0, -0.0)], + ], +} +residue_atoms = { + "ALA": ["C", "CA", "CB", "N", "O"], + "ARG": ["C", "CA", "CB", "CG", "CD", "CZ", "N", "NE", "O", "NH1", "NH2"], + "ASP": ["C", "CA", "CB", "CG", "N", "O", "OD1", "OD2"], + "ASN": ["C", "CA", "CB", "CG", "N", "ND2", "O", "OD1"], + "CYS": ["C", "CA", "CB", "N", "O", "SG"], + "GLU": ["C", "CA", "CB", "CG", "CD", "N", "O", "OE1", "OE2"], + "GLN": ["C", "CA", "CB", "CG", "CD", "N", "NE2", "O", "OE1"], + "GLY": ["C", "CA", "N", "O"], + "HIS": ["C", "CA", "CB", "CG", "CD2", "CE1", "N", "ND1", "NE2", "O"], + "ILE": ["C", "CA", "CB", "CG1", "CG2", "CD1", "N", "O"], + "LEU": ["C", "CA", "CB", "CG", "CD1", "CD2", "N", "O"], + "LYS": ["C", "CA", "CB", "CG", "CD", "CE", "N", "NZ", "O"], + "MET": ["C", "CA", "CB", "CG", "CE", "N", "O", "SD"], + "PHE": ["C", "CA", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "N", "O"], + "PRO": ["C", "CA", "CB", "CG", "CD", "N", "O"], + "SER": ["C", "CA", "CB", "N", "O", "OG"], + "THR": ["C", "CA", "CB", "CG2", "N", "O", "OG1"], + "TRP": [ + "C", + "CA", + "CB", + "CG", + "CD1", + "CD2", + "CE2", + "CE3", + "CZ2", + "CZ3", + "CH2", + "N", + "NE1", + "O", + ], + "TYR": ["C", "CA", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "N", "O", "OH"], + "VAL": ["C", "CA", "CB", "CG1", "CG2", "N", "O"], + "UNK": ["C", "CA", "N"], +} +residue_atom_renaming_swaps = { + "ASP": {"OD1": "OD2"}, + "GLU": {"OE1": "OE2"}, + "PHE": {"CD1": "CD2", "CE1": "CE2"}, + "TYR": {"CD1": "CD2", "CE1": "CE2"}, +} +van_der_waals_radius = {"C": 1.7, "N": 1.55, "O": 1.52, "S": 1.8} +Bond = namedtuple("Bond", ["atom1_name", "atom2_name", "length", "stddev"]) +BondAngle = namedtuple( + "BondAngle", ["atom1_name", "atom2_name", "atom3name", "angle_rad", "stddev"] +) + +_STEREO_CHEMICAL_PROPS_PATH = Path("evolutionaryscale/structure/stereo_chemical_props.txt") + + +def _bond_key(atom_a: str, atom_b: str) -> tuple[str, str]: + """Return an order-independent key for a covalent bond.""" + + return (atom_a, atom_b) if atom_a <= atom_b else (atom_b, atom_a) + + +def _read_stereo_sections(text: str) -> tuple[list[str], list[str]]: + """Split the two tabular sections while discarding their headers.""" + + lines = iter(text.splitlines()) + next(lines) + bond_rows = list(iter(lambda: next(lines).strip(), "-")) + next(lines) + next(lines) + angle_rows = list(iter(lambda: next(lines).strip(), "-")) + return bond_rows, angle_rows + + +@functools.cache +def load_stereo_chemical_props() -> tuple[ + dict[str, list[Any]], dict[str, list[Any]], dict[str, list[Any]] +]: + """Load covalent geometry and derive virtual bonds for bond angles. + + The returned dictionaries are keyed by three-letter residue name. Virtual + bond lengths and uncertainties use the same operation order as the + checkpoint's reference feature pipeline so their floating-point values are + bitwise reproducible. + """ + + bond_rows, angle_rows = _read_stereo_sections(_STEREO_CHEMICAL_PROPS_PATH.read_text()) + residue_bonds: dict[str, list[Any]] = {} + for row in bond_rows: + atom_pair, residue_name, length, stddev = row.split() + atom_a, atom_b = atom_pair.split("-") + residue_bonds.setdefault(residue_name, []).append( + Bond(atom_a, atom_b, float(length), float(stddev)) + ) + residue_bonds["UNK"] = [] + + residue_bond_angles: dict[str, list[Any]] = {} + for row in angle_rows: + atom_triple, residue_name, angle_degrees, stddev_degrees = row.split() + atom_a, atom_b, atom_c = atom_triple.split("-") + residue_bond_angles.setdefault(residue_name, []).append( + BondAngle( + atom_a, + atom_b, + atom_c, + float(angle_degrees) / 180.0 * np.pi, + float(stddev_degrees) / 180.0 * np.pi, + ) + ) + residue_bond_angles["UNK"] = [] + + residue_virtual_bonds: dict[str, list[Any]] = {} + for residue_name, angles in residue_bond_angles.items(): + lookup = { + _bond_key(bond.atom1_name, bond.atom2_name): bond + for bond in residue_bonds[residue_name] + } + derived = residue_virtual_bonds.setdefault(residue_name, []) + for angle in angles: + left = lookup[_bond_key(angle.atom1_name, angle.atom2_name)] + right = lookup[_bond_key(angle.atom2_name, angle.atom3name)] + theta = angle.angle_rad + length = np.sqrt( + left.length**2 + right.length**2 - 2 * left.length * right.length * np.cos(theta) + ) + dl_outer = 0.5 / length + dl_dgamma = 2 * left.length * right.length * np.sin(theta) * dl_outer + dl_db1 = (2 * left.length - 2 * right.length * np.cos(theta)) * dl_outer + dl_db2 = (2 * right.length - 2 * left.length * np.cos(theta)) * dl_outer + stddev = np.sqrt( + (dl_dgamma * angle.stddev) ** 2 + + (dl_db1 * left.stddev) ** 2 + + (dl_db2 * right.stddev) ** 2 + ) + derived.append(Bond(angle.atom1_name, angle.atom3name, length, stddev)) + return residue_bonds, residue_virtual_bonds, residue_bond_angles + + +between_res_bond_length_c_n = [1.329, 1.341] +between_res_bond_length_stddev_c_n = [0.014, 0.016] +between_res_cos_angles_c_n_ca = [-0.5203, 0.0353] +between_res_cos_angles_ca_c_n = [-0.4473, 0.0311] +atom_types = [ + "N", + "CA", + "C", + "CB", + "O", + "CG", + "CG1", + "CG2", + "OG", + "OG1", + "SG", + "CD", + "CD1", + "CD2", + "ND1", + "ND2", + "OD1", + "OD2", + "SD", + "CE", + "CE1", + "CE2", + "CE3", + "NE", + "NE1", + "NE2", + "OE1", + "OE2", + "CH2", + "NH1", + "NH2", + "OH", + "CZ", + "CZ2", + "CZ3", + "NZ", + "OXT", +] +atom_order = {atom_type: i for (i, atom_type) in enumerate(atom_types)} +atom_type_num = len(atom_types) +restype_name_to_atom14_names = { + "ALA": ["N", "CA", "C", "O", "CB", "", "", "", "", "", "", "", "", ""], + "ARG": [ + "N", + "CA", + "C", + "O", + "CB", + "CG", + "CD", + "NE", + "CZ", + "NH1", + "NH2", + "", + "", + "", + ], + "ASN": ["N", "CA", "C", "O", "CB", "CG", "OD1", "ND2", "", "", "", "", "", ""], + "ASP": ["N", "CA", "C", "O", "CB", "CG", "OD1", "OD2", "", "", "", "", "", ""], + "CYS": ["N", "CA", "C", "O", "CB", "SG", "", "", "", "", "", "", "", ""], + "GLN": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "NE2", "", "", "", "", ""], + "GLU": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "OE2", "", "", "", "", ""], + "GLY": ["N", "CA", "C", "O", "", "", "", "", "", "", "", "", "", ""], + "HIS": [ + "N", + "CA", + "C", + "O", + "CB", + "CG", + "ND1", + "CD2", + "CE1", + "NE2", + "", + "", + "", + "", + ], + "ILE": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "CD1", "", "", "", "", "", ""], + "LEU": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "", "", "", "", "", ""], + "LYS": ["N", "CA", "C", "O", "CB", "CG", "CD", "CE", "NZ", "", "", "", "", ""], + "MET": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE", "", "", "", "", "", ""], + "PHE": [ + "N", + "CA", + "C", + "O", + "CB", + "CG", + "CD1", + "CD2", + "CE1", + "CE2", + "CZ", + "", + "", + "", + ], + "PRO": ["N", "CA", "C", "O", "CB", "CG", "CD", "", "", "", "", "", "", ""], + "SER": ["N", "CA", "C", "O", "CB", "OG", "", "", "", "", "", "", "", ""], + "THR": ["N", "CA", "C", "O", "CB", "OG1", "CG2", "", "", "", "", "", "", ""], + "TRP": [ + "N", + "CA", + "C", + "O", + "CB", + "CG", + "CD1", + "CD2", + "NE1", + "CE2", + "CE3", + "CZ2", + "CZ3", + "CH2", + ], + "TYR": [ + "N", + "CA", + "C", + "O", + "CB", + "CG", + "CD1", + "CD2", + "CE1", + "CE2", + "CZ", + "OH", + "", + "", + ], + "VAL": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "", "", "", "", "", "", ""], + "UNK": ["N", "CA", "C", "", "", "", "", "", "", "", "", "", "", ""], +} +restypes = [ + "A", + "R", + "N", + "D", + "C", + "Q", + "E", + "G", + "H", + "I", + "L", + "K", + "M", + "F", + "P", + "S", + "T", + "W", + "Y", + "V", +] +restype_order = {restype: i for (i, restype) in enumerate(restypes)} +restype_num = len(restypes) +unk_restype_index = restype_num +restypes_with_x = [*restypes, "X"] +restype_order_with_x = {restype: i for (i, restype) in enumerate(restypes_with_x)} +bb_atoms = ["N", "CA", "C", "O"] +hydrophobicity = { + "ALA": 0.116, + "ARG": -0.5, + "ASN": -0.264, + "ASP": -0.472, + "CYS": 0.18, + "GLN": -0.249, + "GLU": -0.457, + "GLY": 0.001, + "HIS": -0.335, + "ILE": 0.443, + "LEU": 0.443, + "LYS": -0.217, + "MET": 0.238, + "PHE": 0.5, + "PRO": 0.211, + "SER": -0.141, + "THR": -0.05, + "TRP": 0.378, + "TYR": 0.38, + "VAL": 0.325, +} +side_chain_asa = { + "ALA": 64.7809, + "ARG": 210.02, + "ASN": 113.187, + "ASP": 110.209, + "CYS": 95.2439, + "GLN": 147.855, + "GLU": 143.924, + "GLY": 23.1338, + "HIS": 146.449, + "ILE": 151.242, + "LEU": 139.524, + "LYS": 177.366, + "MET": 164.674, + "PHE": 186.7, + "PRO": 111.533, + "SER": 81.2159, + "THR": 111.597, + "TRP": 229.619, + "TYR": 200.306, + "VAL": 124.237, +} +amino_acid_volumes = { + "A": 88.6, + "R": 173.4, + "N": 114.1, + "D": 111.1, + "C": 108.5, + "Q": 143.8, + "E": 138.4, + "G": 60.1, + "H": 153.2, + "I": 166.7, + "L": 166.7, + "K": 168.6, + "M": 162.9, + "F": 189.9, + "P": 112.7, + "S": 89.0, + "T": 116.1, + "W": 227.8, + "Y": 193.6, + "V": 140.0, + "X": 88.6, +} + + +def sequence_to_onehot( + sequence: str, mapping: Mapping[str, int], map_unknown_to_x: bool = False +) -> np.ndarray: + """Encode a sequence as X with shape ``(l, n_alphabet)``. + + Unknown uppercase letters map to ``X`` only when ``map_unknown_to_x`` is + enabled. Non-alphabetic or lowercase input remains invalid in that mode. + """ + + n_alphabet = max(mapping.values()) + 1 + observed_indices = sorted(set(mapping.values())) + if observed_indices != list(range(n_alphabet)): + raise ValueError( + "The mapping must have values from 0 to num_unique_aas-1 without any gaps. " + f"Got: {sorted(mapping.values())}" + ) + + encoded: np.ndarray = np.empty(len(sequence), dtype=np.intp) + for position, symbol in enumerate(sequence): + if map_unknown_to_x: + if not symbol.isalpha() or not symbol.isupper(): + raise ValueError(f"Invalid character in the sequence: {symbol}") + encoded[position] = mapping.get(symbol, mapping["X"]) + else: + encoded[position] = mapping[symbol] + + one_hot: np.ndarray = np.zeros((len(sequence), n_alphabet), dtype=np.int32) + one_hot[np.arange(len(sequence)), encoded] = 1 + return one_hot + + +restype_1to3 = { + "A": "ALA", + "R": "ARG", + "N": "ASN", + "D": "ASP", + "C": "CYS", + "Q": "GLN", + "E": "GLU", + "G": "GLY", + "H": "HIS", + "I": "ILE", + "L": "LEU", + "K": "LYS", + "M": "MET", + "F": "PHE", + "P": "PRO", + "S": "SER", + "T": "THR", + "W": "TRP", + "Y": "TYR", + "V": "VAL", + "X": "UNK", +} +restype_3to1 = {v: k for (k, v) in restype_1to3.items()} +unk_restype = "UNK" +resnames = [restype_1to3[r] for r in restypes] + [unk_restype] +resname_to_idx = {resname: i for (i, resname) in enumerate(resnames)} +hydrophobic_resnames = {"VAL", "ILE", "LEU", "PHE", "MET", "TRP"} +HHBLITS_AA_TO_ID = { + "A": 0, + "B": 2, + "C": 1, + "D": 2, + "E": 3, + "F": 4, + "G": 5, + "H": 6, + "I": 7, + "J": 20, + "K": 8, + "L": 9, + "M": 10, + "N": 11, + "O": 20, + "P": 12, + "Q": 13, + "R": 14, + "S": 15, + "T": 16, + "U": 1, + "V": 17, + "W": 18, + "X": 20, + "Y": 19, + "Z": 3, + "-": 21, +} +ID_TO_HHBLITS_AA = { + 0: "A", + 1: "C", + 2: "D", + 3: "E", + 4: "F", + 5: "G", + 6: "H", + 7: "I", + 8: "K", + 9: "L", + 10: "M", + 11: "N", + 12: "P", + 13: "Q", + 14: "R", + 15: "S", + 16: "T", + 17: "V", + 18: "W", + 19: "Y", + 20: "X", + 21: "-", +} +restypes_with_x_and_gap = [*restypes, "X", "-"] +MAP_HHBLITS_AATYPE_TO_OUR_AATYPE = tuple( + restypes_with_x_and_gap.index(ID_TO_HHBLITS_AA[i]) for i in range(len(restypes_with_x_and_gap)) +) + + +def _make_standard_atom_mask() -> np.ndarray: + """Return M with shape ``(n_residue_types, n_atom_types)``.""" + + mask: np.ndarray = np.zeros((restype_num + 1, atom_type_num), dtype=np.int32) + for residue_index, residue_code in enumerate(restypes): + residue_name = restype_1to3[residue_code] + atom_indices = [atom_order[name] for name in residue_atoms[residue_name]] + mask[residue_index, atom_indices] = 1 + return mask + + +STANDARD_ATOM_MASK = _make_standard_atom_mask() + + +def chi_angle_atom(atom_index: int) -> np.ndarray: + """Return chi-group selectors X with shape ``(21, 37, 4)``.""" + + selectors: list[np.ndarray] = [] + identity = np.eye(atom_type_num) + for residue_code in restypes: + groups = chi_angles_atoms[restype_1to3[residue_code]] + indices = [atom_order[group[atom_index]] for group in groups] + indices += [-1] * (4 - len(indices)) + selectors.append(identity[indices]) + selectors.append(np.zeros((4, atom_type_num))) + return cast(np.ndarray, np.stack(selectors).transpose(0, 2, 1)) + + +chi_atom_1_one_hot = chi_angle_atom(1) +chi_atom_2_one_hot = chi_angle_atom(2) +chi_angles_atom_indices = [chi_angles_atoms[restype_1to3[r]] for r in restypes] +chi_angles_atom_indices = np.array( + [chi_atoms + [[0, 0, 0, 0]] * (4 - len(chi_atoms)) for chi_atoms in chi_angles_atom_indices] +) +_chi_groups_for_atom: defaultdict[tuple[str, str], list[tuple[int, int]]] = defaultdict(list) +for res_name, chi_angle_atoms_for_res in chi_angles_atoms.items(): + for chi_group_i, chi_group in enumerate(chi_angle_atoms_for_res): + for atom_i, atom in enumerate(chi_group): + _chi_groups_for_atom[res_name, atom].append((chi_group_i, atom_i)) +chi_groups_for_atom = dict(_chi_groups_for_atom) + + +def _make_rigid_transformation_4x4( + ex: np.ndarray, ey: np.ndarray, translation: np.ndarray +) -> np.ndarray: + """Construct homogeneous transform M from two basis vectors and an origin.""" + + unit_x = ex / np.linalg.norm(ex) + orthogonal_y = ey - np.dot(ey, unit_x) * unit_x + unit_y = orthogonal_y / np.linalg.norm(orthogonal_y) + unit_z = np.cross(unit_x, unit_y) + rotation_and_origin = np.stack((unit_x, unit_y, unit_z, translation), axis=0).T + homogeneous_row = np.asarray(((0.0, 0.0, 0.0, 1.0),)) + return cast(np.ndarray, np.concatenate((rotation_and_origin, homogeneous_row), axis=0)) + + +restype_atom37_to_rigid_group: np.ndarray = np.zeros((21, 37), dtype=int) +restype_atom37_mask: np.ndarray = np.zeros((21, 37), dtype=np.float32) +restype_atom37_rigid_group_positions: np.ndarray = np.zeros((21, 37, 3), dtype=np.float32) +restype_atom14_to_rigid_group: np.ndarray = np.zeros((21, 14), dtype=int) +restype_atom14_mask: np.ndarray = np.zeros((21, 14), dtype=np.float32) +restype_atom14_rigid_group_positions: np.ndarray = np.zeros((21, 14, 3), dtype=np.float32) +restype_rigid_group_default_frame: np.ndarray = np.zeros((21, 8, 4, 4), dtype=np.float32) + + +def _make_rigid_group_constants() -> None: + """Populate atom-to-frame assignments and default rigid transforms.""" + + for residue_index, residue_code in enumerate(restypes_with_x): + residue_name = restype_1to3[residue_code] + atom14_names = restype_name_to_atom14_names[residue_name] + for atom_name, group_index, coordinates in rigid_group_atom_positions[residue_name]: + atom37_index = atom_order[atom_name] + atom14_index = atom14_names.index(atom_name) + restype_atom37_to_rigid_group[residue_index, atom37_index] = group_index + restype_atom37_mask[residue_index, atom37_index] = 1 + restype_atom37_rigid_group_positions[residue_index, atom37_index] = coordinates + restype_atom14_to_rigid_group[residue_index, atom14_index] = group_index + restype_atom14_mask[residue_index, atom14_index] = 1 + restype_atom14_rigid_group_positions[residue_index, atom14_index] = coordinates + + positions = { + atom_name: np.asarray(coordinates) + for atom_name, _group_index, coordinates in rigid_group_atom_positions[residue_name] + } + restype_rigid_group_default_frame[residue_index, :2] = np.eye(4) + restype_rigid_group_default_frame[residue_index, 2] = _make_rigid_transformation_4x4( + positions["N"] - positions["CA"], + np.asarray((1.0, 0.0, 0.0)), + positions["N"], + ) + restype_rigid_group_default_frame[residue_index, 3] = _make_rigid_transformation_4x4( + positions["C"] - positions["CA"], + positions["CA"] - positions["N"], + positions["C"], + ) + + groups = chi_angles_atoms[residue_name] + if groups: + first_group = [positions[name] for name in groups[0]] + restype_rigid_group_default_frame[residue_index, 4] = _make_rigid_transformation_4x4( + first_group[2] - first_group[1], + first_group[0] - first_group[1], + first_group[2], + ) + for chi_index, group in enumerate(groups[1:], start=1): + axis_end = positions[group[2]] + restype_rigid_group_default_frame[residue_index, 4 + chi_index] = ( + _make_rigid_transformation_4x4( + axis_end, + np.asarray((-1.0, 0.0, 0.0)), + axis_end, + ) + ) + + +_make_rigid_group_constants() + + +def make_atom14_dists_bounds( + overlap_tolerance: float = 1.5, + bond_length_tolerance_factor: float = 15.0, +) -> dict[str, np.ndarray]: + """Build lower, upper, and uncertainty tensors with shape ``(21, 14, 14)``.""" + + lower_bounds: np.ndarray = np.zeros((21, 14, 14), np.float32) + upper_bounds: np.ndarray = np.zeros((21, 14, 14), np.float32) + stddevs: np.ndarray = np.zeros((21, 14, 14), np.float32) + residue_bonds, residue_virtual_bonds, _angles = load_stereo_chemical_props() + for residue_index, residue_code in enumerate(restypes): + residue_name = restype_1to3[residue_code] + atom_names = restype_name_to_atom14_names[residue_name] + for atom_a_index, atom_a_name in enumerate(atom_names): + if not atom_a_name: + continue + radius_a = van_der_waals_radius[atom_a_name[0]] + for atom_b_index, atom_b_name in enumerate(atom_names): + if not atom_b_name or atom_a_index == atom_b_index: + continue + clash_floor = radius_a + van_der_waals_radius[atom_b_name[0]] - overlap_tolerance + lower_bounds[residue_index, atom_a_index, atom_b_index] = clash_floor + lower_bounds[residue_index, atom_b_index, atom_a_index] = clash_floor + upper_bounds[residue_index, atom_a_index, atom_b_index] = 1e10 + upper_bounds[residue_index, atom_b_index, atom_a_index] = 1e10 + + for bond in residue_bonds[residue_name] + residue_virtual_bonds[residue_name]: + atom_a_index = atom_names.index(bond.atom1_name) + atom_b_index = atom_names.index(bond.atom2_name) + lower = bond.length - bond_length_tolerance_factor * bond.stddev + upper = bond.length + bond_length_tolerance_factor * bond.stddev + for row, column in ( + (atom_a_index, atom_b_index), + (atom_b_index, atom_a_index), + ): + lower_bounds[residue_index, row, column] = lower + upper_bounds[residue_index, row, column] = upper + stddevs[residue_index, row, column] = bond.stddev + return { + "lower_bound": lower_bounds, + "upper_bound": upper_bounds, + "stddev": stddevs, + } + + +restype_atom14_ambiguous_atoms: np.ndarray = np.zeros((21, 14), dtype=np.float32) +restype_atom14_ambiguous_atoms_swap_idx = np.tile(np.arange(14, dtype=int), (21, 1)) + + +def _make_atom14_ambiguity_feats() -> None: + """Mark symmetry-equivalent atom names and their exchange indices.""" + + for residue_name, swaps in residue_atom_renaming_swaps.items(): + residue_index = restype_order[restype_3to1[residue_name]] + atom_names = restype_name_to_atom14_names[residue_name] + for atom_a, atom_b in swaps.items(): + atom_a_index = atom_names.index(atom_a) + atom_b_index = atom_names.index(atom_b) + restype_atom14_ambiguous_atoms[residue_index, (atom_a_index, atom_b_index)] = 1 + restype_atom14_ambiguous_atoms_swap_idx[residue_index, (atom_a_index, atom_b_index)] = ( + atom_b_index, + atom_a_index, + ) + + +_make_atom14_ambiguity_feats() + + +def aatype_to_str_sequence(aatype: np.ndarray) -> str: + """Decode residue-type indices without changing their order.""" + + return "".join(restypes_with_x[index] for index in aatype) + + +CA_TO_N_NORM = 1.4591 +CA_TO_C_NORM = 1.5252 + + +def _make_restype_atom37_to_atom14() -> np.ndarray: + """Return atom37-to-atom14 lookup M with shape ``(21, 37)``.""" + + rows: list[list[int]] = [] + for residue_code in restypes: + names = restype_name_to_atom14_names[restype_1to3[residue_code]] + atom14_index = {name: index for index, name in enumerate(names)} + rows.append([atom14_index.get(name, 0) for name in atom_types]) + rows.append([0] * atom_type_num) + return np.asarray(rows, dtype=np.int32) + + +def _make_restype_atom14_to_atom37() -> np.ndarray: + """Return atom14-to-atom37 lookup M with shape ``(21, 14)``.""" + + rows = [ + [atom_order.get(name, 0) for name in restype_name_to_atom14_names[residue_name]] + for residue_name in resnames[:-1] + ] + rows.append([0] * 14) + return np.asarray(rows, dtype=np.int32) + + +RESTYPE_ATOM14_TO_ATOM37 = _make_restype_atom14_to_atom37() +RESTYPE_ATOM37_TO_ATOM14 = _make_restype_atom37_to_atom14() +CHAIN_BREAK_TOKEN = "|" diff --git a/fastplms/models/esmfold2/esmfold2_sequential_dataclass.py b/fastplms/models/esmfold2/esmfold2_sequential_dataclass.py new file mode 100644 index 0000000000000000000000000000000000000000..97b1a47c3c65d51625541f41abfbd0821617301a --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_sequential_dataclass.py @@ -0,0 +1,112 @@ +"""Dataclass support for aligned residue-level fields.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterable +from dataclasses import Field, dataclass, fields, replace +from typing import Any, Self + +import numpy as np + +from .esmfold2_misc import concat_objects, slice_any_object + +Index = int | list[int] | slice | np.ndarray + + +def _is_sequential(field: Field[Any]) -> bool: + return bool(field.metadata.get("sequence", False)) + + +def _sequence_axis(field: Field[Any]) -> int: + axis = int(field.metadata.get("sequence_dim", 0)) + if axis not in (0, 1): + raise NotImplementedError("SequentialDataclass supports sequence_dim values zero and one.") + return axis + + +def _slice_value(value: Any, index: Index, axis: int) -> Any: + if axis == 0: + return slice_any_object(value, index) + sliced = [slice_any_object(track, index) for track in value] + return value.__class__(sliced) + + +def _iter_sequence_lengths(value: Any, axis: int) -> Iterable[int]: + if axis == 0: + yield len(value) + else: + yield from (len(track) for track in value) + + +@dataclass(frozen=True) +class SequentialDataclass(ABC): + """Keep dataclass fields aligned along a shared residue dimension. + + A subclass marks aligned fields with ``metadata={"sequence": True}``. + ``sequence_dim`` may be zero for a direct sequence or one for a collection + of aligned tracks. ``join_token`` is passed to the package concatenation + helper when instances are joined. + """ + + def __post_init__(self) -> None: + expected = len(self) + for field in fields(self): + if not _is_sequential(field) or field.name == "complex": + continue + value = getattr(self, field.name) + if value is None: + continue + for actual in _iter_sequence_lengths(value, _sequence_axis(field)): + if actual != expected: + raise ValueError( + f"Mismatch in sequence length for field: {field.name}. " + f"Expected {expected}, received {actual}" + ) + + @abstractmethod + def __len__(self) -> int: + """Return the shared sequence length.""" + + raise NotImplementedError + + def __getitem__(self, index: Index) -> Self: + """Apply one sequence index to every aligned field.""" + + normalized_index: Index = [index] if isinstance(index, int) else index + updates: dict[str, Any] = {} + for field in fields(self): + if not _is_sequential(field): + continue + value = getattr(self, field.name) + if value is not None: + updates[field.name] = _slice_value(value, normalized_index, _sequence_axis(field)) + return replace(self, **updates) + + @classmethod + def concat(cls, items: list[Self], **overrides: Any) -> Self: + """Join aligned fields and retain non-sequential values from the first item.""" + + if not items: + raise ValueError("SequentialDataclass.concat requires at least one item.") + + updates: dict[str, Any] = {} + for field in fields(cls): + if not _is_sequential(field): + continue + first_value = getattr(items[0], field.name) + if first_value is None: + continue + values = [getattr(item, field.name) for item in items] + join_token = field.metadata.get("join_token") + if _sequence_axis(field) == 0: + updates[field.name] = concat_objects(values, join_token) + else: + tracks = [concat_objects(track, join_token) for track in zip(*values, strict=True)] + updates[field.name] = first_value.__class__(tracks) + + updates.update(overrides) + return replace(items[0], **updates) + + +__all__ = ["SequentialDataclass"] diff --git a/fastplms/models/esmfold2/esmfold2_system.py b/fastplms/models/esmfold2/esmfold2_system.py new file mode 100644 index 0000000000000000000000000000000000000000..23b69dc10fae713e3b7be76fd9475ab054a58913 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_system.py @@ -0,0 +1,56 @@ +"""Filesystem and subprocess types used by optional structure utilities.""" + +from __future__ import annotations + +import io +import subprocess +from pathlib import Path +from typing import Any, TypeAlias + +PathLike: TypeAlias = str | Path +PathOrBuffer: TypeAlias = PathLike | io.StringIO + + +def _stdout_destination(*, capture_output: bool, quiet: bool) -> int | None: + if capture_output: + return subprocess.PIPE + if quiet: + return subprocess.DEVNULL + return None + + +def _stderr_text(error: subprocess.CalledProcessError) -> str: + stderr = error.stderr + if stderr is None: + return "" + if isinstance(stderr, bytes): + return stderr.decode(errors="replace") + return str(stderr) + + +def run_subprocess_with_errorcheck( + *popenargs: Any, + capture_output: bool = False, + quiet: bool = False, + env: dict[str, str] | None = None, + shell: bool = False, + executable: str | None = None, + **kwargs: Any, +) -> subprocess.CompletedProcess[Any]: + """Run a command and include captured standard error in failures.""" + + stdout = _stdout_destination(capture_output=capture_output, quiet=quiet) + try: + return subprocess.run( + *popenargs, + check=True, + env=env, + executable=executable, + shell=shell, + stderr=subprocess.PIPE, + stdout=stdout, + **kwargs, + ) + except subprocess.CalledProcessError as error: + message = f"Command failed with errorcode {error.returncode}.\n\n{_stderr_text(error)}" + raise RuntimeError(message) from error diff --git a/fastplms/models/esmfold2/esmfold2_types.py b/fastplms/models/esmfold2/esmfold2_types.py new file mode 100644 index 0000000000000000000000000000000000000000..38d581ec65c1f918063c271f15b5a71ae1fe7c99 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_types.py @@ -0,0 +1,31 @@ +"""Stable namespace for ESMFold2 input schema types.""" + +from __future__ import annotations + +from . import esmfold2_input_builder as _input_schema +from .esmfold2_msa import MSA +from .esmfold2_parsing import FastaEntry + +Modification = _input_schema.Modification +ProteinInput = _input_schema.ProteinInput +RNAInput = _input_schema.RNAInput +DNAInput = _input_schema.DNAInput +LigandInput = _input_schema.LigandInput +DistogramConditioning = _input_schema.DistogramConditioning +PocketConditioning = _input_schema.PocketConditioning +CovalentBond = _input_schema.CovalentBond +StructurePredictionInput = _input_schema.StructurePredictionInput + +__all__ = [ + "MSA", + "CovalentBond", + "DNAInput", + "DistogramConditioning", + "FastaEntry", + "LigandInput", + "Modification", + "PocketConditioning", + "ProteinInput", + "RNAInput", + "StructurePredictionInput", +] diff --git a/fastplms/models/esmfold2/esmfold2_utils_types.py b/fastplms/models/esmfold2/esmfold2_utils_types.py new file mode 100644 index 0000000000000000000000000000000000000000..13590243d386bc83fa3c01bb28f8ed6fb79762f2 --- /dev/null +++ b/fastplms/models/esmfold2/esmfold2_utils_types.py @@ -0,0 +1,38 @@ +"""Small public types shared by the ESMFold2 structure utilities. + +These definitions are intentionally independent of cloud-storage packages. Any +path object implementing :class:`os.PathLike` is accepted by the runtime file +helpers, including cloud-path implementations installed by an application. +""" + +from __future__ import annotations + +import io +import os +from dataclasses import dataclass +from typing import TypeAlias + +PathLike: TypeAlias = str | os.PathLike[str] +PathOrBuffer: TypeAlias = PathLike | io.TextIOBase + + +@dataclass(slots=True) +class FunctionAnnotation: + """A residue-range annotation using one-based inclusive coordinates.""" + + label: str + start: int + end: int + + def to_tuple(self) -> tuple[str, int, int]: + """Return the serialization order used by annotation tokenizers.""" + + return (self.label, self.start, self.end) + + def __len__(self) -> int: + """Return the number of annotated residues.""" + + return self.end - self.start + 1 + + +__all__ = ["FunctionAnnotation", "PathLike", "PathOrBuffer"] diff --git a/fastplms/models/esmfold2/modeling_esmfold2.py b/fastplms/models/esmfold2/modeling_esmfold2.py new file mode 100644 index 0000000000000000000000000000000000000000..2a9b475f260c6e537c8052f70a8fc0d8987db40d --- /dev/null +++ b/fastplms/models/esmfold2/modeling_esmfold2.py @@ -0,0 +1,2002 @@ +"""PyTorch ESMFold2 model: the standard released architecture. + +Quickstart:: + + from transformers import ESMFold2Model + + model = ESMFold2Model.from_pretrained("biohub/ESMFold2").cuda().eval() + open("ubq.pdb", "w").write(model.infer_protein_as_pdb("MQIFVKTLTGKT...")) + +For multi-chain, ligand, and MSA inputs, use ``model.input_types`` together +with ``model.fold(...)`` or ``model.prepare_structure_input(...)``. +""" + +from __future__ import annotations + +import gc +import importlib +import importlib.metadata +import math +from contextlib import contextmanager +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, ClassVar, Literal, cast + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch import Tensor +from transformers.modeling_outputs import ModelOutput +from transformers.modeling_utils import PreTrainedModel + +from ...attention import get_attn_implementation, set_config_attn_implementation + +try: + from fastplms.models.ttt import FastPLMTestTimeTrainingMixin, TTTConfig +except ModuleNotFoundError as error: + if error.name != "fastplms": + raise + from ..ttt import FastPLMTestTimeTrainingMixin, TTTConfig + +from .attention import ESMFold2AttentionMixin +from .configuration_esmfold2 import ESMFold2Config, normalize_esmc_id +from .embedding import ESMFold2EmbeddingMixin +from .esmfold2_constants_esm3 import ( + SEQUENCE_BOS_TOKEN, + SEQUENCE_EOS_TOKEN, + SEQUENCE_MASK_TOKEN, + SEQUENCE_PAD_TOKEN, + SEQUENCE_STANDARD_AA_MAX_TOKEN, + SEQUENCE_STANDARD_AA_MIN_TOKEN, + SEQUENCE_VOCAB, +) +from .modeling_esmfold2_common import ( + CHAR_VOCAB_SIZE, + MAX_ATOMIC_NUMBER, + MSA_CONDITIONING_INPUT_NAMES, + NUM_RES_TYPES, + DiffusionStructureHead, + FoldingTrunk, + InputsEmbedder, + LanguageModelShim, + MSAPairWeightedAveraging, + OuterProductMean, + ResIdxAsymIdSymIdEntityIdEncoding, + RowAttentionPooling, + SwiGLUMLP, + TriangleMultiplicativeUpdate, + _categorical_mean, + _compute_intra_token_idx, + compute_lm_hidden_states, + gather_rep_atom_coords, + gather_token_to_atom, + maybe_apply_msa_column_masking, + maybe_subsample_msa, + validate_kernel_backend, + validate_msa_conditioning_inputs, +) + +_ESMC_FP8_LINEAR_SUFFIX = ".attn.out_proj" +_ESMC_FP8_EXPECTED_PROJECTIONS = 80 +_EPS = 1e-6 +_NONPOLYMER_ID = 4 + +# Default for the triangle, OPM, and pair-transition l^2 operations. Caps peak +# memory so l around 2k folds on an 80 GB GPU (about 76 GB at chunk=128 for +# l=1438; +# chunk=64 leaves headroom for the largest foldbench targets). Override via +# ``model.set_chunk_size(...)``; pass None to disable chunking (faster for +# short l but OOM-prone past approximately 600). +_DEFAULT_CHUNK_SIZE = 64 + + +@dataclass +class ESMFold2Output(ModelOutput): + """Transformers-compatible output shared by released and experimental folds. + + ``last_hidden_state`` is the final pair representation. When requested, + ``hidden_states`` contains the token-input representation followed by the + final pair representation. The structure trunks do not expose normalized + post-softmax attention tensors, so ``output_attentions=True`` fails + explicitly instead of returning incomplete data. + """ + + last_hidden_state: Tensor | None = None + hidden_states: tuple[Tensor, ...] | None = None + attentions: tuple[Tensor, ...] | None = None + distogram_logits: Tensor | None = None + sample_atom_coords: Tensor | None = None + representative_atom_coords: Tensor | None = None + atom_pad_mask: Tensor | None = None + residue_index: Tensor | None = None + entity_id: Tensor | None = None + plddt_logits: Tensor | None = None + plddt: Tensor | None = None + plddt_per_atom: Tensor | None = None + plddt_ca: Tensor | None = None + complex_plddt: Tensor | None = None + complex_iplddt: Tensor | None = None + pae_logits: Tensor | None = None + pae: Tensor | None = None + pde_logits: Tensor | None = None + pde: Tensor | None = None + resolved_logits: Tensor | None = None + ptm: Tensor | None = None + iptm: Tensor | None = None + pair_chains_iptm: Tensor | None = None + + +def _resolve_structure_output_controls( + config: ESMFold2Config, + *, + output_attentions: bool | None, + output_hidden_states: bool | None, + return_dict: bool | None, +) -> tuple[bool, bool]: + resolved_attentions = ( + config.output_attentions if output_attentions is None else output_attentions + ) + if resolved_attentions: + raise NotImplementedError( + "ESMFold2 does not expose normalized attention tensors from its structure " + "trunk. output_attentions=True is unsupported." + ) + resolved_hidden_states = ( + config.output_hidden_states + if output_hidden_states is None + else output_hidden_states + ) + resolved_return_dict = config.use_return_dict if return_dict is None else return_dict + return bool(resolved_hidden_states), bool(resolved_return_dict) + + +def _finalize_structure_output( + output: dict[str, Tensor], + *, + token_input_state: Tensor, + pair_state: Tensor, + output_hidden_states: bool, + return_dict: bool, +) -> ESMFold2Output | tuple[Any, ...]: + model_output = ESMFold2Output( + last_hidden_state=pair_state, + hidden_states=(token_input_state, pair_state) if output_hidden_states else None, + **output, + ) + return model_output if return_dict else model_output.to_tuple() + + +class _ESMFold2ESMplusplusAdapter(nn.Module): + def __init__(self, model: nn.Module) -> None: + super().__init__() + self.model = model + + @property + def config(self): + return self.model.config + + def set_attn_implementation(self, attn_implementation: str) -> None: + """Update ESMC through its Transformers-compatible attention API.""" + + self.model.set_attn_implementation(attn_implementation) + + def forward( + self, + input_ids: Tensor, + attention_mask: Tensor | None = None, + sequence_id: Tensor | None = None, + output_hidden_states: bool | None = None, + output_attentions: bool | None = None, + return_dict: bool | None = None, + compute_sae: bool = True, + normalize_sae: bool = False, + ): + del return_dict, compute_sae, normalize_sae + output = self.model( + input_ids=input_ids, + attention_mask=attention_mask, + sequence_id=sequence_id, + output_hidden_states=output_hidden_states, + output_attentions=output_attentions, + return_dict=True, + esmfold2_hidden_states=True, + ) + if output_hidden_states: + hidden_states = output.hidden_states + if hidden_states is None: + raise RuntimeError("ESM++ did not return requested hidden states.") + if isinstance(hidden_states, torch.Tensor): + output.hidden_states = hidden_states + else: + output.hidden_states = torch.stack(tuple(hidden_states), dim=0) + return output + + +def _load_fastplms_esmplusplus_for_esmfold2( + esmc_model_path: str, + attn_backend: str, + device: torch.device, + dtype: torch.dtype, + local_files_only: bool = False, +) -> _ESMFold2ESMplusplusAdapter: + from fastplms.models.esm_plusplus.modeling_esm_plusplus import ( + ESMplusplusConfig, + ESMplusplusModel, + ) + + normalized_path = normalize_esmc_id(esmc_model_path) + source_revision, _ = _manifest_esmc_checkpoint_contract(normalized_path) + revision_kwargs: dict[str, Any] = { + "local_files_only": local_files_only, + } + if source_revision is not None: + revision_kwargs["revision"] = source_revision + esmc_config = ESMplusplusConfig.from_pretrained(normalized_path, **revision_kwargs) + set_config_attn_implementation(esmc_config, attn_backend) + load_kwargs: dict[str, Any] = { + "config": esmc_config, + "torch_dtype": dtype, + **revision_kwargs, + } + if device.type == "cuda": + # Device mapping constructs parameters on the destination GPU instead + # of materializing the 6B backbone in host memory first. + load_kwargs["device_map"] = {"": str(device)} + esmc = ESMplusplusModel.from_pretrained(normalized_path, **load_kwargs) + if device.type != "cuda": + esmc = esmc.to(device=device, dtype=dtype) + else: + loaded_device = next(esmc.parameters()).device + if loaded_device != device: + raise RuntimeError( + f"ESMC loaded on {loaded_device}, expected direct loading on {device}." + ) + return _ESMFold2ESMplusplusAdapter(esmc).eval() + + +def _manifest_esmc_checkpoint_contract( + esmc_model_path: str, +) -> tuple[str | None, dict[str, str]]: + """Return the immutable manifest identity for a registered ESMC source. + + Local checkpoint directories deliberately return no Hub revision. A known + Hub repository is always loaded at the revision and file identities in + ``models.toml`` instead of following a mutable branch. + """ + + normalized_path = normalize_esmc_id(esmc_model_path) + try: + if Path(normalized_path).exists(): + return None, {} + except OSError: + # A repository ID may be too long or otherwise invalid as a local path. + pass + + from fastplms.registry import get_model_registry + + registry = get_model_registry() + backbone_model = registry.families["esmfold2"].backbone_model + if backbone_model is None: + raise RuntimeError("families.esmfold2 must declare backbone_model.") + spec = registry[backbone_model] + for checkpoint in (spec.fast, spec.official): + if checkpoint.repo_id == normalized_path: + return checkpoint.revision, { + item.path: item.encoded for item in checkpoint.files + } + if "/" in normalized_path: + raise ValueError( + f"Remote ESMC source {normalized_path!r} is not the manifest-declared " + f"ESMFold2 backbone {spec.fast.repo_id!r}." + ) + return None, {} + + +ESMCPrecision = Literal["auto", "bf16", "fp32", "fp8"] + + +@dataclass(frozen=True, slots=True) +class ESMCPrecisionStatus: + """Resolved ESMC precision and the evidence used to choose it.""" + + requested: str + resolved: str + reason: str + device: str + transformer_engine_version: str | None + + def as_dict(self) -> dict[str, str | None]: + return asdict(self) + + +def _transformer_engine_version() -> str | None: + try: + return importlib.metadata.version("transformer-engine") + except importlib.metadata.PackageNotFoundError: + return None + + +def _load_transformer_engine() -> tuple[Any, Any]: + """Load Transformer Engine lazily so core imports stay dependency-free.""" + + try: + te = importlib.import_module("transformer_engine.pytorch") + recipe = importlib.import_module("transformer_engine.common.recipe") + except (ImportError, OSError, RuntimeError) as error: + raise RuntimeError( + f"Transformer Engine could not be imported: {type(error).__name__}: {error}" + ) from error + if not hasattr(recipe, "Float8CurrentScaling"): + raise RuntimeError( + "Transformer Engine does not expose Float8CurrentScaling, which is " + "required by the validated ESMC FP8 path." + ) + return te, recipe + + +def _te_fp8_capability(device: torch.device) -> tuple[bool, str]: + """Return whether the validated Transformer Engine FP8 path can run.""" + + if device.type != "cuda": + return False, "FP8 requires direct ESMC loading onto a CUDA device." + if not torch.cuda.is_available(): + return False, "CUDA is unavailable." + try: + major, minor = torch.cuda.get_device_capability(device) + except (AssertionError, RuntimeError, ValueError) as error: + return False, f"CUDA capability query failed: {error}" + if not (major >= 9 or (major == 8 and minor >= 9)): + return False, f"CUDA capability {major}.{minor} does not support FP8." + try: + te, _ = _load_transformer_engine() + except RuntimeError as error: + return False, str(error) + + probe = getattr(te, "is_fp8_available", None) + if probe is None: + try: + probe = importlib.import_module("transformer_engine.pytorch.fp8").is_fp8_available + except (ImportError, AttributeError, OSError, RuntimeError) as error: + return False, f"Transformer Engine has no usable FP8 probe: {error}" + try: + try: + result = probe(return_reason=True) + except TypeError: + result = probe() + except (OSError, RuntimeError) as error: + return False, f"Transformer Engine FP8 probe failed: {error}" + if isinstance(result, tuple): + available = bool(result[0]) + detail = str(result[1]) if len(result) > 1 and result[1] else "" + else: + available = bool(result) + detail = "" + if not available: + return False, detail or "Transformer Engine reports FP8 unavailable." + return True, ( + "Transformer Engine reports FP8 availability; FastPLMs will convert " + "the validated ESMC attention output projections." + ) + + +def _resolve_esmc_precision(requested: str, device: torch.device) -> ESMCPrecisionStatus: + allowed = {"auto", "bf16", "fp32", "fp8"} + if requested not in allowed: + raise ValueError(f"precision must be one of {sorted(allowed)}, got {requested!r}.") + if requested in {"auto", "bf16", "fp32"}: + resolved = "bf16" if requested == "auto" else requested + reason = ( + "Automatic precision defaults to BF16; select esmc_precision='fp8' " + "explicitly to opt in to the validated Transformer Engine path." + if requested == "auto" + else "Precision was selected explicitly." + ) + return ESMCPrecisionStatus( + requested=requested, + resolved=resolved, + reason=reason, + device=str(device), + transformer_engine_version=_transformer_engine_version(), + ) + available, reason = _te_fp8_capability(device) + if not available: + raise RuntimeError(f"esmc_precision='fp8' is unavailable: {reason}") + return ESMCPrecisionStatus( + requested=requested, + resolved="fp8", + reason=reason, + device=str(device), + transformer_engine_version=_transformer_engine_version(), + ) + + +def _install_esmc_backbone( + model: Any, + esmc_model_path: str, + *, + precision: str, + device: str | torch.device | None = None, + local_files_only: bool = False, +) -> None: + target_device = torch.device(device) if device is not None else model.device + if target_device.type == "cuda" and target_device.index is None and torch.cuda.is_available(): + target_device = torch.device("cuda", torch.cuda.current_device()) + model_device = torch.device(model.device) + if model_device.type == "cuda" and model_device.index is None and torch.cuda.is_available(): + model_device = torch.device("cuda", torch.cuda.current_device()) + if target_device != model_device: + raise ValueError( + f"ESMC target device {target_device} must match the ESMFold2 device " + f"{model_device}. Move ESMFold2 before loading or reloading ESMC." + ) + status = _resolve_esmc_precision(precision, target_device) + normalized_source = normalize_esmc_id(esmc_model_path) + source_revision, source_files = _manifest_esmc_checkpoint_contract(normalized_source) + attention_implementation = get_attn_implementation(model.config) + model.config.esmc_attn_backend = attention_implementation + dtype = torch.float32 if status.resolved == "fp32" else torch.bfloat16 + esmc = _load_fastplms_esmplusplus_for_esmfold2( + esmc_model_path=esmc_model_path, + attn_backend=attention_implementation, + device=target_device, + dtype=dtype, + local_files_only=local_files_only, + ) + if esmc.config.hidden_size != model.config.lm_d_model: + raise ValueError( + f"ESMFold2 expected lm_d_model={model.config.lm_d_model}, " + f"but loaded ESMC hidden_size={esmc.config.hidden_size}." + ) + if esmc.config.num_hidden_layers != model.config.lm_num_layers: + raise ValueError( + f"ESMFold2 expected lm_num_layers={model.config.lm_num_layers}, " + f"but loaded ESMC num_hidden_layers={esmc.config.num_hidden_layers}." + ) + esmc.eval().requires_grad_(False) + fp8_module_paths: tuple[str, ...] = () + if status.resolved == "fp8": + fp8_module_paths = _convert_esmc_attention_outputs_to_te(esmc) + status = ESMCPrecisionStatus( + requested=status.requested, + resolved=status.resolved, + reason=( + f"{status.reason} Converted {len(fp8_module_paths)} projections; " + "canonical checkpoint weights remain BF16." + ), + device=status.device, + transformer_engine_version=status.transformer_engine_version, + ) + model._esmc_source = normalized_source + model._esmc_source_revision = source_revision + model._esmc_source_files = source_files + model._esmc_local_files_only = local_files_only + model._esmc_precision_policy = precision + model._esmc_precision_status = status + model._esmc_fp8 = status.resolved == "fp8" + model._esmc_fp8_module_paths = fp8_module_paths + model.config.esmc_precision = precision + model._esmc = esmc + model._ttt_lm_head = None + + +def _drop_transient_esmc_state( + module: nn.Module, + state_dict: dict[str, Tensor], + prefix: str, + local_metadata: dict[str, Any], +) -> None: + """Exclude runtime ESMC/TTT modules from canonical folding checkpoints.""" + + del module, local_metadata + transient_prefixes = (f"{prefix}_esmc.", f"{prefix}_ttt_lm_head.") + for key in tuple(state_dict): + if key.startswith(transient_prefixes): + del state_dict[key] + + +def _reload_esmc_bf16_for_gradients(model: Any, *, reason: str) -> None: + """Use BF16 temporarily without overwriting the persisted serving policy.""" + + policy = model._esmc_precision_policy + model.reload_esmc(precision="bf16", device=model.device) + model._esmc_precision_policy = policy + model.config.esmc_precision = policy + status = model._esmc_precision_status + model._esmc_precision_status = ESMCPrecisionStatus( + requested=policy, + resolved="bf16", + reason=reason, + device=status.device, + transformer_engine_version=status.transformer_engine_version, + ) + + +class PairTransition(nn.Module): + """LayerNorm + SwiGLU feed-forward residual block on the pair representation.""" + + def __init__(self, d_model: int, expansion_ratio: int = 4) -> None: + super().__init__() + self.norm = nn.LayerNorm(d_model) + self.ffn = SwiGLUMLP(d_model, expansion_ratio=expansion_ratio, bias=False) + self._chunk_size: int | None = _DEFAULT_CHUNK_SIZE + + def set_chunk_size(self, chunk_size: int | None) -> None: + self._chunk_size = chunk_size + + def forward(self, x: Tensor) -> Tensor: + if self._chunk_size is None or x.shape[1] <= self._chunk_size: + return self.ffn(self.norm(x)) + out: list[Tensor] = [] + for s in range(0, x.shape[1], self._chunk_size): + e = min(s + self._chunk_size, x.shape[1]) + sl = x[:, s:e] + out.append(self.ffn(self.norm(sl))) + return torch.cat(out, dim=1) + + +class ConfidenceHead(nn.Module): + """Predicts pLDDT, PAE, PDE, resolved-atom probability and distogram bins.""" + + boundaries: Tensor + + def __init__(self, config: ESMFold2Config) -> None: + super().__init__() + ch = config.confidence_head + d_single = config.d_single + d_pair = config.d_pair + d_inputs = config.inputs.d_inputs + + boundaries = torch.linspace(ch.min_dist, ch.max_dist, ch.distogram_bins - 1) + self.register_buffer("boundaries", boundaries) + self.dist_bin_pairwise_embed = nn.Embedding(ch.distogram_bins, d_pair) + + self.s_norm = nn.LayerNorm(d_single) + self.s_inputs_to_single = nn.Linear(d_inputs, d_single, bias=False) + self.s_to_z = nn.Linear(d_inputs, d_pair, bias=False) + self.s_to_z_transpose = nn.Linear(d_inputs, d_pair, bias=False) + self.s_to_z_prod_in1 = nn.Linear(d_inputs, d_pair, bias=False) + self.s_to_z_prod_in2 = nn.Linear(d_inputs, d_pair, bias=False) + self.s_to_z_prod_out = nn.Linear(d_pair, d_pair, bias=False) + self.s_input_to_s = nn.Linear(d_inputs, d_single, bias=False) + self.s_inputs_norm = nn.LayerNorm(d_inputs) + self.z_norm = nn.LayerNorm(d_pair) + + self.row_attention_pooling = RowAttentionPooling(d_pair=d_pair, d_single=d_single) + + pf = ch.folding_trunk + self.folding_trunk = FoldingTrunk(n_layers=pf.n_layers, d_pair=d_pair, expansion_ratio=4) + + # Heads. + self.plddt_ln = nn.LayerNorm(d_single) + max_atoms_per_token = 23 + self.plddt_weight = nn.Parameter( + torch.zeros(max_atoms_per_token, d_single, ch.num_plddt_bins) + ) + + self.pae_ln = nn.LayerNorm(d_pair) + self.pae_head = nn.Linear(d_pair, ch.num_pae_bins, bias=False) + + self.pde_ln = nn.LayerNorm(d_pair) + self.pde_head = nn.Linear(d_pair, ch.num_pde_bins, bias=False) + + self.resolved_ln = nn.LayerNorm(d_single) + # 2 = resolved logits ([unresolved, resolved]). + self.resolved_weight = nn.Parameter(torch.zeros(max_atoms_per_token, d_single, 2)) + + def set_kernel_backend(self, backend: str | None) -> None: + self.folding_trunk.set_kernel_backend(backend) + + def set_chunk_size(self, chunk_size: int | None) -> None: + self.folding_trunk.set_chunk_size(chunk_size) + + @staticmethod + def _repeat_batch(x: Tensor, num_diffusion_samples: int) -> Tensor: + return x if num_diffusion_samples == 1 else x.repeat_interleave(num_diffusion_samples, 0) + + @staticmethod + def _flatten_sample_axis(x: Tensor) -> Tensor: + if x.ndim == 4: + b, mult, n, c = x.shape + return x.reshape(b * mult, n, c) + return x + + def forward( + self, + s_inputs: Tensor, + z: Tensor, + x_pred: Tensor, + distogram_atom_idx: Tensor, + token_attention_mask: Tensor, + atom_to_token: Tensor, + atom_attention_mask: Tensor, + asym_id: Tensor, + mol_type: Tensor, + num_diffusion_samples: int = 1, + relative_position_encoding: Tensor | None = None, + token_bonds_encoding: Tensor | None = None, + ) -> dict[str, Tensor]: + s_inputs_normed = self.s_inputs_norm(s_inputs) + + z_base = self.z_norm(z) + if relative_position_encoding is not None: + z_base = z_base + relative_position_encoding + if token_bonds_encoding is not None: + z_base = z_base + token_bonds_encoding + z_base = z_base + self.s_to_z(s_inputs_normed).unsqueeze(2) + z_base = z_base + self.s_to_z_transpose(s_inputs_normed).unsqueeze(1) + z_base = z_base + self.s_to_z_prod_out( + self.s_to_z_prod_in1(s_inputs_normed)[:, :, None, :] + * self.s_to_z_prod_in2(s_inputs_normed)[:, None, :, :] + ) + + pair = self._repeat_batch(z_base, num_diffusion_samples) + x_pred_flat = self._flatten_sample_axis(x_pred) + atom_to_token_m = self._repeat_batch(atom_to_token, num_diffusion_samples) + atom_mask_m = self._repeat_batch(atom_attention_mask, num_diffusion_samples) + rep_idx_m = self._repeat_batch(distogram_atom_idx, num_diffusion_samples).long() + mask = self._repeat_batch(token_attention_mask, num_diffusion_samples) + expanded_batch_size = pair.shape[0] + + rep_coords = gather_rep_atom_coords(x_pred_flat, rep_idx_m) + rep_distances = torch.cdist( + rep_coords, rep_coords, compute_mode="donot_use_mm_for_euclid_dist" + ) + distogram_bins = (rep_distances.unsqueeze(-1) > self.boundaries).sum(dim=-1).long() + pair = pair + self.dist_bin_pairwise_embed(distogram_bins) + + pair_mask = mask[:, :, None].float() * mask[:, None, :].float() + + # FoldingTrunk handles the bf16 cast internally during inference so + # each block's fused trimul engages. In-place residual avoids an + # extra fp32 pair allocation. + with torch.amp.autocast("cuda", enabled=pair.is_cuda, dtype=torch.bfloat16): + pair_delta = self.folding_trunk(pair, pair_attention_mask=pair_mask) + pair.add_(pair_delta.float()) + del pair_delta + single = self.row_attention_pooling(pair, mask) + + atom_mask_f = atom_mask_m.float() + s_at_atoms = gather_token_to_atom(single, atom_to_token_m) + s_at_atoms_ln = self.plddt_ln(s_at_atoms) + + intra_idx = _compute_intra_token_idx(atom_to_token_m) + intra_idx = intra_idx.clamp(max=self.plddt_weight.shape[0] - 1) + w_plddt = self.plddt_weight[intra_idx] + plddt_logits = torch.einsum("...c,...cb->...b", s_at_atoms_ln, w_plddt) + plddt_per_atom = _categorical_mean(plddt_logits, start=0.0, end=1.0) + + sequence_length = single.shape[1] + plddt_sum = torch.zeros( + expanded_batch_size, + sequence_length, + device=single.device, + dtype=plddt_per_atom.dtype, + ) + atom_count = torch.zeros( + expanded_batch_size, + sequence_length, + device=single.device, + dtype=plddt_per_atom.dtype, + ) + atom_mask_t = atom_mask_f.to(plddt_per_atom.dtype) + plddt_sum.scatter_add_(1, atom_to_token_m, plddt_per_atom * atom_mask_t) + atom_count.scatter_add_(1, atom_to_token_m, atom_mask_t) + plddt = plddt_sum / atom_count.clamp(min=1e-6) + + complex_plddt = (plddt_per_atom * atom_mask_f).sum(dim=-1) / ( + atom_mask_f.sum(dim=-1) + _EPS + ) + + expanded_type = self._repeat_batch(mol_type, num_diffusion_samples) + expanded_asym = self._repeat_batch(asym_id, num_diffusion_samples) + is_ligand = (expanded_type == _NONPOLYMER_ID).float() + inter_chain = (expanded_asym.unsqueeze(-1) != expanded_asym.unsqueeze(-2)).float() + near_contact = (rep_distances < 8).float() + interface_per_token = (near_contact * inter_chain * (1.0 - is_ligand).unsqueeze(-1)).amax( + dim=-1 + ) + iplddt_weight = torch.where( + is_ligand.bool(), + torch.full_like(interface_per_token, 2.0), + interface_per_token, + ) + iplddt_weight_atoms = gather_token_to_atom( + iplddt_weight.unsqueeze(-1), atom_to_token_m + ).squeeze(-1) + atom_iplddt_w = atom_mask_f * iplddt_weight_atoms + complex_iplddt = (plddt_per_atom * atom_iplddt_w).sum(dim=-1) / ( + atom_iplddt_w.sum(dim=-1) + _EPS + ) + + plddt_ca = plddt_per_atom.gather(1, rep_idx_m) + + # PAE + pae_logits = self.pae_head(self.pae_ln(pair)) + pae = _categorical_mean(pae_logits, start=0.0, end=32.0).detach() + + # PDE + pde_logits = self.pde_head(self.pde_ln(pair)) + pde = _categorical_mean(pde_logits, start=0.0, end=32.0).detach() + + # Resolved (per-atom binary). + s_at_atoms_res = self.resolved_ln(s_at_atoms) + w_res = self.resolved_weight[intra_idx] + resolved_logits = torch.einsum("...c,...cb->...b", s_at_atoms_res, w_res) + + # pTM / ipTM from pae_logits. + n_bins = pae_logits.shape[-1] + bin_width = 32.0 / n_bins + bin_centers = torch.arange(0.5 * bin_width, 32.0, bin_width, device=pae_logits.device) + mask_f = mask.float() + n_residues = mask_f.sum(dim=-1, keepdim=True) + d0 = 1.24 * (n_residues.clamp(min=19) - 15) ** (1 / 3) - 1.8 + tm_per_bin = 1 / (1 + (bin_centers / d0) ** 2) + pae_probs = F.softmax(pae_logits, dim=-1) + tm_expected = (pae_probs * tm_per_bin[:, None, None, :]).sum(dim=-1) + + pair_mask_2d = mask_f.unsqueeze(-1) * mask_f.unsqueeze(-2) + ptm_per_row = (tm_expected * pair_mask_2d).sum(dim=-1) / (pair_mask_2d.sum(dim=-1) + _EPS) + ptm = ptm_per_row.max(dim=-1).values + + inter_chain_mask = ( + expanded_asym.unsqueeze(-1) != expanded_asym.unsqueeze(-2) + ).float() * pair_mask_2d + iptm_per_row = (tm_expected * inter_chain_mask).sum(dim=-1) / ( + inter_chain_mask.sum(dim=-1) + _EPS + ) + iptm = iptm_per_row.max(dim=-1).values + + max_chain_id = int(expanded_asym.max().item()) if expanded_batch_size > 0 else 0 + n_chains = max_chain_id + 1 + pair_chains_iptm = torch.zeros( + expanded_batch_size, + n_chains, + n_chains, + device=tm_expected.device, + dtype=tm_expected.dtype, + ) + for c1 in range(n_chains): + chain_c1 = (expanded_asym == c1).float() * mask_f + if chain_c1.sum() == 0: + continue + for c2 in range(n_chains): + chain_c2 = (expanded_asym == c2).float() * mask_f + pair_m = chain_c1.unsqueeze(-1) * chain_c2.unsqueeze(-2) + denom = pair_m.sum(dim=(-1, -2)) + _EPS + pair_chains_iptm[:, c1, c2] = (tm_expected * pair_m).sum(dim=(-1, -2)) / denom + + return { + "plddt_logits": plddt_logits, + "plddt": plddt.detach(), + "plddt_per_atom": plddt_per_atom.detach(), + "plddt_ca": plddt_ca.detach(), + "complex_plddt": complex_plddt.detach(), + "complex_iplddt": complex_iplddt.detach(), + "pae_logits": pae_logits, + "pae": pae, + "pde_logits": pde_logits, + "pde": pde, + "resolved_logits": resolved_logits, + "ptm": ptm.detach(), + "iptm": iptm.detach(), + "pair_chains_iptm": pair_chains_iptm.detach(), + } + + +def _inverse_softplus(value: float) -> float: + return value + math.log(-math.expm1(-value)) + + +def _convert_esmc_attention_outputs_to_te(module: nn.Module) -> tuple[str, ...]: + """Replace the 80 ESMC attention output projections with TE linears. + + Converting every ESMC linear compounds FP8 error across the 80-layer + network. The validated inference path limits FP8 GEMMs to each layer's + attention output projection. Transformer Engine retains canonical BF16 + parameters and creates runtime quantization workspaces during autocast. + """ + + te, _ = _load_transformer_engine() + converted: list[str] = [] + + def walk(owner: nn.Module, prefix: str = "") -> None: + for name, child in tuple(owner.named_children()): + path = f"{prefix}.{name}" if prefix else name + if isinstance(child, nn.Linear) and path.endswith(_ESMC_FP8_LINEAR_SUFFIX): + replacement = te.Linear( + child.in_features, + child.out_features, + bias=child.bias is not None, + params_dtype=child.weight.dtype, + device=child.weight.device, + ) + with torch.no_grad(): + replacement.weight.copy_(child.weight) + if child.bias is not None: + replacement.bias.copy_(child.bias) + replacement.eval().requires_grad_(False) + setattr(owner, name, replacement) + converted.append(path) + else: + walk(child, path) + + walk(module) + if len(converted) != _ESMC_FP8_EXPECTED_PROJECTIONS: + raise RuntimeError( + "ESMC FP8 conversion expected exactly " + f"{_ESMC_FP8_EXPECTED_PROJECTIONS} attention output projections, " + f"found {len(converted)}." + ) + return tuple(converted) + + +@contextmanager +def _lm_precision_context(precision: str, device: torch.device): + """Apply the resolved ESMC inference precision.""" + + if device.type != "cuda" or precision == "fp32": + yield + return + with torch.autocast(device_type="cuda", dtype=torch.bfloat16): + if precision == "fp8": + te, recipe = _load_transformer_engine() + fp8_recipe = recipe.Float8CurrentScaling( + use_power_2_scales=False, + fp8_format=recipe.Format.HYBRID, + ) + with te.autocast(enabled=True, recipe=fp8_recipe): + yield + else: + yield + + +class ESMFold2Model( + FastPLMTestTimeTrainingMixin, + ESMFold2EmbeddingMixin, + ESMFold2AttentionMixin, + PreTrainedModel, +): + """ESMFold2: all-atom structure prediction with an ESMC PLM backbone. + + This is the standard released ESMFold2 architecture (uses a linear- + recurrent trunk, internally referred to as "parcae"). + + Forward kwargs that callers commonly override: + + * ``num_loops`` (default ``config.num_loops``): trunk refinement + loops. + * ``num_diffusion_samples`` (default ``config.num_diffusion_samples``): + parallel structure samples; the confidence head re-runs once per + sample, so memory scales linearly. Pass ``1`` for cheap inference. + * ``num_sampling_steps`` (default ``config.structure_head.inference_num_steps``): + diffusion ODE solver steps. Lower for speed, higher for quality. + + Memory / perf knobs: + + * ``model.set_chunk_size(int|None)``: caps l^2 ops (triangle / OPM / + pair transition) at this token-axis chunk. Default 64: fits + l approximately 2k on an 80 GB GPU. Pass ``None`` for faster inference + when l is below 600. + * ``model.set_kernel_backend(None | "fused" | "cuequivariance")``: + select kernel backend (None = reference path). + """ + + config_class = ESMFold2Config + _keys_to_ignore_on_load_unexpected: ClassVar[list[str]] = [r"\._extra_state$"] + + def __init__(self, config: ESMFold2Config) -> None: + super().__init__(config) + d_inputs = config.inputs.d_inputs + d_pair = config.d_pair + + self.inputs_embedder = InputsEmbedder(config) + self.z_init_1 = nn.Linear(d_inputs, d_pair, bias=False) + self.z_init_2 = nn.Linear(d_inputs, d_pair, bias=False) + self.rel_pos = ResIdxAsymIdSymIdEntityIdEncoding( + n_relative_residx_bins=config.n_relative_residx_bins, + n_relative_chain_bins=config.n_relative_chain_bins, + d_pair=d_pair, + ) + self.token_bonds = nn.Linear(1, d_pair, bias=False) + self.language_model = LanguageModelShim( + d_z=d_pair, d_model=config.lm_d_model, num_layers=config.lm_num_layers + ) + self._esmc: nn.Module | None = None + self._esmc_fp8: bool = False + self._esmc_fp8_module_paths: tuple[str, ...] = () + self._esmc_source: str = config.esmc_id + self._esmc_source_revision: str | None = None + self._esmc_source_files: dict[str, str] = {} + self._esmc_local_files_only = False + self._esmc_precision_policy: str = str(getattr(config, "esmc_precision", "auto")) + self._esmc_precision_status = ESMCPrecisionStatus( + requested=self._esmc_precision_policy, + resolved="unloaded", + reason="ESMC has not been loaded.", + device=str(self.device), + transformer_engine_version=_transformer_engine_version(), + ) + self._ttt_lm_head: nn.Module | None = None + self._esmfold2_input_builder: Any | None = None + self._kernel_backend: str | None = None + + pf = config.folding_trunk + self.folding_trunk = FoldingTrunk(n_layers=pf.n_layers, d_pair=d_pair, expansion_ratio=4) + if config.lm_encoder.enabled: + self.lm_encoder: FoldingTrunk | None = FoldingTrunk( + n_layers=config.lm_encoder.n_layers, d_pair=d_pair, expansion_ratio=4 + ) + else: + self.lm_encoder = None + + self.parcae_input_norm = nn.LayerNorm(d_pair) + self.parcae_log_a = nn.Parameter(torch.zeros(d_pair)) + parcae_decay_init = math.sqrt(1.0 / 5.0) + parcae_delta_init = -math.log(parcae_decay_init) + self.parcae_log_delta = nn.Parameter( + torch.full((d_pair,), _inverse_softplus(parcae_delta_init), dtype=torch.float32) + ) + self.parcae_b_cont = nn.Parameter(torch.eye(d_pair)) + self.parcae_readout = nn.Linear(d_pair, d_pair, bias=False) + nn.init.eye_(self.parcae_readout.weight) + self.parcae_coda = FoldingTrunk( + n_layers=config.parcae.coda_n_layers, d_pair=d_pair, expansion_ratio=4 + ) + + # Heads -------------------------------------------------------------- + self.structure_head = DiffusionStructureHead(config) + self.distogram_head = nn.Linear(d_pair, config.structure_head.distogram_bins, bias=True) + self.confidence_head = ConfidenceHead(config) + + msa_cfg = config.msa_encoder + self.msa_encoder = None + if msa_cfg.enabled: + self.msa_encoder = MSAEncoder( + d_msa=msa_cfg.d_msa, + d_pair=d_pair, + d_inputs=d_inputs, + d_hidden=msa_cfg.d_hidden, + n_layers=msa_cfg.n_layers, + n_heads_msa=msa_cfg.n_heads_msa, + msa_head_width=msa_cfg.msa_head_width, + ) + + self.post_init() + self._register_state_dict_hook(_drop_transient_esmc_state) + self.init_ttt({"lora_target_replace_module": "MultiHeadAttention"}) + + @property + def esmc_precision_status(self) -> ESMCPrecisionStatus: + return self._esmc_precision_status + + def load_esmc( + self, + esmc_model_path: str, + precision: ESMCPrecision = "auto", + device: str | torch.device | None = None, + local_files_only: bool = False, + ) -> None: + """Load canonical ESMC weights and resolve the inference precision.""" + + _install_esmc_backbone( + self, + esmc_model_path, + precision=precision, + device=device, + local_files_only=local_files_only, + ) + + def reload_esmc( + self, + precision: ESMCPrecision = "auto", + device: str | torch.device | None = None, + local_files_only: bool | None = None, + ) -> None: + """Reload canonical weights with the requested precision policy.""" + + source = self._esmc_source or self.config.esmc_id + old_esmc = self._esmc + old_head = self._ttt_lm_head + self._esmc = None + self._esmc_fp8 = False + self._esmc_fp8_module_paths = () + self._ttt_lm_head = None + del old_esmc, old_head + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + self.load_esmc( + source, + precision=precision, + device=device, + local_files_only=( + self._esmc_local_files_only + if local_files_only is None + else local_files_only + ), + ) + + def _ensure_ttt_bf16(self) -> None: + if self._esmc_fp8: + _reload_esmc_bf16_for_gradients( + self, + reason="TTT requires BF16; the persisted serving policy is unchanged.", + ) + + def _ensure_ttt_lm_head(self) -> None: + self._ensure_ttt_bf16() + if self._esmc is None: + raise RuntimeError("ESMFold2 TTT requires load_esmc=True.") + if self._ttt_lm_head is not None: + return + from fastplms.models.esm_plusplus.modeling_esm_plusplus import ( + ESMplusplusConfig, + ESMplusplusForMaskedLM, + ) + + source = self._esmc_source or self.config.esmc_id + source_revision = self._esmc_source_revision + if source_revision is None: + source_revision, _ = _manifest_esmc_checkpoint_contract(source) + revision_kwargs: dict[str, Any] = { + "local_files_only": self._esmc_local_files_only, + } + if source_revision is not None: + revision_kwargs["revision"] = source_revision + esmc_config = ESMplusplusConfig.from_pretrained( + source, + **revision_kwargs, + ) + set_config_attn_implementation(esmc_config, get_attn_implementation(self.config)) + mlm, loading_info = ESMplusplusForMaskedLM.from_pretrained( + source, + config=esmc_config, + output_loading_info=True, + **revision_kwargs, + ) + missing_head_keys = [ + key for key in loading_info["missing_keys"] if key.startswith("sequence_head") + ] + if missing_head_keys: + raise RuntimeError( + "ESMFold2 TTT could not load a pretrained ESM++ MLM head from " + f"{source}: missing {missing_head_keys}" + ) + dtype = next(self._esmc.parameters()).dtype + mlm = mlm.to(device=self.device, dtype=dtype).eval() + self._ttt_lm_head = mlm.sequence_head + self._ttt_lm_head.requires_grad_(False) + del mlm + + def _ttt_get_trainable_modules(self) -> list[nn.Module]: + self._ensure_ttt_bf16() + if self._esmc is None: + raise RuntimeError("ESMFold2 TTT requires load_esmc=True.") + return [self._esmc] + + def _ttt_tokenize( + self, + seq: str | list[str] | None = None, + input_ids: torch.Tensor | None = None, + **kwargs, + ) -> torch.Tensor: + del kwargs + if input_ids is not None: + return input_ids + if seq is None: + raise ValueError("Pass either seq or input_ids for ESMFold2 TTT.") + sequences = [seq] if isinstance(seq, str) else seq + if not sequences: + raise ValueError("ESMFold2 TTT requires at least one protein sequence.") + token_to_id = {token: idx for idx, token in enumerate(SEQUENCE_VOCAB)} + encoded = [] + for sequence in sequences: + token_ids = [SEQUENCE_BOS_TOKEN] + for amino_acid in sequence: + token_ids.append(token_to_id[amino_acid if amino_acid in token_to_id else "X"]) + token_ids.append(SEQUENCE_EOS_TOKEN) + encoded.append(token_ids) + max_len = max(len(token_ids) for token_ids in encoded) + input_tensor = torch.full( + (len(encoded), max_len), + SEQUENCE_PAD_TOKEN, + dtype=torch.long, + ) + for row, token_ids in enumerate(encoded): + input_tensor[row, : len(token_ids)] = torch.tensor( + token_ids, + dtype=torch.long, + ) + return input_tensor + + def _ttt_mask_token(self) -> int: + return SEQUENCE_MASK_TOKEN + + def _ttt_padding_token(self) -> int: + return SEQUENCE_PAD_TOKEN + + def _ttt_replacement_tokens(self, input_ids: torch.Tensor) -> torch.Tensor: + return torch.arange( + SEQUENCE_STANDARD_AA_MIN_TOKEN, + SEQUENCE_STANDARD_AA_MAX_TOKEN, + device=input_ids.device, + dtype=input_ids.dtype, + ) + + def _ttt_non_special_mask(self, input_ids: torch.Tensor) -> torch.Tensor: + return (input_ids >= SEQUENCE_STANDARD_AA_MIN_TOKEN) & ( + input_ids < SEQUENCE_STANDARD_AA_MAX_TOKEN + ) + + def _ttt_predict_logits( + self, + batch: torch.Tensor | dict[str, torch.Tensor], + **kwargs, + ) -> torch.Tensor: + del kwargs + if not isinstance(batch, torch.Tensor): + raise TypeError("ESMFold2 TTT expects input_ids tensors.") + self._ensure_ttt_bf16() + if self._esmc is None: + raise RuntimeError("ESMFold2 TTT requires load_esmc=True.") + self._ensure_ttt_lm_head() + if self._ttt_lm_head is None: + raise RuntimeError("ESMFold2 TTT MLM head initialization failed.") + attention_mask = batch.ne(SEQUENCE_PAD_TOKEN) + output = self._esmc( + input_ids=batch, + attention_mask=attention_mask, + return_dict=True, + compute_sae=False, + ) + return self._ttt_lm_head(output.last_hidden_state) + + @classmethod + def from_pretrained( + cls, pretrained_model_name_or_path, *args, load_esmc: bool = True, **kwargs + ): + if cls is ESMFold2Model and "config" not in kwargs: + config = ESMFold2Config.from_pretrained(pretrained_model_name_or_path, **kwargs) + if config.type == "experimental": + raise ValueError( + "FastPLMs ESMFold2 supports the released ESMFold2 and " + "ESMFold2-Fast checkpoints. Experimental ESMFold2 configs " + "are not part of the self-contained AutoModel package." + ) + kwargs["config"] = config + # Pop the precision knob before forwarding to the HF loader. + esmc_precision = kwargs.pop("esmc_precision", None) + local_files_only = bool(kwargs.get("local_files_only", False)) + output_loading_info = bool(kwargs.get("output_loading_info", False)) + loaded = super().from_pretrained(pretrained_model_name_or_path, *args, **kwargs) + if output_loading_info: + model, loading_info = loaded + else: + model = loaded + if load_esmc: + model.load_esmc( + model.config.esmc_id, + precision=esmc_precision or model.config.esmc_precision, + local_files_only=local_files_only, + ) + return (model, loading_info) if output_loading_info else model + + def set_kernel_backend(self, backend: str | None) -> None: + """Select kernel backend. + + Args: + backend: ``None`` (reference path), ``"fused"`` (requires the + unavailable source-built Triton bundle), or + ``"cuequivariance"`` (requires the ``structure,cueq`` extras + on a supported Linux CUDA 13 host). + """ + validate_kernel_backend(backend) + self.folding_trunk.set_kernel_backend(backend) + if self.lm_encoder is not None: + self.lm_encoder.set_kernel_backend(backend) + self.parcae_coda.set_kernel_backend(backend) + self.confidence_head.set_kernel_backend(backend) + self.structure_head.set_kernel_backend(backend) + self._kernel_backend = backend + + def apply_torch_compile(self, mode: str = "fixed_seqlen", dynamic: bool | None = None) -> None: + """Compile l^2-heavy blocks. + + ``mode='fixed_seqlen'`` recompiles per l; ``'dynamic_seqlen'`` compiles + once. + + Does NOT stack with our Triton kernels: call ``set_kernel_backend(None)`` + before compiling. + """ + if dynamic is None: + dynamic = mode == "dynamic_seqlen" + kwargs: dict = {"dynamic": dynamic} + + from .modeling_esmfold2_common import ( + DiffusionModule, + DiffusionTransformer, + PairUpdateBlock, + ) + + compile_targets = ( + PairUpdateBlock, + DiffusionTransformer, + DiffusionModule, + MSAEncoderBlock, + ) + + def _maybe_compile(module: nn.Module) -> None: + if isinstance(module, compile_targets): + module.forward = torch.compile(module.forward, **kwargs) # type: ignore[assignment] + + self.apply(_maybe_compile) + + def set_chunk_size(self, chunk_size: int | None) -> None: + self.folding_trunk.set_chunk_size(chunk_size) + if self.lm_encoder is not None: + self.lm_encoder.set_chunk_size(chunk_size) + self.parcae_coda.set_chunk_size(chunk_size) + self.confidence_head.set_chunk_size(chunk_size) + if self.msa_encoder is not None: + self.msa_encoder.set_chunk_size(chunk_size) + + def _compute_lm_hidden_states( + self, + input_ids: Tensor, + asym_id: Tensor, + residue_index: Tensor, + mol_type: Tensor, + tok_mask: Tensor, + lm_mask_pct: float = 0.0, + ) -> Tensor: + if self._esmc_fp8 and torch.is_grad_enabled(): + _reload_esmc_bf16_for_gradients( + self, + reason=( + "Gradient-enabled ESMC execution requires BF16; the persisted " + "serving policy is unchanged." + ), + ) + if self._esmc is None: + raise RuntimeError("ESMFold2 requires load_esmc=True for LM feature extraction.") + # Transformer Engine FP8 kernels require l to be a multiple of 16. + pad_to = 16 if self._esmc_fp8 else None + with _lm_precision_context(self._esmc_precision_status.resolved, self.device): + return compute_lm_hidden_states( + self._esmc, + input_ids, + asym_id, + residue_index, + mol_type, + tok_mask, + pad_to_multiple=pad_to, + lm_mask_pct=lm_mask_pct, + mask_token_id=SEQUENCE_MASK_TOKEN, + ) + + def _discretized_dynamics(self) -> tuple[Tensor, Tensor]: + delta = F.softplus(self.parcae_log_delta) + a = torch.exp(-delta * torch.exp(self.parcae_log_a)) + b = delta[:, None] * self.parcae_b_cont + return a, b + + def _init_pair_state(self, ref: Tensor) -> Tensor: + std = math.sqrt(2.0 / (5.0 * ref.shape[-1])) + state = torch.empty_like(ref, dtype=torch.float32) + nn.init.trunc_normal_(state, mean=0.0, std=std, a=-3 * std, b=3 * std) + return state.to(dtype=ref.dtype) + + def _run_one_loop( + self, + z: Tensor, + z_init: Tensor, + lm_z: Tensor | None, + _msa_inputs: dict | None, + pair_mask: Tensor, + a: Tensor, + b_mat: Tensor, + tok_mask: Tensor, + total_steps: int, + ) -> Tensor: + # Helper method (not inline) so per-iter locals free on return: + # otherwise leaks about 2 GB of l^2 * c_z data into distogram/sample scope. + # training=True forces dropout under eval(), matching the per-loop + # dropout strategy used at train time. + lm_cfg = self.config.lm_encoder + _per_loop_lm_dropout = ( + lm_z is not None + and getattr(lm_cfg, "per_loop_lm_dropout", False) + and getattr(lm_cfg, "lm_dropout", 0.0) > 0.0 + ) + _lm_dropout_p = getattr(lm_cfg, "lm_dropout", 0.0) + + for _ in range(total_steps): + if _per_loop_lm_dropout: + if lm_z is None: + raise RuntimeError("Per-loop LM dropout requires LM pair features.") + lm_z_i: Tensor | None = F.dropout(lm_z, p=_lm_dropout_p, training=True) + else: + lm_z_i = lm_z + + refined_lm_z: Tensor | None = None + if lm_z_i is not None and self.lm_encoder is not None: + refined_lm_z = self.lm_encoder( + lm_z_i.to(z_init.dtype), pair_attention_mask=pair_mask + ) + + z_inject_pair = z_init + if lm_z_i is not None and self.lm_encoder is None: + z_inject_pair = z_inject_pair + lm_z_i.to(z_inject_pair.dtype) + + if self.msa_encoder is not None and _msa_inputs is not None: + msa_i, mask_i, hd_i, dv_i = maybe_subsample_msa( + _msa_inputs["msa"], + _msa_inputs["msa_attention_mask"], + _msa_inputs["has_deletion"], + _msa_inputs["deletion_value"], + max_depth=_msa_inputs["max_depth"], + enabled=_msa_inputs["subsample_enabled"], + ) + b_msa, m, l_msa = msa_i.shape + msa_oh = F.one_hot(msa_i.permute(0, 2, 1).long(), num_classes=NUM_RES_TYPES).float() + msa_attn = ( + mask_i.permute(0, 2, 1).float() + if mask_i is not None + else tok_mask[:, :, None].expand(-1, -1, m).float() + ) + # Bias-free MSAEncoder.embed requires zeroed padding. + msa_oh = msa_oh * msa_attn.unsqueeze(-1) + hd = ( + hd_i.permute(0, 2, 1).float() + if hd_i is not None + else torch.zeros(b_msa, l_msa, m, device=msa_i.device) + ) + dv = ( + dv_i.permute(0, 2, 1).float() + if dv_i is not None + else torch.zeros(b_msa, l_msa, m, device=msa_i.device) + ) + msa_pair = self.msa_encoder( + x_pair=z_inject_pair, + x_inputs=_msa_inputs["x_inputs"], + msa_oh=msa_oh, + has_deletion=hd, + deletion_value=dv, + msa_attention_mask=msa_attn, + ).to(z_inject_pair.dtype) + z_inject_pair = ( + msa_pair if self.config.msa_encoder_overwrite else (z_inject_pair + msa_pair) + ) + + if refined_lm_z is not None: + z_inject_pair = z_inject_pair + refined_lm_z.to(z_inject_pair.dtype) + + injected_pair = self.parcae_input_norm(z_inject_pair) + z = a * z + F.linear(injected_pair.to(z.dtype), b_mat) + z = self.folding_trunk(z, pair_attention_mask=pair_mask) + + return z + + def forward( + self, + token_index: Tensor, + residue_index: Tensor, + asym_id: Tensor, + sym_id: Tensor, + entity_id: Tensor, + mol_type: Tensor, + res_type: Tensor, + token_bonds: Tensor, + token_attention_mask: Tensor, + ref_pos: Tensor, + ref_element: Tensor, + ref_charge: Tensor, + ref_atom_name_chars: Tensor, + ref_space_uid: Tensor, + atom_attention_mask: Tensor, + atom_to_token: Tensor, + distogram_atom_idx: Tensor, + deletion_mean: Tensor | None = None, + msa: Tensor | None = None, + has_deletion: Tensor | None = None, + deletion_value: Tensor | None = None, + msa_attention_mask: Tensor | None = None, + input_ids: Tensor | None = None, + lm_hidden_states: Tensor | None = None, + num_loops: int | None = None, + num_diffusion_samples: int | None = None, + num_sampling_steps: int | None = None, + lm_mask_pct: float | None = None, + msa_max_depth: int = 1024, + msa_column_mask_rate: float = 0.1, + msa_subsample_at_inference: bool = True, + early_exit: bool = False, + noise_scale: float | None = None, + step_scale: float | None = None, + max_inference_sigma: float | None = None, + output_attentions: bool | None = None, + output_hidden_states: bool | None = None, + return_dict: bool | None = None, + ) -> ESMFold2Output | tuple[Any, ...]: + output_hidden_states, return_dict = _resolve_structure_output_controls( + self.config, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + ) + validate_msa_conditioning_inputs( + self.config, + msa=msa, + msa_attention_mask=msa_attention_mask, + has_deletion=has_deletion, + deletion_value=deletion_value, + deletion_mean=deletion_mean, + ) + tok_mask = token_attention_mask + atm_mask = atom_attention_mask + disto_idx = distogram_atom_idx + + n_loops: int = num_loops if num_loops is not None else self.config.num_loops + n_samples: int = ( + num_diffusion_samples + if num_diffusion_samples is not None + else self.config.num_diffusion_samples + ) + total_steps = max(1, n_loops + 1) + + if res_type.dim() == 2: + res_type_oh = F.one_hot(res_type.long(), num_classes=NUM_RES_TYPES).float() + res_type_oh = res_type_oh * tok_mask.unsqueeze(-1).float() + else: + res_type_oh = res_type.float() + + if msa is not None: + msa_oh_profile = F.one_hot(msa.long(), num_classes=NUM_RES_TYPES).float() + if msa_attention_mask is not None: + mask_f = msa_attention_mask.float().unsqueeze(-1) + msa_oh_profile = msa_oh_profile * mask_f + valid_seq_count = msa_attention_mask.float().sum(dim=1).clamp(min=1) + profile = msa_oh_profile.sum(dim=1) / valid_seq_count.unsqueeze(-1) + else: + profile = msa_oh_profile.mean(dim=1) + else: + profile = res_type_oh + + if deletion_mean is None: + deletion_mean = torch.zeros( + res_type.shape[0], res_type.shape[1], device=res_type.device + ) + + ref_element_oh = F.one_hot(ref_element.long(), num_classes=MAX_ATOMIC_NUMBER).float() + ref_atom_name_chars_oh = F.one_hot( + ref_atom_name_chars.long(), num_classes=CHAR_VOCAB_SIZE + ).float() + # Bias-free downstream Linears require zeroed padding. + atm_mask_f = atm_mask.float() + ref_element_oh = ref_element_oh * atm_mask_f.unsqueeze(-1) + ref_atom_name_chars_oh = ref_atom_name_chars_oh * atm_mask_f.unsqueeze(-1).unsqueeze(-1) + atom_to_token = atom_to_token * atm_mask.long() + + use_amp = ref_pos.device.type == "cuda" + with torch.amp.autocast("cuda", enabled=use_amp, dtype=torch.bfloat16): + x_inputs = self.inputs_embedder( + aatype=res_type_oh, + profile=profile.float(), + deletion_mean=deletion_mean.float(), + ref_pos=ref_pos, + atom_attention_mask=atm_mask, + ref_space_uid=ref_space_uid, + ref_charge=ref_charge, + ref_element=ref_element_oh, + ref_atom_name_chars=ref_atom_name_chars_oh, + atom_to_token=atom_to_token, + ) + + z_init = self.z_init_1(x_inputs).unsqueeze(2) + self.z_init_2(x_inputs).unsqueeze(1) + + relative_position_encoding = self.rel_pos( + residue_index=residue_index, + asym_id=asym_id, + sym_id=sym_id, + entity_id=entity_id, + token_index=token_index, + ) + token_bonds_encoding = self.token_bonds(token_bonds.float()) + z_init = z_init + relative_position_encoding + token_bonds_encoding + + if lm_hidden_states is None and input_ids is not None and self._esmc is not None: + lm_hidden_states = self._compute_lm_hidden_states( + input_ids, + asym_id, + residue_index, + mol_type, + tok_mask, + lm_mask_pct=(self.config.lm_mask_pct if lm_mask_pct is None else lm_mask_pct), + ) + lm_z: Tensor | None = None + if lm_hidden_states is not None: + lm_z = self.language_model(lm_hidden_states.detach()) + del lm_hidden_states + + pair_mask = tok_mask[:, :, None].float() * tok_mask[:, None, :].float() + + z = self._init_pair_state(z_init) + + a, b = self._discretized_dynamics() + a = a.view(1, 1, 1, -1).to(device=z.device, dtype=z.dtype) + b_mat = b.to(device=z.device, dtype=z.dtype) + + _msa_inputs: dict | None = None + if self.msa_encoder is not None and msa is not None: + msa_attention_mask = maybe_apply_msa_column_masking( + msa_attention_mask, + msa_column_mask_rate, + ) + _msa_inputs = dict( + x_inputs=x_inputs, + msa=msa, + msa_attention_mask=msa_attention_mask, + has_deletion=has_deletion, + deletion_value=deletion_value, + max_depth=msa_max_depth, + subsample_enabled=msa_subsample_at_inference, + ) + + # Method call (not inline loop) frees per-iteration l^2 * c_z locals. + z = self._run_one_loop( + z=z, + z_init=z_init, + lm_z=lm_z, + _msa_inputs=_msa_inputs, + pair_mask=pair_mask, + a=a, + b_mat=b_mat, + tok_mask=tok_mask, + total_steps=total_steps, + ) + del z_init, lm_z, _msa_inputs, a, b_mat + + z = self.parcae_readout(z) + z = self.parcae_coda(z, pair_attention_mask=pair_mask) + + z = z.float() + distogram_logits = self.distogram_head(z + z.transpose(-2, -3)) + + structure_output = self.structure_head.sample( + z_trunk=z, + s_inputs=x_inputs, + s_trunk=None, + relative_position_encoding=relative_position_encoding, + ref_pos=ref_pos, + ref_charge=ref_charge, + ref_mask=atm_mask, + ref_element=ref_element_oh, + ref_atom_name_chars=ref_atom_name_chars_oh, + ref_space_uid=ref_space_uid, + tok_idx=atom_to_token, + asym_id=asym_id, + residue_index=residue_index, + entity_id=entity_id, + token_index=token_index, + sym_id=sym_id, + token_attention_mask=tok_mask, + num_diffusion_samples=n_samples, + num_sampling_steps=num_sampling_steps, + max_inference_sigma=max_inference_sigma, + noise_scale=noise_scale, + step_scale=step_scale, + return_atom_repr=False, + denoising_early_exit_rmsd=(0.10 if early_exit else None), + ) + + sample_coords = structure_output["sample_atom_coords"] + if sample_coords is None: + raise RuntimeError("ESMFold2 structure sampling did not return coordinates.") + output: dict[str, Tensor] = {"distogram_logits": distogram_logits} + output["sample_atom_coords"] = sample_coords + + confidence_output = self.confidence_head( + s_inputs=x_inputs.detach(), + z=z.detach().float(), + x_pred=sample_coords.detach(), + distogram_atom_idx=disto_idx, + token_attention_mask=tok_mask, + atom_to_token=atom_to_token, + atom_attention_mask=atm_mask, + asym_id=asym_id, + mol_type=mol_type, + num_diffusion_samples=n_samples, + relative_position_encoding=relative_position_encoding.detach(), + token_bonds_encoding=token_bonds_encoding.detach(), + ) + output.update(confidence_output) + output["atom_pad_mask"] = atm_mask.unsqueeze(0) if atm_mask.dim() == 1 else atm_mask + output["residue_index"] = residue_index + output["entity_id"] = entity_id + return _finalize_structure_output( + output, + token_input_state=x_inputs, + pair_state=z, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + ) + + @torch.no_grad() + def infer_protein(self, seq: str, **forward_kwargs) -> ESMFold2Output: + from .protein_utils import prepare_protein_features + + if forward_kwargs.pop("return_dict", True) is not True: + raise ValueError( + "infer_protein always returns a mapping; return_dict=False is invalid." + ) + features = prepare_protein_features(seq) + if not self.config.msa_conditioning: + for name in MSA_CONDITIONING_INPUT_NAMES: + features.pop(name, None) + features = {k: v.to(self.device) for k, v in features.items()} + return self(**features, **forward_kwargs, return_dict=True) + + @property + def input_builder(self): + if self._esmfold2_input_builder is None: + from .esmfold2_processor import ESMFold2InputBuilder + + self._esmfold2_input_builder = ESMFold2InputBuilder() + return self._esmfold2_input_builder + + @property + def input_types(self): + from . import esmfold2_types + + return esmfold2_types + + def prepare_structure_input(self, input, seed: int | None = None): + return self.input_builder.prepare_model_input( + self, + input, + seed=seed, + device=self.device, + ) + + def fold( + self, + input, + *, + num_loops: int = 3, + num_sampling_steps: int = 50, + num_diffusion_samples: int = 1, + seed: int | None = None, + noise_scale: float | None = None, + step_scale: float | None = None, + max_inference_sigma: int | None = None, + early_exit: bool = False, + complex_id: str = "pred", + ): + return self.input_builder.fold( + self, + input, + num_loops=num_loops, + num_sampling_steps=num_sampling_steps, + num_diffusion_samples=num_diffusion_samples, + seed=seed, + noise_scale=noise_scale, + step_scale=step_scale, + max_inference_sigma=max_inference_sigma, + early_exit=early_exit, + complex_id=complex_id, + ) + + def _fold_protein_no_ttt( + self, + sequence: str, + *, + chain_id: str = "A", + msa: Any | None = None, + msa_path: str | Path | None = None, + msa_max_sequences: int | None = None, + num_loops: int = 3, + num_sampling_steps: int = 50, + num_diffusion_samples: int = 1, + seed: int | None = None, + complex_id: str = "pred", + ): + from .esmfold2_types import MSA, ProteinInput, StructurePredictionInput + + if msa is not None and msa_path is not None: + raise ValueError("Pass at most one of msa or msa_path.") + if msa_path is not None: + msa = MSA.from_a3m(msa_path, max_sequences=msa_max_sequences) + if msa is not None: + query = str(msa.query).replace("-", "").upper() + if query != sequence.upper(): + raise ValueError( + "MSA query does not match sequence: " + f"expected {sequence.upper()!r}, got {query!r}" + ) + + input = StructurePredictionInput( + sequences=[ProteinInput(id=chain_id, sequence=sequence, msa=msa)] + ) + return self.fold( + input, + num_loops=num_loops, + num_sampling_steps=num_sampling_steps, + num_diffusion_samples=num_diffusion_samples, + seed=seed, + complex_id=complex_id, + ) + + @staticmethod + def _ttt_mean_plddt(result) -> float: + if result.plddt is None: + raise RuntimeError("ESMFold2 result has no pLDDT tensor.") + return float(result.plddt.float().mean().item()) + + def _ttt_select_result(self, result): + if isinstance(result, list): + if not result: + raise RuntimeError("ESMFold2 fold returned an empty result list.") + return max(result, key=self._ttt_mean_plddt) + return result + + def _ttt_eval_step( + self, + step: int, + loss: float, + seq: str | list[str] | None = None, + input_ids: torch.Tensor | None = None, + **kwargs, + ) -> tuple[dict[str, Any], float | None]: + del input_ids + if not isinstance(seq, str): + raise TypeError("ESMFold2 fold TTT is protein-only and sequence-string only.") + fold_kwargs = kwargs["fold_kwargs"] + was_training = self.training + self.eval() + try: + result = self._fold_protein_no_ttt(seq, **fold_kwargs) + finally: + self.train(was_training) + selected = self._ttt_select_result(result) + plddt = self._ttt_mean_plddt(selected) + return { + "step": step, + "loss": loss, + "plddt": plddt, + "result": selected, + }, plddt + + def fold_protein( + self, + sequence: str, + *, + chain_id: str = "A", + msa: Any | None = None, + msa_path: str | Path | None = None, + msa_max_sequences: int | None = None, + num_loops: int = 3, + num_sampling_steps: int = 50, + num_diffusion_samples: int = 1, + seed: int | None = None, + complex_id: str = "pred", + ttt: bool = False, + ttt_config: TTTConfig | dict[str, Any] | None = None, + ): + if ttt: + return self.fold_protein_ttt( + sequence=sequence, + chain_id=chain_id, + msa=msa, + msa_path=msa_path, + msa_max_sequences=msa_max_sequences, + num_loops=num_loops, + num_sampling_steps=num_sampling_steps, + num_diffusion_samples=num_diffusion_samples, + seed=seed, + complex_id=complex_id, + ttt_config=ttt_config, + ) + return self._fold_protein_no_ttt( + sequence=sequence, + chain_id=chain_id, + msa=msa, + msa_path=msa_path, + msa_max_sequences=msa_max_sequences, + num_loops=num_loops, + num_sampling_steps=num_sampling_steps, + num_diffusion_samples=num_diffusion_samples, + seed=seed, + complex_id=complex_id, + ) + + def fold_protein_ttt( + self, + sequence: str, + *, + chain_id: str = "A", + msa: Any | None = None, + msa_path: str | Path | None = None, + msa_max_sequences: int | None = None, + num_loops: int = 3, + num_sampling_steps: int = 50, + num_diffusion_samples: int = 1, + seed: int | None = None, + complex_id: str = "pred", + ttt_config: TTTConfig | dict[str, Any] | None = None, + ): + self._ensure_ttt_bf16() + if self._esmc is None: + raise RuntimeError("ESMFold2 TTT requires load_esmc=True.") + fold_kwargs = { + "chain_id": chain_id, + "msa": msa, + "msa_path": msa_path, + "msa_max_sequences": msa_max_sequences, + "num_loops": num_loops, + "num_sampling_steps": num_sampling_steps, + "num_diffusion_samples": num_diffusion_samples, + "seed": seed, + "complex_id": complex_id, + } + baseline = self._ttt_select_result(self._fold_protein_no_ttt(sequence, **fold_kwargs)) + baseline_plddt = self._ttt_mean_plddt(baseline) + best_result = baseline + best_plddt = baseline_plddt + best_step = 0 + step_plddts = [baseline_plddt] + + cfg = self.ttt_config.merged(ttt_config).merged( + {"eval_each_step": True, "automatic_best_state_reset": False} + ) + try: + metrics = self.ttt( + seq=sequence, + ttt_config=cfg, + fold_kwargs=fold_kwargs, + ) + for step_metric in metrics["step_metrics"]: + step_plddt = step_metric["plddt"] + step_plddts.append(step_plddt) + if step_plddt > best_plddt: + best_plddt = step_plddt + best_step = step_metric["step"] + best_result = step_metric["result"] + best_result.ttt_metrics = { + "losses": metrics["losses"], + "step_plddts": step_plddts, + "baseline_plddt": baseline_plddt, + "best_plddt": best_plddt, + "best_step": best_step, + } + return best_result + finally: + if "_ttt_initialized" in self.__dict__ and self._ttt_initialized: + self.ttt_reset() + + @staticmethod + def result_to_cif(result) -> str: + if isinstance(result, list): + raise TypeError("Pass one MolecularComplexResult at a time.") + return result.complex.to_mmcif() + + @staticmethod + def result_to_pdb(result) -> str: + if isinstance(result, list): + raise TypeError("Pass one MolecularComplexResult at a time.") + return result.complex.to_protein_complex().to_pdb_string() + + def save_as_cif(self, result, output_path: str | Path) -> None: + Path(output_path).write_text(self.result_to_cif(result)) + + def save_as_pdb(self, result, output_path: str | Path) -> None: + Path(output_path).write_text(self.result_to_pdb(result)) + + def infer_protein_as_cif(self, seq: str, **forward_kwargs) -> str: + return self.result_to_cif(self.fold_protein(seq, **forward_kwargs)) + + def infer_protein_as_pdb(self, seq: str, **forward_kwargs) -> str: + return self.result_to_pdb(self.fold_protein(seq, **forward_kwargs)) + + +class MSAEncoderBlock(nn.Module): + """One MSA encoder block: OPM into pair, MSA pair-weighted averaging, triangle update.""" + + def __init__( + self, + d_msa: int, + d_pair: int, + d_hidden: int, + n_heads_msa: int, + msa_head_width: int, + is_final_block: bool = False, + ) -> None: + super().__init__() + self.is_final_block = is_final_block + self.outer_product_mean = OuterProductMean(d_msa, d_hidden, d_pair) + if not is_final_block: + self.msa_pair_weighted_averaging = MSAPairWeightedAveraging( + d_msa, d_pair, n_heads_msa, msa_head_width + ) + self.msa_transition = PairTransition(d_msa, expansion_ratio=4) + self.tri_mul_out = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=True) + self.tri_mul_in = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=False) + self.pair_transition = PairTransition(d_pair, expansion_ratio=4) + + def set_chunk_size(self, chunk_size: int | None) -> None: + self.outer_product_mean.set_chunk_size(chunk_size) + self.tri_mul_out.set_chunk_size(chunk_size) + self.tri_mul_in.set_chunk_size(chunk_size) + if not self.is_final_block: + self.msa_transition.set_chunk_size(chunk_size) + self.pair_transition.set_chunk_size(chunk_size) + + def forward( + self, + m: Tensor, + pair: Tensor, + msa_attention_mask: Tensor, + pair_attention_mask: Tensor, + ) -> tuple[Tensor, Tensor]: + pair = pair + self.outer_product_mean(m, msa_attention_mask) + if not self.is_final_block: + m = m + self.msa_pair_weighted_averaging(m, pair, pair_attention_mask) + m = m + self.msa_transition(m) + pair = pair + self.tri_mul_out(pair, mask=pair_attention_mask) + pair = pair + self.tri_mul_in(pair, mask=pair_attention_mask) + pair = pair + self.pair_transition(pair) + return m, pair + + +class MSAEncoder(nn.Module): + """Stack of [`MSAEncoderBlock`] layers that conditions the pair on an MSA.""" + + def __init__( + self, + d_msa: int, + d_pair: int, + d_inputs: int, + d_hidden: int = 32, + n_layers: int = 4, + n_heads_msa: int = 8, + msa_head_width: int = 16, + ) -> None: + super().__init__() + self.embed = nn.Linear(35, d_msa, bias=False) + self.project_inputs = nn.Linear(d_inputs, d_msa, bias=False) + self.blocks = nn.ModuleList( + [ + MSAEncoderBlock( + d_msa=d_msa, + d_pair=d_pair, + d_hidden=d_hidden, + n_heads_msa=n_heads_msa, + msa_head_width=msa_head_width, + is_final_block=(i == n_layers - 1), + ) + for i in range(n_layers) + ] + ) + + def set_chunk_size(self, chunk_size: int | None) -> None: + for block in self.blocks: + cast(MSAEncoderBlock, block).set_chunk_size(chunk_size) + + def forward( + self, + x_pair: Tensor, + x_inputs: Tensor, + msa_oh: Tensor, + has_deletion: Tensor, + deletion_value: Tensor, + msa_attention_mask: Tensor, + ) -> Tensor: + # Every input tensor is pre-transposed to shape (b, l, m, ...) before this call. + m_feat = torch.cat( + [msa_oh, has_deletion.unsqueeze(-1), deletion_value.unsqueeze(-1)], dim=-1 + ) + m = self.embed(m_feat) + self.project_inputs(x_inputs).unsqueeze(2) + tok_mask = msa_attention_mask[:, :, 0].bool() + pair_attention_mask = tok_mask.unsqueeze(2) & tok_mask.unsqueeze(1) + for block in self.blocks: + m, x_pair = block(m, x_pair, msa_attention_mask, pair_attention_mask) + return x_pair diff --git a/fastplms/models/esmfold2/modeling_esmfold2_common.py b/fastplms/models/esmfold2/modeling_esmfold2_common.py new file mode 100644 index 0000000000000000000000000000000000000000..c59005bdc2ae3ab85b5f72fe33c9ef3093b555a6 --- /dev/null +++ b/fastplms/models/esmfold2/modeling_esmfold2_common.py @@ -0,0 +1,2699 @@ +# Copyright 2026 Biohub. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +"""Shared building blocks for ESMFold2 HuggingFace model variants.""" + +from __future__ import annotations + +import importlib +from functools import partial +from importlib.util import find_spec +from typing import ClassVar, cast + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch import Tensor +from torch.utils.checkpoint import checkpoint + +from .configuration_esmfold2 import ESMFold2Config +from .reproducibility import seed_context + +_seed_context = seed_context + +try: + if find_spec("cuequivariance_ops_torch") is None: + raise ImportError("cuequivariance_ops_torch is unavailable") + cue_module = importlib.import_module("cuequivariance_torch") + _cue_attn_pair_bias = cue_module.attention_pair_bias + _cue_tri_mul = cue_module.triangle_multiplicative_update + + CUE_AVAILABLE = True +except (AttributeError, ImportError): + _cue_attn_pair_bias = None # type: ignore[assignment] + _cue_tri_mul = None # type: ignore[assignment] + CUE_AVAILABLE = False + +# Biohub ships optional source-built Triton helpers. FastPLMs does not bundle or +# compile them; these placeholders retain checkpoint-compatible control flow +# while the portable PyTorch path remains flat and self-contained. +_fused_pair_bias = None +_fused_trimul_with_residual = None +_FusedLNLinearSwiGLU = None +_FusedDropoutResidual = None +TRITON_KERNELS_AVAILABLE = False + +BACKEND_FUSED = "fused" +BACKEND_CUEQ = "cuequivariance" +_VALID_BACKENDS = (None, BACKEND_FUSED, BACKEND_CUEQ) +MSA_CONDITIONING_INPUT_NAMES = ( + "msa", + "msa_attention_mask", + "has_deletion", + "deletion_value", + "deletion_mean", +) + + +def validate_kernel_backend(backend: str | None) -> None: + """Fail before mutating modules when a named kernel cannot execute.""" + + if backend not in _VALID_BACKENDS: + raise ValueError(f"backend must be one of {_VALID_BACKENDS}, got {backend!r}") + if backend == BACKEND_FUSED and not TRITON_KERNELS_AVAILABLE: + raise RuntimeError( + "backend='fused' is unavailable because FastPLMs does not bundle the " + "source-built ESMFold2 Triton kernels." + ) + if backend == BACKEND_CUEQ and not CUE_AVAILABLE: + raise RuntimeError( + "backend='cuequivariance' requires cuequivariance_torch and the CUDA 13 " + "cuequivariance_ops_torch runtime. Install FastPLMs with the " + "'structure,cueq' extras on a supported Linux CUDA 13 host." + ) + + +def validate_msa_conditioning_inputs( + config: ESMFold2Config, + *, + msa: Tensor | None, + msa_attention_mask: Tensor | None, + has_deletion: Tensor | None, + deletion_value: Tensor | None, + deletion_mean: Tensor | None, +) -> None: + """Reject MSA-derived tensors for checkpoints trained without MSA conditioning.""" + + if config.msa_conditioning: + return + values = { + "msa": msa, + "msa_attention_mask": msa_attention_mask, + "has_deletion": has_deletion, + "deletion_value": deletion_value, + "deletion_mean": deletion_mean, + } + provided = sorted(name for name, value in values.items() if value is not None) + if provided: + raise ValueError( + "This ESMFold2 checkpoint was trained without MSA conditioning and rejects " + f"MSA-derived inputs: {', '.join(provided)}." + ) + + +def _fused_active(module: nn.Module, tensor: Tensor) -> bool: + """Return whether an optional fused implementation can handle this call.""" + return ( + TRITON_KERNELS_AVAILABLE + and getattr(module, "_kernel_backend", None) == BACKEND_FUSED + and not torch.is_grad_enabled() + and tensor.is_cuda + ) + + +def _cueq_active(module: nn.Module) -> bool: + return CUE_AVAILABLE and getattr(module, "_kernel_backend", None) == BACKEND_CUEQ + + +class DropoutResidual(nn.Module): + """``residual + dropout(delta)`` with row/col-shared dropout. + + Same signature on both paths. ``use_fused_kernels=True`` + ``batch_dim=1`` + routes through ``FusedDropoutResidual`` (single-pass over pair tensor, + in-place residual add). Falls back to unfused otherwise. + """ + + def __init__(self, r: float, batch_dim: int, use_fused_kernels: bool = False) -> None: + super().__init__() + if isinstance(batch_dim, bool) or batch_dim not in (1, 2): + raise ValueError(f"batch_dim must be 1 or 2, got {batch_dim}") + self._use_fused_kernels = ( + use_fused_kernels and batch_dim == 1 and _FusedDropoutResidual is not None + ) + self._batch_dim = batch_dim + self._r = r + if self._use_fused_kernels: + assert _FusedDropoutResidual is not None + self._impl: nn.Module = _FusedDropoutResidual(r) + else: + self._impl = nn.Dropout(r) + + def forward(self, residual: Tensor, delta: Tensor) -> Tensor: + if self._use_fused_kernels: + return self._impl(residual, delta) + # The unfused path broadcasts a row/column-shared mask M with shape (1, ...). + if self._r == 0.0 or not self.training: + return residual + delta + shape = list(delta.shape) + shape[self._batch_dim] = 1 + mask = self._impl(delta.new_ones(shape)) + return residual + delta * mask + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +CHAR_VOCAB_SIZE: int = 64 +MAX_CHARS: int = 4 +XYZ_DIMS: int = 3 +MAX_ATOMIC_NUMBER: int = 128 + +# Input feature dim = 3 + 1 + 1 + 128 + 64*4 = 389 +ATOM_FEATURE_DIM: int = XYZ_DIMS + 1 + 1 + MAX_ATOMIC_NUMBER + CHAR_VOCAB_SIZE * MAX_CHARS + + +NUM_RES_TYPES: int = 33 + +_EPS = 1e-5 + +# Default for the quadratic triangle, OPM, and pair-transition operations. +# It caps peak memory so l around 2,000 fits on an 80 GiB GPU. At l=1,438, +# chunk=128 uses roughly 76 GiB, while chunk=64 leaves headroom for the +# largest foldbench targets. Pass None to disable chunking; this is faster for +# short sequences but prone to out-of-memory errors beyond l around 600. +_DEFAULT_CHUNK_SIZE = 64 + + +# =========================================================================== +# MSA inference-time diversity augmentations +# =========================================================================== + + +def maybe_subsample_msa( + msa: Tensor, + msa_attention_mask: Tensor | None, + has_deletion: Tensor | None, + deletion_value: Tensor | None, + *, + max_depth: int | None, + enabled: bool, +) -> tuple[Tensor, Tensor | None, Tensor | None, Tensor | None]: + if not enabled or max_depth is None: + return msa, msa_attention_mask, has_deletion, deletion_value + + depth = msa.size(1) + if depth <= 1 or depth <= max_depth: + return msa, msa_attention_mask, has_deletion, deletion_value + + indices = torch.zeros(max_depth, dtype=torch.long, device=msa.device) + indices[1:] = torch.randperm(depth - 1, device=msa.device)[: max_depth - 1] + 1 + indices = indices.sort().values + + msa = msa[:, indices] + if msa_attention_mask is not None: + msa_attention_mask = msa_attention_mask[:, indices] + if has_deletion is not None: + has_deletion = has_deletion[:, indices] + if deletion_value is not None: + deletion_value = deletion_value[:, indices] + return msa, msa_attention_mask, has_deletion, deletion_value + + +def maybe_apply_msa_column_masking( + msa_attention_mask: Tensor | None, + rate: float, +) -> Tensor | None: + if msa_attention_mask is None or rate <= 0.0 or msa_attention_mask.size(1) <= 1: + return msa_attention_mask + + batch_size, _, length = msa_attention_mask.shape + col_keep = torch.rand(batch_size, length, device=msa_attention_mask.device) >= rate + col_keep = col_keep.unsqueeze(1).expand_as(msa_attention_mask).clone() + col_keep[:, 0, :] = True + return msa_attention_mask.bool() & col_keep + + +# =========================================================================== +# Atom-token utilities +# =========================================================================== + + +def gather_token_to_atom(token_features: Tensor, atom_to_token_idx: Tensor) -> Tensor: + """Broadcast per-token features to per-atom features using gather. + + Args: + token_features: X with shape (b, l, d). + atom_to_token_idx: I with shape (b, a), int64. + + Returns: + X with shape (b, a, d). + """ + idx = atom_to_token_idx.unsqueeze(-1).expand(-1, -1, token_features.size(-1)) + return torch.gather(token_features, 1, idx) + + +def scatter_atom_to_token( + atom_features: Tensor, + atom_to_token_idx: Tensor, + n_tokens: int, + atom_mask: Tensor | None = None, +) -> Tensor: + """Aggregate per-atom features to per-token features (mean). + + Args: + atom_features: X with shape (b, a, d). + atom_to_token_idx: I with shape (b, a), int64. + n_tokens: Token count l. + atom_mask: M with shape (b, a), Boolean. + + Returns: + X with shape (b, l, d). + """ + batch_size, n_atoms, d_model = atom_features.shape + n_out = n_tokens + idx = atom_to_token_idx + if atom_mask is not None: + idx = torch.where(atom_mask, atom_to_token_idx, n_tokens) + n_out = n_tokens + 1 + idx_expanded = idx.unsqueeze(-1).expand(batch_size, n_atoms, d_model) + out = torch.zeros( + batch_size, + n_out, + d_model, + device=atom_features.device, + dtype=atom_features.dtype, + ) + out.scatter_reduce_(1, idx_expanded, atom_features, reduce="mean", include_self=False) + return out[:, :n_tokens, :] + + +def gather_rep_atom_coords(coords: Tensor, rep_atom_idx: Tensor) -> Tensor: + """Gather representative atom coordinates for each token. + + Args: + coords: X with shape (b, a, 3). + rep_atom_idx: I with shape (b, l), int64. + + Returns: + X with shape (b, l, 3). + """ + idx = rep_atom_idx.unsqueeze(-1).expand(-1, -1, coords.size(-1)) + return torch.gather(coords, 1, idx) + + +def _compute_intra_token_idx(atom_to_token: Tensor) -> Tensor: + """Compute local atom index within each token (vectorised). + + Atoms belonging to the same token are contiguous, so this computes a + running count that resets at each token boundary. + + Args: + atom_to_token: I with shape (b, a), mapping each atom to its token. + + Returns: + Index tensor I with shape (b, a) and values from zero through + ``max_atoms_per_token - 1``. + """ + same_as_prev = F.pad(atom_to_token[:, 1:] == atom_to_token[:, :-1], (1, 0), value=False) + ones = torch.ones_like(atom_to_token) + cumsum = torch.cumsum(ones, dim=-1) + group_start = cumsum.masked_fill(same_as_prev, 0) + group_start = torch.cummax(group_start, dim=-1).values + return cumsum - group_start + + +def _categorical_mean(logits: Tensor, start: float, end: float) -> Tensor: + """Expected value of a categorical distribution over evenly-spaced bins. + + Equivalent to ``CategoricalMixture(logits, bins=logits.shape[-1], start, end).mean()``. + + Args: + logits: Logit tensor X with shape (..., n_bins). + start: left boundary + end: right boundary + + Returns: + Expected value tensor Y with shape (...). + """ + n_bins = logits.shape[-1] + edges = torch.linspace(start, end, n_bins + 1, device=logits.device, dtype=torch.float32) + v_bins = (edges[:-1] + edges[1:]) / 2 # V_bin has shape (n_bins,). + return (logits.float().softmax(-1) @ v_bins.unsqueeze(1)).squeeze(-1) + + +# =========================================================================== +# Feature preparation and language-model projection +# =========================================================================== + + +class RowAttentionPooling(nn.Module): + """Row-wise attention pooling: attn_proj, out_proj.""" + + def __init__(self, d_pair: int, d_single: int) -> None: + super().__init__() + self.attn_proj = nn.Linear(d_pair, 1, bias=False) + self.out_proj = nn.Linear(d_pair, d_single, bias=False) + + def forward(self, z: Tensor, mask: Tensor) -> Tensor: + scores = self.attn_proj(z).squeeze(-1) + mask_bias = torch.where( + mask[:, None, :].bool(), + torch.zeros_like(scores), + torch.full_like(scores, -1e9), + ) + scores = scores + mask_bias + weights = F.softmax(scores, dim=-1) + pooled = torch.einsum("bnm,bnmd->bnd", weights, z) + return self.out_proj(pooled) + + +# =========================================================================== +# InputsEmbedder +# =========================================================================== + + +class InputsEmbedder(nn.Module): + """Embeds input features including atom-level encoding via SWA attention.""" + + def __init__(self, config: ESMFold2Config) -> None: + super().__init__() + swa_cfg = config.inputs.atom_encoder + + self.atom_attention_encoder = ESMFold2AtomEncoder( + d_atom=swa_cfg.d_atom, + d_token=swa_cfg.d_token, + n_blocks=swa_cfg.n_blocks, + n_heads=swa_cfg.n_heads, + swa_window_size=swa_cfg.swa_window_size, + expansion_ratio=swa_cfg.expansion_ratio, + structure_prediction=False, # no coords_linear + spatial_rope_base_frequency=swa_cfg.spatial_rope_base_frequency, + n_spatial_rope_pairs_per_axis=swa_cfg.n_spatial_rope_pairs_per_axis, + n_uid_rope_pairs=swa_cfg.n_uid_rope_pairs, + uid_rope_base_frequency=swa_cfg.uid_rope_base_frequency, + ) + + def forward( + self, + aatype: Tensor, + profile: Tensor, + deletion_mean: Tensor, + ref_pos: Tensor, + atom_attention_mask: Tensor, + ref_space_uid: Tensor, + ref_charge: Tensor, + ref_element: Tensor, + ref_atom_name_chars: Tensor, + atom_to_token: Tensor, + ) -> Tensor: + """Embed inputs into per-token features. + + Returns: + X with shape (b, l, d_inputs), concatenating atom encoding, + aatype, profile, and deletion mean. + """ + a, _q, _c, _attn_params, _intermediates = self.atom_attention_encoder( + ref_pos=ref_pos, + atom_attention_mask=atom_attention_mask, + ref_space_uid=ref_space_uid, + ref_charge=ref_charge, + ref_element=ref_element, + ref_atom_name_chars=ref_atom_name_chars, + atom_to_token=atom_to_token, + ) + return torch.cat([a, aatype, profile, deletion_mean.unsqueeze(-1)], dim=-1) + + +# =========================================================================== +# ResIdxAsymIdSymIdEntityIdEncoding (trunk relative position) +# =========================================================================== + + +class ResIdxAsymIdSymIdEntityIdEncoding(nn.Module): + """Embedding weight W has shape (d_pair, n_features). + + Here ``n_features = 2 * (2 * r_bins + 2) + 1 + (2 * c_bins + 2)``. + + For default r_bins=32, c_bins=2: 2*66 + 1 + 6 = 139. + """ + + def __init__( + self, + n_relative_residx_bins: int = 32, + n_relative_chain_bins: int = 2, + d_pair: int = 256, + ) -> None: + super().__init__() + self.n_relative_residx_bins = n_relative_residx_bins + self.n_relative_chain_bins = n_relative_chain_bins + self.d_pair = d_pair + + n_feats_residue = 2 * n_relative_residx_bins + 2 + n_feats_token = 2 * n_relative_residx_bins + 2 + n_feats_chain = 2 * n_relative_chain_bins + 2 + n_feats_same_entity = 1 + total_feats = n_feats_residue + n_feats_token + n_feats_chain + n_feats_same_entity + self.embed = nn.Linear(total_feats, d_pair, bias=False) + + def forward( + self, + residue_index: Tensor, + asym_id: Tensor, + sym_id: Tensor, + entity_id: Tensor, + token_index: Tensor, + ) -> Tensor: + bij_same_chain = asym_id.unsqueeze(2) == asym_id.unsqueeze(1) + bij_same_residue = residue_index.unsqueeze(2) == residue_index.unsqueeze(1) + bij_same_entity = entity_id.unsqueeze(2) == entity_id.unsqueeze(1) + + dij_residue = residue_index.unsqueeze(2) - residue_index.unsqueeze(1) + dij_residue = torch.clip( + dij_residue + self.n_relative_residx_bins, + 0, + 2 * self.n_relative_residx_bins, + ) + dij_residue = torch.where(bij_same_chain, dij_residue, 2 * self.n_relative_residx_bins + 1) + aij_rel_pos = F.one_hot(dij_residue, 2 * self.n_relative_residx_bins + 2) + + dij_token = torch.clip( + token_index.unsqueeze(2) - token_index.unsqueeze(1) + self.n_relative_residx_bins, + 0, + 2 * self.n_relative_residx_bins, + ) + dij_token = torch.where( + bij_same_chain & bij_same_residue, + dij_token, + 2 * self.n_relative_residx_bins + 1, + ) + aij_rel_token = F.one_hot(dij_token, 2 * self.n_relative_residx_bins + 2) + + dij_chain = torch.clip( + sym_id.unsqueeze(2) - sym_id.unsqueeze(1) + self.n_relative_chain_bins, + 0, + 2 * self.n_relative_chain_bins, + ) + dij_chain = torch.where(bij_same_chain, 2 * self.n_relative_chain_bins + 1, dij_chain) + aij_rel_chain = F.one_hot(dij_chain, 2 * self.n_relative_chain_bins + 2) + + feats = torch.cat( + [ + aij_rel_pos.float(), + aij_rel_token.float(), + bij_same_entity.float().unsqueeze(-1), + aij_rel_chain.float(), + ], + dim=-1, + ) + + return self.embed(feats) + + +# =========================================================================== +# SingleToPair (for LanguageModelShim) +# =========================================================================== + + +class SingleToPair(nn.Module): + """downproject, output_mlp (Sequential of Linear, GELU, Linear).""" + + def __init__(self, input_dim: int, downproject_dim: int, output_dim: int) -> None: + super().__init__() + self.downproject = nn.Linear(input_dim, downproject_dim) + self.output_mlp = nn.Sequential( + nn.Linear(2 * downproject_dim, output_dim), + nn.GELU(), + nn.Linear(output_dim, output_dim), + ) + + def forward(self, x: Tensor) -> Tensor: + x = self.downproject(x) + x = torch.cat( + [(x.unsqueeze(2) * x.unsqueeze(1)), (x.unsqueeze(2) - x.unsqueeze(1))], + dim=3, + ) + return self.output_mlp(x) + + +# =========================================================================== +# LanguageModelShim +# =========================================================================== + + +class LanguageModelShim(nn.Module): + """Shim holding the trainable projection weights for LM integration. + + Contains: + - base_z_combine: nn.Parameter with shape ``(n_layers + 1,)`` + - base_z_linear: Sequential(LayerNorm(d_model), Linear(d_model, d_z, bias=False)) + - base_z_mlp: Sequential(SingleToPair(d_z, d_z, d_z), LayerNorm(d_z)) + """ + + def __init__(self, d_z: int = 256, d_model: int = 2560, num_layers: int = 80) -> None: + super().__init__() + + self.base_z_mlp = nn.Sequential(SingleToPair(d_z, d_z, d_z), nn.LayerNorm(d_z)) + self.base_z_linear = nn.Sequential( + nn.LayerNorm(d_model), nn.Linear(d_model, d_z, bias=False) + ) + self.base_z_combine = nn.Parameter(torch.zeros(num_layers + 1)) + + def project_sequence( + self, + hidden_states: Tensor, + residue_mask: Tensor | None = None, + ) -> Tensor: + """Project all ESMC layer states into the learned sequence summary. + + Args: + hidden_states: H with shape ``(b, l, n_layers + 1, d_model)``. + residue_mask: Optional M with shape ``(b, l)``. Non-residue rows + are set to zero in the returned tensor. + + Returns: + Z with shape ``(b, l, d_z)``. + """ + + if hidden_states.ndim != 4: + raise ValueError( + "H must have shape (b, l, n_layers + 1, d_model), " + f"got {tuple(hidden_states.shape)}." + ) + expected_layers = self.base_z_combine.numel() + if hidden_states.shape[-2] != expected_layers: + raise ValueError( + f"H contains {hidden_states.shape[-2]} states; expected " + f"{expected_layers} in the official ESMC ordering." + ) + expected_width = cast(nn.LayerNorm, self.base_z_linear[0]).normalized_shape[0] + if hidden_states.shape[-1] != expected_width: + raise ValueError(f"H has width {hidden_states.shape[-1]}; expected {expected_width}.") + + # H can be FP32 even when the folding checkpoint is loaded in BF16. + # Match the learned projection parameters at this explicit boundary; + # this preserves the official BF16 path and leaves FP32 models exact. + projection_dtype = cast(nn.LayerNorm, self.base_z_linear[0]).weight.dtype + hidden_states = hidden_states.to(dtype=projection_dtype) + projected_states = self.base_z_linear(hidden_states) + layer_weights = self.base_z_combine.softmax(dim=0) + # Preserve Biohub's matmul path exactly so checkpoint inference does + # not change through a different reduction order. + projected = layer_weights @ projected_states + if residue_mask is not None: + if residue_mask.shape != hidden_states.shape[:2]: + raise ValueError( + "M must have shape (b, l), got " + f"{tuple(residue_mask.shape)} for H {tuple(hidden_states.shape)}." + ) + projected = projected * residue_mask.to( + device=projected.device, dtype=projected.dtype + ).unsqueeze(-1) + return projected + + def forward(self, hidden_states: Tensor, *, lm_dropout: float = 0.0) -> Tensor: + """Project pre-computed ESMC hidden states to pair representation. + + Args: + hidden_states: H with shape ``(b, l, n_layers + 1, d_model)``. + lm_dropout: Dropout probability applied to the pair + representation after ``base_z_mlp``. + + Returns: + Z_pair with shape ``(b, l, l, d_pair)``. + """ + lm_z = self.project_sequence(hidden_states) + lm_z = self.base_z_mlp(lm_z) + if lm_dropout > 0: + lm_z = F.dropout(lm_z, p=lm_dropout, training=True) + return lm_z + + +# =========================================================================== +# ESMFold2ExperimentalModel: the top-level PreTrainedModel +# =========================================================================== + + +def compute_lm_hidden_states( + esmc: nn.Module, + input_ids: Tensor, + asym_id: Tensor, + residue_index: Tensor, + mol_type: Tensor, + token_mask: Tensor, + pad_to_multiple: int | None = None, + lm_mask_pct: float = 0.0, + mask_token_id: int = 32, +) -> Tensor: + """Run ESMC and return H with shape ``(b, l, n_states, d_model)``. + + Atom-tokenized modified residues (HYP, MSE, ACE, NH2, ...) span multiple + structure tokens but share a single ``(asym_id, residue_index)`` key: + collapse them to one LM token per residue before running the LM (the LM + was trained on per-residue inputs, not per-atom), then scatter the + hidden states back to the per-token layout. + """ + b_size, l_size = input_ids.shape + device = input_ids.device + protein_mask = (mol_type == 0) & token_mask + + lm_input_list = [] + lm_lengths = [] + # Per-batch maps from (original protein-token index) to (LM input position). + expand_maps: list[Tensor] = [] + for batch_index in range(b_size): + mask_b = protein_mask[batch_index] + ids_b = input_ids[batch_index][mask_b] + asym_b = asym_id[batch_index][mask_b] + res_b = residue_index[batch_index][mask_b] + + # Collapse: keep first token per (asym_id, residue_index) key, in + # input order. ``inverse`` maps each original protein-token to its + # collapsed residue index. + keys = torch.stack((asym_b, res_b), dim=1) + unique_keys, inverse = torch.unique(keys, dim=0, return_inverse=True) + n_unique = unique_keys.size(0) + token_positions = torch.arange(keys.size(0), device=device, dtype=torch.long) + first_pos = torch.full((n_unique,), keys.size(0), device=device, dtype=torch.long) + first_pos.scatter_reduce_(0, inverse, token_positions, reduce="amin", include_self=True) + ordered = torch.argsort(first_pos) + first_pos_ordered = first_pos[ordered] + ids_collapsed = ids_b[first_pos_ordered] + asym_collapsed = asym_b[first_pos_ordered] + remap = torch.empty_like(ordered) + remap[ordered] = torch.arange(n_unique, device=device, dtype=torch.long) + inverse_ordered = remap[inverse] + + chain_ids = asym_collapsed.unique(sorted=True) + # [BOS] chain1 [EOS BOS] chain2 ... [EOS] + parts: list[Tensor] = [torch.tensor([0], device=device, dtype=ids_b.dtype)] + # Per-chain LM positions accumulate; track them for the expand map. + per_token_lm_pos = torch.empty(n_unique, device=device, dtype=torch.long) + cursor = 1 # position 0 is the leading BOS + for i, cid in enumerate(chain_ids): + in_chain = (asym_collapsed == cid).nonzero(as_tuple=True)[0] + parts.append(ids_collapsed[in_chain]) + per_token_lm_pos[in_chain] = torch.arange( + cursor, cursor + in_chain.shape[0], device=device, dtype=torch.long + ) + cursor += in_chain.shape[0] + if i < len(chain_ids) - 1: + parts.append(torch.tensor([2, 0], device=device, dtype=ids_b.dtype)) + cursor += 2 # EOS + BOS + parts.append(torch.tensor([2], device=device, dtype=ids_b.dtype)) + lm_seq = torch.cat(parts) + lm_input_list.append(lm_seq) + lm_lengths.append(lm_seq.shape[0]) + + # Map each original protein-token position to its LM input position. + prot_pos_b = mask_b.nonzero(as_tuple=True)[0] + expand_map = torch.full((l_size,), -1, device=device, dtype=torch.long) + expand_map[prot_pos_b] = per_token_lm_pos[inverse_ordered] + expand_maps.append(expand_map) + + # Pad the language-model input to its longest sequence. FP8 callers round + # l to a multiple of 16 for Transformer Engine kernels. + max_len = max(lm_lengths) + if pad_to_multiple is not None and pad_to_multiple > 1: + max_len = ((max_len + pad_to_multiple - 1) // pad_to_multiple) * pad_to_multiple + lm_input_ids = torch.full( + (b_size, max_len), + 1, + device=device, + dtype=input_ids.dtype, # PAD=1 + ) + for batch_index in range(b_size): + lm_input_ids[batch_index, : lm_lengths[batch_index]] = lm_input_list[batch_index] + + # sequence_id for chain-aware attention; PAD tokens get -1 (no attention). + sequence_id = (lm_input_ids == 0).cumsum(dim=1) - 1 # BOS=0 + sequence_id = sequence_id.masked_fill(lm_input_ids == 1, -1) # PAD=1 + + if lm_mask_pct > 0.0: + special = (lm_input_ids == 0) | (lm_input_ids == 1) | (lm_input_ids == 2) + do_mask = (torch.rand(lm_input_ids.shape, device=device) < lm_mask_pct) & ~special + lm_input_ids = lm_input_ids.masked_fill(do_mask, mask_token_id) + + with torch.inference_mode(): + esmc_out = esmc(input_ids=lm_input_ids, sequence_id=sequence_id, output_hidden_states=True) + + hidden_stack = esmc_out.hidden_states + n_states, _, _, d_model = hidden_stack.shape + result = torch.zeros(b_size, l_size, n_states, d_model, device=device, dtype=hidden_stack.dtype) + for batch_index in range(b_size): + M_i = protein_mask[batch_index] + positions = expand_maps[batch_index][M_i] + gathered = hidden_stack[:, batch_index, positions, :].permute(1, 0, 2) + result[batch_index, M_i.nonzero(as_tuple=True)[0]] = gathered + + return result.detach() + + +# =========================================================================== +# TriangleMultiplicativeUpdate +# =========================================================================== + + +class TriangleMultiplicativeBlock(nn.Module): + """Triangle multiplicative update block with gated signal routing.""" + + _FLOW_TO_EINSUM: ClassVar[dict[str, str]] = { + "outgoing": "bikd,bjkd->bijd", + "incoming": "bkid,bkjd->bijd", + } + _VALID_FLOWS = ("outgoing", "incoming") + + def __init__(self, input_channels: int, latent_channels: int, flow: str) -> None: + super().__init__() + if flow not in self._FLOW_TO_EINSUM: + raise ValueError(f"Invalid flow={flow!r}. Expected one of {self._VALID_FLOWS}.") + + self.input_channels = input_channels + self.latent_channels = latent_channels + self.flow = flow + self._einsum_equation = self._FLOW_TO_EINSUM[flow] + self.norm_start = nn.LayerNorm(self.input_channels, eps=_EPS) + self.norm_mix = nn.LayerNorm(self.latent_channels, eps=_EPS) + self.proj_bundle = nn.Linear(self.input_channels, 4 * self.latent_channels, bias=False) + self.proj_emit = nn.Linear(self.latent_channels, self.input_channels, bias=False) + self.proj_gate = nn.Linear(self.input_channels, self.input_channels, bias=False) + + self._use_kernels: bool = False + # Default chunked for memory on long sequences; tests override with + # ``set_chunk_size(None)`` for the unchunked path under bit-exact bf16 + # parity checks. + self._chunk_size: int | None = 64 + + def set_chunk_size(self, chunk_size: int | None) -> None: + self._chunk_size = chunk_size + + def split_kernel_weights(self) -> tuple[Tensor, Tensor]: + return ( + self.proj_bundle.weight[: 2 * self.latent_channels, :], + self.proj_bundle.weight[2 * self.latent_channels :, :], + ) + + def _kernel_flow_direction(self) -> str: + return self.flow + + def _triangular_contract(self, left_stream: Tensor, right_stream: Tensor) -> Tensor: + return torch.einsum(self._einsum_equation, left_stream, right_stream) + + def _triangular_contract_chunked( + self, left_stream: Tensor, right_stream: Tensor, chunk_size: int + ) -> Tensor: + """Compute the triangular einsum in chunks along the output i-dimension.""" + length = left_stream.shape[1] if self.flow == "outgoing" else left_stream.shape[2] + chunks = [] + for start in range(0, length, chunk_size): + end = min(start + chunk_size, length) + if self.flow == "outgoing": + chunk = torch.einsum(self._einsum_equation, left_stream[:, start:end], right_stream) + else: + chunk = torch.einsum( + self._einsum_equation, left_stream[:, :, start:end], right_stream + ) + chunks.append(chunk) + return torch.cat(chunks, dim=1) + + def forward(self, pair_grid: Tensor, visibility: Tensor | None = None) -> Tensor: + if visibility is None: + visibility = pair_grid.new_ones(pair_grid.shape[:-1]) + + if self._use_kernels: + p_in_weight, g_in_weight = self.split_kernel_weights() + return _cue_tri_mul( # type: ignore[misc] + pair_grid, + direction=self._kernel_flow_direction(), + mask=visibility, + norm_in_weight=self.norm_start.weight, + norm_in_bias=self.norm_start.bias, + p_in_weight=p_in_weight, + g_in_weight=g_in_weight, + norm_out_weight=self.norm_mix.weight, + norm_out_bias=self.norm_mix.bias, + p_out_weight=self.proj_emit.weight, + g_out_weight=self.proj_gate.weight, + eps=_EPS, + ) + + normalized_grid = self.norm_start(pair_grid) + bundled = self.proj_bundle(normalized_grid) + signal, gate_logits = bundled.split(2 * self.latent_channels, dim=-1) + routed = signal * torch.sigmoid(gate_logits) + routed = routed * visibility.unsqueeze(-1) + + left_stream, right_stream = routed.float().chunk(2, dim=-1) + if self._chunk_size is not None: + contracted = self._triangular_contract_chunked( + left_stream, right_stream, self._chunk_size + ) + else: + contracted = self._triangular_contract(left_stream, right_stream) + mixed = self.proj_emit(self.norm_mix(contracted)) + output_gate = torch.sigmoid(self.proj_gate(normalized_grid)) + return mixed * output_gate + + +class TriangleMultiplicativeUpdate(nn.Module): + """Thin wrapper exposing the triangular mixer with explicit orientation (v3).""" + + def __init__(self, dim: int = 128, _outgoing: bool = True) -> None: + super().__init__() + flow = "outgoing" if _outgoing else "incoming" + self._engine = TriangleMultiplicativeBlock( + input_channels=dim, latent_channels=dim, flow=flow + ) + + def set_kernel_backend(self, backend: str | None) -> None: + # Engine uses cueq when backend=="cuequivariance"; the "fused" backend + # routes through the parent PairUpdateBlock's fused path (bypassing this). + validate_kernel_backend(backend) + self._engine._use_kernels = backend == BACKEND_CUEQ + + def set_chunk_size(self, chunk_size: int | None) -> None: + self._engine.set_chunk_size(chunk_size) + + def forward(self, z: Tensor, mask: Tensor | None = None) -> Tensor: + return self._engine(z, visibility=mask) + + +# =========================================================================== +# FoldingTrunk: Transition, PairUpdateBlock, FoldingTrunk +# =========================================================================== + + +class Transition(nn.Module): + """LN + SwiGLU FFN with addmm-fused residual; optional Triton LN+w12+SwiGLU kernel.""" + + def __init__(self, d_model: int, expansion_ratio: int = 4) -> None: + super().__init__() + self.norm = nn.LayerNorm(d_model) + self.ffn = SwiGLUMLP(d_model, expansion_ratio=expansion_ratio, bias=False) + # Default chunked; set_chunk_size(None) disables for bit-exact parity tests. + self._chunk_size: int | None = 64 + self._fused_swiglu: nn.Module | None = None + self._kernel_backend: str | None = None + + def set_chunk_size(self, chunk_size: int | None) -> None: + self._chunk_size = chunk_size + + def set_kernel_backend(self, backend: str | None) -> None: + """Install / uninstall FusedLNLinearSwiGLU (no cueq equivalent).""" + validate_kernel_backend(backend) + self._kernel_backend = backend + if backend == BACKEND_FUSED and TRITON_KERNELS_AVAILABLE: + assert _FusedLNLinearSwiGLU is not None + d_model = self.norm.normalized_shape[0] + d_inner = self.ffn.hidden_features + has_ln_bias = self.norm.bias is not None + device = self.ffn.w12.weight.device + dtype = self.ffn.w12.weight.dtype + fused = _FusedLNLinearSwiGLU( + d_model=d_model, + d_inner=d_inner, + has_ln_bias=has_ln_bias, + device=device, + dtype=dtype, + ) + with torch.no_grad(): + fused.LN_W.copy_(self.norm.weight) + if has_ln_bias: + fused.LN_B.copy_(self.norm.bias) # type: ignore[union-attr] + # FusedLNLinearSwiGLU.W12 is (d_model, 2*d_inner); transpose nn.Linear once. + fused.W12.copy_(self.ffn.w12.weight.t().contiguous()) + self._fused_swiglu = fused.eval().requires_grad_(False) + else: + self._fused_swiglu = None + + def _can_use_fused_path(self, x: Tensor) -> bool: + return ( + _fused_active(self, x) and self._fused_swiglu is not None and x.dtype == torch.bfloat16 + ) + + def _swiglu_pre_w3(self, x_normed: Tensor) -> Tensor: + """SwiGLU through silu(x1)*x2, before the final w3.""" + ffn = self.ffn + x12 = ffn.w12(x_normed) + x1, x2 = x12.split(ffn.hidden_features, dim=-1) + return F.silu(x1) * x2 + + def _addmm_residual(self, x: Tensor, hidden: Tensor) -> Tensor: + """x + w3(hidden) via single cuBLAS addmm: avoids transition-output allocation.""" + ffn = self.ffn + x_shape = x.shape + out = torch.addmm( + x.contiguous().view(-1, x_shape[-1]), + hidden.view(-1, hidden.shape[-1]), + ffn.w3.weight.t(), + ) + return out.view(x_shape) + + def forward(self, x: Tensor) -> Tensor: + # Inference-only fast path (addmm-fused residual + pre-alloc out) + #: diverges bit-exactly from ``x + ffn(norm(x))`` so we only use + # it when grad is disabled (binder-design / bit-exact tests run + # with grad on and need the reference path). + if not torch.is_grad_enabled() and self._can_use_fused_path(x): + fused = self._fused_swiglu + assert fused is not None + pre_w3 = fused + if self._chunk_size is None or x.shape[1] <= self._chunk_size: + hidden = pre_w3(x) + return self._addmm_residual(x, hidden) + out = torch.empty_like(x) + for s in range(0, x.shape[1], self._chunk_size): + e = min(s + self._chunk_size, x.shape[1]) + sl = x[:, s:e] + hidden = pre_w3(sl) + out[:, s:e] = self._addmm_residual(sl, hidden) + return out + # Reference path: bit-exact with main: x + ffn(norm(x)). + if self._chunk_size is None or x.shape[1] <= self._chunk_size: + return x + self.ffn(self.norm(x)) + out_list: list[Tensor] = [] + for s in range(0, x.shape[1], self._chunk_size): + e = min(s + self._chunk_size, x.shape[1]) + sl = x[:, s:e] + out_list.append(sl + self.ffn(self.norm(sl))) + return torch.cat(out_list, dim=1) + + +class PairUpdateBlock(nn.Module): + """tri_mul_out, tri_mul_in, pair_transition.""" + + def __init__(self, d_pair: int = 256, expansion_ratio: int = 4) -> None: + super().__init__() + self.tri_mul_out = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=True) + self.tri_mul_in = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=False) + self.pair_transition = Transition(d_pair, expansion_ratio=expansion_ratio) + self._kernel_backend: str | None = None + # Row-shared dropout-residual; r=0 for inference (HF model is inference-only). + # backend='fused' swaps in the FusedDropoutResidual Triton kernel. + self.row_drop = DropoutResidual(0.0, batch_dim=1, use_fused_kernels=False) + + def set_kernel_backend(self, backend: str | None) -> None: + if backend not in _VALID_BACKENDS: + raise ValueError(f"backend must be one of {_VALID_BACKENDS}, got {backend!r}") + self.tri_mul_out.set_kernel_backend(backend) + self.tri_mul_in.set_kernel_backend(backend) + self.pair_transition.set_kernel_backend(backend) + self._kernel_backend = backend + self.row_drop = DropoutResidual( + 0.0, batch_dim=1, use_fused_kernels=(backend == BACKEND_FUSED) + ) + + def set_chunk_size(self, chunk_size: int | None) -> None: + self.tri_mul_out.set_chunk_size(chunk_size) + self.tri_mul_in.set_chunk_size(chunk_size) + self.pair_transition.set_chunk_size(chunk_size) + + def _can_use_fused_trimul_with_residual(self, pair: Tensor) -> bool: + return _fused_active(self, pair) and pair.dtype == torch.bfloat16 + + def _fused_trimul_with_residual( + self, pair: Tensor, direction: str, pair_attention_mask: Tensor | None + ) -> Tensor: + """Fused TriMul+residual call; weights from the corresponding engine.""" + tri = self.tri_mul_out if direction == "outgoing" else self.tri_mul_in + engine: TriangleMultiplicativeBlock = tri._engine # type: ignore[assignment] + p_in_weight, g_in_weight = engine.split_kernel_weights() + + def _bf16(t: Tensor) -> Tensor: + return t if t.dtype == torch.bfloat16 else t.to(torch.bfloat16) + + return _fused_trimul_with_residual( # type: ignore[misc] + pair, + direction, + residual=pair, + drop_mask=None, # inference: no dropout, matches internal's eval path + norm_in_weight=_bf16(engine.norm_start.weight), + norm_in_bias=_bf16(engine.norm_start.bias), + p_in_weight=_bf16(p_in_weight), + g_in_weight=_bf16(g_in_weight), + norm_out_weight=_bf16(engine.norm_mix.weight), + norm_out_bias=_bf16(engine.norm_mix.bias), + p_out_weight=_bf16(engine.proj_emit.weight), + g_out_weight=_bf16(engine.proj_gate.weight), + mask=pair_attention_mask, + eps=_EPS, + ) + + def forward(self, pair: Tensor, pair_attention_mask: Tensor | None = None) -> Tensor: + if self._can_use_fused_trimul_with_residual(pair): + pair = self._fused_trimul_with_residual(pair, "outgoing", pair_attention_mask) + pair = self._fused_trimul_with_residual(pair, "incoming", pair_attention_mask) + else: + pair = self.row_drop(pair, self.tri_mul_out(pair, mask=pair_attention_mask)) + pair = self.row_drop(pair, self.tri_mul_in(pair, mask=pair_attention_mask)) + pair = self.pair_transition(pair) + return pair + + +class FoldingTrunk(nn.Module): + """ModuleList of PairUpdateBlocks.""" + + def __init__(self, n_layers: int = 24, d_pair: int = 256, expansion_ratio: int = 4) -> None: + super().__init__() + self.blocks = nn.ModuleList( + [ + PairUpdateBlock(d_pair=d_pair, expansion_ratio=expansion_ratio) + for _ in range(n_layers) + ] + ) + + def set_kernel_backend(self, backend: str | None) -> None: + for block in self.blocks: + cast(PairUpdateBlock, block).set_kernel_backend(backend) + + def set_chunk_size(self, chunk_size: int | None) -> None: + for block in self.blocks: + cast(PairUpdateBlock, block).set_chunk_size(chunk_size) + + def forward(self, pair: Tensor, pair_attention_mask: Tensor | None = None) -> Tensor: + # Cast the pair tensor to BF16 when the fused triangle backend is enabled + # (its bwd kernel requires bf16). Other backends keep the input dtype. + orig_dtype = pair.dtype + fused_on = ( + len(self.blocks) > 0 + and getattr(self.blocks[0], "_kernel_backend", None) == BACKEND_FUSED + ) + if pair.is_cuda and fused_on and orig_dtype != torch.bfloat16: + pair = pair.to(torch.bfloat16) + for block in self.blocks: + fn = partial(block, pair_attention_mask=pair_attention_mask) + if torch.is_grad_enabled(): + pair = checkpoint(fn, pair, use_reentrant=False) # pyright: ignore + else: + pair = fn(pair) + if pair.dtype != orig_dtype: + pair = pair.to(orig_dtype) + return pair + + +# =========================================================================== +# MSA Encoder +# =========================================================================== + + +class OuterProductMean(nn.Module): + """Outer-product mean: maps an MSA representation into a pair update. + + The order of the ``/ n_valid`` divide vs. the ``Wout`` projection is + selectable via ``divide_outer_before_proj`` because different ESMFold2 + checkpoints were trained with different orderings: + + * ``False`` (default): ``Wout(outer) / n_valid``: the projection bias + is scaled by 1/n_valid alongside the outer product. + * ``True``: ``Wout(outer / n_valid)``: the projection bias is added + unscaled, post-divide. + """ + + def __init__( + self, + d_msa: int, + d_hidden: int, + d_pair: int, + divide_outer_before_proj: bool = False, + ) -> None: + super().__init__() + self.d_hidden = d_hidden + self.divide_outer_before_proj = divide_outer_before_proj + self.norm = nn.LayerNorm(d_msa) + self.W = nn.Linear(d_msa, 2 * d_hidden, bias=False) + self.Wout = nn.Linear(d_hidden * d_hidden, d_pair, bias=True) + # Off for bit-exact bf16; ``set_chunk_size(64)`` for long sequences. + self._chunk_size: int | None = None + + def set_chunk_size(self, chunk_size: int | None) -> None: + self._chunk_size = chunk_size + + def forward(self, m: Tensor, msa_attention_mask: Tensor) -> Tensor: + m_norm = self.norm(m) + x = self.W(m_norm) * msa_attention_mask.unsqueeze(-1).to(m_norm.dtype) + a, b = x.chunk(2, dim=-1) + mask_f = msa_attention_mask.to(a.dtype) + n_valid = (mask_f @ mask_f.transpose(-1, -2)).unsqueeze(-1).clamp(min=1.0) + if self._chunk_size is None: + outer = torch.einsum("bimc,bjmd->bijcd", a, b).flatten(-2) + if self.divide_outer_before_proj: + return self.Wout(outer / n_valid) + return self.Wout(outer) / n_valid + # Chunk along the left (i) axis so the peak einsum intermediate is + # X uses shape (b, chunk, l, c, d) instead of (b, l, l, c, d). + length = a.shape[1] + out_chunks: list[Tensor] = [] + for start in range(0, length, self._chunk_size): + end = min(start + self._chunk_size, length) + outer_chunk = torch.einsum("bimc,bjmd->bijcd", a[:, start:end], b).flatten(-2) + if self.divide_outer_before_proj: + out_chunks.append(self.Wout(outer_chunk / n_valid[:, start:end])) + else: + out_chunks.append(self.Wout(outer_chunk) / n_valid[:, start:end]) + return torch.cat(out_chunks, dim=1) + + +class MSAPairWeightedAveraging(nn.Module): + """Pair-biased MSA row update (AF3 Supplement Algorithm 10).""" + + def __init__(self, d_msa: int, d_pair: int, n_heads: int = 8, head_width: int = 32) -> None: + super().__init__() + self.n_heads = n_heads + self.head_width = head_width + self.norm_single = nn.LayerNorm(d_msa) + self.compute_bias = nn.Sequential( + nn.LayerNorm(d_pair), nn.Linear(d_pair, n_heads, bias=False) + ) + self.Wv = nn.Linear(d_msa, n_heads * head_width, bias=False) + self.Wgate = nn.Linear(d_msa, n_heads * head_width, bias=False) + self.Wout = nn.Linear(n_heads * head_width, d_msa, bias=False) + + def forward(self, msa_repr: Tensor, pair_repr: Tensor, pair_attention_mask: Tensor) -> Tensor: + """ + Args: + msa_repr: X with shape (b, l, m, d_msa). + pair_repr: Z with shape (b, l, l, d_pair). + pair_attention_mask: M with shape (b, l, l). + Returns: + X with shape (b, l, m, d_msa). + """ + batch_size, length, depth, _ = msa_repr.shape + n_heads, head_width = self.n_heads, self.head_width + + msa_normed = self.norm_single(msa_repr) + bias = self.compute_bias(pair_repr) # A has shape (b, l, l, n_heads). + bias.masked_fill_(~pair_attention_mask.unsqueeze(-1).bool(), -1e5) + attn = torch.softmax(bias, dim=-2) # softmax over j + + v = self.Wv(msa_normed).reshape(batch_size, length, depth, n_heads, head_width) + gate = torch.sigmoid(self.Wgate(msa_normed)).reshape( + batch_size, length, depth, n_heads, head_width + ) + + output = torch.einsum("bijh,bjmhd,bimhd->bimhd", attn, v, gate) + return self.Wout(output.reshape(batch_size, length, depth, n_heads * head_width)) + + +# =========================================================================== +# Atom and diffusion stack +# =========================================================================== + + +class TransitionLayer(nn.Module): + """SwiGLU transition: norm -> a_proj, b_proj -> silu(a)*b -> out_proj.""" + + def __init__(self, d_model: int, n: int, eps: float = 1e-5) -> None: + super().__init__() + hidden = n * d_model + self.norm = nn.LayerNorm(d_model, eps=eps) + self.a_proj = nn.Linear(d_model, hidden, bias=False) + self.b_proj = nn.Linear(d_model, hidden, bias=False) + self.out_proj = nn.Linear(hidden, d_model, bias=False) + + def forward(self, x: Tensor) -> Tensor: + x = self.norm(x) + a = self.a_proj(x) + b = self.b_proj(x) + return self.out_proj(F.silu(a) * b) + + +# =========================================================================== +# AdaptiveLayerNorm (used in DiffusionTransformer) +# =========================================================================== + + +class AdaptiveLayerNorm(nn.Module): + """Adaptive layer normalization (adaLN-Zero).""" + + def __init__(self, d_model: int, d_cond: int, eps: float = 1e-5) -> None: + super().__init__() + self.d_model = d_model + self.d_cond = d_cond + self.eps = eps + self.s_scale = nn.Parameter(torch.ones(d_cond)) + self.s_gate = nn.Linear(d_cond, d_model, bias=True) + self.s_shift = nn.Linear(d_cond, d_model, bias=False) + + def forward(self, a: Tensor, s: Tensor) -> Tensor: + a_norm = F.layer_norm(a, (self.d_model,), None, None, self.eps) + s_norm = F.layer_norm(s, (self.d_cond,), self.s_scale, None, self.eps) + return torch.sigmoid(self.s_gate(s_norm)) * a_norm + self.s_shift(s_norm) + + +# =========================================================================== +# FourierEmbedding +# =========================================================================== + + +class FourierEmbedding(nn.Module): + """Fourier embedding: cos(2*pi*(t*w + b)).""" + + w: Tensor + b: Tensor + + def __init__(self, c: int) -> None: + super().__init__() + self.c = c + self.register_buffer("w", torch.randn(c)) + self.register_buffer("b", torch.randn(c)) + + def forward(self, t_hat: Tensor) -> Tensor: + t = torch.as_tensor(t_hat, device=self.w.device, dtype=self.w.dtype).reshape(-1) + return torch.cos(2.0 * torch.pi * (t[:, None] * self.w[None, :] + self.b[None, :])) + + +# =========================================================================== +# SwiGLU / SwiGLUMLP +# =========================================================================== + + +def _compute_swiglu_hidden_size(d_model: int, expansion_ratio: int) -> int: + return expansion_ratio * d_model + + +class SwiGLU(nn.Module): + """SwiGLU with packed w12 and output w3.""" + + def __init__( + self, + in_features: int, + hidden_features: int, + out_features: int | None = None, + bias: bool = True, + ) -> None: + super().__init__() + out_features = out_features or in_features + self.w12 = nn.Linear(in_features, 2 * hidden_features, bias=bias) + self.w3 = nn.Linear(hidden_features, out_features, bias=bias) + self.hidden_features = hidden_features + + def forward(self, x: Tensor) -> Tensor: + x12 = self.w12(x) + x1, x2 = x12.split(self.hidden_features, dim=-1) + hidden = F.silu(x1) * x2 + return self.w3(hidden) + + +class SwiGLUMLP(SwiGLU): + """SwiGLU MLP with packed weights, no bias.""" + + def __init__(self, d_model: int, expansion_ratio: int = 4, bias: bool = False) -> None: + hidden = _compute_swiglu_hidden_size(d_model, expansion_ratio) + super().__init__( + in_features=d_model, hidden_features=hidden, out_features=d_model, bias=bias + ) + + +# =========================================================================== +# SWA Atom Attention components +# =========================================================================== + + +def _rotate_half(x: Tensor) -> Tensor: + x1, x2 = x.chunk(2, dim=-1) + return torch.cat((-x2, x1), dim=-1) + + +def apply_rotary_emb_3d(x: Tensor, cos: Tensor, sin: Tensor) -> Tensor: + """Apply RoPE with batch-dependent cos/sin. + + Args: + x: X with shape (b, l, h, d). + cos: C with shape (b, l, d / 2). + sin: S with shape (b, l, d / 2). + """ + ro_dim = cos.shape[-1] * 2 + cos = cos.unsqueeze(2).repeat(1, 1, 1, 2) + sin = sin.unsqueeze(2).repeat(1, 1, 1, 2) + return torch.cat( + [x[..., :ro_dim] * cos + _rotate_half(x[..., :ro_dim]) * sin, x[..., ro_dim:]], + dim=-1, + ) + + +@torch.compiler.disable +def build_3d_rope( + ref_pos: Tensor, + ref_space_uid: Tensor, + head_dim: int, + n_spatial_per_axis: int = 4, + n_uid_pairs: int = 2, + spatial_base_freq: float = 10000.0, + uid_base_freq: float = 10.0, +) -> tuple[Tensor, Tensor]: + """Build cos/sin for 3D RoPE + UID RoPE.""" + device = ref_pos.device + batch_size, n_atoms = ref_pos.shape[:2] + half_dim = head_dim // 2 + n_spatial_total = 3 * n_spatial_per_axis + + spatial_inv_freq = 1.0 / ( + spatial_base_freq + ** ( + torch.arange(0, n_spatial_per_axis, dtype=torch.float32, device=device) + / n_spatial_per_axis + ) + ) + uid_inv_freq = 1.0 / ( + uid_base_freq + ** (torch.arange(0, n_uid_pairs, dtype=torch.float32, device=device) / n_uid_pairs) + ) + + pos_f32 = ref_pos.float() + spatial_freqs = torch.einsum("bna,k->bnak", pos_f32, spatial_inv_freq) + spatial_freqs = spatial_freqs.reshape(batch_size, n_atoms, n_spatial_total) + + uid_f32 = ref_space_uid.float() + uid_freqs = torch.einsum("bn,k->bnk", uid_f32, uid_inv_freq) + + n_active = n_spatial_total + n_uid_pairs + freqs = torch.cat([spatial_freqs, uid_freqs], dim=-1) + + if n_active < half_dim: + padding = torch.zeros( + batch_size, + n_atoms, + half_dim - n_active, + device=device, + dtype=torch.float32, + ) + freqs = torch.cat([freqs, padding], dim=-1) + + cos = freqs.cos().to(torch.bfloat16) + sin = freqs.sin().to(torch.bfloat16) + return cos, sin + + +def qk_norm(x: Tensor) -> Tensor: + return F.rms_norm(x, (x.size(-1),)).to(x.dtype) + + +# =========================================================================== +# SwiGLUFFN (atom transformer blocks) +# =========================================================================== + + +class SwiGLUFFN(nn.Module): + """SwiGLU FFN with rounded hidden size for hardware alignment.""" + + def __init__(self, d_model: int, expansion_ratio: int = 2) -> None: + super().__init__() + hidden_size = ((expansion_ratio * (d_model // 3) * 2) + 255) // 256 * 256 + self.w_up = nn.Linear(d_model, 2 * hidden_size, bias=False) + self.w_down = nn.Linear(hidden_size, d_model, bias=False) + + def forward(self, x: Tensor) -> Tensor: + x = x.to(self.w_up.weight.dtype) + x1, x2 = self.w_up(x).chunk(2, dim=-1) + return self.w_down(F.silu(x1) * x2) + + +# =========================================================================== +# SWA3DRoPEAttention +# =========================================================================== + + +class SWA3DRoPEAttention(nn.Module): + """Sliding window attention with 3D RoPE. Has Wqkv, gate_proj, out_proj.""" + + def __init__(self, d_model: int, n_heads: int, half_window: int = 64) -> None: + super().__init__() + self.n_heads = n_heads + self.head_dim = d_model // n_heads + self.scale = self.head_dim**-0.5 + self.half_window = half_window + + self.Wqkv = nn.Linear(d_model, 3 * d_model, bias=False) + self.out_proj = nn.Linear(d_model, d_model, bias=False) + self.gate_proj = nn.Linear(d_model, d_model, bias=False) + + def forward(self, x: Tensor, attention_params: tuple) -> Tensor: + batch_size, n_atoms = x.shape[:2] + cos, sin = attention_params[0], attention_params[1] + + x_input = x + qkv = self.Wqkv(x) + qkv = qkv.view(batch_size, n_atoms, 3, self.n_heads, self.head_dim).permute(2, 0, 1, 3, 4) + q, k, v = qkv.unbind(0) + q, k = qk_norm(q), qk_norm(k) + + q = apply_rotary_emb_3d(q, cos, sin) + k = apply_rotary_emb_3d(k, cos, sin) + + input_dtype = q.dtype + if q.dtype not in (torch.float16, torch.bfloat16): + q, k, v = q.bfloat16(), k.bfloat16(), v.bfloat16() + + # ESMFold2 does not advertise FlashAttention. Keep this atom path on + # PyTorch. Models that advertise FlashAttention dispatch through the + # precompiled Hugging Face kernels interface in fastplms.attention. + q_t = q.transpose(1, 2) + k_t = k.transpose(1, 2) + v_t = v.transpose(1, 2) + attn = torch.matmul(q_t, k_t.transpose(-2, -1)) * self.scale + attn = F.softmax(attn, dim=-1) + out = torch.matmul(attn, v_t).transpose(1, 2) + + out = out.to(input_dtype).reshape( # type: ignore[union-attr] + batch_size, n_atoms, -1 + ) + out = out * torch.sigmoid(self.gate_proj(x_input)) + return self.out_proj(out) + + +# =========================================================================== +# SWAAtomBlock, SWAAtomTransformer +# =========================================================================== + + +def _rms_adaln_raw(x: Tensor, scale: Tensor, shift: Tensor) -> Tensor: + return F.rms_norm(x, (x.shape[-1],)) * (1 + scale) + shift + + +def _gated_residual_raw(x: Tensor, gate: Tensor, y: Tensor) -> Tensor: + return x + gate * y + + +class SWAAtomBlock(nn.Module): + """adaLN-Zero + SWA attention + SwiGLU FFN. + + Creates adaln_modulation = Sequential(SiLU(), Linear) -> keys like adaln_modulation.1.weight + """ + + def __init__( + self, + d_atom: int, + n_heads: int, + half_window: int = 64, + expansion_ratio: int = 2, + use_compile_fusions: bool = False, + ) -> None: + super().__init__() + self.attn_norm = nn.RMSNorm(d_atom, elementwise_affine=False) + self.ffn_norm = nn.RMSNorm(d_atom, elementwise_affine=False) + + adaln_linear = nn.Linear(d_atom, 6 * d_atom, bias=False) + nn.init.zeros_(adaln_linear.weight) + self.adaln_modulation = nn.Sequential(nn.SiLU(), adaln_linear) + + self.attn = SWA3DRoPEAttention(d_atom, n_heads, half_window=half_window) + self.ffn = SwiGLUFFN(d_atom, expansion_ratio) + + self._rms_adaln = torch.compile(_rms_adaln_raw) if use_compile_fusions else _rms_adaln_raw + self._gated_residual = ( + torch.compile(_gated_residual_raw) if use_compile_fusions else _gated_residual_raw + ) + + def forward(self, x: Tensor, c_l: Tensor, attention_params: tuple) -> Tensor: + mod = self.adaln_modulation(c_l) + if mod.dim() == 2: + mod = mod.unsqueeze(1) + shift_a, scale_a, gate_a, shift_f, scale_f, gate_f = mod.chunk(6, dim=-1) + + attn_input = self._rms_adaln(x, scale_a, shift_a) + attn_out = self.attn(attn_input, attention_params) + x = self._gated_residual(x, gate_a, attn_out) + + ffn_input = self._rms_adaln(x, scale_f, shift_f) + ffn_out = self.ffn(ffn_input) + x = self._gated_residual(x, gate_f, ffn_out) + return x + + +class SWAAtomTransformer(nn.Module): + """Stack of SWAAtomBlocks.""" + + def __init__( + self, + d_atom: int = 128, + n_blocks: int = 3, + n_heads: int = 4, + swa_window_size: int = 128, + expansion_ratio: int = 2, + spatial_rope_base_frequency: float = 20.0, + n_spatial_rope_pairs_per_axis: int = 2, + n_uid_rope_pairs: int = 10, + uid_rope_base_frequency: float = 10000.0, + ) -> None: + super().__init__() + self.swa_window_size = swa_window_size + self.head_dim = d_atom // n_heads + self.spatial_rope_base_frequency = spatial_rope_base_frequency + self.n_spatial_rope_pairs_per_axis = n_spatial_rope_pairs_per_axis + self.n_uid_rope_pairs = n_uid_rope_pairs + self.uid_rope_base_frequency = uid_rope_base_frequency + + self.blocks = nn.ModuleList( + [ + SWAAtomBlock( + d_atom=d_atom, + n_heads=n_heads, + half_window=swa_window_size // 2, + expansion_ratio=expansion_ratio, + ) + for _ in range(n_blocks) + ] + ) + + def _build_3d_rope(self, ref_pos: Tensor, ref_space_uid: Tensor) -> tuple[Tensor, Tensor]: + return build_3d_rope( + ref_pos=ref_pos, + ref_space_uid=ref_space_uid, + head_dim=self.head_dim, + n_spatial_per_axis=self.n_spatial_rope_pairs_per_axis, + n_uid_pairs=self.n_uid_rope_pairs, + spatial_base_freq=self.spatial_rope_base_frequency, + uid_base_freq=self.uid_rope_base_frequency, + ) + + def forward( + self, + q_l: Tensor, + c_l: Tensor, + attention_params: tuple, + return_intermediates: bool = False, + ) -> Tensor | tuple[Tensor, list[Tensor]]: + intermediates: list[Tensor] = [] + for block in self.blocks: + q_l = block(q_l, c_l, attention_params) + if return_intermediates: + intermediates.append(q_l) + if return_intermediates: + return q_l, intermediates + return q_l + + +# =========================================================================== +# ESMFold2AtomEncoder (for both inputs_embedder and diffusion_module) +# =========================================================================== + + +class ESMFold2AtomEncoder(nn.Module): + """Encode atom inputs with normalization and sliding-window attention. + + Args: + d_atom: atom hidden dim + d_token: token dim for atom_to_token aggregation + n_blocks, n_heads, swa_window_size, expansion_ratio: transformer params + structure_prediction: if True, creates coords_linear and uses full d_token + spatial_rope_base_frequency, n_spatial_rope_pairs_per_axis, + n_uid_rope_pairs, uid_rope_base_frequency: 3D RoPE config + """ + + def __init__( + self, + d_atom: int = 128, + d_token: int = 768, + n_blocks: int = 3, + n_heads: int = 4, + swa_window_size: int = 128, + expansion_ratio: int = 2, + structure_prediction: bool = True, + spatial_rope_base_frequency: float = 20.0, + n_spatial_rope_pairs_per_axis: int = 2, + n_uid_rope_pairs: int = 10, + uid_rope_base_frequency: float = 10000.0, + ) -> None: + super().__init__() + self.d_atom = d_atom + self.d_token = d_token + self.structure_prediction = structure_prediction + + self.atom_linear = nn.Linear(ATOM_FEATURE_DIM, d_atom, bias=False) + self.atom_norm = nn.LayerNorm(d_atom) + + if structure_prediction: + self.coords_linear = nn.Linear(6, d_atom, bias=False) + + self.atom_transformer = SWAAtomTransformer( + d_atom=d_atom, + n_blocks=n_blocks, + n_heads=n_heads, + swa_window_size=swa_window_size, + expansion_ratio=expansion_ratio, + spatial_rope_base_frequency=spatial_rope_base_frequency, + n_spatial_rope_pairs_per_axis=n_spatial_rope_pairs_per_axis, + n_uid_rope_pairs=n_uid_rope_pairs, + uid_rope_base_frequency=uid_rope_base_frequency, + ) + + # Output aggregation: d_token for structure prediction, d_token//2 for inputs + out_dim = d_token if structure_prediction else d_token // 2 + self.atom_to_token_linear = nn.Linear(d_atom, out_dim, bias=False) + + def forward( + self, + ref_pos: Tensor, + atom_attention_mask: Tensor, + ref_space_uid: Tensor, + ref_charge: Tensor, + ref_element: Tensor, + ref_atom_name_chars: Tensor, + atom_to_token: Tensor, + r_l: Tensor | None = None, + pred_r1: Tensor | None = None, + s_i: Tensor | None = None, + z_ij: Tensor | None = None, + num_diffusion_samples: int = 1, + return_intermediates: bool = False, + inference_cache: dict | None = None, + ) -> tuple[Tensor, Tensor, Tensor, tuple, list[Tensor]]: + """Returns (a, q, c, attention_params, intermediates). + + ``inference_cache`` caches step-invariant tensors (c_base, 3D RoPE, + attention indices, n_tokens) across diffusion steps. + """ + batch_size, n_atoms = ref_pos.shape[:2] + + layer_cache = None + if inference_cache is not None: + layer_cache = inference_cache.setdefault("atomencoder", {}) + + if layer_cache is None or len(layer_cache) == 0: + atom_feats = torch.cat( + [ + ref_pos, + ref_charge.unsqueeze(-1), + atom_attention_mask.unsqueeze(-1), + ref_element, + ref_atom_name_chars.reshape(batch_size, n_atoms, MAX_CHARS * CHAR_VOCAB_SIZE), + ], + dim=-1, + ) + c_base = self.atom_norm(self.atom_linear(atom_feats)) + cos, sin = self.atom_transformer._build_3d_rope(ref_pos, ref_space_uid) + cos = cos.repeat_interleave(num_diffusion_samples, 0) + sin = sin.repeat_interleave(num_diffusion_samples, 0) + mask_exp = atom_attention_mask.repeat_interleave(num_diffusion_samples, 0) + seqlens = mask_exp.sum(dim=-1, dtype=torch.int32) + indices = torch.nonzero(mask_exp.flatten(), as_tuple=False).flatten() + max_seqlen = int(seqlens.max().item()) + cu_seqlens = F.pad(torch.cumsum(seqlens, dim=0, dtype=torch.int32), (1, 0)) + attention_params = (cos, sin, indices, cu_seqlens, max_seqlen) + n_tokens = int(atom_to_token.max().item()) + 1 + if layer_cache is not None: + layer_cache["c_base"] = c_base + layer_cache["attention_params"] = attention_params + layer_cache["mask_exp"] = mask_exp + layer_cache["n_tokens"] = n_tokens + layer_cache["atom_to_token_exp"] = atom_to_token.repeat_interleave( + num_diffusion_samples, 0 + ) + else: + c_base = layer_cache["c_base"] + attention_params = layer_cache["attention_params"] + mask_exp = layer_cache["mask_exp"] + n_tokens = layer_cache["n_tokens"] + + c = c_base + + q = c + + if self.structure_prediction and r_l is not None: + q = q.repeat_interleave(num_diffusion_samples, 0) + if pred_r1 is None: + pred_r1 = torch.zeros_like(r_l) + r_input = torch.cat([r_l, pred_r1], dim=-1) + r_to_q = self.coords_linear(r_input) + q = q + r_to_q + + c = c.repeat_interleave(num_diffusion_samples, 0) + + result = self.atom_transformer( + q_l=q, + c_l=c, + attention_params=attention_params, + return_intermediates=return_intermediates, + ) + if return_intermediates: + q, intermediates = result + else: + q = result + intermediates = [] + + q_to_a = F.relu(self.atom_to_token_linear(q)) + if layer_cache is not None and "atom_to_token_exp" in layer_cache: + atom_to_token_exp = layer_cache["atom_to_token_exp"] + else: + atom_to_token_exp = atom_to_token.repeat_interleave(num_diffusion_samples, 0) + a = scatter_atom_to_token(q_to_a, atom_to_token_exp, n_tokens, atom_mask=mask_exp.bool()) + + return a, q, c, attention_params, intermediates + + +# =========================================================================== +# ESMFold2AtomDecoder +# =========================================================================== + + +class ESMFold2AtomDecoder(nn.Module): + """SWA atom decoder with token_to_atom_linear, atom_transformer, norm, output_linear.""" + + def __init__( + self, + d_atom: int = 128, + d_token: int = 768, + n_blocks: int = 3, + n_heads: int = 4, + swa_window_size: int = 128, + expansion_ratio: int = 2, + spatial_rope_base_frequency: float = 20.0, + n_spatial_rope_pairs_per_axis: int = 2, + n_uid_rope_pairs: int = 10, + uid_rope_base_frequency: float = 10000.0, + ) -> None: + super().__init__() + self.token_to_atom_linear = nn.Linear(d_token, d_atom, bias=False) + + self.atom_transformer = SWAAtomTransformer( + d_atom=d_atom, + n_blocks=n_blocks, + n_heads=n_heads, + swa_window_size=swa_window_size, + expansion_ratio=expansion_ratio, + spatial_rope_base_frequency=spatial_rope_base_frequency, + n_spatial_rope_pairs_per_axis=n_spatial_rope_pairs_per_axis, + n_uid_rope_pairs=n_uid_rope_pairs, + uid_rope_base_frequency=uid_rope_base_frequency, + ) + + self.norm = nn.LayerNorm(d_atom) + self.output_linear = nn.Linear(d_atom, XYZ_DIMS, bias=False) + + def forward( + self, + a_i: Tensor, + q_l: Tensor, + c_l: Tensor, + p_lm: tuple, + atom_to_token: Tensor, + atom_attention_mask: Tensor, + num_diffusion_samples: int = 1, + return_intermediates: bool = False, + ) -> tuple[Tensor, list[Tensor]]: + """Returns (r_update, intermediates).""" + atom_to_token_exp = atom_to_token.repeat_interleave(num_diffusion_samples, 0) + a_to_q = self.token_to_atom_linear(a_i) + a_to_q = gather_token_to_atom(a_to_q, atom_to_token_exp) + q_l = q_l + a_to_q + + result = self.atom_transformer( + q_l=q_l, + c_l=c_l, + attention_params=p_lm, + return_intermediates=return_intermediates, + ) + if return_intermediates: + q_l, intermediates = result + else: + q_l = result + intermediates = [] + + r_l = self.output_linear(self.norm(q_l)) + return r_l, intermediates + + +# =========================================================================== +# AttentionPairBias (DiffusionTransformer attention block) +# =========================================================================== + + +class AttentionPairBias(nn.Module): + """Gated multi-head attention with pair bias conditioning.""" + + def __init__( + self, + d_model: int, + d_pair: int, + num_heads: int, + d_cond: int | None = None, + use_conditioning: bool = True, + ) -> None: + super().__init__() + self.d_model = d_model + self.num_heads = num_heads + self.head_dim = d_model // num_heads + self.scale = self.head_dim**-0.5 + d_cond = d_cond or d_model + + if use_conditioning: + self.adaln = AdaptiveLayerNorm(d_model, d_cond, eps=1e-5) + self.out_gate = nn.Linear(d_cond, d_model, bias=True) + # adaln init: weight=0, bias=-2 + nn.init.zeros_(self.out_gate.weight) + nn.init.constant_(self.out_gate.bias, -2.0) + else: + self.pre_norm = nn.LayerNorm(d_model, eps=1e-5) + + self.q_proj = nn.Linear(d_model, d_model, bias=True) + self.kv_proj = nn.Linear(d_model, 2 * d_model, bias=False) + self.g_proj = nn.Linear(d_model, d_model, bias=False) + self.out_proj = nn.Linear(d_model, d_model, bias=False) + + if d_pair > 0: + self.pair_norm = nn.LayerNorm(d_pair, eps=1e-5) + self.pair_bias_proj = nn.Linear(d_pair, num_heads, bias=False) + + self._kernel_backend: str | None = None + + def set_kernel_backend(self, backend: str | None) -> None: + if backend not in _VALID_BACKENDS: + raise ValueError(f"backend must be one of {_VALID_BACKENDS}, got {backend!r}") + self._kernel_backend = backend + + def _is_zero_beta(self, beta: Tensor | float) -> bool: + if isinstance(beta, (int, float)): + return beta == 0.0 + return bool((beta == 0).all()) + + def _can_use_fused_pair_bias(self, z: Tensor, n_queries: int, beta: Tensor | float) -> bool: + return ( + _fused_active(self, z) + and z.dim() == 4 + and self._is_zero_beta(beta) + and hasattr(self, "pair_bias_proj") + and hasattr(self, "pair_norm") + ) + + def _can_use_cueq_pair_bias(self, z: Tensor, n_queries: int, beta: Tensor | float) -> bool: + return ( + _cueq_active(self) + and n_queries > 750 + and z.dim() == 4 + and self._is_zero_beta(beta) + and hasattr(self, "pair_bias_proj") + ) + + def forward( + self, + a: Tensor, + s: Tensor | None, + z: Tensor, + beta: Tensor | float = 0.0, + attention_mask: Tensor | None = None, + num_diffusion_samples: int = 1, + ) -> Tensor: + bsz, n_queries, d_model = a.shape + + x = self.adaln(a, s) if s is not None else self.pre_norm(a) + + n_keys = x.shape[1] + q = self.q_proj(x).view(bsz, n_queries, self.num_heads, self.head_dim) + kv = self.kv_proj(x) + k, v = kv.chunk(2, dim=-1) + k = k.view(bsz, n_keys, self.num_heads, self.head_dim) + v = v.view(bsz, n_keys, self.num_heads, self.head_dim) + + # Expand z for num_diffusion_samples + if z.dim() == 4 and z.shape[0] != bsz and num_diffusion_samples > 1: + z = z.repeat_interleave(num_diffusion_samples, dim=0) + if ( + attention_mask is not None + and attention_mask.shape[0] != bsz + and num_diffusion_samples > 1 + ): + attention_mask = attention_mask.repeat_interleave(num_diffusion_samples, dim=0) + + if self._can_use_fused_pair_bias(z, n_queries, beta): + kernel_mask = ( + attention_mask + if attention_mask is not None + else torch.ones(bsz, n_queries, device=a.device, dtype=torch.bool) + ) + pair_norm_w = self.pair_norm.weight + pair_norm_b = ( + self.pair_norm.bias + if self.pair_norm.bias is not None + else torch.zeros_like(pair_norm_w) + ) + z_bf = z if z.dtype == torch.bfloat16 else z.to(torch.bfloat16) + bias = _fused_pair_bias( # type: ignore[misc] + z_bf, + kernel_mask, + self.pair_bias_proj.weight, + num_heads=self.num_heads, + pair_norm_w=pair_norm_w, + pair_norm_b=pair_norm_b, + ) # A has shape (b, h, q, k). + q_bhqd = q.transpose(1, 2) + k_bhqd = k.transpose(1, 2) + v_bhqd = v.transpose(1, 2) + attn_out = F.scaled_dot_product_attention( + q_bhqd, k_bhqd, v_bhqd, attn_mask=bias.to(q_bhqd.dtype) + ) + g = torch.sigmoid(self.g_proj(x)).view(bsz, n_queries, self.num_heads, self.head_dim) + ctx = g * attn_out.transpose(1, 2) + out = self.out_proj(ctx.reshape(bsz, n_queries, d_model)) + if s is not None: + out = torch.sigmoid(self.out_gate(s)) * out + return out + + if self._can_use_cueq_pair_bias(z, n_queries, beta): + kernel_mask = ( + attention_mask + if attention_mask is not None + else torch.ones(bsz, n_queries, device=a.device, dtype=torch.bool) + ) + out, _ = _cue_attn_pair_bias( # type: ignore[misc] + s=x, + q=q.transpose(1, 2), + k=k.transpose(1, 2), + v=v.transpose(1, 2), + z=z, + mask=kernel_mask, + num_heads=self.num_heads, + w_proj_z=self.pair_bias_proj.weight, + w_proj_g=self.g_proj.weight, + w_proj_o=self.out_proj.weight, + w_ln_z=self.pair_norm.weight, + b_ln_z=self.pair_norm.bias, + return_z_proj=False, + is_cached_z_proj=False, + ) + else: + # Standard attention with pair bias + g = torch.sigmoid(self.g_proj(x)).view(bsz, n_queries, self.num_heads, self.head_dim) + + logits = torch.einsum("... i h d, ... j h d -> ... i j h", q, k) * self.scale + + pair_bias = self.pair_bias_proj(self.pair_norm(z)) if z.dim() == 4 else z.unsqueeze(-1) + logits = logits + pair_bias.to(dtype=logits.dtype) + + if attention_mask is not None: + min_val = torch.finfo(logits.dtype).min + mask_bias = torch.where(attention_mask.bool()[:, None, :, None], 0.0, min_val) + logits = logits + mask_bias.to(dtype=logits.dtype) + + attn = torch.softmax(logits, dim=-2).to(dtype=v.dtype) + ctx = torch.einsum("... i j h, ... j h d -> ... i h d", attn, v) + ctx = g * ctx + out = self.out_proj(ctx.reshape(bsz, n_queries, d_model)) + + if s is not None: + out = torch.sigmoid(self.out_gate(s)) * out + return out + + +# =========================================================================== +# ConditionedTransitionBlock +# =========================================================================== + + +class ConditionedTransitionBlock(nn.Module): + """Conditioned SwiGLU transition with adaptive layer norm.""" + + def __init__( + self, + d_model: int, + d_cond: int | None = None, + transition_multiplier: int = 2, + use_conditioning: bool = True, + ) -> None: + super().__init__() + d_cond = d_cond or d_model + hidden = transition_multiplier * d_model + + if use_conditioning: + self.adaln = AdaptiveLayerNorm(d_model, d_cond, eps=1e-5) + self.output_gate = nn.Linear(d_cond, d_model, bias=True) + nn.init.zeros_(self.output_gate.weight) + nn.init.constant_(self.output_gate.bias, -2.0) + else: + self.pre_norm = nn.LayerNorm(d_model, eps=1e-5) + + self.lin_swish = nn.Linear(d_model, 2 * hidden, bias=False) + self.lin_out = nn.Linear(hidden, d_model, bias=False) + + def forward(self, a: Tensor, s: Tensor | None) -> Tensor: + x = self.adaln(a, s) if s is not None else self.pre_norm(a) + + swish_a, swish_b = self.lin_swish(x).chunk(2, dim=-1) + b = F.silu(swish_a) * swish_b + out = self.lin_out(b) + + if s is not None: + out = torch.sigmoid(self.output_gate(s)) * out + return out + + +# =========================================================================== +# DiffusionTransformer (token transformer) +# =========================================================================== + + +class DiffusionTransformer(nn.Module): + """Diffusion denoising transformer with attention pair bias.""" + + def __init__( + self, + d_model: int, + d_pair: int, + num_heads: int, + num_blocks: int, + d_cond: int | None = None, + transition_multiplier: int = 2, + use_conditioning: bool = True, + ) -> None: + super().__init__() + d_cond = d_cond or d_model + + self.attn_blocks = nn.ModuleList( + [ + AttentionPairBias( + d_model=d_model, + d_pair=d_pair, + num_heads=num_heads, + d_cond=d_cond, + use_conditioning=use_conditioning, + ) + for _ in range(num_blocks) + ] + ) + self.transition_blocks = nn.ModuleList( + [ + ConditionedTransitionBlock( + d_model=d_model, + d_cond=d_cond, + transition_multiplier=transition_multiplier, + use_conditioning=use_conditioning, + ) + for _ in range(num_blocks) + ] + ) + + def set_kernel_backend(self, backend: str | None) -> None: + for attn in self.attn_blocks: + cast(AttentionPairBias, attn).set_kernel_backend(backend) + + def forward( + self, + a: Tensor, + s: Tensor | None, + z: Tensor, + beta: Tensor | float = 0.0, + attention_mask: Tensor | None = None, + num_diffusion_samples: int = 1, + return_intermediates: bool = False, + ) -> tuple[Tensor, list[Tensor]]: + intermediates: list[Tensor] = [] + x = a + for attn, transition in zip(self.attn_blocks, self.transition_blocks, strict=True): + x = x + attn( + x, + s, + z, + beta, + attention_mask=attention_mask, + num_diffusion_samples=num_diffusion_samples, + ) + x = x + transition(x, s) + if return_intermediates: + intermediates.append(x) + return x, intermediates + + +# =========================================================================== +# DiffusionConditioning +# =========================================================================== + + +class DiffusionConditioning(nn.Module): + """Conditions pair and single representations on noise timestep.""" + + def __init__( + self, + c_z: int = 256, + c_s: int = 768, + c_s_inputs: int = 451, + sigma_data: float = 16.0, + fourier_dim: int = 256, + transition_multiplier: int = 2, + layer_norm_eps: float = 1e-5, + ) -> None: + super().__init__() + self.sigma_data = float(sigma_data) + self.c_z = c_z + self.c_s = c_s + self.c_s_inputs = c_s_inputs + + self.z_input_norm = nn.LayerNorm(2 * c_z, eps=layer_norm_eps) + self.z_proj = nn.Linear(2 * c_z, c_z, bias=False) + self.z_transitions = nn.ModuleList( + [TransitionLayer(c_z, n=transition_multiplier, eps=layer_norm_eps) for _ in range(2)] + ) + + self.s_input_norm = nn.LayerNorm(c_s_inputs, eps=layer_norm_eps) + self.s_proj = nn.Linear(c_s_inputs, c_s, bias=False) + self.fourier = FourierEmbedding(fourier_dim) + self.noise_norm = nn.LayerNorm(fourier_dim, eps=layer_norm_eps) + self.noise_proj = nn.Linear(fourier_dim, c_s, bias=False) + self.s_transitions = nn.ModuleList( + [TransitionLayer(c_s, n=transition_multiplier, eps=layer_norm_eps) for _ in range(2)] + ) + + def forward( + self, + t_hat: Tensor, + s_inputs: Tensor, + s_trunk: Tensor | None, + z_trunk: Tensor, + relative_position_encoding: Tensor, + sigma_data: float | None = None, + num_diffusion_samples: int = 1, + inference_cache: dict[str, Tensor] | None = None, + ) -> tuple[Tensor, Tensor]: + sigma = self.sigma_data if sigma_data is None else float(sigma_data) + base_batch = z_trunk.shape[0] + target_batch = base_batch * num_diffusion_samples + + # z conditioning (cached across diffusion steps: independent of t_hat) + if inference_cache is not None and "z" in inference_cache: + z = inference_cache["z"] + else: + z_rel = relative_position_encoding.to(dtype=torch.float32) + z = torch.cat([z_trunk.to(dtype=torch.float32), z_rel], dim=-1) + z = self.z_proj(self.z_input_norm(z)) + with torch.autocast(device_type="cuda", dtype=torch.bfloat16): + for block in self.z_transitions: + z = z + block(z) + if inference_cache is not None: + inference_cache["z"] = z + + # s conditioning + s_inputs_eff = s_inputs + if s_inputs_eff.shape[0] != target_batch: + s_inputs_eff = s_inputs_eff.repeat_interleave(num_diffusion_samples, 0) + + s = self.s_proj(self.s_input_norm(s_inputs_eff.to(dtype=torch.float32))) + + # Noise embedding + t = torch.as_tensor(t_hat, dtype=torch.float32, device=s.device).reshape(-1) + if t.numel() == 1: + t = t.expand(target_batch) + elif t.shape[0] != target_batch: + t = t.repeat_interleave(num_diffusion_samples, 0) + t_noise = 0.25 * torch.log((t / sigma).clamp(min=1e-20)) + n = self.fourier(t_noise) + n = self.noise_proj(self.noise_norm(n)) + s = s + n.unsqueeze(1) + + for block in self.s_transitions: + s = s + block(s) + + return s, z + + +# =========================================================================== +# DiffusionModule +# =========================================================================== + + +class DiffusionModule(nn.Module): + """Diffusion denoising module for structure prediction.""" + + def __init__( + self, + c_atom: int = 128, + c_token: int = 768, + c_z: int = 256, + c_s_inputs: int = 451, + sigma_data: float = 16.0, + fourier_dim: int = 256, + atom_num_blocks: int = 3, + atom_num_heads: int = 4, + token_num_blocks: int = 12, + token_num_heads: int = 16, + transition_multiplier: int = 2, + swa_window_size: int = 128, + spatial_rope_base_frequency: float = 20.0, + n_spatial_rope_pairs_per_axis: int = 2, + n_uid_rope_pairs: int = 10, + uid_rope_base_frequency: float = 10000.0, + ) -> None: + super().__init__() + self.sigma_data = float(sigma_data) + + self.conditioning = DiffusionConditioning( + c_z=c_z, + c_s=c_token, # conditioning s output is c_token + c_s_inputs=c_s_inputs, + sigma_data=sigma_data, + fourier_dim=fourier_dim, + transition_multiplier=transition_multiplier, + ) + + # Atom encoder (structure_prediction=True, with coords_linear) + self.atom_encoder = ESMFold2AtomEncoder( + d_atom=c_atom, + d_token=c_token, + n_blocks=atom_num_blocks, + n_heads=atom_num_heads, + swa_window_size=swa_window_size, + expansion_ratio=2, + structure_prediction=True, + spatial_rope_base_frequency=spatial_rope_base_frequency, + n_spatial_rope_pairs_per_axis=n_spatial_rope_pairs_per_axis, + n_uid_rope_pairs=n_uid_rope_pairs, + uid_rope_base_frequency=uid_rope_base_frequency, + ) + + # Atom decoder + self.atom_decoder = ESMFold2AtomDecoder( + d_atom=c_atom, + d_token=c_token, + n_blocks=atom_num_blocks, + n_heads=atom_num_heads, + swa_window_size=swa_window_size, + expansion_ratio=2, + spatial_rope_base_frequency=spatial_rope_base_frequency, + n_spatial_rope_pairs_per_axis=n_spatial_rope_pairs_per_axis, + n_uid_rope_pairs=n_uid_rope_pairs, + uid_rope_base_frequency=uid_rope_base_frequency, + ) + + self.s_to_token = nn.Linear(c_token, c_token, bias=False) + nn.init.zeros_(self.s_to_token.weight) + + # Token transformer (DiffusionTransformer with pair bias) + self.token_transformer = DiffusionTransformer( + d_model=c_token, + d_pair=c_z, + num_heads=token_num_heads, + num_blocks=token_num_blocks, + d_cond=c_token, + transition_multiplier=transition_multiplier, + use_conditioning=True, + ) + + self.s_step_norm = nn.LayerNorm(c_token) + self.token_norm = nn.LayerNorm(c_token) + + def set_kernel_backend(self, backend: str | None) -> None: + self.token_transformer.set_kernel_backend(backend) + + def forward( + self, + x_noisy: Tensor, + t_hat: Tensor, + ref_pos: Tensor, + ref_charge: Tensor, + ref_mask: Tensor, + ref_element: Tensor, + ref_atom_name_chars: Tensor, + ref_space_uid: Tensor, + tok_idx: Tensor, + s_inputs: Tensor, + s_trunk: Tensor | None, + z_trunk: Tensor, + relative_position_encoding: Tensor, + asym_id: Tensor, + residue_index: Tensor, + entity_id: Tensor, + token_index: Tensor, + sym_id: Tensor, + sigma_data: float | None = None, + token_attention_mask: Tensor | None = None, + num_diffusion_samples: int = 1, + return_token_repr: bool = False, + return_atom_repr: bool = False, + inference_cache: dict[str, Tensor] | None = None, + ) -> dict[str, Tensor | None]: + bsz = x_noisy.shape[0] + sigma = self.sigma_data if sigma_data is None else float(sigma_data) + t = torch.as_tensor(t_hat, dtype=torch.float32, device=x_noisy.device).reshape(-1) + if t.numel() == 1: + t = t.expand(bsz) + + # Step 1: conditioning (pair z is cached across diffusion steps) + s, z = self.conditioning( + t_hat=t, + s_inputs=s_inputs, + s_trunk=s_trunk, + z_trunk=z_trunk, + relative_position_encoding=relative_position_encoding, + sigma_data=sigma, + num_diffusion_samples=num_diffusion_samples, + inference_cache=inference_cache, + ) + + # Step 2: normalize noisy coords + denom = torch.sqrt(t * t + sigma * sigma) + r_noisy = x_noisy / denom[:, None, None] + + # Step 3: atom encoder + a, q_skip, c_skip, p_skip, enc_intermediates = self.atom_encoder( + ref_pos=ref_pos, + atom_attention_mask=ref_mask, + ref_space_uid=ref_space_uid, + ref_charge=ref_charge, + ref_element=ref_element, + ref_atom_name_chars=ref_atom_name_chars, + atom_to_token=tok_idx, + r_l=r_noisy, + s_i=s_trunk, + num_diffusion_samples=num_diffusion_samples, + return_intermediates=return_atom_repr, + inference_cache=inference_cache, + ) + + # Step 4: add conditioned s + a = a + self.s_to_token(self.s_step_norm(s)) + + # Step 5: token transformer + a, _ = self.token_transformer( + a, + s, + z, + beta=0.0, + attention_mask=token_attention_mask, + num_diffusion_samples=num_diffusion_samples, + ) + + # Step 6: token norm + a = self.token_norm(a) + + # Step 7: atom decoder + r_update, dec_intermediates = self.atom_decoder( + a_i=a, + q_l=q_skip, + c_l=c_skip, + p_lm=p_skip, + atom_to_token=tok_idx, + atom_attention_mask=ref_mask, + num_diffusion_samples=num_diffusion_samples, + return_intermediates=return_atom_repr, + ) + + # Step 8: compute denoised output + sigma2 = sigma * sigma + t2 = t * t + out = (sigma2 / (sigma2 + t2))[:, None, None] * x_noisy + out = out + ((sigma * t) / torch.sqrt(sigma2 + t2))[:, None, None] * r_update + + # Collect atom intermediates from encoder + decoder + atom_intermediates: Tensor | None = None + if return_atom_repr: + all_ints = enc_intermediates + dec_intermediates + if all_ints: + atom_intermediates = torch.stack(all_ints, dim=2) + + return { + "x_denoised": out, + "token_repr": a if return_token_repr else None, + "atom_intermediates": atom_intermediates, + } + + +# =========================================================================== +# DiffusionStructureHead +# =========================================================================== + + +class DiffusionStructureHead(nn.Module): + """Wrapper around DiffusionModule with diffusion sampling.""" + + def __init__(self, config: ESMFold2Config) -> None: + super().__init__() + dm = config.structure_head.diffusion_module + swa_cfg = config.inputs.atom_encoder + sh = config.structure_head + + self.diffusion_module = DiffusionModule( + c_atom=dm.c_atom, + c_token=dm.c_token, + c_z=dm.c_z, + c_s_inputs=dm.c_s_inputs, + sigma_data=dm.sigma_data, + fourier_dim=dm.fourier_dim, + atom_num_blocks=dm.atom_num_blocks, + atom_num_heads=dm.atom_num_heads, + token_num_blocks=dm.token_num_blocks, + token_num_heads=dm.token_num_heads, + transition_multiplier=dm.transition_multiplier, + swa_window_size=swa_cfg.swa_window_size, + spatial_rope_base_frequency=swa_cfg.spatial_rope_base_frequency, + n_spatial_rope_pairs_per_axis=swa_cfg.n_spatial_rope_pairs_per_axis, + n_uid_rope_pairs=swa_cfg.n_uid_rope_pairs, + uid_rope_base_frequency=swa_cfg.uid_rope_base_frequency, + ) + + # Sampling hyperparameters + self.sigma_data = dm.sigma_data + self.gamma_0 = sh.gamma_0 + self.gamma_min = sh.gamma_min + self.noise_scale = sh.noise_scale + self.step_scale = sh.step_scale + self.inference_s_max = sh.inference_s_max + self.inference_s_min = sh.inference_s_min + self.inference_p = sh.inference_p + self.inference_num_steps = sh.inference_num_steps + + def set_kernel_backend(self, backend: str | None) -> None: + self.diffusion_module.set_kernel_backend(backend) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def inference_noise_schedule( + self, num_steps: int | None = None, device: torch.device | None = None + ) -> Tensor: + """Karras power-law noise schedule.""" + steps = self.inference_num_steps if num_steps is None else int(num_steps) + if steps == 1: + return torch.tensor( + [self.inference_s_max * self.sigma_data, 0.0], + device=device, + dtype=torch.float32, + ) + p = float(self.inference_p) + inv_p = 1.0 / p + k = torch.arange(steps, device=device, dtype=torch.float32) + base = self.inference_s_max**inv_p + (k / (steps - 1)) * ( + self.inference_s_min**inv_p - self.inference_s_max**inv_p + ) + schedule = self.sigma_data * base.pow(p) + return F.pad(schedule, (0, 1), value=0.0) + + @staticmethod + def _random_rotations(n: int, dtype: torch.dtype, device: torch.device) -> Tensor: + q = torch.randn((n, 4), dtype=dtype, device=device) + scale = torch.sqrt((q * q).sum(dim=1)) + signs = torch.where(q[:, 0] < 0, -scale, scale) + q = q / signs[:, None] + r, i, j, k = torch.unbind(q, dim=-1) + two_s = 2.0 / (q * q).sum(dim=-1) + return torch.stack( + ( + 1 - two_s * (j * j + k * k), + two_s * (i * j - k * r), + two_s * (i * k + j * r), + two_s * (i * j + k * r), + 1 - two_s * (i * i + k * k), + two_s * (j * k - i * r), + two_s * (i * k - j * r), + two_s * (j * k + i * r), + 1 - two_s * (i * i + j * j), + ), + dim=-1, + ).reshape(n, 3, 3) + + def _center_random_augmentation( + self, x: Tensor, atom_mask: Tensor, second_coords: Tensor | None = None + ) -> tuple[Tensor, Tensor | None]: + """Algorithm 19: center + random rotation + translation.""" + bsz = x.shape[0] + mask = atom_mask.unsqueeze(-1) # M has shape (b, a, 1). + denom = mask.sum(dim=1, keepdim=True).clamp(min=1) + mean = (x * mask).sum(dim=1, keepdim=True) / denom + x = x - mean + if second_coords is not None: + second_coords = second_coords - mean + + r = self._random_rotations(bsz, x.dtype, x.device) + x = torch.einsum("bmd,bds->bms", x, r) + if second_coords is not None: + second_coords = torch.einsum("bmd,bds->bms", second_coords, r) + + t = torch.randn_like(x[:, 0:1, :]) + x = x + t + if second_coords is not None: + second_coords = second_coords + t + return x, second_coords + + @staticmethod + def _weighted_rigid_align(x: Tensor, x_gt: Tensor, w: Tensor, mask: Tensor) -> Tensor: + """Kabsch alignment: align x to x_gt with weights w.""" + w = (mask * w).unsqueeze(-1) # W has shape (b, n, 1). + denom = w.sum(dim=-2, keepdim=True).clamp(min=1e-8) + mu = (x * w).sum(dim=-2, keepdim=True) / denom + mu_gt = (x_gt * w).sum(dim=-2, keepdim=True) / denom + x_c = x - mu + xgt_c = x_gt - mu_gt + covariance = torch.einsum("bni,bnj->bij", w * xgt_c, x_c) + covariance_f32 = covariance.float() + u, _, vh = torch.linalg.svd( + covariance_f32, driver="gesvd" if covariance_f32.is_cuda else None + ) + det = torch.linalg.det(u @ vh) + ones = torch.ones_like(det) + rotation = (u @ torch.diag_embed(torch.stack([ones, ones, det], dim=-1)) @ vh).to( + covariance.dtype + ) + return x_c @ rotation.transpose(-1, -2) + mu_gt + + # ------------------------------------------------------------------ + # Sampling + # ------------------------------------------------------------------ + + @torch.inference_mode() + def sample( + self, + z_trunk: Tensor, + s_inputs: Tensor, + s_trunk: Tensor | None, + relative_position_encoding: Tensor, + ref_pos: Tensor, + ref_charge: Tensor, + ref_mask: Tensor, + ref_element: Tensor, + ref_atom_name_chars: Tensor, + ref_space_uid: Tensor, + tok_idx: Tensor, + asym_id: Tensor, + residue_index: Tensor, + entity_id: Tensor, + token_index: Tensor, + sym_id: Tensor, + token_attention_mask: Tensor | None = None, + num_diffusion_samples: int = 1, + num_sampling_steps: int | None = None, + max_inference_sigma: float | None = 256.0, + noise_scale: float | None = None, + step_scale: float | None = None, + return_atom_repr: bool = False, + use_inference_cache: bool = True, + denoising_early_exit_rmsd: float | None = None, + ) -> dict[str, Tensor | None]: + """Diffusion sampling (Algorithm 18). + + ``num_sampling_steps`` is the number of denoising steps actually run. + When ``max_inference_sigma`` is set, the Karras schedule built with + ``num_sampling_steps`` entries would lose its high-sigma tail to the cap, + so we inflate the underlying schedule length here to land back at the + requested step count post-truncation. + """ + n_atoms = tok_idx.shape[1] + device = s_inputs.device + target_batch = s_inputs.shape[0] * num_diffusion_samples + + inference_cache: dict[str, Tensor] | None = {} if use_inference_cache else None + + steps = self.inference_num_steps if num_sampling_steps is None else int(num_sampling_steps) + + schedule = self.inference_noise_schedule(steps, device) + if max_inference_sigma is not None: + schedule = schedule[schedule <= float(max_inference_sigma)] + schedule = F.pad(schedule, (1, 0), value=float(max_inference_sigma)) + + lam = self.noise_scale if noise_scale is None else float(noise_scale) + eta = self.step_scale if step_scale is None else float(step_scale) + + x = schedule[0] * torch.randn(target_batch, n_atoms, 3, device=device, dtype=torch.float32) + atom_mask = ref_mask.repeat_interleave(num_diffusion_samples, 0).float() + + gammas = torch.where( + schedule > self.gamma_min, + torch.full_like(schedule, self.gamma_0), + torch.zeros_like(schedule), + ) + + x_denoised_prev: Tensor | None = None + token_repr: Tensor | None = None + diff_atom_intermediates: Tensor | None = None + + step_pairs = list(zip(schedule[:-1], schedule[1:], gammas[1:], strict=True)) + num_steps = len(step_pairs) + + for step_idx, (sigma_tm, sigma_t, gamma) in enumerate(step_pairs): + x, x_denoised_prev = self._center_random_augmentation( + x, atom_mask, second_coords=x_denoised_prev + ) + + sigma_tm_val = float(sigma_tm.item()) + t_hat_val = sigma_tm_val * (1.0 + float(gamma.item())) + eps_std = lam * max(t_hat_val**2 - sigma_tm_val**2, 0.0) ** 0.5 + x_noisy = x + eps_std * torch.randn_like(x) + + is_last_step = step_idx == num_steps - 1 + request_atom_repr = return_atom_repr and ( + is_last_step or denoising_early_exit_rmsd is not None + ) + + dm_out = self.diffusion_module( + x_noisy=x_noisy, + t_hat=torch.full((target_batch,), t_hat_val, device=device, dtype=torch.float32), + ref_pos=ref_pos, + ref_charge=ref_charge, + ref_mask=ref_mask, + ref_element=ref_element, + ref_atom_name_chars=ref_atom_name_chars, + ref_space_uid=ref_space_uid, + tok_idx=tok_idx, + s_inputs=s_inputs, + s_trunk=s_trunk, + z_trunk=z_trunk, + relative_position_encoding=relative_position_encoding, + asym_id=asym_id, + residue_index=residue_index, + entity_id=entity_id, + token_index=token_index, + sym_id=sym_id, + token_attention_mask=token_attention_mask, + num_diffusion_samples=num_diffusion_samples, + return_token_repr=True, + return_atom_repr=request_atom_repr, + inference_cache=inference_cache, + ) + + x_denoised = dm_out["x_denoised"] + token_repr = dm_out["token_repr"] + if request_atom_repr: + diff_atom_intermediates = dm_out.get("atom_intermediates") + + # Reverse diffusion alignment (Kabsch) + with torch.autocast(device_type="cuda", enabled=False): + x_noisy = self._weighted_rigid_align( + x_noisy.float(), x_denoised.float(), atom_mask, atom_mask + ) + x_noisy = x_noisy.to(dtype=x_denoised.dtype) + + # ODE/SDE step + sigma_t_val = float(sigma_t.item()) + denoised_over_sigma = (x_noisy - x_denoised) / t_hat_val + x = x_noisy + eta * (sigma_t_val - t_hat_val) * denoised_over_sigma + + # Denoising early-exit: stop when consecutive predictions converge + if ( + denoising_early_exit_rmsd is not None + and x_denoised_prev is not None + and step_idx >= 1 + ): + with torch.autocast(device_type="cuda", enabled=False): + aligned = self._weighted_rigid_align( + x_denoised_prev.float(), + x_denoised.float(), + atom_mask, + atom_mask, + ) + diff = (x_denoised.float() - aligned) * atom_mask.unsqueeze(-1) + per_sample_rmsd = ( + diff.pow(2).sum(dim=(-1, -2)) / atom_mask.sum(dim=-1).clamp(min=1) + ).sqrt() + if per_sample_rmsd.max().item() < denoising_early_exit_rmsd: + x = x_denoised + x_denoised_prev = x_denoised + break + + x_denoised_prev = x_denoised + + result: dict[str, Tensor | None] = { + "sample_atom_coords": x, + "diff_token_repr": token_repr, + } + if return_atom_repr: + result["diff_atom_intermediates"] = diff_atom_intermediates + return result diff --git a/fastplms/models/esmfold2/modeling_esmfold2_experimental.py b/fastplms/models/esmfold2/modeling_esmfold2_experimental.py new file mode 100644 index 0000000000000000000000000000000000000000..d09f783dfda59f8cb9067e45be08cec66f8eab8f --- /dev/null +++ b/fastplms/models/esmfold2/modeling_esmfold2_experimental.py @@ -0,0 +1,1070 @@ +"""FastPLMs ESMFold2 experimental architecture. + +This module supports Biohub's experimental binder-design checkpoints. The +released ESMFold2 architecture in ``modeling_esmfold2.py`` intentionally +rejects those configs because the experimental trunk uses explicit pair-loop +re-injection and a different confidence/MSA stack. +""" + +from __future__ import annotations + +import gc +from pathlib import Path +from typing import Any, ClassVar, cast + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch import Tensor +from transformers.modeling_utils import PreTrainedModel + +from .attention import ESMFold2AttentionMixin +from .configuration_esmfold2 import ESMFold2Config +from .embedding import ESMFold2EmbeddingMixin +from .modeling_esmfold2 import ( + ESMCPrecision, + ESMCPrecisionStatus, + ESMFold2Output, + _drop_transient_esmc_state, + _finalize_structure_output, + _install_esmc_backbone, + _lm_precision_context, + _reload_esmc_bf16_for_gradients, + _resolve_structure_output_controls, + _transformer_engine_version, +) +from .modeling_esmfold2_common import ( + CHAR_VOCAB_SIZE, + MAX_ATOMIC_NUMBER, + MSA_CONDITIONING_INPUT_NAMES, + NUM_RES_TYPES, + DiffusionModule, + DiffusionStructureHead, + DiffusionTransformer, + FoldingTrunk, + InputsEmbedder, + LanguageModelShim, + MSAPairWeightedAveraging, + OuterProductMean, + PairUpdateBlock, + ResIdxAsymIdSymIdEntityIdEncoding, + RowAttentionPooling, + SwiGLUMLP, + TriangleMultiplicativeUpdate, + _categorical_mean, + _compute_intra_token_idx, + _seed_context, + compute_lm_hidden_states, + gather_rep_atom_coords, + gather_token_to_atom, + validate_kernel_backend, + validate_msa_conditioning_inputs, +) + +_EPS = 1e-5 +_NONPOLYMER_ID = 3 + + +class ConfidenceHead(nn.Module): + """Experimental confidence head predicting pLDDT, PAE, pTM, and ipTM.""" + + boundaries: Tensor + + def __init__(self, config: ESMFold2Config) -> None: + super().__init__() + ch = config.confidence_head + d_single = config.d_single + d_pair = config.d_pair + d_inputs = config.inputs.d_inputs + + boundaries = torch.linspace(ch.min_dist, ch.max_dist, ch.distogram_bins - 1) + self.register_buffer("boundaries", boundaries) + self.dist_bin_pairwise_embed = nn.Embedding(ch.distogram_bins, d_pair) + + self.s_norm = nn.LayerNorm(d_single) + self.s_inputs_to_single = nn.Linear(d_inputs, d_single, bias=False) + self.s_to_z = nn.Linear(d_inputs, d_pair, bias=False) + self.s_to_z_transpose = nn.Linear(d_inputs, d_pair, bias=False) + self.s_to_z_prod_in1 = nn.Linear(d_inputs, d_pair, bias=False) + self.s_to_z_prod_in2 = nn.Linear(d_inputs, d_pair, bias=False) + self.s_to_z_prod_out = nn.Linear(d_pair, d_pair, bias=False) + self.s_input_to_s = nn.Linear(d_inputs, d_single, bias=False) + self.s_inputs_norm = nn.LayerNorm(d_inputs) + self.z_norm = nn.LayerNorm(d_pair) + self.row_attention_pooling = RowAttentionPooling(d_pair=d_pair, d_single=d_single) + + pf = ch.folding_trunk + self.folding_trunk = FoldingTrunk(n_layers=pf.n_layers, d_pair=d_pair, expansion_ratio=4) + + self.plddt_ln = nn.LayerNorm(d_single) + max_atoms_per_token = 23 + self.plddt_weight = nn.Parameter( + torch.zeros(max_atoms_per_token, d_single, ch.num_plddt_bins) + ) + self.pae_head = nn.Linear(d_pair, ch.num_pae_bins, bias=False) + + def set_kernel_backend(self, backend: str | None) -> None: + validate_kernel_backend(backend) + self.folding_trunk.set_kernel_backend(backend) + + def set_chunk_size(self, chunk_size: int | None) -> None: + self.folding_trunk.set_chunk_size(chunk_size) + + @staticmethod + def _repeat_batch(x: Tensor, num_diffusion_samples: int) -> Tensor: + if num_diffusion_samples == 1: + return x + return x.repeat_interleave(num_diffusion_samples, 0) + + @staticmethod + def _flatten_sample_axis(x: Tensor) -> Tensor: + if x.ndim == 4: + b, mult, n, c = x.shape + return x.reshape(b * mult, n, c) + return x + + def forward( + self, + s_inputs: Tensor, + z: Tensor, + x_pred: Tensor, + distogram_atom_idx: Tensor, + token_attention_mask: Tensor, + atom_to_token: Tensor, + atom_attention_mask: Tensor, + asym_id: Tensor, + mol_type: Tensor, + num_diffusion_samples: int = 1, + relative_position_encoding: Tensor | None = None, + token_bonds_encoding: Tensor | None = None, + ) -> dict[str, Tensor]: + s_inputs_normed = self.s_inputs_norm(s_inputs) + z_base = self.z_norm(z) + if relative_position_encoding is not None: + z_base = z_base + relative_position_encoding + if token_bonds_encoding is not None: + z_base = z_base + token_bonds_encoding + z_base = z_base + self.s_to_z(s_inputs_normed).unsqueeze(2) + z_base = z_base + self.s_to_z_transpose(s_inputs_normed).unsqueeze(1) + z_base = z_base + self.s_to_z_prod_out( + self.s_to_z_prod_in1(s_inputs_normed)[:, :, None, :] + * self.s_to_z_prod_in2(s_inputs_normed)[:, None, :, :] + ) + + pair = self._repeat_batch(z_base, num_diffusion_samples) + x_pred_flat = self._flatten_sample_axis(x_pred) + atom_to_token_m = self._repeat_batch(atom_to_token, num_diffusion_samples) + atom_mask_m = self._repeat_batch(atom_attention_mask, num_diffusion_samples) + rep_idx_m = self._repeat_batch(distogram_atom_idx, num_diffusion_samples).long() + mask = self._repeat_batch(token_attention_mask, num_diffusion_samples) + batch_mult = pair.shape[0] + + rep_coords = gather_rep_atom_coords(x_pred_flat, rep_idx_m) + rep_distances = torch.cdist( + rep_coords, rep_coords, compute_mode="donot_use_mm_for_euclid_dist" + ) + distogram_bins = (rep_distances.unsqueeze(-1) > self.boundaries).sum(dim=-1).long() + pair = pair + self.dist_bin_pairwise_embed(distogram_bins) + + pair_mask = mask[:, :, None].float() * mask[:, None, :].float() + pair = pair + self.folding_trunk(pair, pair_attention_mask=pair_mask) + single = self.row_attention_pooling(pair, mask) + + atom_mask_f = atom_mask_m.float() + s_at_atoms = gather_token_to_atom(single, atom_to_token_m) + s_at_atoms = self.plddt_ln(s_at_atoms) + intra_idx = _compute_intra_token_idx(atom_to_token_m) + intra_idx = intra_idx.clamp(max=self.plddt_weight.shape[0] - 1) + plddt_weight = self.plddt_weight[intra_idx] + plddt_logits = torch.einsum("...c,...cb->...b", s_at_atoms, plddt_weight) + plddt_per_atom = _categorical_mean(plddt_logits, start=0.0, end=1.0) + + length = single.shape[1] + plddt_sum = torch.zeros( + batch_mult, length, device=single.device, dtype=plddt_per_atom.dtype + ) + atom_count = torch.zeros( + batch_mult, length, device=single.device, dtype=plddt_per_atom.dtype + ) + atom_mask_t = atom_mask_f.to(plddt_per_atom.dtype) + plddt_sum.scatter_add_(1, atom_to_token_m, plddt_per_atom * atom_mask_t) + atom_count.scatter_add_(1, atom_to_token_m, atom_mask_t) + plddt = plddt_sum / atom_count.clamp(min=1e-6) + + complex_plddt = (plddt_per_atom * atom_mask_f).sum(dim=-1) / ( + atom_mask_f.sum(dim=-1) + _EPS + ) + + expanded_type = self._repeat_batch(mol_type, num_diffusion_samples) + expanded_asym = self._repeat_batch(asym_id, num_diffusion_samples) + is_ligand = (expanded_type == _NONPOLYMER_ID).float() + inter_chain = (expanded_asym.unsqueeze(-1) != expanded_asym.unsqueeze(-2)).float() + near_contact = (rep_distances < 8).float() + interface_per_token = (near_contact * inter_chain * (1.0 - is_ligand).unsqueeze(-1)).amax( + dim=-1 + ) + iplddt_weight = torch.where( + is_ligand.bool(), + torch.full_like(interface_per_token, 2.0), + interface_per_token, + ) + iplddt_weight_atoms = gather_token_to_atom( + iplddt_weight.unsqueeze(-1), atom_to_token_m + ).squeeze(-1) + atom_iplddt_w = atom_mask_f * iplddt_weight_atoms + complex_iplddt = (plddt_per_atom * atom_iplddt_w).sum(dim=-1) / ( + atom_iplddt_w.sum(dim=-1) + _EPS + ) + plddt_ca = plddt_per_atom.gather(1, rep_idx_m) + + pae_logits = self.pae_head(pair) + pae = _categorical_mean(pae_logits, start=0.0, end=32.0).detach() + + n_bins = pae_logits.shape[-1] + bin_width = 32.0 / n_bins + bin_centers = torch.arange(0.5 * bin_width, 32.0, bin_width, device=pae_logits.device) + mask_f = mask.float() + n_res = mask_f.sum(dim=-1, keepdim=True) + d0 = 1.24 * (n_res.clamp(min=19) - 15) ** (1 / 3) - 1.8 + tm_per_bin = 1 / (1 + (bin_centers / d0) ** 2) + pae_probs = F.softmax(pae_logits, dim=-1) + tm_expected = (pae_probs * tm_per_bin[:, None, None, :]).sum(dim=-1) + + pair_mask_2d = mask_f.unsqueeze(-1) * mask_f.unsqueeze(-2) + ptm_per_row = (tm_expected * pair_mask_2d).sum(dim=-1) / (pair_mask_2d.sum(dim=-1) + _EPS) + ptm = ptm_per_row.max(dim=-1).values + + inter_chain_mask = ( + expanded_asym.unsqueeze(-1) != expanded_asym.unsqueeze(-2) + ).float() * pair_mask_2d + iptm_per_row = (tm_expected * inter_chain_mask).sum(dim=-1) / ( + inter_chain_mask.sum(dim=-1) + _EPS + ) + iptm = iptm_per_row.max(dim=-1).values + + max_chain_id = int(expanded_asym.max().item()) if batch_mult > 0 else 0 + n_chains = max_chain_id + 1 + pair_chains_iptm = torch.zeros( + batch_mult, + n_chains, + n_chains, + device=tm_expected.device, + dtype=tm_expected.dtype, + ) + for c1 in range(n_chains): + chain_c1 = (expanded_asym == c1).float() * mask_f + if chain_c1.sum() == 0: + continue + for c2 in range(n_chains): + chain_c2 = (expanded_asym == c2).float() * mask_f + pair_m = chain_c1.unsqueeze(-1) * chain_c2.unsqueeze(-2) + denom = pair_m.sum(dim=(-1, -2)) + _EPS + pair_chains_iptm[:, c1, c2] = (tm_expected * pair_m).sum(dim=(-1, -2)) / denom + + return { + "plddt_logits": plddt_logits, + "plddt": plddt.detach(), + "plddt_per_atom": plddt_per_atom.detach(), + "plddt_ca": plddt_ca.detach(), + "complex_plddt": complex_plddt.detach(), + "complex_iplddt": complex_iplddt.detach(), + "pae_logits": pae_logits, + "pae": pae, + "ptm": ptm.detach(), + "iptm": iptm.detach(), + "pair_chains_iptm": pair_chains_iptm.detach(), + } + + +class _TransitionFFN(nn.Module): + def __init__(self, d_model: int, expansion_ratio: int = 4) -> None: + super().__init__() + self.norm = nn.LayerNorm(d_model) + self.ffn = SwiGLUMLP(d_model, expansion_ratio=expansion_ratio, bias=False) + + def forward(self, x: Tensor) -> Tensor: + return self.ffn(self.norm(x)) + + +class MSAEncoderBlock(nn.Module): + """One experimental MSA update block.""" + + def __init__( + self, + d_msa: int, + d_pair: int, + d_hidden: int = 32, + n_heads_msa: int = 8, + msa_head_width: int = 32, + ) -> None: + super().__init__() + self.outer_product_mean = OuterProductMean( + d_msa, d_hidden, d_pair, divide_outer_before_proj=True + ) + self.msa_pair_weighted_averaging = MSAPairWeightedAveraging( + d_msa, d_pair, n_heads_msa, msa_head_width + ) + self.msa_transition = _TransitionFFN(d_msa, expansion_ratio=4) + self.tri_mul_out = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=True) + self.tri_mul_in = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=False) + self.pair_transition = _TransitionFFN(d_pair, expansion_ratio=4) + + def set_chunk_size(self, chunk_size: int | None) -> None: + self.outer_product_mean.set_chunk_size(chunk_size) + self.tri_mul_out.set_chunk_size(chunk_size) + self.tri_mul_in.set_chunk_size(chunk_size) + + def forward( + self, + msa_repr: Tensor, + pair_repr: Tensor, + msa_attention_mask: Tensor, + pair_attention_mask: Tensor, + msa_track_mask: Tensor | None = None, + ) -> tuple[Tensor, Tensor]: + mask4d = ( + msa_track_mask[:, None, None, None].to(dtype=msa_repr.dtype) + if msa_track_mask is not None + else None + ) + + pair_mask4d = mask4d[:, :, :1] if mask4d is not None else None + + msa_update = self.msa_pair_weighted_averaging(msa_repr, pair_repr, pair_attention_mask) + if mask4d is not None: + msa_update = msa_update * mask4d + msa_repr = msa_repr + msa_update + + msa_transition = self.msa_transition(msa_repr) + if mask4d is not None: + msa_transition = msa_transition * mask4d + msa_repr = msa_repr + msa_transition + + pair_opm = self.outer_product_mean(msa_repr, msa_attention_mask) + if pair_mask4d is not None: + pair_opm = pair_opm * pair_mask4d + pair_repr = pair_repr + pair_opm + + pair_out = self.tri_mul_out(pair_repr, mask=pair_attention_mask) + if pair_mask4d is not None: + pair_out = pair_out * pair_mask4d + pair_repr = pair_repr + pair_out + + pair_in = self.tri_mul_in(pair_repr, mask=pair_attention_mask) + if pair_mask4d is not None: + pair_in = pair_in * pair_mask4d + pair_repr = pair_repr + pair_in + + pair_transition = self.pair_transition(pair_repr) + if pair_mask4d is not None: + pair_transition = pair_transition * pair_mask4d + pair_repr = pair_repr + pair_transition + return msa_repr, pair_repr + + +class MSAEncoder(nn.Module): + def __init__( + self, + d_msa: int, + d_pair: int, + d_inputs: int, + d_hidden: int = 32, + n_layers: int = 4, + n_heads_msa: int = 8, + msa_head_width: int = 32, + ) -> None: + super().__init__() + self.embed = nn.Linear(35, d_msa, bias=False) + self.project_inputs = nn.Linear(d_inputs, d_msa, bias=False) + self.blocks = nn.ModuleList( + [ + MSAEncoderBlock( + d_msa=d_msa, + d_pair=d_pair, + d_hidden=d_hidden, + n_heads_msa=n_heads_msa, + msa_head_width=msa_head_width, + ) + for _ in range(n_layers) + ] + ) + + def set_chunk_size(self, chunk_size: int | None) -> None: + for block in self.blocks: + cast(MSAEncoderBlock, block).set_chunk_size(chunk_size) + + def forward( + self, + x_pair: Tensor, + x_inputs: Tensor, + msa_oh: Tensor, + has_deletion: Tensor, + deletion_value: Tensor, + msa_attention_mask: Tensor, + ) -> Tensor: + batch_size, _, depth = msa_attention_mask.shape + m_feat = torch.cat( + [msa_oh, has_deletion.unsqueeze(-1), deletion_value.unsqueeze(-1)], + dim=-1, + ) + m = self.embed(m_feat) + self.project_inputs(x_inputs).unsqueeze(2) + if depth > 1: + msa_track_mask = msa_attention_mask[:, :, 1:].any(dim=(1, 2)) + else: + msa_track_mask = torch.zeros(batch_size, dtype=torch.bool, device=x_pair.device) + tok_mask = msa_attention_mask[:, :, 0] + pair_attention_mask = tok_mask.unsqueeze(2) * tok_mask.unsqueeze(1) + for block in self.blocks: + m, x_pair = cast(MSAEncoderBlock, block)( + m, + x_pair, + msa_attention_mask, + pair_attention_mask, + msa_track_mask, + ) + return x_pair * msa_track_mask[:, None, None, None].to(dtype=x_pair.dtype) + + +class ESMFold2ExperimentalModel(ESMFold2EmbeddingMixin, ESMFold2AttentionMixin, PreTrainedModel): + """Experimental ESMFold2 architecture used by binder-design checkpoints.""" + + config_class = ESMFold2Config + _keys_to_ignore_on_load_unexpected: ClassVar[list[str]] = [r"\._extra_state$"] + + def __init__(self, config: ESMFold2Config) -> None: + super().__init__(config) + d_inputs = config.inputs.d_inputs + d_pair = config.d_pair + + self.inputs_embedder = InputsEmbedder(config) + self.z_init_1 = nn.Linear(d_inputs, d_pair, bias=False) + self.z_init_2 = nn.Linear(d_inputs, d_pair, bias=False) + self.rel_pos = ResIdxAsymIdSymIdEntityIdEncoding( + n_relative_residx_bins=config.n_relative_residx_bins, + n_relative_chain_bins=config.n_relative_chain_bins, + d_pair=d_pair, + ) + self.token_bonds = nn.Linear(1, d_pair, bias=False) + self.language_model = LanguageModelShim( + d_z=d_pair, d_model=config.lm_d_model, num_layers=config.lm_num_layers + ) + self._esmc: nn.Module | None = None + self._esmc_fp8 = False + self._esmc_fp8_module_paths: tuple[str, ...] = () + self._esmc_source: str = config.esmc_id + self._esmc_source_revision: str | None = None + self._esmc_source_files: dict[str, str] = {} + self._esmc_local_files_only = False + self._esmc_precision_policy: str = str(getattr(config, "esmc_precision", "auto")) + self._esmc_precision_status = ESMCPrecisionStatus( + requested=self._esmc_precision_policy, + resolved="unloaded", + reason="ESMC has not been loaded.", + device=str(self.device), + transformer_engine_version=_transformer_engine_version(), + ) + self._ttt_lm_head: nn.Module | None = None + self._esmfold2_input_builder: Any | None = None + self._kernel_backend: str | None = None + + pf = config.folding_trunk + self.folding_trunk = FoldingTrunk(n_layers=pf.n_layers, d_pair=d_pair, expansion_ratio=4) + self.pair_loop_proj = nn.Sequential( + nn.LayerNorm(d_pair), nn.Linear(d_pair, d_pair, bias=False) + ) + nn.init.zeros_(cast(nn.Linear, self.pair_loop_proj[1]).weight) + + self.structure_head = DiffusionStructureHead(config) + self.distogram_head = nn.Linear(d_pair, config.structure_head.distogram_bins, bias=True) + self.confidence_head: ConfidenceHead | None = ( + ConfidenceHead(config) if config.confidence_head.enabled else None + ) + + msa_cfg = config.msa_encoder + self.msa_encoder: MSAEncoder | None = None + if msa_cfg.enabled: + self.msa_encoder = MSAEncoder( + d_msa=msa_cfg.d_msa, + d_pair=d_pair, + d_inputs=d_inputs, + d_hidden=msa_cfg.d_hidden, + n_layers=msa_cfg.n_layers, + n_heads_msa=msa_cfg.n_heads_msa, + msa_head_width=msa_cfg.msa_head_width, + ) + + self.post_init() + self._register_state_dict_hook(_drop_transient_esmc_state) + + @property + def device(self) -> torch.device: + return next(self.parameters()).device + + def set_kernel_backend(self, backend: str | None) -> None: + validate_kernel_backend(backend) + self.folding_trunk.set_kernel_backend(backend) + if self.confidence_head is not None: + self.confidence_head.set_kernel_backend(backend) + self.structure_head.set_kernel_backend(backend) + self._kernel_backend = backend + + def set_chunk_size(self, chunk_size: int | None) -> None: + self.folding_trunk.set_chunk_size(chunk_size) + if self.confidence_head is not None: + self.confidence_head.set_chunk_size(chunk_size) + if self.msa_encoder is not None: + self.msa_encoder.set_chunk_size(chunk_size) + + def configure_lm_dropout( + self, + lm_dropout: float, + *, + force_lm_dropout_during_inference: bool = True, + ) -> None: + self.config.lm_dropout = lm_dropout + self.config.force_lm_dropout_during_inference = force_lm_dropout_during_inference + + @property + def esmc_precision_status(self) -> ESMCPrecisionStatus: + return self._esmc_precision_status + + def load_esmc( + self, + esmc_model_path: str, + precision: ESMCPrecision = "auto", + device: str | torch.device | None = None, + local_files_only: bool = False, + ) -> None: + """Load ESMC with the same precision policy as released checkpoints.""" + + _install_esmc_backbone( + self, + esmc_model_path, + precision=precision, + device=device, + local_files_only=local_files_only, + ) + + def reload_esmc( + self, + precision: ESMCPrecision = "auto", + device: str | torch.device | None = None, + local_files_only: bool | None = None, + ) -> None: + """Reload canonical ESMC weights and discard runtime quantization.""" + + source = self._esmc_source or self.config.esmc_id + old_esmc = self._esmc + self._esmc = None + self._esmc_fp8 = False + self._esmc_fp8_module_paths = () + self._ttt_lm_head = None + del old_esmc + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + self.load_esmc( + source, + precision=precision, + device=device, + local_files_only=( + self._esmc_local_files_only + if local_files_only is None + else local_files_only + ), + ) + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path, + *model_args, + load_esmc: bool = True, + **kwargs, + ): + if "config" not in kwargs: + kwargs["config"] = ESMFold2Config.from_pretrained( + pretrained_model_name_or_path, **kwargs + ) + esmc_precision = kwargs.pop("esmc_precision", None) + local_files_only = bool(kwargs.get("local_files_only", False)) + output_loading_info = bool(kwargs.get("output_loading_info", False)) + loaded = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs) + if output_loading_info: + model, loading_info = loaded + else: + model = loaded + if load_esmc: + model.load_esmc( + model.config.esmc_id, + precision=esmc_precision or model.config.esmc_precision, + local_files_only=local_files_only, + ) + return (model, loading_info) if output_loading_info else model + + def apply_torch_compile(self, mode: str = "fixed_seqlen", dynamic: bool | None = None) -> None: + if dynamic is None: + dynamic = mode == "dynamic_seqlen" + compile_kwargs: dict[str, bool] = {"dynamic": dynamic} + compile_targets = ( + PairUpdateBlock, + DiffusionTransformer, + DiffusionModule, + MSAEncoderBlock, + ) + + def _maybe_compile(module: nn.Module) -> None: + if isinstance(module, compile_targets): + module.forward = torch.compile(module.forward, **compile_kwargs) + + self.apply(_maybe_compile) + + def _compute_lm_hidden_states( + self, + input_ids: Tensor, + asym_id: Tensor, + residue_index: Tensor, + mol_type: Tensor, + tok_mask: Tensor, + ) -> Tensor: + if self._esmc_fp8 and torch.is_grad_enabled(): + _reload_esmc_bf16_for_gradients( + self, + reason=( + "Gradient-enabled ESMC execution requires BF16; the persisted " + "serving policy is unchanged." + ), + ) + if self._esmc is None: + raise RuntimeError("ESMFold2 language-model features require load_esmc=True.") + pad_to = 16 if self._esmc_fp8 else None + with _lm_precision_context(self._esmc_precision_status.resolved, self.device): + return compute_lm_hidden_states( + self._esmc, + input_ids, + asym_id, + residue_index, + mol_type, + tok_mask, + pad_to_multiple=pad_to, + ) + + def forward( + self, + token_index: Tensor, + residue_index: Tensor, + asym_id: Tensor, + sym_id: Tensor, + entity_id: Tensor, + mol_type: Tensor, + res_type: Tensor, + token_bonds: Tensor, + token_attention_mask: Tensor, + ref_pos: Tensor, + ref_element: Tensor, + ref_charge: Tensor, + ref_atom_name_chars: Tensor, + ref_space_uid: Tensor, + atom_attention_mask: Tensor, + atom_to_token: Tensor, + distogram_atom_idx: Tensor, + deletion_mean: Tensor | None = None, + msa: Tensor | None = None, + has_deletion: Tensor | None = None, + deletion_value: Tensor | None = None, + msa_attention_mask: Tensor | None = None, + input_ids: Tensor | None = None, + lm_hidden_states: Tensor | None = None, + res_type_soft: Tensor | None = None, + num_loops: int | None = None, + num_diffusion_samples: int | None = None, + num_sampling_steps: int | None = None, + early_exit: bool = False, + seed: int | None = None, + calculate_confidence: bool = True, + provide_soft_sequence_to_msa_and_profile: bool = True, + noise_scale: float | None = None, + step_scale: float | None = None, + max_inference_sigma: float | None = None, + output_attentions: bool | None = None, + output_hidden_states: bool | None = None, + return_dict: bool | None = None, + ) -> ESMFold2Output | tuple[Any, ...]: + output_hidden_states, return_dict = _resolve_structure_output_controls( + self.config, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + ) + validate_msa_conditioning_inputs( + self.config, + msa=msa, + msa_attention_mask=msa_attention_mask, + has_deletion=has_deletion, + deletion_value=deletion_value, + deletion_mean=deletion_mean, + ) + tok_mask = token_attention_mask + atm_mask = atom_attention_mask + n_loops = num_loops if num_loops is not None else self.config.num_loops + n_samples = ( + num_diffusion_samples + if num_diffusion_samples is not None + else self.config.num_diffusion_samples + ) + + if res_type.dim() == 2: + res_type_oh = F.one_hot(res_type.long(), num_classes=NUM_RES_TYPES).float() + res_type_oh = res_type_oh * tok_mask.unsqueeze(-1).float() + else: + res_type_oh = res_type.float() + + if msa is not None: + msa_oh_profile = F.one_hot(msa.long(), num_classes=NUM_RES_TYPES).float() + if msa_attention_mask is not None: + mask_f = msa_attention_mask.float().unsqueeze(-1) + msa_oh_profile = msa_oh_profile * mask_f + valid_seq_count = msa_attention_mask.float().sum(dim=1).clamp(min=1) + profile = msa_oh_profile.sum(dim=1) / valid_seq_count.unsqueeze(-1) + else: + profile = msa_oh_profile.mean(dim=1) + else: + profile = res_type_oh + + if res_type_soft is not None: + res_type_oh = res_type_soft.float() + if not self.config.disable_msa_features and provide_soft_sequence_to_msa_and_profile: + profile = res_type_oh + msa = res_type_oh.unsqueeze(1) + msa_attention_mask = tok_mask.unsqueeze(1) + + if deletion_mean is None: + deletion_mean = torch.zeros( + res_type.shape[0], res_type.shape[1], device=res_type.device + ) + if self.config.disable_msa_features: + profile = torch.zeros_like(profile) + deletion_mean = torch.zeros_like(deletion_mean) + + ref_element_oh = F.one_hot(ref_element.long(), num_classes=MAX_ATOMIC_NUMBER).float() + ref_atom_name_chars_oh = F.one_hot( + ref_atom_name_chars.long(), num_classes=CHAR_VOCAB_SIZE + ).float() + atm_mask_f = atm_mask.float() + ref_element_oh = ref_element_oh * atm_mask_f.unsqueeze(-1) + ref_atom_name_chars_oh = ref_atom_name_chars_oh * atm_mask_f.unsqueeze(-1).unsqueeze(-1) + atom_to_token = atom_to_token * atm_mask.long() + + use_amp = ref_pos.device.type == "cuda" + with torch.amp.autocast("cuda", enabled=use_amp, dtype=torch.bfloat16): + x_inputs = self.inputs_embedder( + aatype=res_type_oh, + profile=profile.float(), + deletion_mean=deletion_mean.float(), + ref_pos=ref_pos, + atom_attention_mask=atm_mask, + ref_space_uid=ref_space_uid, + ref_charge=ref_charge, + ref_element=ref_element_oh, + ref_atom_name_chars=ref_atom_name_chars_oh, + atom_to_token=atom_to_token, + ) + + z_init = self.z_init_1(x_inputs).unsqueeze(2) + self.z_init_2(x_inputs).unsqueeze(1) + relative_position_encoding = self.rel_pos( + residue_index=residue_index, + asym_id=asym_id, + sym_id=sym_id, + entity_id=entity_id, + token_index=token_index, + ) + token_bonds_encoding = self.token_bonds(token_bonds.float()) + z_init = z_init + relative_position_encoding + token_bonds_encoding + + if lm_hidden_states is None and input_ids is not None and self._esmc is not None: + lm_hidden_states = self._compute_lm_hidden_states( + input_ids, asym_id, residue_index, mol_type, tok_mask + ) + if lm_hidden_states is not None: + lm_dropout = ( + self.config.lm_dropout + if self.config.force_lm_dropout_during_inference or self.training + else 0.0 + ) + lm_z = self.language_model(lm_hidden_states.detach(), lm_dropout=lm_dropout) + z_init = z_init + lm_z.to(z_init.dtype) + + msa_kwargs: dict[str, Tensor] | None = None + if self.msa_encoder is not None and msa is not None: + if msa.dim() == 4: + batch_msa, depth, length_msa, _ = msa.shape + msa_oh = msa.permute(0, 2, 1, 3).float() + else: + batch_msa, depth, length_msa = msa.shape + msa_oh = F.one_hot( + msa.permute(0, 2, 1).long(), num_classes=NUM_RES_TYPES + ).float() + msa_attn = ( + msa_attention_mask.permute(0, 2, 1).float() + if msa_attention_mask is not None + else tok_mask[:, :, None].expand(-1, -1, depth).float() + ) + msa_oh = msa_oh * msa_attn.unsqueeze(-1) + hd = ( + has_deletion.permute(0, 2, 1).float() + if has_deletion is not None + else torch.zeros(batch_msa, length_msa, depth, device=msa.device) + ) + dv = ( + deletion_value.permute(0, 2, 1).float() + if deletion_value is not None + else torch.zeros(batch_msa, length_msa, depth, device=msa.device) + ) + msa_kwargs = { + "x_inputs": x_inputs, + "msa_oh": msa_oh, + "has_deletion": hd, + "deletion_value": dv, + "msa_attention_mask": msa_attn, + } + + pair_mask = tok_mask[:, :, None].float() * tok_mask[:, None, :].float() + z = torch.zeros_like(z_init) + prev_pair: Tensor | None = None + prev_disto_probs: Tensor | None = None + for loop_num in range(n_loops + 1): + z = z_init + self.pair_loop_proj(z) + if msa_kwargs is not None and self.msa_encoder is not None: + z = z + self.msa_encoder(x_pair=z, **msa_kwargs).to(z.dtype) + z = self.folding_trunk(z, pair_attention_mask=pair_mask) + + if early_exit and loop_num < n_loops: + l2_converged = False + if prev_pair is not None and loop_num > 0: + rel_l2 = ( + z.float() - prev_pair.float() + ).norm() / prev_pair.float().norm().clamp(min=1e-8) + l2_converged = rel_l2.item() < 0.25 + prev_pair = z.detach().clone() + sym_z = z.float() + z.float().transpose(-2, -3) + cur_probs = F.softmax(self.distogram_head(sym_z).float(), dim=-1) + if prev_disto_probs is not None and loop_num > 0: + kl_per_pair = ( + cur_probs + * (cur_probs.clamp(min=1e-8) / prev_disto_probs.clamp(min=1e-8)).log() + ).sum(-1) + kl = (kl_per_pair + kl_per_pair.transpose(-1, -2)).mean() / 2 + if l2_converged or kl.item() < 0.05: + break + prev_disto_probs = cur_probs.detach() + + distogram_logits = self.distogram_head(z + z.transpose(-2, -3)) + + with torch.no_grad(), _seed_context(seed): + structure_output = self.structure_head.sample( + z_trunk=z.float(), + s_inputs=x_inputs, + s_trunk=None, + relative_position_encoding=relative_position_encoding, + ref_pos=ref_pos, + ref_charge=ref_charge, + ref_mask=atm_mask, + ref_element=ref_element_oh, + ref_atom_name_chars=ref_atom_name_chars_oh, + ref_space_uid=ref_space_uid, + tok_idx=atom_to_token, + asym_id=asym_id, + residue_index=residue_index, + entity_id=entity_id, + token_index=token_index, + sym_id=sym_id, + token_attention_mask=tok_mask, + num_diffusion_samples=n_samples, + num_sampling_steps=num_sampling_steps, + max_inference_sigma=max_inference_sigma, + noise_scale=noise_scale, + step_scale=step_scale, + return_atom_repr=False, + denoising_early_exit_rmsd=(0.10 if early_exit else None), + ) + sample_coords = structure_output["sample_atom_coords"] + if sample_coords is None: + raise RuntimeError("ESMFold2 structure sampling did not return coordinates.") + if sample_coords.ndim == 4: + batch, sample_count, atom_count, coord_dim = sample_coords.shape + sample_coords_for_gather = sample_coords.reshape( + batch * sample_count, + atom_count, + coord_dim, + ) + rep_idx = distogram_atom_idx.repeat_interleave(sample_count, 0).long() + else: + sample_coords_for_gather = sample_coords + rep_idx = distogram_atom_idx.long() + representative_atom_coords = gather_rep_atom_coords( + sample_coords_for_gather, + rep_idx, + ) + + output: dict[str, Tensor] = { + "distogram_logits": distogram_logits, + "sample_atom_coords": sample_coords, + "representative_atom_coords": representative_atom_coords, + } + if calculate_confidence and self.confidence_head is not None: + confidence_output = self.confidence_head( + s_inputs=x_inputs.detach(), + z=z.detach().float(), + x_pred=sample_coords.detach(), + distogram_atom_idx=distogram_atom_idx, + token_attention_mask=tok_mask, + atom_to_token=atom_to_token, + atom_attention_mask=atm_mask, + asym_id=asym_id, + mol_type=mol_type, + num_diffusion_samples=n_samples, + relative_position_encoding=relative_position_encoding.detach(), + token_bonds_encoding=token_bonds_encoding.detach(), + ) + output.update(confidence_output) + output["atom_pad_mask"] = atm_mask.unsqueeze(0) if atm_mask.dim() == 1 else atm_mask + output["residue_index"] = residue_index + output["entity_id"] = entity_id + return _finalize_structure_output( + output, + token_input_state=x_inputs, + pair_state=z, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + ) + + @property + def input_builder(self): + if self._esmfold2_input_builder is None: + from .esmfold2_processor import ESMFold2InputBuilder + + self._esmfold2_input_builder = ESMFold2InputBuilder() + return self._esmfold2_input_builder + + @property + def input_types(self): + from . import esmfold2_types + + return esmfold2_types + + def prepare_structure_input(self, input, seed: int | None = None): + return self.input_builder.prepare_model_input( + self, + input, + seed=seed, + device=self.device, + ) + + @torch.no_grad() + def infer_protein(self, seq: str, **forward_kwargs) -> ESMFold2Output: + from .protein_utils import prepare_protein_features + + if forward_kwargs.pop("return_dict", True) is not True: + raise ValueError( + "infer_protein always returns a mapping; return_dict=False is invalid." + ) + features = prepare_protein_features(seq) + if not self.config.msa_conditioning: + for name in MSA_CONDITIONING_INPUT_NAMES: + features.pop(name, None) + features = {name: tensor.to(self.device) for name, tensor in features.items()} + output = self(**features, **forward_kwargs, return_dict=True) + for name in ( + "res_type", + "atom_to_token", + "ref_atom_name_chars", + "atom_attention_mask", + "token_attention_mask", + "residue_index", + ): + output[name] = features[name] + return output + + def fold( + self, + input, + *, + num_loops: int = 3, + num_sampling_steps: int = 50, + num_diffusion_samples: int = 1, + seed: int | None = None, + noise_scale: float | None = None, + step_scale: float | None = None, + max_inference_sigma: int | None = None, + early_exit: bool = False, + complex_id: str = "pred", + ): + return self.input_builder.fold( + self, + input, + num_loops=num_loops, + num_sampling_steps=num_sampling_steps, + num_diffusion_samples=num_diffusion_samples, + seed=seed, + noise_scale=noise_scale, + step_scale=step_scale, + max_inference_sigma=max_inference_sigma, + early_exit=early_exit, + complex_id=complex_id, + ) + + def fold_protein( + self, + sequence: str, + *, + chain_id: str = "A", + num_loops: int = 3, + num_sampling_steps: int = 50, + num_diffusion_samples: int = 1, + seed: int | None = None, + complex_id: str = "pred", + ): + from .esmfold2_types import ProteinInput, StructurePredictionInput + + input = StructurePredictionInput(sequences=[ProteinInput(id=chain_id, sequence=sequence)]) + return self.fold( + input, + num_loops=num_loops, + num_sampling_steps=num_sampling_steps, + num_diffusion_samples=num_diffusion_samples, + seed=seed, + complex_id=complex_id, + ) + + @staticmethod + def result_to_cif(result) -> str: + if isinstance(result, list): + raise TypeError("Pass one MolecularComplexResult at a time.") + return result.complex.to_mmcif() + + @staticmethod + def result_to_pdb(result) -> str: + if isinstance(result, list): + raise TypeError("Pass one MolecularComplexResult at a time.") + return result.complex.to_protein_complex().to_pdb_string() + + def save_as_cif(self, result, output_path: str | Path) -> None: + Path(output_path).write_text(self.result_to_cif(result)) + + def save_as_pdb(self, result, output_path: str | Path) -> None: + Path(output_path).write_text(self.result_to_pdb(result)) + + def infer_protein_as_cif(self, seq: str, **forward_kwargs) -> str: + return self.result_to_cif(self.fold_protein(seq, **forward_kwargs)) + + def infer_protein_as_pdb(self, seq: str, **forward_kwargs) -> str: + return self.result_to_pdb(self.fold_protein(seq, **forward_kwargs)) + + +__all__ = [ + "ConfidenceHead", + "ESMFold2ExperimentalModel", + "ESMFold2Output", + "MSAEncoder", + "MSAEncoderBlock", +] diff --git a/fastplms/models/esmfold2/protein_reference_geometry.json b/fastplms/models/esmfold2/protein_reference_geometry.json new file mode 100644 index 0000000000000000000000000000000000000000..5cab64b9eaafe3fb4d4bcb37131ae2202587be5a --- /dev/null +++ b/fastplms/models/esmfold2/protein_reference_geometry.json @@ -0,0 +1 @@ +{"dtype":"float32","provenance":{"contract":"biohub_esmfold2_input_v1","manifest_family":"esmfold2"},"residues":{"ALA":{"C":[1.2127548456192017,0.4737588167190552,0.19521640241146088],"CA":[-0.04190138354897499,0.17447763681411743,-0.5729365348815918],"CB":[-1.276943325996399,0.4288230538368225,0.29937705397605896],"N":[-0.01003183238208294,-1.2073018550872803,-1.0555061101913452],"O":[1.9390329122543335,1.4484562873840332,-0.13759790360927582]},"ARG":{"C":[-3.469440460205078,-1.0612813234329224,-0.2755832374095917],"CA":[-2.0503084659576416,-0.5735036730766296,-0.4097220301628113],"CB":[-1.4193516969680786,-0.3735991418361664,0.9852858781814575],"CD":[0.6643245816230774,1.0068185329437256,0.3963329493999481],"CG":[0.11878877878189087,-0.3112654983997345,0.963895857334137],"CZ":[3.098905324935913,0.3215920031070709,-0.09047172218561172],"N":[-2.0170421600341797,0.6717798113822937,-1.1794233322143555],"NE":[2.1090238094329834,1.0977025032043457,0.6120952367782593],"NH1":[4.461230278015137,0.3844667971134186,0.34141138195991516],"NH2":[2.7856509685516357,-0.4166366159915924,-1.1148239374160767],"O":[-3.8218462467193604,-2.1369943618774414,-0.8294969797134399]},"ASN":{"C":[-1.9211044311523438,-0.6982439160346985,-0.42196929454803467],"CA":[-0.76087886095047,0.23876343667507172,-0.23573364317417145],"CB":[0.5504899024963379,-0.5078350305557251,-0.5390339493751526],"CG":[1.7250099182128906,0.4264017939567566,-0.5778228640556335],"N":[-0.7595629096031189,0.7503494620323181,1.1369825601577759],"ND2":[2.57365345954895,0.5730618834495544,0.5608599781990051],"O":[-2.677666187286377,-0.5753439664840698,-1.4223182201385498],"OD1":[1.9470350742340088,1.1086392402648926,-1.613560438156128]},"ASP":{"C":[-0.9431572556495667,1.0356197357177734,0.18555717170238495],"CA":[-0.6379959583282471,-0.41974392533302307,0.41681644320487976],"CB":[0.48594576120376587,-0.8970447778701782,-0.5209363698959351],"CG":[1.780342936515808,-0.19918935000896454,-0.2310730367898941],"N":[-1.8452696800231934,-1.2169504165649414,0.19437327980995178],"O":[-1.5183608531951904,1.4045922756195068,-0.8739855885505676],"OD1":[2.5202910900115967,-0.6044584512710571,0.7049641013145447],"OD2":[2.1454880237579346,0.9208861589431763,-0.9712985157966614]},"CYS":{"C":[-1.2652032375335693,-0.6832379698753357,-0.3594406247138977],"CA":[0.11344368755817413,-0.09400428831577301,-0.45952197909355164],"CB":[0.6919880509376526,0.09034398198127747,0.952482283115387],"N":[0.0469963513314724,1.190075159072876,-1.1607273817062378],"O":[-1.4631439447402954,-1.8851220607757568,-0.6826791763305664],"SG":[2.4619927406311035,0.5235707759857178,0.9020372629165649]},"GLN":{"C":[-1.7545503377914429,0.7091967463493347,0.8433493971824646],"CA":[-1.370002269744873,-0.6000258922576904,0.2103111445903778],"CB":[0.02040259726345539,-0.5004461407661438,-0.44764479994773865],"CD":[2.4745187759399414,-0.24800164997577667,-0.09364881366491318],"CG":[1.1377512216567993,-0.28680720925331116,0.582992434501648],"N":[-2.370004653930664,-0.9637529850006104,-0.7942749261856079],"NE2":[2.947425603866577,0.9601329565048218,-0.6888364553451538],"O":[-1.8520662784576416,0.7999289631843567,2.0964975357055664],"OE1":[3.1685523986816406,-1.2966246604919434,-0.1717153936624527]},"GLU":{"C":[-1.7741456031799316,0.9664392471313477,0.09259600937366486],"CA":[-1.0560977458953857,0.027459044009447098,1.0306966304779053],"CB":[0.4706551432609558,0.048803869634866714,0.8114414811134338],"CD":[2.398822069168091,-0.3097084164619446,-0.7210537791252136],"CG":[0.9133604764938354,-0.4219329059123993,-0.5830985307693481],"N":[-1.5850872993469238,-1.337684154510498,0.9490851163864136],"O":[-1.9012441635131836,2.181349992752075,0.402479350566864],"OE1":[3.1389315128326416,-1.274524450302124,-0.39029765129089355],"OE2":[2.9647817611694336,0.8781346082687378,-1.1732689142227173]},"GLY":{"C":[0.9440054893493652,-0.10314033925533295,0.19859643280506134],"CA":[-0.39974430203437805,0.5488945245742798,0.15242962539196014],"N":[-1.3942985534667969,-0.39875128865242004,-0.3370324671268463],"O":[1.3352899551391602,-0.669218122959137,1.2541258335113525]},"HIS":{"C":[-2.675257921218872,0.6571555733680725,-0.30441102385520935],"CA":[-1.3396095037460327,0.24797579646110535,0.24960045516490936],"CB":[-0.3041955828666687,0.21721023321151733,-0.8885309100151062],"CD2":[1.780855417251587,-1.1011489629745483,-0.3814258575439453],"CE1":[2.9566943645477295,0.4924798905849457,0.6477115750312805],"CG":[1.0887513160705566,0.028941065073013306,-0.36419469118118286],"N":[-1.4532867670059204,-1.0689626932144165,0.881072461605072],"ND1":[1.840459942817688,1.0411773920059204,0.29804590344429016],"NE2":[3.0280203819274902,-0.8751969337463379,0.26084381341934204],"O":[-3.1311378479003906,1.8079776763916016,-0.06785715371370316]},"ILE":{"C":[-1.3896740674972534,0.8142145276069641,-1.1164065599441528],"CA":[-1.0636085271835327,-0.35169270634651184,-0.21393552422523499],"CB":[0.061667006462812424,0.01599610224366188,0.8057394623756409],"CD1":[1.7929610013961792,0.899773120880127,-0.8863027691841125],"CG1":[1.502519965171814,-0.08899776637554169,0.24154816567897797],"CG2":[-0.053174979984760284,-0.8521055579185486,2.0702083110809326],"N":[-0.7167549729347229,-1.5426139831542969,-0.9983330368995667],"O":[-1.2377792596817017,0.7302915453910828,-2.3656840324401855]},"LEU":{"C":[1.9905058145523071,0.24182087182998657,0.7879968285560608],"CA":[1.3077669143676758,-0.6677430868148804,-0.19492436945438385],"CB":[-0.20306941866874695,-0.8093230128288269,0.11243502795696259],"CD1":[-2.4228057861328125,0.29949337244033813,0.573042094707489],"CD2":[-1.0282856225967407,1.1250264644622803,-1.346014380455017],"CG":[-0.9916267395019531,0.5234957337379456,0.06723011285066605],"N":[1.9657520055770874,-1.9763224124908447,-0.18391533195972443],"O":[2.06896710395813,-0.07880014181137085,2.0048046112060547]},"LYS":{"C":[2.7168593406677246,1.595757246017456,-0.20924785733222961],"CA":[2.0314927101135254,0.2786507308483124,-0.4298512041568756],"CB":[0.5018402934074402,0.4873858690261841,-0.49062973260879517],"CD":[-1.769762635231018,-0.5552700161933899,-1.040329933166504],"CE":[-2.576533555984497,-1.0221366882324219,0.18493641912937164],"CG":[-0.25062066316604614,-0.7894009947776794,-0.9055535793304443],"N":[2.4221372604370117,-0.6473312377929688,0.6370573043823242],"NZ":[-2.269151210784912,-0.24293844401836395,1.3849012851715088],"O":[3.397681713104248,2.116427421569824,-1.1332510709762573]},"MET":{"C":[2.30391001701355,0.8367712497711182,-0.7254616618156433],"CA":[1.2630571126937866,-0.24417810142040253,-0.7626462578773499],"CB":[0.10567972809076309,0.10861825942993164,0.19741646945476532],"CE":[-3.265165090560913,0.7033554911613464,-0.11588376015424728],"CG":[-1.0658042430877686,-0.8736631274223328,0.08811883628368378],"N":[1.8903918266296387,-1.5252995491027832,-0.42638593912124634],"O":[2.465414524078369,1.5928632020950317,-1.7207728624343872],"SD":[-2.4557132720947266,-0.3332225978374481,1.1461700201034546]},"PHE":{"C":[-1.8900631666183472,0.45833414793014526,1.0232222080230713],"CA":[-1.591969609260559,-0.8545162677764893,0.35214468836784363],"CB":[-0.760358452796936,-0.6342853307723999,-0.9257160425186157],"CD1":[0.8468314409255981,1.2480632066726685,-0.7146694660186768],"CD2":[1.6827683448791504,-0.9758077263832092,-0.1423054188489914],"CE1":[2.1801748275756836,1.7875733375549316,-0.3744623064994812],"CE2":[2.888307809829712,-0.48277512192726135,0.16804970800876617],"CG":[0.604112982749939,-0.07200468331575394,-0.6148118376731873],"CZ":[3.149812936782837,0.9656873941421509,0.04440271109342575],"N":[-2.8484435081481934,-1.525790810585022,0.01789816841483116],"O":[-1.3424992561340332,0.74432373046875,2.121629476547241]},"PRO":{"C":[1.6121541261672974,-1.1711241006851196,0.31082412600517273],"CA":[0.32722190022468567,-0.6164458394050598,-0.25072571635246277],"CB":[0.3248198926448822,0.9028244018554688,-0.33368146419525146],"CD":[-1.8495968580245972,0.026575811207294464,0.2681289613246918],"CG":[-1.1425083875656128,1.2730128765106201,-0.2590600252151489],"N":[-0.836250364780426,-0.9899801015853882,0.5561304688453674],"O":[1.6127740144729614,-2.2771971225738525,0.9156193733215332]},"SER":{"C":[0.9941009879112244,-0.5374617576599121,0.73505038022995],"CA":[0.00013792862591799349,0.4966467022895813,0.28510504961013794],"CB":[-1.1279288530349731,-0.1659376323223114,-0.5160963535308838],"N":[0.674650251865387,1.5018702745437622,-0.5367295145988464],"O":[1.0545241832733154,-0.8683545589447021,1.9495396614074707],"OG":[-1.8135979175567627,-1.085249662399292,0.28947514295578003]},"THR":{"C":[-1.294381856918335,0.7077372074127197,-0.5549946427345276],"CA":[-0.5433306097984314,-0.16364754736423492,0.41697052121162415],"CB":[0.853203296661377,-0.5363803505897522,-0.14109353721141815],"CG2":[1.7225933074951172,0.7054727077484131,-0.3651331067085266],"N":[-1.325830340385437,-1.3728225231170654,0.6882233023643494],"O":[-1.6939635276794434,0.23654410243034363,-1.6540418863296509],"OG1":[1.5220820903778076,-1.379003643989563,0.7635167837142944]},"TRP":{"C":[2.1113572120666504,-0.6121063232421875,-0.7733646035194397],"CA":[2.384092092514038,0.09079249948263168,0.5325262546539307],"CB":[1.281521201133728,1.1139036417007446,0.8559791445732117],"CD1":[-0.42329534888267517,-0.15470874309539795,2.2227554321289062],"CD2":[-1.1023900508880615,0.2158389836549759,0.11529432237148285],"CE2":[-2.045644998550415,-0.4881173074245453,0.710669219493866],"CE3":[-1.2173502445220947,0.6102271676063538,-1.300106406211853],"CG":[-0.04292375594377518,0.44645074009895325,1.0942792892456055],"CH2":[-3.3817875385284424,-0.5677337646484375,-1.3032053709030151],"CZ2":[-3.256009340286255,-0.9164394736289978,-0.00984987337142229],"CZ3":[-2.315925121307373,0.2306906282901764,-1.9776310920715332],"N":[3.686030864715576,0.7599999904632568,0.496155709028244],"NE1":[-1.7030320167541504,-0.7665823101997375,2.0595016479492188],"O":[1.796526312828064,-1.8323148488998413,-0.7775964140892029]},"TYR":{"C":[-3.347280740737915,0.3588399887084961,-0.09830684959888458],"CA":[-1.913882851600647,0.23552845418453217,0.330669641494751],"CB":[-1.0093992948532104,0.0004731413209810853,-0.8981552124023438],"CD1":[1.0992432832717896,1.1877919435501099,-0.3579142987728119],"CD2":[1.1803174018859863,-1.253401279449463,-0.31122180819511414],"CE1":[2.5253450870513916,1.1990256309509277,0.029804613441228867],"CE2":[2.471151113510132,-1.240687608718872,0.043534230440855026],"CG":[0.4520410895347595,0.021162061020731926,-0.5305932760238647],"CZ":[3.180687665939331,0.04672492295503616,0.2214856892824173],"N":[-1.7900604009628296,-0.8409399390220642,1.3180142641067505],"O":[-3.967811346054077,-0.6449354290962219,-0.5423302054405212],"OH":[4.523719787597656,0.0671030730009079,0.5877485871315002]},"UNK":{"C":[0.0,0.0,0.0],"CA":[0.0,0.0,0.0],"N":[0.0,0.0,0.0],"O":[0.0,0.0,0.0]},"VAL":{"C":[1.8391697406768799,0.4067850410938263,0.06351757049560547],"CA":[0.6014357209205627,-0.10503966361284256,-0.6336286664009094],"CB":[-0.694736897945404,0.4259096384048462,0.03581475466489792],"CG1":[-1.9276031255722046,0.09515828639268875,-0.8172357082366943],"CG2":[-0.8938426971435547,-0.08640842139720917,1.472349762916565],"N":[0.5987519025802612,-1.569443702697754,-0.7379124760627747],"O":[2.3952062129974365,-0.2666190266609192,0.9731166958808899]}},"schema":"fastplms.esmfold2.reference_geometry.v1"} diff --git a/fastplms/models/esmfold2/protein_utils.py b/fastplms/models/esmfold2/protein_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..1d7d0eaa25d0f4285056c0a8a7eb7ea7f2bd54ec --- /dev/null +++ b/fastplms/models/esmfold2/protein_utils.py @@ -0,0 +1,178 @@ +"""Protein-only ESMFold2 featurization without the Biohub runtime package. + +Input is one amino-acid sequence. The transformation expands each residue into +the checkpoint atom schema, pads atoms to a multiple of 32, and emits batched +token, atom, and single-sequence MSA tensors. Reference coordinates are loaded +lazily from a provenance-bearing declarative package asset. +""" + +from __future__ import annotations + +import json +from functools import cache +from importlib.resources import files +from typing import Any + +import torch +from torch import Tensor + +from .esmfold2_constants import ( + CHARGED_ATOMS, + ELEMENT_TO_ATOMIC_NUM, + ESM_PROTEIN_VOCAB, + MOL_TYPE_PROTEIN, + PROTEIN_1TO3, + PROTEIN_HEAVY_ATOMS, + PROTEIN_RESIDUE_TO_RES_TYPE, + PROTEIN_UNK_RES_TYPE, +) + +_GEOMETRY_ASSET = "protein_reference_geometry.json" +_GEOMETRY_SCHEMA = "fastplms.esmfold2.reference_geometry.v1" + + +@cache +def _reference_geometry() -> dict[str, dict[str, tuple[float, float, float]]]: + resource = files(__package__).joinpath(_GEOMETRY_ASSET) + with resource.open(mode="r", encoding="utf-8") as handle: + payload = json.load(handle) + if ( + payload.get("schema") != _GEOMETRY_SCHEMA + or payload.get("dtype") != "float32" + or payload.get("provenance", {}).get("manifest_family") != "esmfold2" + ): + raise RuntimeError("The ESMFold2 reference-geometry asset has invalid provenance.") + + raw_residues = payload.get("residues") + if not isinstance(raw_residues, dict): + raise RuntimeError("The ESMFold2 reference-geometry asset has no residue table.") + geometry: dict[str, dict[str, tuple[float, float, float]]] = {} + for residue, atom_positions in raw_residues.items(): + if not isinstance(residue, str) or not isinstance(atom_positions, dict): + raise RuntimeError("The ESMFold2 reference-geometry residue table is malformed.") + geometry[residue] = {} + for atom_name, position in atom_positions.items(): + if ( + not isinstance(atom_name, str) + or not isinstance(position, list) + or len(position) != 3 + ): + raise RuntimeError("The ESMFold2 reference-geometry atom table is malformed.") + geometry[residue][atom_name] = tuple(float(value) for value in position) + + expected_residues = set(PROTEIN_HEAVY_ATOMS) - {"MSE"} + if set(geometry) != expected_residues: + raise RuntimeError("The ESMFold2 reference-geometry residue set is incomplete.") + for residue, atom_names in PROTEIN_HEAVY_ATOMS.items(): + if residue == "MSE": + continue + if set(geometry[residue]) != set(atom_names): + raise RuntimeError(f"Reference geometry differs from the atom schema for {residue}.") + return geometry + + +def _encode_atom_name(atom_name: str) -> tuple[int, int, int, int]: + padded = atom_name.ljust(4)[:4] + return tuple(ord(character) - 32 if character != " " else 0 for character in padded) + + +def _padded_atom_count(actual_count: int) -> int: + return max(32, ((actual_count + 31) // 32) * 32) + + +def _residue_records(sequence: str) -> tuple[list[dict[str, Any]], list[int], list[int], list[int]]: + geometry = _reference_geometry() + atoms: list[dict[str, Any]] = [] + residue_types: list[int] = [] + input_ids: list[int] = [] + representative_atoms: list[int] = [] + + for token_index, residue_letter in enumerate(sequence): + residue_name = PROTEIN_1TO3.get(residue_letter, "UNK") + atom_names = PROTEIN_HEAVY_ATOMS[residue_name] + atom_start = len(atoms) + for atom_name in atom_names: + atoms.append( + { + "token_index": token_index, + "name": atom_name, + "element": atom_name[0], + "charge": CHARGED_ATOMS.get((residue_name, atom_name), 0), + "position": geometry[residue_name][atom_name], + } + ) + + representative_name = "CB" if "CB" in atom_names else "CA" + representative_atoms.append(atom_start + atom_names.index(representative_name)) + residue_types.append(PROTEIN_RESIDUE_TO_RES_TYPE.get(residue_name, PROTEIN_UNK_RES_TYPE)) + input_ids.append(ESM_PROTEIN_VOCAB.get(residue_letter, ESM_PROTEIN_VOCAB["X"])) + + return atoms, residue_types, input_ids, representative_atoms + + +def prepare_protein_features(sequence: str) -> dict[str, Tensor]: + """Build the protein-only feature mapping consumed by ESMFold2. + + Every tensor includes a leading batch dimension. Biological tokens have + length ``l``; atom tensors have length ``n_atoms``, where ``n_atoms`` is the + smallest multiple of 32 covering all heavy atoms. + """ + + if not sequence: + raise ValueError("sequence must be non-empty") + + atoms, residue_types, input_ids, representative_atoms = _residue_records(sequence) + sequence_length = len(sequence) + n_atoms = _padded_atom_count(len(atoms)) + + ref_pos = torch.zeros((n_atoms, 3), dtype=torch.float32) + ref_element = torch.zeros(n_atoms, dtype=torch.int64) + ref_charge = torch.zeros(n_atoms, dtype=torch.int8) + ref_atom_name_chars = torch.zeros((n_atoms, 4), dtype=torch.int64) + ref_space_uid = torch.zeros(n_atoms, dtype=torch.int64) + atom_attention_mask = torch.zeros(n_atoms, dtype=torch.bool) + atom_to_token = torch.zeros(n_atoms, dtype=torch.int64) + + for atom_index, atom in enumerate(atoms): + token_index = atom["token_index"] + ref_pos[atom_index] = torch.tensor(atom["position"], dtype=torch.float32) + ref_element[atom_index] = ELEMENT_TO_ATOMIC_NUM[atom["element"]] + ref_charge[atom_index] = atom["charge"] + ref_atom_name_chars[atom_index] = torch.tensor( + _encode_atom_name(atom["name"]), dtype=torch.int64 + ) + ref_space_uid[atom_index] = token_index + atom_attention_mask[atom_index] = True + atom_to_token[atom_index] = token_index + + residue_type_tensor = torch.tensor(residue_types, dtype=torch.int64) + msa = residue_type_tensor.unsqueeze(0) + features = { + "token_index": torch.arange(sequence_length, dtype=torch.int64), + "residue_index": torch.arange(sequence_length, dtype=torch.int64), + "asym_id": torch.zeros(sequence_length, dtype=torch.int64), + "sym_id": torch.zeros(sequence_length, dtype=torch.int64), + "entity_id": torch.ones(sequence_length, dtype=torch.int64), + "mol_type": torch.full((sequence_length,), MOL_TYPE_PROTEIN, dtype=torch.int64), + "res_type": residue_type_tensor, + "input_ids": torch.tensor(input_ids, dtype=torch.int64), + "token_bonds": torch.zeros((sequence_length, sequence_length, 1), dtype=torch.float32), + "token_attention_mask": torch.ones(sequence_length, dtype=torch.bool), + "ref_pos": ref_pos, + "ref_element": ref_element, + "ref_charge": ref_charge, + "ref_atom_name_chars": ref_atom_name_chars, + "ref_space_uid": ref_space_uid, + "atom_attention_mask": atom_attention_mask, + "atom_to_token": atom_to_token, + "distogram_atom_idx": torch.tensor(representative_atoms, dtype=torch.int64), + "msa": msa, + "msa_attention_mask": torch.ones_like(msa, dtype=torch.bool), + "has_deletion": torch.zeros_like(msa, dtype=torch.bool), + "deletion_value": torch.zeros_like(msa, dtype=torch.float32), + "deletion_mean": torch.zeros(sequence_length, dtype=torch.float32), + } + return {name: tensor.unsqueeze(0) for name, tensor in features.items()} + + +__all__ = ["prepare_protein_features"] diff --git a/fastplms/models/esmfold2/reproducibility.py b/fastplms/models/esmfold2/reproducibility.py new file mode 100644 index 0000000000000000000000000000000000000000..92194cec1867567899ff7fd26646961ac44bd607 --- /dev/null +++ b/fastplms/models/esmfold2/reproducibility.py @@ -0,0 +1,64 @@ +"""Dependency-light RNG scoping for ESMFold2 workflows.""" + +from __future__ import annotations + +import random +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any + +import numpy as np +import torch +from torch import Tensor + + +@dataclass(frozen=True) +class _RandomState: + python: object + numpy: tuple[Any, ...] + torch_cpu: Tensor + torch_cuda: list[Tensor] | None + + +def _capture_random_state() -> _RandomState: + cuda_state = torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None + return _RandomState( + python=random.getstate(), + numpy=np.random.get_state(), + torch_cpu=torch.random.get_rng_state(), + torch_cuda=cuda_state, + ) + + +def _restore_random_state(state: _RandomState) -> None: + random.setstate(state.python) + np.random.set_state(state.numpy) + torch.random.set_rng_state(state.torch_cpu) + if state.torch_cuda is not None: + torch.cuda.set_rng_state_all(state.torch_cuda) + + +@contextmanager +def seed_context(seed: int | None) -> Iterator[None]: + """Seed Python, NumPy, and Torch temporarily, then restore every stream.""" + + if seed is None: + yield + return + if isinstance(seed, bool) or not isinstance(seed, int): + raise TypeError("seed must be None or an integer (excluding bool).") + state = _capture_random_state() + normalized_seed = seed % (2**32) + random.seed(normalized_seed) + np.random.seed(normalized_seed) + torch.manual_seed(normalized_seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(normalized_seed) + try: + yield + finally: + _restore_random_state(state) + + +__all__ = ["seed_context"] diff --git a/fastplms/models/ttt.py b/fastplms/models/ttt.py new file mode 100644 index 0000000000000000000000000000000000000000..224303537436b9ea514d000ce31c8a24d20397a6 --- /dev/null +++ b/fastplms/models/ttt.py @@ -0,0 +1,866 @@ +from __future__ import annotations + +import contextlib +import math +import numbers +import typing as T +from dataclasses import asdict, dataclass, fields + +import torch +import torch.nn as nn +import torch.nn.functional as F + +_STANDARD_AMINO_ACIDS = "ACDEFGHIKLMNPQRSTVWY" +_TTT_SERIALIZATION_VERSION = 1 + + +@dataclass +class TTTConfig: + lr: float = 4e-4 + steps: int = 30 + ags: int = 16 + batch_size: int = 2 + mask_ratio: float = 0.15 + crop_size: int = 1024 + bert_leave_prob: float = 0.1 + bert_replace_prob: float = 0.1 + optimizer: str = "sgd" + momentum: float = 0.0 + weight_decay: float = 0.0 + seed: int | None = 0 + lora_rank: int = 8 + lora_alpha: float = 32.0 + lora_target_replace_module: str | None = None + lora_target_modules: tuple[str, ...] | None = None + initial_state_reset: bool = True + automatic_best_state_reset: bool = False + eval_each_step: bool = False + gradient_clip: bool = False + gradient_clip_max_norm: float = 1.0 + + def __post_init__(self) -> None: + self.verify() + + @classmethod + def from_kwargs(cls, **kwargs: T.Any) -> TTTConfig: + valid_names = {field.name for field in fields(cls)} + unknown_names = set(kwargs) - valid_names + if unknown_names: + raise ValueError(f"Unknown TTTConfig fields: {sorted(unknown_names)}") + # JSON has no tuple type. Normalize the serialized representation while + # keeping the public constructor and runtime overrides type-strict. + if isinstance(kwargs.get("lora_target_modules"), list): + kwargs["lora_target_modules"] = tuple(kwargs["lora_target_modules"]) + return cls(**kwargs) + + def merged(self, overrides: T.Mapping[str, T.Any] | TTTConfig | None) -> TTTConfig: + if overrides is None: + return self + if isinstance(overrides, TTTConfig): + return overrides + values = {field.name: self.__dict__[field.name] for field in fields(self)} + for name, value in overrides.items(): + if name not in values: + raise ValueError(f"Unknown TTTConfig field: {name}") + values[name] = value + return TTTConfig(**values) + + def to_dict(self) -> dict[str, T.Any]: + return asdict(self) + + def verify(self) -> None: + numeric_fields = { + "lr": self.lr, + "mask_ratio": self.mask_ratio, + "lora_alpha": self.lora_alpha, + "bert_leave_prob": self.bert_leave_prob, + "bert_replace_prob": self.bert_replace_prob, + "gradient_clip_max_norm": self.gradient_clip_max_norm, + "momentum": self.momentum, + "weight_decay": self.weight_decay, + } + for name, value in numeric_fields.items(): + if isinstance(value, bool) or not isinstance(value, numbers.Real): + raise TypeError(f"TTT {name} must be a real number.") + if not math.isfinite(float(value)): + raise ValueError(f"TTT {name} must be finite.") + + integer_fields = { + "steps": self.steps, + "ags": self.ags, + "batch_size": self.batch_size, + "crop_size": self.crop_size, + "lora_rank": self.lora_rank, + } + for name, value in integer_fields.items(): + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"TTT {name} must be an integer.") + + if self.seed is not None and ( + isinstance(self.seed, bool) or not isinstance(self.seed, int) + ): + raise TypeError("TTT seed must be None or an integer.") + + boolean_fields = { + "initial_state_reset": self.initial_state_reset, + "automatic_best_state_reset": self.automatic_best_state_reset, + "eval_each_step": self.eval_each_step, + "gradient_clip": self.gradient_clip, + } + for name, value in boolean_fields.items(): + if type(value) is not bool: + raise TypeError(f"TTT {name} must be a boolean.") + + if self.lr <= 0.0: + raise ValueError("TTT learning rate must be positive.") + if self.steps < 1: + raise ValueError("TTT steps must be >= 1.") + if self.ags < 1: + raise ValueError("TTT gradient accumulation steps must be >= 1.") + if self.batch_size < 1: + raise ValueError("TTT batch_size must be >= 1.") + if not 0.0 < self.mask_ratio <= 1.0: + raise ValueError("TTT mask_ratio must be in (0, 1].") + if self.crop_size < 1: + raise ValueError("TTT crop_size must be >= 1.") + if self.lora_rank < 1: + raise ValueError("TTT v1 is LoRA-only, so lora_rank must be >= 1.") + if self.lora_alpha <= 0.0: + raise ValueError("TTT lora_alpha must be positive.") + if not isinstance(self.optimizer, str): + raise TypeError("TTT optimizer must be a string.") + if self.optimizer not in {"adamw", "sgd"}: + raise ValueError("TTT optimizer must be 'adamw' or 'sgd'.") + if self.momentum < 0.0: + raise ValueError("TTT momentum must be non-negative.") + if self.weight_decay < 0.0: + raise ValueError("TTT weight_decay must be non-negative.") + if not 0.0 <= self.bert_leave_prob <= 1.0: + raise ValueError("bert_leave_prob must be in [0, 1].") + if not 0.0 <= self.bert_replace_prob <= 1.0: + raise ValueError("bert_replace_prob must be in [0, 1].") + if self.bert_leave_prob + self.bert_replace_prob > 1.0: + raise ValueError("bert_leave_prob + bert_replace_prob must be <= 1.") + if self.gradient_clip and self.gradient_clip_max_norm <= 0.0: + raise ValueError("gradient_clip_max_norm must be positive.") + if self.lora_target_replace_module is not None: + if not isinstance(self.lora_target_replace_module, str): + raise TypeError("lora_target_replace_module must be None or a string.") + if not self.lora_target_replace_module.strip(): + raise ValueError("lora_target_replace_module must not be empty.") + if self.lora_target_modules is not None: + if not isinstance(self.lora_target_modules, tuple): + raise TypeError("lora_target_modules must be None or a tuple of strings.") + if not self.lora_target_modules: + raise ValueError("lora_target_modules must not be empty.") + if any(not isinstance(name, str) for name in self.lora_target_modules): + raise TypeError("lora_target_modules must contain only strings.") + if any(not name.strip() for name in self.lora_target_modules): + raise ValueError( + "lora_target_modules must contain only non-empty strings." + ) + if len(set(self.lora_target_modules)) != len(self.lora_target_modules): + raise ValueError("lora_target_modules must not contain duplicates.") + + +class LoraInjectedLinear(nn.Module): + """ProteinTTT-compatible low-rank adapter. + + ``alpha`` is the direct adapter-output multiplier used by the pinned + ProteinTTT ``inject_trainable_lora(..., scale=lora_alpha)`` contract. It + is intentionally not divided by ``rank`` as it would be in the common + PEFT LoRA convention. + """ + + def __init__( + self, + linear: nn.Module, + rank: int, + alpha: float, + generator: torch.Generator | None = None, + ) -> None: + super().__init__() + weight = linear._parameters.get("weight") + if not isinstance(weight, torch.Tensor): + raise TypeError("LoRA targets must expose a tensor weight parameter.") + if weight.ndim != 2: + raise ValueError("LoRA can only wrap 2D linear weights.") + self.linear = linear + self.linear.requires_grad_(False) + self.rank = rank + # ProteinTTT names this setting ``lora_alpha`` but passes it directly + # to cloneofsimo/lora's ``scale`` argument. Preserve that numerical + # contract for parity and for saved FastPLMs TTT configurations. + self.scale = alpha + in_features = weight.shape[1] + out_features = weight.shape[0] + # ``nn.Linear`` initializes from the process-global CPU generator. Preserve + # that state when TTT supplies its own generator so lazy adapter injection + # is reproducible without perturbing the caller's RNG stream. + with torch.random.fork_rng(devices=[], enabled=generator is not None): + self.lora_down = nn.Linear(in_features, rank, bias=False, dtype=torch.float32) + self.lora_up = nn.Linear(rank, out_features, bias=False, dtype=torch.float32) + nn.init.normal_(self.lora_down.weight, std=1.0 / rank, generator=generator) + nn.init.zeros_(self.lora_up.weight) + self.lora_down.to(device=weight.device) + self.lora_up.to(device=weight.device) + self.register_buffer( + "_ttt_initial_lora_down", + self.lora_down.weight.detach().clone(), + persistent=True, + ) + self.register_buffer( + "_ttt_initial_lora_up", + self.lora_up.weight.detach().clone(), + persistent=True, + ) + + @property + def weight(self) -> torch.Tensor: + return self.linear._parameters["weight"] + + @property + def bias(self) -> torch.Tensor | None: + return self.linear._parameters["bias"] + + def forward(self, x: torch.Tensor) -> torch.Tensor: + base = self.linear(x) + delta = self.lora_up(self.lora_down(x.to(dtype=torch.float32))) * self.scale + return base + delta.to(dtype=base.dtype) + + def reset_lora_parameters(self) -> None: + with torch.no_grad(): + self.lora_down.weight.copy_(self._ttt_initial_lora_down) + self.lora_up.weight.copy_(self._ttt_initial_lora_up) + + +class FastPLMTestTimeTrainingMixin: + def init_ttt(self, ttt_config: TTTConfig | T.Mapping[str, T.Any] | None = None) -> None: + base_config = self.__dict__.get("_ttt_cfg") + if base_config is None: + base_config = TTTConfig() + if not isinstance(base_config, TTTConfig): + raise TypeError("Existing TTT configuration must be a TTTConfig instance.") + configured = base_config.merged(ttt_config) + serialized = getattr(getattr(self, "config", None), "fastplms_ttt", None) + serialized_initialized = False + if serialized is not None: + if not isinstance(serialized, T.Mapping): + raise ValueError("config.fastplms_ttt must be a mapping.") + version = serialized.get("version") + if version != _TTT_SERIALIZATION_VERSION: + raise ValueError( + "Unsupported FastPLMs TTT serialization version " + f"{version!r}; expected {_TTT_SERIALIZATION_VERSION}." + ) + serialized_config = serialized.get("config") + if not isinstance(serialized_config, T.Mapping): + raise ValueError("Serialized FastPLMs TTT state is missing its config mapping.") + configured = TTTConfig.from_kwargs(**dict(serialized_config)) + initialized_value = serialized.get("initialized", False) + if type(initialized_value) is not bool: + raise ValueError("Serialized FastPLMs TTT initialized flag must be a boolean.") + serialized_initialized = initialized_value + + self._ttt_cfg = configured + self._ttt_cfg.verify() + self._ttt_initialized = False + if serialized_initialized: + self._ttt_inject_lora() + self._ttt_initialized = True + + @property + def ttt_config(self) -> TTTConfig: + if "_ttt_cfg" not in self.__dict__: + self.init_ttt() + return self._ttt_cfg + + def _ttt_get_trainable_modules(self) -> list[nn.Module]: + return [self] + + def _ttt_get_frozen_modules(self) -> list[nn.Module]: + return [] + + def _ttt_tokenize( + self, + seq: str | list[str] | None = None, + input_ids: torch.Tensor | None = None, + **kwargs: T.Any, + ) -> torch.Tensor | dict[str, torch.Tensor]: + del kwargs + if input_ids is not None: + return input_ids + if seq is None: + raise ValueError("Pass either seq or input_ids for TTT.") + tokenized = self.tokenizer(seq, return_tensors="pt", padding=True) + return tokenized["input_ids"] + + def _ttt_mask_token(self) -> int: + return int(self.tokenizer.mask_token_id) + + def _ttt_padding_token(self) -> int: + return int(self.tokenizer.pad_token_id) + + def _ttt_replacement_tokens(self, input_ids: torch.Tensor) -> torch.Tensor: + tokenizer = self.tokenizer + special_ids = set(tokenizer.all_special_ids) + vocab_size = int(self.config.vocab_size) + unknown_id = getattr(tokenizer, "unk_token_id", None) + if unknown_id is not None: + special_ids.add(int(unknown_id)) + + vocab: T.Mapping[str, T.Any] = {} + get_vocab = getattr(tokenizer, "get_vocab", None) + if callable(get_vocab): + vocab = get_vocab() + elif isinstance(getattr(tokenizer, "vocab", None), T.Mapping): + vocab = tokenizer.vocab + elif isinstance(getattr(tokenizer, "_token_to_id", None), T.Mapping): + vocab = tokenizer._token_to_id + + ids: list[int] = [] + convert = getattr(tokenizer, "convert_tokens_to_ids", None) + for amino_acid in _STANDARD_AMINO_ACIDS: + token_id = convert(amino_acid) if callable(convert) else vocab.get(amino_acid) + if ( + isinstance(token_id, int) + and 0 <= token_id < vocab_size + and token_id not in special_ids + and token_id not in ids + ): + ids.append(token_id) + if not ids: + raise ValueError( + "TTT could not resolve any canonical amino-acid token IDs from the tokenizer; " + "refusing to sample arbitrary or reserved vocabulary entries." + ) + return torch.tensor(ids, device=input_ids.device, dtype=input_ids.dtype) + + def _ttt_predict_logits( + self, + batch: torch.Tensor | dict[str, torch.Tensor], + **kwargs: T.Any, + ) -> torch.Tensor: + del kwargs + if isinstance(batch, dict): + output = self(**batch) + return output.logits + attention_mask = batch.ne(self._ttt_padding_token()) + output = self(input_ids=batch, attention_mask=attention_mask) + return output.logits + + def _ttt_eval_step( + self, + step: int, + loss: float, + seq: str | list[str] | None = None, + input_ids: torch.Tensor | None = None, + **kwargs: T.Any, + ) -> tuple[dict[str, T.Any], float | None]: + del step, loss, seq, input_ids, kwargs + return {}, None + + def _ttt_is_lora_target( + self, + name: str, + full_name: str, + module: nn.Module, + active: bool, + target_modules: tuple[str, ...] | None, + ) -> bool: + if not active: + return False + if isinstance(module, LoraInjectedLinear): + return False + if ( + target_modules is not None + and name not in target_modules + and full_name not in target_modules + ): + return False + if isinstance(module, nn.Linear): + return True + if "weight" not in module._parameters: + return False + weight = module._parameters["weight"] + if weight is None or weight.ndim != 2: + return False + return "Linear" in module.__class__.__name__ + + def _ttt_inject_lora(self) -> int: + cfg = self.ttt_config + cfg.verify() + target_class = cfg.lora_target_replace_module + target_modules = cfg.lora_target_modules + wrapped = 0 + generator = None + if cfg.seed is not None: + generator = torch.Generator(device="cpu") + generator.manual_seed(cfg.seed) + + def inject(module: nn.Module, prefix: str, active: bool) -> None: + nonlocal wrapped + for name, child in list(module.named_children()): + full_name = f"{prefix}.{name}" if prefix else name + child_active = active + if target_class is not None: + child_active = active or child.__class__.__name__ == target_class + if self._ttt_is_lora_target(name, full_name, child, child_active, target_modules): + setattr( + module, + name, + LoraInjectedLinear( + child, + rank=cfg.lora_rank, + alpha=cfg.lora_alpha, + generator=generator, + ), + ) + wrapped += 1 + continue + inject(child, full_name, child_active) + + for trainable_module in self._ttt_get_trainable_modules(): + inject(trainable_module, "", target_class is None) + if wrapped == 0: + raise ValueError("TTT LoRA injection did not find any target modules.") + return wrapped + + def _ttt_lora_modules(self) -> list[LoraInjectedLinear]: + return [module for module in self.modules() if isinstance(module, LoraInjectedLinear)] + + def _ttt_lora_parameters(self) -> list[nn.Parameter]: + params: list[nn.Parameter] = [] + for module in self._ttt_lora_modules(): + params.extend(module.lora_down.parameters()) + params.extend(module.lora_up.parameters()) + if not params: + raise RuntimeError("TTT has no LoRA parameters.") + return params + + def _ttt_snapshot_lora_state(self) -> list[dict[str, torch.Tensor]]: + snapshot = [] + for module in self._ttt_lora_modules(): + snapshot.append( + { + "lora_down.weight": module.lora_down.weight.detach().clone(), + "lora_up.weight": module.lora_up.weight.detach().clone(), + } + ) + if not snapshot: + raise RuntimeError("TTT has no LoRA state to snapshot.") + return snapshot + + def _ttt_restore_lora_state(self, state: list[dict[str, torch.Tensor]]) -> None: + modules = self._ttt_lora_modules() + if len(modules) != len(state): + raise RuntimeError("TTT LoRA state/module count mismatch.") + with torch.no_grad(): + for module, module_state in zip(modules, state, strict=True): + module.lora_down.weight.copy_(module_state["lora_down.weight"]) + module.lora_up.weight.copy_(module_state["lora_up.weight"]) + + def _ttt_ensure_initialized(self) -> None: + if "_ttt_cfg" not in self.__dict__: + self.init_ttt() + if self._ttt_initialized: + return + self._ttt_inject_lora() + self._ttt_initialized = True + + def ttt_reset(self) -> None: + self._ttt_ensure_initialized() + for module in self._ttt_lora_modules(): + module.reset_lora_parameters() + + def _ttt_serialized_contract(self) -> dict[str, T.Any]: + return { + "version": _TTT_SERIALIZATION_VERSION, + "initialized": bool(self._ttt_initialized), + "config": self.ttt_config.to_dict(), + } + + def save_pretrained(self, save_directory: T.Any, *args: T.Any, **kwargs: T.Any) -> T.Any: + """Save initialized adapters, their reset baseline, and the TTT config. + + Adapter injection changes the module tree, so the serialized config must + reconstruct that tree before Transformers loads the state dict. Models + whose own state-dict hooks omit their trainable TTT modules fail closed + instead of producing an artifact that cannot restore the adaptation. + """ + + if self._ttt_initialized: + state_keys = set(self.state_dict()) + missing_adapter_keys = [ + name + for name, _ in self.named_parameters() + if ".lora_" in name and name not in state_keys + ] + if missing_adapter_keys: + raise RuntimeError( + "This model attaches TTT adapters to transient modules that its " + "checkpoint excludes, so save_pretrained cannot persist the adapted " + "state safely. Reset the model or use a model-specific adapter export." + ) + self.config.fastplms_ttt = self._ttt_serialized_contract() + return super().save_pretrained(save_directory, *args, **kwargs) + + def _ttt_make_optimizer(self) -> torch.optim.Optimizer: + cfg = self.ttt_config + params = self._ttt_lora_parameters() + if cfg.optimizer == "sgd": + return torch.optim.SGD( + params, + lr=cfg.lr, + momentum=cfg.momentum, + weight_decay=cfg.weight_decay, + ) + return torch.optim.AdamW(params, lr=cfg.lr, weight_decay=cfg.weight_decay) + + def _ttt_to_device( + self, + batch: torch.Tensor | dict[str, torch.Tensor], + device: torch.device, + ) -> torch.Tensor | dict[str, torch.Tensor]: + if isinstance(batch, dict): + return {name: tensor.to(device) for name, tensor in batch.items()} + return batch.to(device) + + def _ttt_input_ids_from_batch( + self, + batch: torch.Tensor | dict[str, torch.Tensor], + ) -> torch.Tensor: + if isinstance(batch, dict): + return batch["input_ids"] + return batch + + def _ttt_set_input_ids( + self, + batch: torch.Tensor | dict[str, torch.Tensor], + input_ids: torch.Tensor, + ) -> torch.Tensor | dict[str, torch.Tensor]: + if isinstance(batch, dict): + updated = dict(batch) + updated["input_ids"] = input_ids + return updated + return input_ids + + def _ttt_non_special_mask(self, input_ids: torch.Tensor) -> torch.Tensor: + residue_ids = self._ttt_replacement_tokens(input_ids) + return torch.isin(input_ids, residue_ids) + + def _ttt_validate_tokenized_batch( + self, + batch: torch.Tensor | dict[str, torch.Tensor], + ) -> None: + input_ids = self._ttt_input_ids_from_batch(batch) + if input_ids.ndim != 2 or input_ids.shape[0] == 0 or input_ids.shape[1] == 0: + raise ValueError( + "TTT input_ids must have non-empty shape (batch, sequence); got " + f"{tuple(input_ids.shape)}." + ) + + if str(getattr(self.config, "model_type", "")) == "dplm2": + tokenizer = self.tokenizer + token_to_id = getattr(tokenizer, "_token_to_id", {}) + struct_cls_token = getattr(tokenizer, "struct_cls_token", None) + struct_boundary = token_to_id.get(struct_cls_token) + if struct_boundary is None: + raise ValueError( + "DPLM2 TTT could not resolve the structure-token boundary safely." + ) + pad_token = self._ttt_padding_token() + generic_aa_special_ids = torch.tensor( + [int(self.config.vocab_size) + offset for offset in range(4)], + device=input_ids.device, + dtype=input_ids.dtype, + ) + is_structure = input_ids.ge(int(struct_boundary)) & input_ids.ne(pad_token) + is_structure &= ~torch.isin(input_ids, generic_aa_special_ids) + if bool(is_structure.any()): + raise ValueError( + "DPLM2 TTT currently supports amino-acid-only inputs. Packed or " + "structure-token inputs require a modality-specific corruption objective." + ) + + if isinstance(batch, dict) and "type_ids" in batch: + type_ids = batch["type_ids"] + attention_mask = batch.get("attention_mask", input_ids.ne(pad_token)).bool() + if bool(((type_ids == int(self.config.struct_type)) & attention_mask).any()): + raise ValueError( + "DPLM2 TTT currently supports amino-acid-only inputs; structure " + "type_ids are not accepted." + ) + + if not bool(self._ttt_non_special_mask(input_ids).any()): + raise ValueError( + "TTT input contains no trainable biological residue tokens after excluding " + "padding, boundary, mask, and reserved tokens." + ) + + def _ttt_sample_crop( + self, + batch: torch.Tensor | dict[str, torch.Tensor], + generator: torch.Generator, + ) -> torch.Tensor | dict[str, torch.Tensor]: + input_ids = self._ttt_input_ids_from_batch(batch) + cfg = self.ttt_config + if input_ids.shape[1] <= cfg.crop_size: + return batch + position_has_residue = self._ttt_non_special_mask(input_ids).any(dim=0).to(torch.int64) + prefix = F.pad(position_has_residue.cumsum(dim=0), (1, 0)) + window_counts = prefix[cfg.crop_size :] - prefix[: -cfg.crop_size] + valid_starts = torch.where(window_counts > 0)[0] + if valid_starts.numel() == 0: + raise ValueError("TTT could not find a crop containing a biological residue token.") + selected = torch.randint( + valid_starts.numel(), + (1,), + generator=generator, + device=input_ids.device, + ) + start = int(valid_starts[selected].item()) + end = start + cfg.crop_size + if isinstance(batch, dict): + cropped = {} + for name, tensor in batch.items(): + if tensor.ndim >= 2 and tensor.shape[1] == input_ids.shape[1]: + cropped[name] = tensor[:, start:end] + else: + cropped[name] = tensor + return cropped + return input_ids[:, start:end] + + def _ttt_sample_batch( + self, + tokenized: torch.Tensor | dict[str, torch.Tensor], + generator: torch.Generator, + ) -> tuple[torch.Tensor | dict[str, torch.Tensor], torch.Tensor]: + cfg = self.ttt_config + batch = self._ttt_sample_crop(tokenized, generator) + input_ids = self._ttt_input_ids_from_batch(batch) + row_has_residue = self._ttt_non_special_mask(input_ids).any(dim=1) + eligible_rows = torch.where(row_has_residue)[0] + if eligible_rows.numel() == 0: + raise ValueError( + "TTT sampled batch contains no trainable biological residue tokens." + ) + sampled_row_indices = torch.randint( + eligible_rows.numel(), + (cfg.batch_size,), + generator=generator, + device=input_ids.device, + ) + rows = eligible_rows[sampled_row_indices] + if isinstance(batch, dict): + sampled: torch.Tensor | dict[str, torch.Tensor] = {} + for name, tensor in batch.items(): + if tensor.ndim >= 1 and tensor.shape[0] == input_ids.shape[0]: + sampled[name] = tensor.index_select(0, rows) + else: + sampled[name] = tensor + else: + sampled = input_ids.index_select(0, rows) + + sampled_ids = self._ttt_input_ids_from_batch(sampled) + labels = sampled_ids.clone() + non_special = self._ttt_non_special_mask(sampled_ids) + label_mask = torch.zeros_like(non_special) + for row_idx in range(sampled_ids.shape[0]): + candidate_positions = torch.where(non_special[row_idx])[0] + if candidate_positions.numel() == 0: + continue + num_mask = max(1, round(candidate_positions.numel() * cfg.mask_ratio)) + order = torch.randperm( + candidate_positions.numel(), + generator=generator, + device=sampled_ids.device, + ) + chosen = candidate_positions[order[:num_mask]] + label_mask[row_idx, chosen] = True + labels = labels.masked_fill(~label_mask, -100) + + masked_ids = sampled_ids.clone() + chosen_positions = torch.where(label_mask) + if chosen_positions[0].numel() > 0: + random_values = torch.rand( + chosen_positions[0].shape, + generator=generator, + device=sampled_ids.device, + ) + leave = random_values < cfg.bert_leave_prob + replace = (random_values >= cfg.bert_leave_prob) & ( + random_values < cfg.bert_leave_prob + cfg.bert_replace_prob + ) + mask = ~(leave | replace) + if mask.any(): + masked_ids[ + chosen_positions[0][mask], + chosen_positions[1][mask], + ] = self._ttt_mask_token() + if replace.any(): + replacement_tokens = self._ttt_replacement_tokens(sampled_ids) + replacement_idx = torch.randint( + replacement_tokens.shape[0], + (int(replace.sum().item()),), + generator=generator, + device=sampled_ids.device, + ) + masked_ids[ + chosen_positions[0][replace], + chosen_positions[1][replace], + ] = replacement_tokens[replacement_idx] + + return self._ttt_set_input_ids(sampled, masked_ids), labels + + @contextlib.contextmanager + def _ttt_seed_scope(self, seed: int | None) -> T.Iterator[None]: + if seed is None: + yield + return + cuda_devices = sorted( + { + parameter.device.index + for parameter in self.parameters() + if parameter.device.type == "cuda" and parameter.device.index is not None + } + ) + with torch.random.fork_rng(devices=cuda_devices): + torch.random.default_generator.manual_seed(seed) + for device_index in cuda_devices: + with torch.cuda.device(device_index): + torch.cuda.manual_seed(seed) + yield + + def ttt( + self, + seq: str | list[str] | None = None, + input_ids: torch.Tensor | None = None, + ttt_config: TTTConfig | T.Mapping[str, T.Any] | None = None, + **kwargs: T.Any, + ) -> dict[str, T.Any]: + if ttt_config is not None: + if "_ttt_initialized" in self.__dict__ and self._ttt_initialized: + next_cfg = self.ttt_config.merged(ttt_config) + current_cfg = self.ttt_config + if next_cfg.lora_rank != current_cfg.lora_rank: + raise ValueError( + "Changing lora_rank after TTT initialization is not supported." + ) + if next_cfg.lora_alpha != current_cfg.lora_alpha: + raise ValueError( + "Changing lora_alpha after TTT initialization is not supported." + ) + if ( + next_cfg.lora_target_replace_module + != current_cfg.lora_target_replace_module + ): + raise ValueError( + "Changing LoRA target class after TTT initialization is not supported." + ) + if next_cfg.lora_target_modules != current_cfg.lora_target_modules: + raise ValueError( + "Changing LoRA target modules after TTT initialization is not supported." + ) + self._ttt_cfg = next_cfg + else: + # Family constructors preconfigure the attention class that may + # receive LoRA adapters. A first-call mapping changes only the + # requested fields; rebuilding from TTTConfig defaults here + # would erase that family target immediately before injection. + self._ttt_cfg = self.ttt_config.merged(ttt_config) + self._ttt_cfg.verify() + + cfg = self.ttt_config + device = next(self.parameters()).device + tokenized = self._ttt_tokenize(seq=seq, input_ids=input_ids, **kwargs) + tokenized = self._ttt_to_device(tokenized, device) + self._ttt_validate_tokenized_batch(tokenized) + self._ttt_ensure_initialized() + if cfg.initial_state_reset: + self.ttt_reset() + + generator_device = device if device.type == "cuda" else torch.device("cpu") + generator = torch.Generator(device=generator_device) + if cfg.seed is not None: + generator.manual_seed(cfg.seed) + + module_modes = {module: module.training for module in self.modules()} + requires_grad = {param: param.requires_grad for param in self.parameters()} + losses: list[float] = [] + step_metrics: list[dict[str, T.Any]] = [] + best_state: list[dict[str, torch.Tensor]] | None = None + best_metric: float | None = None + best_step = 0 + + with self._ttt_seed_scope(cfg.seed): + try: + self.train() + for param in self.parameters(): + param.requires_grad_(False) + for param in self._ttt_lora_parameters(): + param.requires_grad_(True) + + optimizer = self._ttt_make_optimizer() + optimizer.zero_grad(set_to_none=True) + total_micro_steps = cfg.steps * cfg.ags + for micro_step in range(total_micro_steps): + batch, labels = self._ttt_sample_batch(tokenized, generator) + if not bool(labels.ne(-100).any()): + raise RuntimeError( + "TTT produced an all-ignored label batch; refusing a NaN update." + ) + logits = self._ttt_predict_logits(batch, **kwargs) + labels = labels.to(device=logits.device) + loss = F.cross_entropy( + logits.reshape(-1, logits.shape[-1]), + labels.reshape(-1), + ignore_index=-100, + ) + if not bool(torch.isfinite(loss)): + raise FloatingPointError( + f"TTT loss is non-finite at micro-step {micro_step + 1}." + ) + (loss / cfg.ags).backward() + if (micro_step + 1) % cfg.ags != 0: + continue + + if cfg.gradient_clip: + torch.nn.utils.clip_grad_norm_( + self._ttt_lora_parameters(), + cfg.gradient_clip_max_norm, + ) + optimizer.step() + optimizer.zero_grad(set_to_none=True) + step = (micro_step + 1) // cfg.ags + loss_value = float(loss.detach().item()) + losses.append(loss_value) + if cfg.eval_each_step: + metrics, metric = self._ttt_eval_step( + step=step, + loss=loss_value, + seq=seq, + input_ids=input_ids, + **kwargs, + ) + if len(metrics) > 0: + step_metrics.append(metrics) + if metric is not None and (best_metric is None or metric > best_metric): + best_metric = metric + best_step = step + best_state = self._ttt_snapshot_lora_state() + + if cfg.automatic_best_state_reset and best_state is not None: + self._ttt_restore_lora_state(best_state) + finally: + for param, value in requires_grad.items(): + param.requires_grad_(value) + for module, training in module_modes.items(): + module.train(training) + + return { + "losses": losses, + "step_metrics": step_metrics, + "best_step": best_step, + "best_metric": best_metric, + } diff --git a/fastplms/registry.py b/fastplms/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..7091701ac66a7b00598aa9d731308fbe8b28a083 --- /dev/null +++ b/fastplms/registry.py @@ -0,0 +1,1486 @@ +"""Typed access to the FastPLMs model and provenance manifest. + +The registry is intentionally independent of Torch and Transformers. Tooling can +therefore inspect supported checkpoints, licenses, and reference sources without +initializing a model runtime or downloading any files. +""" + +from __future__ import annotations + +import re +import tomllib +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from functools import lru_cache +from importlib import resources +from pathlib import Path, PurePosixPath, PureWindowsPath +from types import MappingProxyType +from typing import Any, Literal, cast +from urllib.parse import urlparse + +_HEX_RE = re.compile(r"^[0-9a-f]+$") +_IDENTIFIER_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") +_HUB_LICENSE_NAME_RE = re.compile(r"[^a-z0-9.]+") +_WINDOWS_INVALID_PATH_CHARACTERS = frozenset('<>:"|?*') +_WINDOWS_RESERVED_PATH_NAMES = frozenset( + {"AUX", "CON", "NUL", "PRN"} + | {f"COM{index}" for index in range(1, 10)} + | {f"LPT{index}" for index in range(1, 10)} +) +_REPOSITORY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$") +_REFERENCE_CONTAINER_RE = re.compile(r"^reference-[a-z0-9]+(?:-[a-z0-9]+)*$") +_REFERENCE_ADAPTER_RE = re.compile( + r"^tests\.parity\.support\.reference_adapters\.[a-z_][a-z0-9_]*$" +) +_DOCUMENTATION_FRAGMENT_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_ALLOWED_ATTENTION = frozenset( + {"eager", "sdpa", "flex_attention", "flash_attention_2", "flash_attention_3"} +) +_ALLOWED_DTYPES = frozenset({"float32", "bfloat16"}) +_ALLOWED_PRECISIONS = frozenset({"default", "auto", "fp32", "bf16", "fp8"}) +_ALLOWED_BF16_EXECUTIONS = frozenset({"static_parameters", "fp32_parameters_autocast"}) +HUB_LICENSE_IDENTIFIERS = frozenset({"mit", "apache-2.0", "cc-by-nc-sa-4.0", "other"}) +_ALLOWED_TOKENIZER_MODES = frozenset({"tokenizer", "sequence", "structure"}) +_ALLOWED_SIZE_CATEGORIES = frozenset({"small", "medium", "large", "xlarge", "structure"}) +RuntimeExtra = Literal["core", "structure"] +TestTier = Literal["check", "compliance", "structure", "feature", "artifact", "benchmark"] +VramTier = Literal["sequence", "large-sequence", "structure", "structure-6b"] +GenerationContract = Literal["not_applicable", "required", "official_unavailable"] +RuntimeAssetTrustKind = Literal["hash_pinned_pickle"] +Bf16Execution = Literal["static_parameters", "fp32_parameters_autocast"] +DtypeName = Literal["float32", "bfloat16"] +_ALLOWED_EXTRAS = frozenset({"core", "structure"}) +_ALLOWED_TEST_TIERS = frozenset( + {"check", "compliance", "structure", "feature", "artifact", "benchmark"} +) +_ALLOWED_VRAM_TIERS = frozenset({"sequence", "large-sequence", "structure", "structure-6b"}) +_ALLOWED_GENERATION_CONTRACTS = frozenset({"not_applicable", "required", "official_unavailable"}) +_ALLOWED_RUNTIME_ASSET_TRUST_KINDS = frozenset({"hash_pinned_pickle"}) +_ALLOWED_RUNTIME_ASSET_OFFLINE_BEHAVIORS = frozenset({"requires_cached_verified_file"}) +_ALLOWED_AUTO_CLASSES = frozenset( + { + "AutoConfig", + "AutoModel", + "AutoModelForMaskedLM", + "AutoModelForProteinFolding", + "AutoModelForSequenceClassification", + "AutoModelForSeq2SeqLM", + "AutoModelForTokenClassification", + } +) +_WEIGHT_SUFFIXES = (".bin", ".ckpt", ".pt", ".pth", ".safetensors") +_ALLOWED_ORACLE_ASSET_ROLES = frozenset({"weights", "contact_regression"}) +_FAIR_ESM_ASSET_HOST = "dl.fbaipublicfiles.com" +_ROOT_FIELDS = frozenset( + { + "schema_version", + "legal_files", + "attention_kernels", + "upstreams", + "families", + "models", + "runtime_assets", + } +) +_UPSTREAM_FIELDS = frozenset( + { + "id", + "path", + "url", + "revision", + "license", + "license_files", + "license_digests", + "distribution_files", + } +) +_FAMILY_FIELDS = frozenset( + { + "architecture", + "upstreams", + "tokenizer_mode", + "public_input", + "extra", + "reference_container", + "reference_adapter", + "attention", + "dtypes", + "bf16_execution", + "precisions", + "experimental_precisions", + "vram_tier", + "checkpoint_license", + "hub_license", + "hub_license_name", + "hub_license_link", + "state_transform", + "conversion_provenance", + "representative", + "documentation", + "test_tiers", + "runtime_paths", + "requires_complete_weight_publication", + "weights_publication_allowed", + "auto_map", + "tokenizer_class", + "backbone_model", + } +) +_MODEL_FIELDS = frozenset( + { + "id", + "family", + "size_category", + "generation_contract", + "fast_repo", + "fast_revision", + "fast_files", + "fast_unresolved_files", + "official_repo", + "official_revision", + "official_files", + "official_unresolved_files", + "oracle_assets", + "official_golden", + "artifact_source", + "canonical_state_sha256", + "tokenizer_source", + "auto_map", + "notes", + "msa_conditioning", + } +) +_RUNTIME_ASSET_FIELDS = frozenset( + { + "id", + "repository", + "revision", + "path", + "sha256", + "size", + "consumer_family", + "trust_kind", + "license", + "offline_behavior", + } +) + + +class RegistryError(ValueError): + """Raised when the model manifest is incomplete or internally inconsistent.""" + + +def _portable_relative_path(value: str, context: str) -> PurePosixPath: + """Return one normalized cross-platform relative path or fail closed.""" + + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + unsafe_windows_part = any( + part.rstrip(" .") != part + or part.split(".", maxsplit=1)[0].upper() in _WINDOWS_RESERVED_PATH_NAMES + or any( + ord(character) < 32 or character in _WINDOWS_INVALID_PATH_CHARACTERS + for character in part + ) + for part in posix.parts + ) + if ( + not value + or not posix.parts + or posix == PurePosixPath(".") + or posix.is_absolute() + or windows.is_absolute() + or windows.drive + or "\\" in value + or "." in posix.parts + or ".." in posix.parts + or value != posix.as_posix() + or any( + part.lower() in {".git", ".cache", "__pycache__"} + for part in posix.parts + ) + or unsafe_windows_part + ): + raise RegistryError(f"{context} is not portable: {value!r}") + return posix + + +@dataclass(frozen=True, slots=True) +class FileDigest: + """Expected content identity for one pinned file.""" + + path: str + algorithm: str + digest: str + + @classmethod + def parse(cls, value: str) -> FileDigest: + try: + path, encoded_digest = value.split("=", maxsplit=1) + algorithm, digest = encoded_digest.split(":", maxsplit=1) + except ValueError as error: + raise RegistryError("File digests must use '=:'.") from error + + _portable_relative_path(path, "Checkpoint file path") + + expected_length = {"git-sha1": 40, "sha256": 64}.get(algorithm) + if expected_length is None: + raise RegistryError(f"Unsupported file digest algorithm: {algorithm!r}") + if len(digest) != expected_length or _HEX_RE.fullmatch(digest) is None: + raise RegistryError(f"Invalid {algorithm} digest for {path!r}: {digest!r}") + return cls(path=path, algorithm=algorithm, digest=digest) + + @property + def encoded(self) -> str: + return f"{self.algorithm}:{self.digest}" + + +@dataclass(frozen=True, slots=True) +class CheckpointSource: + """One immutable Hugging Face repository snapshot.""" + + repo_id: str + revision: str + files: tuple[FileDigest, ...] + unresolved_files: tuple[str, ...] = () + + @property + def file_map(self) -> Mapping[str, FileDigest]: + return MappingProxyType({item.path: item for item in self.files}) + + +@dataclass(frozen=True, slots=True) +class OracleAsset: + """Hash-pinned external file required by a native parity oracle.""" + + role: str + path: str + url: str + sha256: str + size: int + + +@dataclass(frozen=True, slots=True) +class RuntimeAsset: + """Immutable runtime data with an explicit deserialization trust boundary.""" + + id: str + repository: str + revision: str + path: str + sha256: str + size: int + consumer_family: str + trust_kind: RuntimeAssetTrustKind + license_expression: str + offline_behavior: str + + +@dataclass(frozen=True, slots=True) +class OfficialGolden: + """Hash-pinned official output bundle required by the check tier.""" + + metadata: FileDigest + tensors: FileDigest + + +@dataclass(frozen=True, slots=True) +class UpstreamSource: + """Pinned official implementation used as a parity oracle.""" + + id: str + path: str + url: str + revision: str + license_expression: str + license_files: tuple[str, ...] + license_digests: tuple[FileDigest, ...] = () + distribution_files: tuple[FileDigest, ...] = () + + +@dataclass(frozen=True, slots=True) +class AttentionKernelSpec: + """Immutable Hugging Face kernel used by one attention backend.""" + + implementation: str + repository: str + revision: str + version: int + expected_variant: str + dtypes: tuple[DtypeName, ...] + + +@dataclass(frozen=True, slots=True) +class ModelFamily: + """Shared runtime and compliance contract for one architecture family.""" + + id: str + architecture: str + upstreams: tuple[str, ...] + tokenizer_mode: str + public_input: str + extra: RuntimeExtra + reference_container: str + reference_adapter: str + attention: tuple[str, ...] + dtypes: tuple[DtypeName, ...] + bf16_execution: Bf16Execution + precisions: tuple[str, ...] + vram_tier: VramTier + checkpoint_license: str + hub_license: str + state_transform: str + representative: str + documentation: str + test_tiers: tuple[TestTier, ...] + runtime_paths: tuple[str, ...] + auto_map_items: tuple[tuple[str, str], ...] + requires_complete_weight_publication: bool = False + weights_publication_allowed: bool = False + experimental_precisions: tuple[str, ...] = () + tokenizer_class: str | None = None + hub_license_name: str | None = None + hub_license_link: str | None = None + conversion_provenance: str = "" + backbone_model: str | None = None + + @property + def auto_map(self) -> Mapping[str, str]: + return MappingProxyType(dict(self.auto_map_items)) + + @property + def hub_license_metadata(self) -> Mapping[str, str]: + """Return valid Hugging Face model-card license fields.""" + + metadata = {"license": self.hub_license} + if self.hub_license_name is not None: + # Hugging Face validates custom license names as lowercase slugs, + # while the manifest retains the reader-facing display name used + # in generated prose. + metadata["license_name"] = _HUB_LICENSE_NAME_RE.sub( + "-", + self.hub_license_name.lower(), + ).strip("-.") + if self.hub_license_link is not None: + metadata["license_link"] = self.hub_license_link + return MappingProxyType(metadata) + + @property + def stable_precisions(self) -> tuple[str, ...]: + """Return precision policies covered by the release contract.""" + + experimental = set(self.experimental_precisions) + return tuple(precision for precision in self.precisions if precision not in experimental) + + +@dataclass(frozen=True, slots=True) +class ModelSpec: + """Complete immutable source and runtime contract for one checkpoint.""" + + id: str + family: ModelFamily + fast: CheckpointSource + official: CheckpointSource + size_category: str + generation_contract: GenerationContract = "not_applicable" + oracle_assets: tuple[OracleAsset, ...] = () + official_golden: OfficialGolden | None = None + artifact_source: str = "fast" + canonical_state_sha256: str | None = None + tokenizer_source_id: str | None = None + auto_map_items: tuple[tuple[str, str], ...] = () + notes: str = "" + msa_conditioning: bool | None = None + + @property + def is_deep_reference(self) -> bool: + return self.id == self.family.representative + + @property + def auto_map(self) -> Mapping[str, str]: + if self.auto_map_items: + return MappingProxyType(dict(self.auto_map_items)) + return self.family.auto_map + + @property + def artifact_checkpoint(self) -> CheckpointSource: + """Return the checkpoint selected for local artifact construction.""" + + return self.fast if self.artifact_source == "fast" else self.official + + @property + def oracle_asset_map(self) -> Mapping[str, OracleAsset]: + """Return native oracle assets keyed by their declared role.""" + + return MappingProxyType({asset.role: asset for asset in self.oracle_assets}) + + +class ModelRegistry(Mapping[str, ModelSpec]): + """Validated mapping of model IDs to typed model specifications.""" + + def __init__( + self, + *, + schema_version: int, + upstreams: Mapping[str, UpstreamSource], + families: Mapping[str, ModelFamily], + models: Mapping[str, ModelSpec], + runtime_assets: Mapping[str, RuntimeAsset] = MappingProxyType({}), + attention_kernels: Mapping[str, AttentionKernelSpec] = MappingProxyType({}), + legal_files: tuple[FileDigest, ...] = (), + ) -> None: + self.schema_version = schema_version + self.upstreams = MappingProxyType(dict(upstreams)) + self.attention_kernels = MappingProxyType(dict(attention_kernels)) + self.families = MappingProxyType(dict(families)) + self._models = MappingProxyType(dict(models)) + self.runtime_assets = MappingProxyType(dict(runtime_assets)) + self.legal_files = legal_files + + def __getitem__(self, key: str) -> ModelSpec: + return self._models[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._models) + + def __len__(self) -> int: + return len(self._models) + + def by_family(self, family_id: str) -> tuple[ModelSpec, ...]: + if family_id not in self.families: + raise KeyError(family_id) + return tuple(model for model in self._models.values() if model.family.id == family_id) + + def supported_attention_dtypes( + self, + family_id: str, + implementation: str, + ) -> tuple[DtypeName, ...]: + """Return manifest-supported dtypes for one family/backend pair.""" + + family = self.families[family_id] + if implementation not in family.attention: + raise KeyError( + f"Family {family_id!r} does not advertise attention backend " + f"{implementation!r}." + ) + kernel = self.attention_kernels.get(implementation) + if kernel is None: + return family.dtypes + return tuple(dtype for dtype in family.dtypes if dtype in kernel.dtypes) + + def require_resolved(self, model_id: str | None = None) -> None: + """Fail release validation when required file identities remain unresolved.""" + + selected = self._models.values() if model_id is None else (self._models[model_id],) + unresolved: list[str] = [] + for model in selected: + for label, checkpoint in (("fast", model.fast), ("official", model.official)): + for path in checkpoint.unresolved_files: + unresolved.append(f"{model.id}.{label}:{path}") + if unresolved: + detail = ", ".join(unresolved) + raise RegistryError(f"Release provenance is unresolved: {detail}") + + +def _reject_unknown_fields( + table: Mapping[str, Any], + allowed: frozenset[str], + context: str, +) -> None: + unknown = sorted(set(table).difference(allowed)) + if unknown: + raise RegistryError(f"{context} contains unknown fields: {unknown}.") + + +def _require_str(table: Mapping[str, Any], key: str, context: str) -> str: + value = table.get(key) + if not isinstance(value, str) or not value.strip(): + raise RegistryError(f"{context}.{key} must be a non-empty string.") + return value + + +def _require_enum( + table: Mapping[str, Any], + key: str, + context: str, + allowed: frozenset[str], +) -> str: + value = _require_str(table, key, context) + if value not in allowed: + raise RegistryError( + f"{context}.{key} must be one of {sorted(allowed)}; received {value!r}." + ) + return value + + +def _parse_reference_container(table: Mapping[str, Any], context: str) -> str: + value = _require_str(table, "reference_container", context) + if _REFERENCE_CONTAINER_RE.fullmatch(value) is None: + raise RegistryError( + f"{context}.reference_container must be a portable 'reference-' target." + ) + return value + + +def _parse_reference_adapter(table: Mapping[str, Any], context: str) -> str: + value = _require_str(table, "reference_adapter", context) + if _REFERENCE_ADAPTER_RE.fullmatch(value) is None: + raise RegistryError( + f"{context}.reference_adapter must name one module under " + "tests.parity.support.reference_adapters." + ) + return value + + +def _parse_documentation_path(table: Mapping[str, Any], context: str) -> str: + value = _require_str(table, "documentation", context) + if value.count("#") > 1 or "\\" in value: + raise RegistryError(f"{context}.documentation must be a portable documentation path.") + raw_path, separator, fragment = value.partition("#") + path = PurePosixPath(raw_path) + if ( + path.is_absolute() + or ".." in path.parts + or len(path.parts) < 2 + or path.parts[0] != "docs" + or path.suffix != ".md" + or path.as_posix() != raw_path + ): + raise RegistryError( + f"{context}.documentation must reference a normalized Markdown file under docs/." + ) + if separator and _DOCUMENTATION_FRAGMENT_RE.fullmatch(fragment) is None: + raise RegistryError(f"{context}.documentation has an invalid heading fragment.") + return value + + +def _require_str_list(table: Mapping[str, Any], key: str, context: str) -> tuple[str, ...]: + value = table.get(key) + if not isinstance(value, list) or not value or any(not isinstance(item, str) for item in value): + raise RegistryError(f"{context}.{key} must be a non-empty string array.") + result = tuple(value) + if len(set(result)) != len(result): + raise RegistryError(f"{context}.{key} contains duplicate values.") + return result + + +def _optional_str_list(table: Mapping[str, Any], key: str, context: str) -> tuple[str, ...]: + value = table.get(key, []) + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise RegistryError(f"{context}.{key} must be a string array.") + result = tuple(value) + if len(set(result)) != len(result): + raise RegistryError(f"{context}.{key} contains duplicate values.") + return result + + +def _optional_str(table: Mapping[str, Any], key: str, context: str) -> str | None: + value = table.get(key) + if value is None: + return None + if ( + not isinstance(value, str) + or not value.strip() + or value != value.strip() + or "\n" in value + or "\r" in value + ): + raise RegistryError(f"{context}.{key} must be a non-empty single-line string.") + return value + + +def _parse_hub_license( + table: Mapping[str, Any], + *, + checkpoint_license: str, + context: str, +) -> tuple[str, str | None, str | None]: + expected_fields = {"hub_license", "hub_license_name", "hub_license_link"} + unknown_fields = sorted( + key for key in table if key.startswith("hub_") and key not in expected_fields + ) + if unknown_fields: + raise RegistryError(f"{context} contains unsupported Hub license fields: {unknown_fields}.") + identifier = _require_str(table, "hub_license", context) + if identifier not in HUB_LICENSE_IDENTIFIERS: + raise RegistryError( + f"{context}.hub_license must be a supported Hugging Face license identifier." + ) + expected_identifier: str | None = None + for prefix, candidate in ( + ("MIT", "mit"), + ("Apache-2.0", "apache-2.0"), + ("CC-BY-NC-SA-4.0", "cc-by-nc-sa-4.0"), + ("Profluent-E1-Agreement", "other"), + ("Unresolved", "other"), + ): + if checkpoint_license.startswith(prefix): + expected_identifier = candidate + break + if expected_identifier is None: + raise RegistryError( + f"{context}.checkpoint_license has no declared Hugging Face identifier mapping." + ) + if identifier != expected_identifier: + raise RegistryError( + f"{context}.hub_license must be {expected_identifier!r} for " + f"checkpoint terms {checkpoint_license!r}." + ) + + name = _optional_str(table, "hub_license_name", context) + link = _optional_str(table, "hub_license_link", context) + if identifier != "other": + if name is not None or link is not None: + raise RegistryError( + f"{context} may define hub_license_name and hub_license_link only " + "when hub_license='other'." + ) + return identifier, None, None + if name is None or link is None: + raise RegistryError( + f"{context} must define hub_license_name and hub_license_link when hub_license='other'." + ) + parsed_link = urlparse(link) + if ( + parsed_link.scheme != "https" + or not parsed_link.netloc + or not parsed_link.path + or parsed_link.username is not None + or parsed_link.password is not None + ): + raise RegistryError(f"{context}.hub_license_link must be an absolute HTTPS URL.") + return identifier, name, link + + +def _require_digest_list( + table: Mapping[str, Any], key: str, context: str +) -> tuple[FileDigest, ...]: + encoded = _require_str_list(table, key, context) + result = tuple(FileDigest.parse(value) for value in encoded) + paths = [item.path for item in result] + if len(paths) != len(set(paths)): + raise RegistryError(f"{context}.{key} contains duplicate paths.") + return result + + +def _validate_revision(revision: str, context: str) -> None: + if len(revision) != 40 or _HEX_RE.fullmatch(revision) is None: + raise RegistryError(f"{context} must be an immutable 40-character commit revision.") + + +def _parse_checkpoint(table: Mapping[str, Any], prefix: str, context: str) -> CheckpointSource: + repo_id = _require_str(table, f"{prefix}_repo", context) + if _REPOSITORY_ID_RE.fullmatch(repo_id) is None: + raise RegistryError(f"{context}.{prefix}_repo must be a Hugging Face repository ID.") + revision = _require_str(table, f"{prefix}_revision", context) + _validate_revision(revision, f"{context}.{prefix}_revision") + encoded_files = _require_str_list(table, f"{prefix}_files", context) + files = tuple(FileDigest.parse(value) for value in encoded_files) + paths = [item.path for item in files] + if len(paths) != len(set(paths)): + raise RegistryError(f"{context}.{prefix}_files contains duplicate paths.") + if not any(item.path.endswith(_WEIGHT_SUFFIXES) for item in files): + raise RegistryError(f"{context}.{prefix}_files does not identify a weight file.") + unresolved_files = _optional_str_list(table, f"{prefix}_unresolved_files", context) + for unresolved_path in unresolved_files: + _portable_relative_path(unresolved_path, "Unresolved checkpoint path") + if unresolved_path in paths: + raise RegistryError( + f"{context}.{prefix} marks {unresolved_path!r} both resolved and unresolved." + ) + return CheckpointSource( + repo_id=repo_id, + revision=revision, + files=files, + unresolved_files=unresolved_files, + ) + + +def _parse_oracle_assets(table: Mapping[str, Any], context: str) -> tuple[OracleAsset, ...]: + raw = table.get("oracle_assets", []) + if not isinstance(raw, list): + raise RegistryError(f"{context}.oracle_assets must be an array of tables.") + result: list[OracleAsset] = [] + for index, value in enumerate(raw): + asset_context = f"{context}.oracle_assets[{index}]" + if not isinstance(value, dict): + raise RegistryError(f"{asset_context} must be a table.") + expected_fields = {"role", "path", "url", "sha256", "size"} + if set(value) != expected_fields: + raise RegistryError(f"{asset_context} must contain exactly {sorted(expected_fields)}.") + role = _require_str(value, "role", asset_context) + if role not in _ALLOWED_ORACLE_ASSET_ROLES: + raise RegistryError(f"Unsupported oracle asset role: {role!r}.") + path = _require_str(value, "path", asset_context) + try: + normalized_path = _portable_relative_path(path, "Oracle asset path") + except RegistryError as error: + raise RegistryError(f"Invalid oracle asset path: {path!r}.") from error + if normalized_path.suffix != ".pt": + raise RegistryError(f"Invalid oracle asset path: {path!r}.") + url = _require_str(value, "url", asset_context) + parsed_url = urlparse(url) + if ( + parsed_url.scheme != "https" + or parsed_url.hostname != _FAIR_ESM_ASSET_HOST + or parsed_url.path != f"/fair-esm/{path}" + or parsed_url.params + or parsed_url.query + or parsed_url.fragment + ): + raise RegistryError(f"Invalid fair-esm oracle asset URL: {url!r}.") + sha256 = _require_str(value, "sha256", asset_context) + if len(sha256) != 64 or _HEX_RE.fullmatch(sha256) is None: + raise RegistryError(f"Invalid oracle asset SHA-256 for {path!r}.") + size = value.get("size") + if isinstance(size, bool) or not isinstance(size, int) or size <= 0: + raise RegistryError(f"{asset_context}.size must be a positive byte count.") + result.append( + OracleAsset( + role=role, + path=path, + url=url, + sha256=sha256, + size=size, + ) + ) + roles = [asset.role for asset in result] + paths = [asset.path for asset in result] + urls = [asset.url for asset in result] + if ( + len(roles) != len(set(roles)) + or len(paths) != len(set(paths)) + or len(urls) != len(set(urls)) + ): + raise RegistryError(f"{context}.oracle_assets contains duplicate identities.") + return tuple(result) + + +def _parse_official_golden( + table: Mapping[str, Any], + model_id: str, + context: str, +) -> OfficialGolden | None: + raw = table.get("official_golden") + if raw is None: + return None + if not isinstance(raw, dict) or set(raw) != {"metadata", "tensors"}: + raise RegistryError( + f"{context}.official_golden must contain exactly 'metadata' and 'tensors'." + ) + parsed: dict[str, FileDigest] = {} + for role in ("metadata", "tensors"): + value = raw[role] + if not isinstance(value, str): + raise RegistryError(f"{context}.official_golden.{role} must be a file digest.") + digest = FileDigest.parse(value) + if digest.algorithm != "sha256": + raise RegistryError( + f"{context}.official_golden.{role} must use an immutable SHA-256 digest." + ) + expected = f"tests/goldens/{model_id}.{'json' if role == 'metadata' else 'safetensors'}" + if digest.path != expected: + raise RegistryError(f"{context}.official_golden.{role} must use path {expected!r}.") + parsed[role] = digest + return OfficialGolden(metadata=parsed["metadata"], tensors=parsed["tensors"]) + + +def _parse_attention_kernels(raw: object) -> dict[str, AttentionKernelSpec]: + if not isinstance(raw, list) or not raw: + raise RegistryError("The manifest must contain [[attention_kernels]] entries.") + result: dict[str, AttentionKernelSpec] = {} + expected_variants = { + "flash_attention_2": "flash_attn2", + "flash_attention_3": "flash_attn3", + } + for index, value in enumerate(raw): + context = f"attention_kernels[{index}]" + if not isinstance(value, dict): + raise RegistryError(f"{context} must be a table.") + expected_fields = frozenset( + { + "implementation", + "repository", + "revision", + "version", + "expected_variant", + "dtypes", + } + ) + _reject_unknown_fields(value, expected_fields, context) + implementation = _require_str(value, "implementation", context) + if implementation not in expected_variants: + raise RegistryError(f"Unsupported attention kernel {implementation!r}.") + if implementation in result: + raise RegistryError(f"Duplicate attention kernel {implementation!r}.") + repository = _require_str(value, "repository", context) + if _REPOSITORY_ID_RE.fullmatch(repository) is None: + raise RegistryError(f"Invalid attention-kernel repository {repository!r}.") + revision = _require_str(value, "revision", context) + _validate_revision(revision, f"{context}.revision") + kernel_version = value.get("version") + if ( + isinstance(kernel_version, bool) + or not isinstance(kernel_version, int) + or kernel_version <= 0 + ): + raise RegistryError(f"{context}.version must be a positive integer.") + expected_variant = _require_str(value, "expected_variant", context) + if expected_variant != expected_variants[implementation]: + raise RegistryError( + f"{context}.expected_variant must be {expected_variants[implementation]!r}." + ) + dtypes = _require_str_list(value, "dtypes", context) + if not set(dtypes).issubset(_ALLOWED_DTYPES): + raise RegistryError(f"{context}.dtypes contains unsupported dtypes.") + result[implementation] = AttentionKernelSpec( + implementation=implementation, + repository=repository, + revision=revision, + version=kernel_version, + expected_variant=expected_variant, + dtypes=cast(tuple[DtypeName, ...], dtypes), + ) + if set(result) != set(expected_variants): + raise RegistryError("The manifest must pin both FlashAttention kernel versions.") + return result + + +def _parse_upstreams(raw: object) -> dict[str, UpstreamSource]: + if not isinstance(raw, list) or not raw: + raise RegistryError("The manifest must contain at least one [[upstreams]] entry.") + result: dict[str, UpstreamSource] = {} + paths: set[str] = set() + for index, value in enumerate(raw): + context = f"upstreams[{index}]" + if not isinstance(value, dict): + raise RegistryError(f"{context} must be a table.") + _reject_unknown_fields(value, _UPSTREAM_FIELDS, context) + source_id = _require_str(value, "id", context) + if _IDENTIFIER_RE.fullmatch(source_id) is None: + raise RegistryError(f"Invalid upstream ID: {source_id!r}") + if source_id in result: + raise RegistryError(f"Duplicate upstream ID: {source_id!r}") + revision = _require_str(value, "revision", context) + _validate_revision(revision, f"{context}.revision") + path = _require_str(value, "path", context) + try: + normalized_path = _portable_relative_path(path, f"{context}.path") + except RegistryError as error: + raise RegistryError( + f"{context}.path must be a normalized directory directly under " + "'vendor/upstream/'." + ) from error + if ( + normalized_path.parts[:2] != ("vendor", "upstream") + or len(normalized_path.parts) != 3 + ): + raise RegistryError( + f"{context}.path must be a normalized directory directly under " + "'vendor/upstream/'." + ) + if path in paths: + raise RegistryError(f"Duplicate upstream path: {path!r}") + paths.add(path) + url = _require_str(value, "url", context) + if not url.startswith("https://github.com/") or not url.endswith(".git"): + raise RegistryError(f"{context}.url must be an HTTPS GitHub clone URL.") + license_files = _require_str_list(value, "license_files", context) + license_digests = _require_digest_list(value, "license_digests", context) + if tuple(item.path for item in license_digests) != license_files: + raise RegistryError( + f"{context}.license_digests must cover license_files in the same order." + ) + distribution_files = _require_digest_list(value, "distribution_files", context) + distribution_map = {item.path: item for item in distribution_files} + for canonical in license_digests: + distributed = distribution_map.get(canonical.path) + if distributed is None or distributed.encoded != canonical.encoded: + raise RegistryError( + f"{context}.distribution_files must include an exact copy of " + f"{canonical.path!r}." + ) + if source_id == "e1": + required_e1 = { + "LICENSE", + "ATTRIBUTION", + "NOTICE", + "Apache-2.0.txt", + "BSD-3-Clause.txt", + "MODIFICATIONS.md", + } + missing_e1 = sorted(required_e1.difference(distribution_map)) + if missing_e1: + raise RegistryError(f"{context} is missing E1 legal files: {missing_e1}") + result[source_id] = UpstreamSource( + id=source_id, + path=path, + url=url, + revision=revision, + license_expression=_require_str(value, "license", context), + license_files=license_files, + license_digests=license_digests, + distribution_files=distribution_files, + ) + return result + + +def _parse_families( + raw: object, + upstreams: Mapping[str, UpstreamSource], +) -> dict[str, ModelFamily]: + if not isinstance(raw, dict) or not raw: + raise RegistryError("The manifest must contain [families.] tables.") + result: dict[str, ModelFamily] = {} + for family_id, value in raw.items(): + context = f"families.{family_id}" + if _IDENTIFIER_RE.fullmatch(family_id) is None or not isinstance(value, dict): + raise RegistryError(f"Invalid family table: {family_id!r}") + checkpoint_license = _require_str(value, "checkpoint_license", context) + hub_license, hub_license_name, hub_license_link = _parse_hub_license( + value, + checkpoint_license=checkpoint_license, + context=context, + ) + _reject_unknown_fields(value, _FAMILY_FIELDS, context) + source_ids = _require_str_list(value, "upstreams", context) + unknown_sources = sorted(set(source_ids).difference(upstreams)) + if unknown_sources: + raise RegistryError(f"{context} references unknown upstreams: {unknown_sources}") + tokenizer_mode = _require_str(value, "tokenizer_mode", context) + if tokenizer_mode not in _ALLOWED_TOKENIZER_MODES: + raise RegistryError(f"Unsupported tokenizer mode in {context}: {tokenizer_mode!r}") + public_input = _require_str(value, "public_input", context) + attention = _require_str_list(value, "attention", context) + if not set(attention).issubset(_ALLOWED_ATTENTION): + raise RegistryError(f"Unsupported attention implementation in {context}.") + dtypes = _require_str_list(value, "dtypes", context) + if not set(dtypes).issubset(_ALLOWED_DTYPES): + raise RegistryError(f"Unsupported dtype in {context}.") + bf16_execution = cast( + Bf16Execution, + _require_enum( + value, + "bf16_execution", + context, + _ALLOWED_BF16_EXECUTIONS, + ), + ) + precisions = _require_str_list(value, "precisions", context) + if not set(precisions).issubset(_ALLOWED_PRECISIONS): + raise RegistryError(f"Unsupported precision policy in {context}.") + experimental_precisions = _optional_str_list( + value, + "experimental_precisions", + context, + ) + unknown_experimental_precisions = sorted( + set(experimental_precisions).difference(precisions) + ) + if unknown_experimental_precisions: + raise RegistryError( + f"{context}.experimental_precisions must be a subset of precisions; " + f"unknown values: {unknown_experimental_precisions}." + ) + extra = cast(RuntimeExtra, _require_enum(value, "extra", context, _ALLOWED_EXTRAS)) + vram_tier = cast( + VramTier, + _require_enum(value, "vram_tier", context, _ALLOWED_VRAM_TIERS), + ) + test_tiers_raw = _require_str_list(value, "test_tiers", context) + unknown_test_tiers = sorted(set(test_tiers_raw).difference(_ALLOWED_TEST_TIERS)) + if unknown_test_tiers: + raise RegistryError( + f"{context}.test_tiers contains unsupported tiers: {unknown_test_tiers}." + ) + test_tiers = cast(tuple[TestTier, ...], test_tiers_raw) + reference_container = _parse_reference_container(value, context) + reference_adapter = _parse_reference_adapter(value, context) + documentation = _parse_documentation_path(value, context) + runtime_paths = _require_str_list(value, "runtime_paths", context) + if len(runtime_paths) != len(set(runtime_paths)): + raise RegistryError(f"{context}.runtime_paths must not contain duplicates.") + for runtime_path in runtime_paths: + try: + _portable_relative_path(runtime_path, f"{context}.runtime_paths entry") + except RegistryError as error: + raise RegistryError( + f"Unsafe runtime path in {context}: {runtime_path!r}" + ) from error + if runtime_path.startswith("vendor/"): + raise RegistryError(f"Unsafe runtime path in {context}: {runtime_path!r}") + requires_complete_weight_publication = value.get( + "requires_complete_weight_publication", + False, + ) + if not isinstance(requires_complete_weight_publication, bool): + raise RegistryError( + f"{context}.requires_complete_weight_publication must be a boolean." + ) + if "weights_publication_allowed" not in value: + raise RegistryError( + f"{context}.weights_publication_allowed must be declared explicitly." + ) + weights_publication_allowed = value["weights_publication_allowed"] + if not isinstance(weights_publication_allowed, bool): + raise RegistryError(f"{context}.weights_publication_allowed must be a boolean.") + raw_auto_map = value.get("auto_map") + if not isinstance(raw_auto_map, dict) or not raw_auto_map: + raise RegistryError(f"{context}.auto_map must be a non-empty table.") + auto_map: list[tuple[str, str]] = [] + for auto_class, class_path in raw_auto_map.items(): + if auto_class not in _ALLOWED_AUTO_CLASSES or not isinstance(class_path, str): + raise RegistryError(f"Invalid AutoClass mapping in {context}: {auto_class!r}") + if not class_path.startswith("fastplms.") or class_path.count(".") < 2: + raise RegistryError(f"Invalid Python class path in {context}: {class_path!r}") + auto_map.append((auto_class, class_path)) + tokenizer_class = value.get("tokenizer_class") + if tokenizer_class is not None: + if tokenizer_mode != "tokenizer": + raise RegistryError( + f"{context}.tokenizer_class requires tokenizer_mode='tokenizer'." + ) + if ( + not isinstance(tokenizer_class, str) + or not tokenizer_class.startswith("fastplms.") + or tokenizer_class.count(".") < 2 + ): + raise RegistryError( + f"Invalid tokenizer class path in {context}: {tokenizer_class!r}" + ) + backbone_model = value.get("backbone_model") + if backbone_model is not None and ( + not isinstance(backbone_model, str) + or _IDENTIFIER_RE.fullmatch(backbone_model) is None + ): + raise RegistryError( + f"{context}.backbone_model must be a valid manifest model ID." + ) + state_transform = _require_str(value, "state_transform", context) + conversion_provenance = _require_str(value, "conversion_provenance", context) + required_sections = ("Input:", "Transformation:", "Output:", "Validation:", "Limitation:") + missing_sections = [ + section for section in required_sections if section not in conversion_provenance + ] + if missing_sections or state_transform not in conversion_provenance: + raise RegistryError( + f"{context}.conversion_provenance must identify {state_transform!r} and " + f"contain mechanism-first sections; missing {missing_sections}." + ) + result[family_id] = ModelFamily( + id=family_id, + architecture=_require_str(value, "architecture", context), + upstreams=source_ids, + tokenizer_mode=tokenizer_mode, + public_input=public_input, + extra=extra, + reference_container=reference_container, + reference_adapter=reference_adapter, + attention=attention, + dtypes=cast(tuple[DtypeName, ...], dtypes), + bf16_execution=bf16_execution, + precisions=precisions, + experimental_precisions=experimental_precisions, + vram_tier=vram_tier, + checkpoint_license=checkpoint_license, + hub_license=hub_license, + state_transform=state_transform, + representative=_require_str(value, "representative", context), + documentation=documentation, + test_tiers=test_tiers, + runtime_paths=runtime_paths, + auto_map_items=tuple(auto_map), + requires_complete_weight_publication=requires_complete_weight_publication, + weights_publication_allowed=weights_publication_allowed, + tokenizer_class=tokenizer_class, + hub_license_name=hub_license_name, + hub_license_link=hub_license_link, + conversion_provenance=conversion_provenance, + backbone_model=backbone_model, + ) + return result + + +def _parse_runtime_assets( + raw: object, + families: Mapping[str, ModelFamily], +) -> dict[str, RuntimeAsset]: + if not isinstance(raw, list) or not raw: + raise RegistryError("The manifest must contain at least one [[runtime_assets]] entry.") + result: dict[str, RuntimeAsset] = {} + identities: set[tuple[str, str, str]] = set() + for index, value in enumerate(raw): + context = f"runtime_assets[{index}]" + if not isinstance(value, dict): + raise RegistryError(f"{context} must be a table.") + _reject_unknown_fields(value, _RUNTIME_ASSET_FIELDS, context) + asset_id = _require_str(value, "id", context) + if _IDENTIFIER_RE.fullmatch(asset_id) is None: + raise RegistryError(f"Invalid runtime asset ID: {asset_id!r}") + if asset_id in result: + raise RegistryError(f"Duplicate runtime asset ID: {asset_id!r}") + repository = _require_str(value, "repository", context) + if _REPOSITORY_ID_RE.fullmatch(repository) is None: + raise RegistryError(f"{context}.repository must be a Hugging Face repository ID.") + revision = _require_str(value, "revision", context) + _validate_revision(revision, f"{context}.revision") + path = _require_str(value, "path", context) + try: + normalized_path = _portable_relative_path(path, "Runtime asset path") + except RegistryError as error: + raise RegistryError(f"Runtime asset path is not portable: {path!r}") from error + sha256 = _require_str(value, "sha256", context) + if len(sha256) != 64 or _HEX_RE.fullmatch(sha256) is None: + raise RegistryError(f"Invalid runtime asset SHA-256 for {path!r}.") + size = value.get("size") + if isinstance(size, bool) or not isinstance(size, int) or size <= 0: + raise RegistryError(f"{context}.size must be a positive byte count.") + consumer_family = _require_str(value, "consumer_family", context) + if consumer_family not in families: + raise RegistryError( + f"{context}.consumer_family references unknown family {consumer_family!r}." + ) + trust_kind = cast( + RuntimeAssetTrustKind, + _require_enum( + value, + "trust_kind", + context, + _ALLOWED_RUNTIME_ASSET_TRUST_KINDS, + ), + ) + license_expression = _require_str(value, "license", context) + offline_behavior = _require_str(value, "offline_behavior", context) + if offline_behavior not in _ALLOWED_RUNTIME_ASSET_OFFLINE_BEHAVIORS: + raise RegistryError( + f"{context}.offline_behavior is unsupported: {offline_behavior!r}." + ) + if trust_kind == "hash_pinned_pickle" and normalized_path.suffix != ".pkl": + raise RegistryError( + f"{context}.path must end in '.pkl' for trust_kind='hash_pinned_pickle'." + ) + identity = (repository, revision, path) + if identity in identities: + raise RegistryError(f"Duplicate runtime asset identity: {identity!r}") + identities.add(identity) + result[asset_id] = RuntimeAsset( + id=asset_id, + repository=repository, + revision=revision, + path=path, + sha256=sha256, + size=size, + consumer_family=consumer_family, + trust_kind=trust_kind, + license_expression=license_expression, + offline_behavior=offline_behavior, + ) + return result + + +def _parse_models( + raw: object, + families: Mapping[str, ModelFamily], +) -> dict[str, ModelSpec]: + if not isinstance(raw, list) or not raw: + raise RegistryError("The manifest must contain at least one [[models]] entry.") + result: dict[str, ModelSpec] = {} + fast_repositories: set[str] = set() + for index, value in enumerate(raw): + context = f"models[{index}]" + if not isinstance(value, dict): + raise RegistryError(f"{context} must be a table.") + _reject_unknown_fields(value, _MODEL_FIELDS, context) + model_id = _require_str(value, "id", context) + if _IDENTIFIER_RE.fullmatch(model_id) is None: + raise RegistryError(f"Invalid model ID: {model_id!r}") + if model_id in result: + raise RegistryError(f"Duplicate model ID: {model_id!r}") + family_id = _require_str(value, "family", context) + if family_id not in families: + raise RegistryError(f"{context} references unknown family {family_id!r}.") + fast = _parse_checkpoint(value, "fast", context) + official = _parse_checkpoint(value, "official", context) + if fast.repo_id in fast_repositories: + raise RegistryError(f"Duplicate FastPLMs repository ID: {fast.repo_id!r}") + fast_repositories.add(fast.repo_id) + family = families[family_id] + oracle_assets = _parse_oracle_assets(value, context) + official_golden = _parse_official_golden(value, model_id, context) + size_category = _require_str(value, "size_category", context) + if size_category not in _ALLOWED_SIZE_CATEGORIES: + raise RegistryError(f"Unsupported size category in {context}: {size_category!r}") + generation_contract = cast( + GenerationContract, + _require_enum( + value, + "generation_contract", + context, + _ALLOWED_GENERATION_CONTRACTS, + ), + ) + if family.tokenizer_mode == "structure" and size_category != "structure": + raise RegistryError( + f"Structure checkpoint {model_id!r} must use size_category='structure'." + ) + artifact_source = value.get("artifact_source", "fast") + if artifact_source not in {"fast", "official"}: + raise RegistryError(f"{context}.artifact_source must be 'fast' or 'official'.") + canonical_state_sha256 = value.get("canonical_state_sha256") + if artifact_source == "official": + if ( + not isinstance(canonical_state_sha256, str) + or len(canonical_state_sha256) != 64 + or _HEX_RE.fullmatch(canonical_state_sha256) is None + ): + raise RegistryError( + f"{context}.canonical_state_sha256 must be a SHA-256 commitment " + "for an official-source artifact." + ) + elif canonical_state_sha256 is not None: + raise RegistryError( + f"{context}.canonical_state_sha256 is restricted to official-source artifacts." + ) + if family.tokenizer_mode == "tokenizer" and not any( + "tokenizer" in item.path or "vocab" in item.path for item in fast.files + ): + raise RegistryError(f"{context} does not pin a tokenizer asset.") + tokenizer_source_id = value.get("tokenizer_source") + if tokenizer_source_id is not None and ( + family.tokenizer_mode != "tokenizer" + or not isinstance(tokenizer_source_id, str) + or _IDENTIFIER_RE.fullmatch(tokenizer_source_id) is None + ): + raise RegistryError(f"{context}.tokenizer_source is invalid.") + notes = value.get("notes", "") + if not isinstance(notes, str): + raise RegistryError(f"{context}.notes must be a string.") + msa_conditioning = value.get("msa_conditioning") + if family_id == "esmfold2": + if not isinstance(msa_conditioning, bool): + raise RegistryError( + f"{context}.msa_conditioning must be an explicit boolean for " + "ESMFold2 checkpoints." + ) + elif "msa_conditioning" in value: + raise RegistryError( + f"{context}.msa_conditioning is only valid for ESMFold2 checkpoints." + ) + raw_auto_map = value.get("auto_map") + auto_map: list[tuple[str, str]] = [] + if raw_auto_map is not None: + if not isinstance(raw_auto_map, dict) or not raw_auto_map: + raise RegistryError(f"{context}.auto_map must be a non-empty table.") + for auto_class, class_path in raw_auto_map.items(): + if auto_class not in _ALLOWED_AUTO_CLASSES or not isinstance(class_path, str): + raise RegistryError(f"Invalid AutoClass mapping in {context}: {auto_class!r}") + if not class_path.startswith("fastplms.") or class_path.count(".") < 2: + raise RegistryError(f"Invalid Python class path in {context}: {class_path!r}") + auto_map.append((auto_class, class_path)) + result[model_id] = ModelSpec( + id=model_id, + family=family, + fast=fast, + official=official, + size_category=size_category, + generation_contract=generation_contract, + oracle_assets=oracle_assets, + official_golden=official_golden, + artifact_source=artifact_source, + canonical_state_sha256=canonical_state_sha256, + tokenizer_source_id=tokenizer_source_id, + auto_map_items=tuple(auto_map), + notes=notes, + msa_conditioning=msa_conditioning, + ) + return result + + +def _validate_registry( + upstreams: Mapping[str, UpstreamSource], + attention_kernels: Mapping[str, AttentionKernelSpec], + families: Mapping[str, ModelFamily], + models: Mapping[str, ModelSpec], +) -> None: + for spec in models.values(): + if spec.tokenizer_source_id is None: + continue + source = models.get(spec.tokenizer_source_id) + if source is None: + raise RegistryError( + f"Model {spec.id!r} references unknown tokenizer source " + f"{spec.tokenizer_source_id!r}." + ) + if not any( + PurePosixPath(item.path).name + in { + "added_tokens.json", + "merges.txt", + "sentencepiece.bpe.model", + "special_tokens_map.json", + "spiece.model", + "tokenizer.json", + "tokenizer_config.json", + "vocab.json", + "vocab.txt", + } + for item in source.official.files + ): + raise RegistryError( + f"Tokenizer source {source.id!r} has no official tokenizer assets." + ) + expected_esmfold2 = { + "esmfold2": ("Synthyra/ESMFold2", "biohub/ESMFold2"), + "esmfold2_fast": ("Synthyra/ESMFold2-Fast", "biohub/ESMFold2-Fast"), + "esmfold2_experimental_cutoff2025": ( + "Synthyra/ESMFold2-Experimental-Cutoff2025", + "biohub/ESMFold2-Experimental-Cutoff2025", + ), + "esmfold2_experimental_fast_cutoff2025": ( + "Synthyra/ESMFold2-Experimental-Fast-Cutoff2025", + "biohub/ESMFold2-Experimental-Fast-Cutoff2025", + ), + } + actual_esmfold2 = { + model.id: (model.fast.repo_id, model.official.repo_id) + for model in models.values() + if model.family.id == "esmfold2" + } + if actual_esmfold2 != expected_esmfold2: + raise RegistryError( + "ESMFold2 support must contain exactly the four approved model IDs and " + "official/Synthyra repositories." + ) + + golden_paths: list[str] = [] + for model in models.values(): + if model.official_golden is not None: + golden_paths.extend( + ( + model.official_golden.metadata.path, + model.official_golden.tensors.path, + ) + ) + if len(golden_paths) != len(set(golden_paths)): + raise RegistryError("Official golden paths must be unique across model declarations.") + unused_upstreams = sorted( + set(upstreams).difference( + source for family in families.values() for source in family.upstreams + ) + ) + if unused_upstreams: + raise RegistryError( + f"Upstream sources are not connected to a model family: {unused_upstreams}" + ) + advertised_flash = { + implementation + for family in families.values() + for implementation in family.attention + if implementation.startswith("flash_attention_") + } + missing_kernels = sorted(advertised_flash.difference(attention_kernels)) + if missing_kernels: + raise RegistryError( + f"Advertised FlashAttention backends lack kernel specs: {missing_kernels}." + ) + for family in families.values(): + for implementation in family.attention: + kernel = attention_kernels.get(implementation) + if kernel is not None and not set(family.dtypes).intersection(kernel.dtypes): + raise RegistryError( + f"Family {family.id!r} and attention kernel {implementation!r} " + "have no supported dtype in common." + ) + family_models = [model for model in models.values() if model.family.id == family.id] + if not family_models: + raise RegistryError(f"Family {family.id!r} has no checkpoints.") + representative = models.get(family.representative) + if representative is None or representative.family.id != family.id: + raise RegistryError( + f"Family {family.id!r} has invalid representative {family.representative!r}." + ) + if family.backbone_model is not None and family.backbone_model not in models: + raise RegistryError( + f"Family {family.id!r} references unknown backbone model " + f"{family.backbone_model!r}." + ) + + +def _load_manifest_bytes(raw_bytes: bytes) -> ModelRegistry: + try: + data = tomllib.loads(raw_bytes.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as error: + raise RegistryError(f"Unable to parse model manifest: {error}") from error + _reject_unknown_fields(data, _ROOT_FIELDS, "manifest") + if data.get("schema_version") != 1: + raise RegistryError("Unsupported model manifest schema_version; expected 1.") + legal_files = _require_digest_list(data, "legal_files", "manifest") + required_legal_paths = {"LICENSE", "THIRD_PARTY_NOTICES.md"} + if {item.path for item in legal_files} != required_legal_paths: + raise RegistryError("manifest.legal_files must contain LICENSE and THIRD_PARTY_NOTICES.md.") + attention_kernels = _parse_attention_kernels(data.get("attention_kernels")) + upstreams = _parse_upstreams(data.get("upstreams")) + families = _parse_families(data.get("families"), upstreams) + runtime_assets = _parse_runtime_assets(data.get("runtime_assets"), families) + models = _parse_models(data.get("models"), families) + _validate_registry(upstreams, attention_kernels, families, models) + return ModelRegistry( + schema_version=1, + upstreams=upstreams, + attention_kernels=attention_kernels, + families=families, + models=models, + runtime_assets=runtime_assets, + legal_files=legal_files, + ) + + +def load_model_registry(path: str | Path | None = None) -> ModelRegistry: + """Load and validate a model manifest without importing model code.""" + + if path is None: + manifest = resources.files("fastplms").joinpath("models.toml") + return _load_manifest_bytes(manifest.read_bytes()) + return _load_manifest_bytes(Path(path).read_bytes()) + + +@lru_cache(maxsize=1) +def get_model_registry() -> ModelRegistry: + """Return the validated package registry, cached after its first read.""" + + return load_model_registry() + + +def get_model_spec(model_id: str) -> ModelSpec: + """Return one model specification by its stable manifest ID.""" + + try: + return get_model_registry()[model_id] + except KeyError as error: + supported = ", ".join(get_model_registry()) + raise KeyError( + f"Unknown FastPLMs model ID {model_id!r}. Supported IDs: {supported}" + ) from error + + +__all__ = [ + "HUB_LICENSE_IDENTIFIERS", + "CheckpointSource", + "FileDigest", + "GenerationContract", + "ModelFamily", + "ModelRegistry", + "ModelSpec", + "OracleAsset", + "RegistryError", + "RuntimeAsset", + "RuntimeAssetTrustKind", + "RuntimeExtra", + "TestTier", + "UpstreamSource", + "VramTier", + "get_model_registry", + "get_model_spec", + "load_model_registry", +] diff --git a/fastplms/runtime.py b/fastplms/runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..d377c6357cbf6c4bb3fb346b6dbcae24d87f3fd8 --- /dev/null +++ b/fastplms/runtime.py @@ -0,0 +1,68 @@ +"""Explicit, reversible Torch runtime configuration. + +Importing FastPLMs does not change global Torch settings. Callers that want a +runtime profile opt in with :func:`runtime_profile` and receive their previous +settings back when the context exits. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from collections.abc import Iterator + + +MatmulPrecision = Literal["highest", "high", "medium"] + + +@dataclass(frozen=True, slots=True) +class RuntimeProfile: + """Requested Torch settings for a bounded inference or training block.""" + + float32_matmul_precision: MatmulPrecision = "highest" + allow_tf32: bool | None = None + + +@contextmanager +def runtime_profile(profile: RuntimeProfile | None = None) -> Iterator[None]: + """Apply a Torch runtime profile and restore the previous global settings. + + The default profile requests the highest float32 matrix-multiplication + precision and leaves TF32 policy unchanged. Torch is imported only when the + context is entered. + """ + + import torch + + selected = profile or RuntimeProfile() + previous_matmul_precision = torch.get_float32_matmul_precision() + matmul_backend = getattr(getattr(torch.backends, "cuda", None), "matmul", None) + cudnn_backend = getattr(torch.backends, "cudnn", None) + previous_matmul_tf32 = ( + getattr(matmul_backend, "allow_tf32", None) if matmul_backend is not None else None + ) + previous_cudnn_tf32 = ( + getattr(cudnn_backend, "allow_tf32", None) if cudnn_backend is not None else None + ) + + torch.set_float32_matmul_precision(selected.float32_matmul_precision) + if selected.allow_tf32 is not None: + if matmul_backend is not None and hasattr(matmul_backend, "allow_tf32"): + matmul_backend.allow_tf32 = selected.allow_tf32 + if cudnn_backend is not None and hasattr(cudnn_backend, "allow_tf32"): + cudnn_backend.allow_tf32 = selected.allow_tf32 + try: + yield + finally: + torch.set_float32_matmul_precision(previous_matmul_precision) + if selected.allow_tf32 is not None: + if matmul_backend is not None and previous_matmul_tf32 is not None: + matmul_backend.allow_tf32 = previous_matmul_tf32 + if cudnn_backend is not None and previous_cudnn_tf32 is not None: + cudnn_backend.allow_tf32 = previous_cudnn_tf32 + + +__all__ = ["MatmulPrecision", "RuntimeProfile", "runtime_profile"] diff --git a/fastplms_bundle.py b/fastplms_bundle.py new file mode 100644 index 0000000000000000000000000000000000000000..77a1dbcfe6e430adc8b7227be831b9fa4e5e1ef8 --- /dev/null +++ b/fastplms_bundle.py @@ -0,0 +1,3431 @@ +"""Generated deterministic archive of unchanged FastPLMs runtime sources.""" + +RUNTIME_HASH = "278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232" +RUNTIME_DATA = ( + 'P)h>@6aWAK2mk;8AplDmgd>{*002M-000yK003rTb98WQZF4VQUukY>bYEXCaCwcDT~FIE6o&8fE6#kCOkKrpD{WG>fiw*S0w!&%' + 'ka3ff)~XXnw!{T2SB<8Gb_BVutut-z!08p%#2b354F#%3QUNP8H7Yw!7-LCN8eIYB' + 'W$RjlocEQ0s41s#lFWLh3n)2{NcX|JwmQbG8{b0@OzU-$aDGPxkPmr(0rq)(G(MuV{B-*F4?q5WaejGyMS;fz=wjbCom}bG7wZ(jm4Hx3!ZMUKm}{^mf!;EQp6#RR3zo5k+=ihpuc(cH9}g1X*3L2TC)D%;gN?7oPRJ+uoQmvKPQJrAEOA%Ane;<' + '9h~okYJFXOkLh4Jr(3;byUk=lzfgKElufg)7jg=YA!d;N8|wZ+9A+W?AjA9=9qWvSc{AC@E#=1|rS~`8m}ld*rc+A$v&*vNKTt~p' + '1QY-O00;m803iUsS%EJ40RRBY1^@sa0001HVRLkFY;AKdVRUq5ZggpHZZBV7X>MtBUtcb8d5u%cZrd;ryz46nom#-iAM_yS0R)Mi' + '0CIaN3Sup;V!{+@khE*`>$|cNPnOOGp7;*bvqRDA7vcTYE${QIx*F$Vy' + 'mgPlTHHKzwqSP%|vlGK7K' + '<+f9aS!WevAl{-giZr*>efLdg;qp@HCX}jpjznZe=kjA-3%!A!Dnv!f^D6pY@1xWnZ4Mc_{!%_A>F)h?lXmv<' + '>F4&oJk*Oh!Pq3}OF+SSLS3`I8+n!Wa2HTgluzj`Xh-4-w5~I4J5xDl#5qmPIs3tW-_qly+2YkK=Qdry=S&yyq3uGvF<#SXQfNSz' + 'jM;D|tvN>9#c!}ld(NPJgEpj&MMG@Tqyb#8X+s$rH30pr5FML!fDBhWz(d3K<9*8pY{|5NGrEvjXMRd(tQ(li<_)Z`J0xU$VtAtc' + 'iT(pnO9KQH000080000X0Q^Z>5V#xw0P}GG02=@R0A^uxbZ~5Kb1z|ZbY*UIX>V>XUt@1_WiD`e?LBRC+_ur*?^mFg58-tv)|NY+' + 'j`CcmvMsfq*ok60X=gkh9>v4ySnu~s%!lRy&1G5S;xfyxC3J%xZIk7#D2p4ZL{W*8RXsnMEi%=v%auwj^jRXlUE>c~IkFQqQZ!Q4<&B(+x+=FK' + 'TePwfnaH!UgdgPlHOyAD(9+0v8>w0)+NE5@Le-*Jt?Q;Os;kjC%y#kog%TC4s1ft}wkqpvu0-7chO2c^0&aOdm&s^69*;(gre29O' + 'U2NJ-Bhyqvf^}DQn<0*BG}7O0v!;S66pzVkK#Rzs%%E?ZxoqV8X_2>sK1p_&n|@DVMcHL3xs$9mE7NiYKl9gi*~n}T)0w81&+Gi!' + ';?mYlzSQH3s$N?hU{up&)s+TL{gSFO&5KQyGv!65#JOI7lJYjBCCp*EBH8GJoa^S%sBN|&TT%9(if-yOwXU6{WE=X`sD9t>Df20Up_l~1?>-y|2#@Hm&{Wi' + '=83z#Z`#eel(DEU{{{Q|7KS>F(PYG9J5*J0v_)+ffIQ2Wk~d|_3;<1|(OfP>+Q^k$UCJh+P9FRi#CLll^ei`7xsem5A+Y?jN=agH' + 'QM3{muWD-$vdel?%|Y@?3G#E)$Q%}3ZjUw!zR?5;N(*fR{`LkYK%@zF_c>YBH!^MO6!bv^T_)6Yy99CqfPN;1Sog0xn8cBV%Tm^ZyGWBRFRB98t-dvA&Gw5iv^sEQ-d5x7jf@8TSQkk&yNWfe#}Ns3T`@rFXl>LK=G-PE~M>gcMhFF`VDT{rwC)JrWh>V=~624o=?Ns_z`_C%utBujyjuiB+LXb`S3RdSWVQgRpqCKTGo>uipyeH@QExqQhy' + 'o$cU5+)or*V+P>|nQxdYimNI^^QBqyiNi5-xdvGT>sPr0aM7`*WXzO`J5z7*WhS_g&5?YcA$tgoxX?7vR(p+XUA4@Vz-MAMv#M=2' + 'gHBhQ6|@sHnq{L}mK0Lyu;xnMcI&=svh|YIiza*v4o0ixpjHL@ABH*c-(u0%{ves1v^)C3+JrB}ODhzpD{t#&P7~rJK)B1IEZQw|' + 'J?PoGl}G3@z)IR;0WOYWou*-VS^%t9xgi?Ovo{)s7o*SP!bF_9GuM2;8>HRz>bwG^-%3S>J^i8TZ&u)(uCh`3UV?ThL7FZ8l>D8oATjo<62cSR?GFGV1oVtW@1_J4tV<' + 'iXTDEqOPQmz~s$V^SJXFa7oFi51T@2{~krZQw2K>ctWxM_!~>TH{LqLp})QDcymYyXe39(s>Y$}*Irl*S_HJrk)|p;L)spAs@3`;' + 'va{c25U?m5$0`Mu9^68}hm_8;2Gv`Gg24~jOMwqS`r|=4g%H^8KIYw$h@VGEE3t0gezT5z3tR$T@B2RwC' + 'U;1+MB^&{jIep?bU2a+<>Y>>-Rd$mV#9GPNLS|_*+cfnA2pGGC&5kBXTWf7O)k-tL^L25xsW(bHR9}d1L0NM1QClC;-kLqf`4Q6s' + 'H{bdWcswU=5(OG1Fmj3bUKvBiR>{l)t6LzTIe^IV*bQ6V!kH9zdIgJZX#3F8q0YY8!roQ58yXn#&bF1t*#&&Szg>cDMfa2}C74E~' + 'T~%;Q0TA&T;A^a)_CRo9X-6Xc;c' + '>Be}V5?yNukSyQyAbKnR3>LFz0nl&`TzRyZmPVf4?2itb&*2mUq4LGkRpRlW#UbgS?cwl~NA*E#e;0;5JYn^lcUt&91>=sz;yyX{' + '+WEm&xCXwrC9XHu4FPNah4}mC>I&ugJOleM(~-Fu3TmxDepFdpEm0B7iv^t@TXA~vOp*AW!x4RXYCZ5{fQFv_qeMIfqXb$9bqhw6' + 'K)P=-P#u*vL#ozZz7F$ka3<`mvK1(|LalKh6ZS=l3g*?pCy+*iw{yn6vh-zsWq6_CatM' + 'MJq8XqJ0iDtfh?)gYD*6Xd+H~ppK5sECh=~w+mnhWQB%Zg1SHEQH6z}p-KJ5C-PgpJ#<*D8#ERU)TC=*&&C8)Sp(uGb8i?n48TU+=(q>$4btUs2cr=#F?0' + 'CxF=~7O+W2>!QLCl*V-28+q4Z`Qfj3Av8ab&badl_q*nU>m~6H6569aag5NT8QP~4+%c0bSP%g8JAaqZH_3tJP5Z|X?vUlPN}Y?l' + '5HdiBuLeXl7>T=mc=l*$mW;6$kfz89ukwi}>cRva9&re_dJ^lLZjK0X%!qTXg7-0YSWSu(Tc+84>%kGGdb4X#+mt`mug@@6nCYG4perlGhf^l@wX3#_3_%lgE~Q3f7o$j' + 'eW_$~Ls1)K;^bd-QAN-lq$=usb>e>Q%~w8}j))w<*#n(tPH9Sf7mKes4EqDBUA4sMDxMo?p05Pn$`pv{oxyo1>QQ1T_-&w^Mc7X3dc&HKyOsln4z+aUOv6I_5GjGxRH&AwZJv' + '3>pE0*-6WAVIL^!L2)nf`269YgyA(l%UnDxv9o^STywr|=X3hgv' + 'nR7ps(8yOq`rgUa;kjz0x_wxd2lbGGJ' + '_=xoNwLTJR;Ced!MU%(ZFggX`KcQkU*q@ar6qpA*>1Ipco7anWmAy|DoH^u)AY&T3Di7=)O0sp&wJm!z_EZ<+U{x1|2#&g&U`>Ho' + ')H>e`>`8qChsuI<7R8wuKSB8EV8%a|vJx{7a}%?i#Waf0GJb8gUY7-?ytNek$TlU%F=h|NAB1|>w9!(+{?CimWR{4FOsO95>BHo~' + '4Ec*9@EizR!l6AYF$s(#1?xs0QRlh%PDDCXf1}q)c{-O{arWxPaV(x*Jb%IehhNSf#`yWXF6R>tk(_5mNvp37Eu^@`6@SM;dOMF<' + '7mb2ZV' + 'T>7MRDl!1-}TXS!WR|%?IS4r=(k4M&quy})beBTeL{zKV6?-s' + 'V%V*7?1S-E6Fxr)2ai6CCt+h?f*b!qo3Kfhd(r5PrUCdL^Zph3))I%Vb6EV{G$;={`(Fq9U;o$k{Ffbevcvyh' + 'gZYJU)wJWlHk(Svq3{=(m7wIWVQUi1x&myLvRunXQJUzDO^^k6Iy}NTQG^HOg`-0Z2jkYX)lG3#;ByQdiRzZpttiovU%;SdPeyr(' + 'r-o&$0A?Jc3IDLkYQoWPL#LrV=$GpEk!k_P)OK>(SEn@6P4XaG&Lz+=n@Pw&f9eanLxuz' + '{Es;9Z8h!R4=5~wKmu8o0#}$WaEWOKYh-wAvX+1V>rx|Mg;kOwT*wzlc$!PZKxnFyvrfAFsv_HhubX2&A#W' + '7$-WitH{caTLxoy-$HQjIt=g>3O)z0UXwDosc;Aa0vLP0KRoFqQb{;+(m56eIUq1`N|w<;*hNi!V0ipuV&TbR2nqu}eFisH^RSAl_SI-f7(Eu7EWC3F~a+Sr$=ogC$JkGq&5&p55nx7&B;dvN!C0IUVf?a+_1sg+NUHz6($VD|QH' + '&jI4JEw^VVg^S|(elNPy30S@k-vkUNX5ppL0gN2Nb>g#OU%Z@|48w=0;0Zu%GKL(3ar_ZnvW#' + 'XH17eue)>DXl;}V@7b~dx3mgJd5pmHKa&s3ff&Ij4*F$RjKnfb2;aysgDF0qh;NQYpD`O2zbSc2UwMItak7EEbB``K*wyAo{' + '*J;7oRyMl=WIG#m!tk!M;U^`Z_$9!&fmbZf^G5g|eAF!M=BfknF(Wz_-~pz1$pITmo3$4|JAAcl;$zYb^+9h+sB+XRNy#I}kz;&WHF7MpSi$x-H67' + 'N~F@yxw;;G2C^LPaQCQ}e@>(_*1V%M^Z8CvPpn)x8U2F(s;PuID|^~(dd~@i+5ZYI~?i0;^}zMK{y(rBL>d-+f1uh4N3t1n4o=H*;e@lz%V2uCVXh;w9|zk^L6I&y1U#RibQy>4xSxOEctGP`l*gUf^?2BKVWx`v' + '4{ImM*yKv<3|e!X@lsXTBRM&?$r$Vj1@sm@EXPkT+OsyjAX?yxj31Jh(ACBeV@v%sD6?+Q=j)P*gV=tEyOZP~xqCka' + 'g6YRjqK_pglB!30%;tCVfA;h}n4b7fau^ugpK{p|GeEr1`i8{Nxh=ozCtx~>Rv*pVgprGIcT8~}%y6>M*i(r^{P5|VV=wWB%;+co' + 'YRM_XV58-~s(uPx)BQ#+`YyO0Qvae4{s9SH*M*6}mJ`{Ma*mP5^yO2n$kUJBEH4I' + '$Zeq?58Rj1!{lKey~+x(@!LX{ocjp7yjVysyaEd{Ynhp}nI({Y-V_UfmgR6BR)X9O<=>|sk}293K;N_Je+yqs`P}Zl0_|sGAMh68' + 'mqiHpXPCl{dey`9B#24hm|LaiCt(Nhr8ip#k8{^#q78?P4OxzGYT>Zk^Z-9{|L*MCq20R_#H1er{gmte1F7IWl1}*`x5rx}m;4ta' + 'ggzhiZ(Jkqj@P@_o%DLTm$|WbRqOP`kdYUaI4p}d9@2E;a#N)vObi<5xIbAaM8)YLB%K~Y-CyGly}LG_dYf@?fA2E=`k<@vkGfv>' + 'x-UWb*W<7MsxY2UQ;QLx>;d}$#^i9UPsn@b;xAqpJFfR@;Vne4FJSPoAlS}JV&TBdcCi?NuluGS%m!==O<3&+H8p0tafw5@!rBjT!2YVgkq$s3+oq5foNoynS8P(7' + 'OAbJBNl}$JZ&Y<=$Um5#ZcQKnNyVI}kNmjv&i$q9_S`-s`!o^Jj)n!UI2xn*zdzJ@YDh>Ci8^L9WI-aJj5vvN^duGu(-!T9i!Wmt' + 'nV&BeZmER9VlXu6arr>ak?|VnR6E8p|GgT@TSK?60~wr#QHUKNC{WX0Jan+M>FZ`AyMQPKLN;aTRQSSlqAnoW)R&nqkJ*12n}LOT' + 'Z_0h4f3Tv#7h5i5+X11fLGzku&6IMZs`2nnC%o4C82cmn*;WnQ&$9LsB(r>qUK9{ksG_8@$qs{3aEKCcdO9m{O4SWe3{^1gcL^{d' + 'J+$&vvy+26fDjK&sXL!8#XcQQ9dPfxGB&;O13z$w@Hv%hx`=q7bj@LDF@9q3PRnsOKqgRqrB{IkRZ_hWce{L-L269Dlufa4=raxZ' + 'OrDR+RZ8xuaQj(G8KO}$;(EC$D2l49tbpL(;6uTp-TC%iFG-6fm2ki_s88{TEupE?!jjG{Uas^a6ao' + 'GBV}Q_HIdP@Nlc3@rYz^{|>K^g4zS2Kky52rVqs6+{yf!Z*CX>o#b@o=t5I4p+h*;K5Lx{sumhm$8K)exE75r;;sPpD$}*`+wQMv' + 'fNkV!nEqd9J^Np+x?*SBM@qnf?NWBdJbgC)#!F3HyM-62Cfszp' + '>8B%5jYBHG3O<#ru!?wN?|x_?7yngac%?oF;{I_yEDJfPd+U$cTMOM!D-4c5C?lg+b<0QK(1eUV6i=67)B``(mS6Q=a7XT0%;^!wKWNVcqE1KS^p5|zzQKQcF9|Wp' + 'px4uD|3bW=z1KA=bY+T@p2o&Q1MI%C4`_gK_yu6ngZ4~!z&i@e6#$>1)PF;ZV!`xUkVb1CtKpmmj`s9`EsCmW*{!`iHF=+-ef5{az4p{#W>wUCv@>)PrlA7a{aVx0' + 'VbSTy!SX$*yUg2~ENC4_YBnQqs>`!tzM' + 'GrIuqZngVb)>j2|PQge6Ikg^;nHTfSe+mC8grCv1PcoTC|&dya1y8GEnXeM' + '8tR;bUrZD|0cChoWu!{raxhHL{h2I&BTavig7OqOiwnz609OJU9BRlazV7qsOraCK8X_=89to$u@6aWAK2mk;8ApmxOTCPwD008zK001HY' + '003rTb98WQZF4VSbaZ8IbZKvHFJEhAa&Bd8UufGfM9bkE9C95+l&{F+i|yH' + '5t{$~s;Ymu?Ew}pI_bj0?u^~8uKN0`uc|yz6kP}<>s!HiQ?OfJR|VICo%Gk&RddajJQwUnbd9JLTZvM3f>oPVb{g;MwWvC_sv6!^' + 'LZwj@&1PjMH!RD_UiY2IGP@eAH&Sz5$wtj)_L}*xu2$(rXb$^wcju#$jr%N>vDtFHhPBS%IeaxY^nHuY*y~5l{cJWXM9DhwXJ3I2' + '*8QyI`3=7oal-b0WT&zbhcgC$;PsMMH50t6@7dj2G#D3aJCVyxTh*f2Hx^jeH@eyY0E$5{tZJ0zHQcAb(Uk7)jZOTs{y6a1m+;V^' + '{E#jG@$~^M5}$I>YW9ZK9e17V7*|Z-hrvq6D<#;4*_jr_{vLUMKKRL&HCO8+twn=CI{*~Lv*b#jzx`dYy%JaL!S=f2Y2>XXjL@Y0' + 'ErJ%htd{vrhWH!o5PFZ@g@A)Lkl`|8gg1->MM|FQIm_1~zmYwBf(Simtf-Xks#TA!dIXj1yIjy-Y(X2vz$FMS%aXJ+OpOy-ngd~p' + 'tO{1X_hAGU(w27sg?fK*#lB*ZJ&6?EjONci@auOCp+dDq!z6=;a;4}CTK2x6`N6RuigLJ|R*Eo>18c-ZbJTv_Y!{61P~lj1GdvAf#F2|M8b$cXW9KG$Po-YUzA0XjE|F*c9jqVtcUS2(' + 'u#$zxkVc~{aOicS2I;yb@J6&nloZlBgsLk218KuSFTPkz4tor1F2UQT7rVr9DKOd-EWj;uKyIHT`D+LH6m;*_B0$UoIXdM+3ZsV@' + '2M2^EfI2O~ucF8xzqZ&JzBWwd^07h6XU^iZMvIp9GkwL%L6Z#L)K{RKPZ17**^#cFD7b@C@J|ky*G!n{Mb6a(gGzT)usCdW;UdyEr<%T%KM0etdE1uNxGTDQc?Wty)WM`jm;BpuXG!(`7TL%czTaOQLFy_Y-w3@Vu&4' + '0H=bcKtX6Wi`_oHe)I~vPdtvJ^EUy;Bc-A|Uz?^Hx+0OSd*8_d$1nl4dUiF{nyip_4Jw^`meQe=bs@S8k^D=IWprk+90ZG_ZVr89' + ';L<+2G!`)1l>w_WYGiEjn(!q0?fByK`0Zu(%h4|>Jp!ra0NtB5e8ZVK`*Bb*{kC82fD&8LSPT|S!O38B5DT03j#!gj{dq5T' + '9Dp9q-Bl6oYd984xm$pxxTv}~d5s=n`6W6sIX)ThRG>iHpB|h8TQ{N*ECmBq0>y|X)*lAYwki6-KDc4U(d>hp`6z*r3(gS~sU2yf' + '&S#8~8I%SXJh-mc{!CrH5T!}5Y2=W4swzM_v@&6iT8Ia?ZX1nM*Igc(66g|~gxC(n>=aKpXLQ6~479{tx`hcC2uvo_VbHmla(6YK' + 'k>;3hUUmNM#%-LvZAN3~fHH(b9rSkCR9NG7w6cOkisNDR^V7iA&nn{}iUy)pU)6<6EpG@&PB)^E)x*-4@!C+?vT=HnYmQ_!HUT-9' + 'XCFDkL0k&TX)Ia_@Dhb-TTG`t>K;NTwdlypp__1io7npX3Mgz0?s>Zk)GFLDf!U=j1+W%+rO|;Wp8kWK*XFqe=c$DDXxWZL!B&Q^' + 'jvhd}oebZbv%U1AteSXmEZK%>`$Va7LSRV=!i|yg+9t=Ni&K~fhDY41q-URSu7~@$Yi2_vDJh1({{R2(#7y6iqe!ORkm7-uNwC(_' + '`oS)D=wu)y#B8Snz-{x0Y0j#Kuzn$N!_5ZXS{wg!(mG#X#96!e5yB4QIAjr7ZGz)X>=@A1Sx#p0RgWI8$@SgGGqf' + '9PwH&Mkh0BzNS?q5JG4(#zZpx8MSBQw(JFy-K5L<)naA*+jrs35%b1X9?ZUKv*|Ux#tB~jTaNe+>&agg{<}&#ckkDpt&<1=&VSCz' + 'Z*(1o)|G$4_;=Jc@^hgc?`b*R(kZip$c_O)TY3h#-3ke17PMGpi9`fQeLL)tO30=IcbPSmC;&r' + 'I~4Rgp!Gnwd7-|C#{(f_y|QFAYPwI=)S9x*0_4a_-U?9WocDH~LiL3EC?5)YEM4(oP*IG2KX5WS7R$oXdkkS5oR~k3;F>v>#e@vQ' + 'ic!5?Ja%~V+0LuQ9i1IJnautJP)h>@6aWAK2mk;8ApmHGuBJ~5007M;001BW003rTb98WQZF4VSbaZ8IbZKvHFKKRcWpZX=V`XzL' + 'aCyyI-E-SE5`WiUfny#bWn@kpPhZ^8&G_2-F4HE>)lObK9tt8s2{lD902xQg`G3D%d=Vfi+sX81E)TH;EEbFX`0Zk$BuTC$tyL-H' + 'o=J6*i+w{|zNr|YZOiJGi<&4_v0Q%wGIBvx`}VijitI(fDwQTlGMklB>`9iDUE4{PWrXh=AzMQ0TC^0GDoc}#$}&?a-QoWy@)0X4?Z&x~cQ}Fq_%Wt&sW75(S6yr0zhk>Xylp=4{!Iy{78ck6T&OEEkfwBtj$1Q' + 'bq2H*+XIC@w3)nRvIa$EWkuC4^J>lTn1(_!C901snA?Qh6>!f*UGnYpCJQI7*(s|;e(R)`>_f-FBu2wj8rJ1gg*!1OgQ>~O{?mVI^CF46;d=v' + 'up(t8XgfDrPQmNviJeQ9=aQ`q%DHxncy&(DFU|MM@#(eoo6VrSPGVIK+8fGRf+kortkL' + '0~@$QBr$JT2WJ1wq)5p-5aaUax91w?+8s=J!A<;iQoCH*^DrmPX{W$%R%8RP6{&}-%uIrUU;xCCbEA|ci6$>-rPyL&_$g7gd-^F;' + ';EfE}h8%GWBuWgW^@!DjDATJ`$84rR4yIQ6ORAx=5sJ;fUy_%L6i(X0NkymTi=|%(G3}PY$0sh*5$~4iFHc+;BSQAFSj8hfrP1HY' + '{ON}y1+JoRJyDD!t10YWSbWw2XmoAh0|4|H`IWK82ibFH0^@x)))WCW2G?kXaVW{Q9PsJ@&I)<~Urm9}di_sUGf69-fl@=qB3GP9zUeRkA+OqCEKG7)uhn$n2T=>L4l!1-VvtKS)iqd|sZ13O1r=V&' + 'jtyqYiha^-!|7+SPJT$gKkXAa#(Y;%2??5RNzq9ko0z!(a5}BEl$={$S~CYCnilgW=@TZ0fjzBx$y7_=JQAS{z@X*07i~$!z>2zH' + '5~XE^60~<>SZ`P)946{WQz{*u-MSZ%n}OkW^DAr%us$%5q?hC`V?;ZSE$&134V_n40-sxACiCSTy1WrSR$nU*-q?nxn-(tBG~lP~c`f0Oq=685Qq7=;)y' + 'J~Ak{Ec>TQexJt6L)Gdy&LC@LwD=Q{U@8*(vuTBg6~>TMYd^YgXx*y}D!|rkcBTtF=TZT;suFh@{OD0q8Wse845Fz{fMEFryI=L9Q>%HEMz-O+7q7<@2|%mIRQo4|{u$)sVE;nBrNv' + 'yAyI@pJ?93x2W~nDh?$Ctbx1^I0RJ!15ri@;ICot9}LznqPZ`#88h31EHBeJGcvV`yDOBhOF}tw(d+=i6O-$MAph_5d&gw_?_?x`0dbLEjflKm)o$' + 'RBU)n!92G3Chy=wj3~Bh9!B?' + 'cd_E8)iCuH70eXgSebFTT|wn(N_lDP#C9FFfIxNEzcV^|~E{^#S&6LWK3=yJvh2H52f0IFhsW;XWdift+Q>BL#|' + 'ZnjP5wwZ-q2E{D6;wm@-2oZcSL5J&%6lSXsSb8o2@g!Jq}aPphs$HvIkEm8z?4lH_WY&2vN)*' + 'Cu%T*Lbe@Q5CcbE-T;#x1(}BdyJtB-lJ)wnWLHub82YxBx?q4Bz*iEK?733IGHAeRpa$J*VeX0pVMe>EO$qtU_>WJ)Mx{e$fFO#4' + '9?e}5irI?iyoF1n;JLm_uh*fwt=HIhA@-UM*QcRuwH2rLwy27Nf~A5Ihn4u$fOH!Q9%1gwuw=l^HFCIZ0%NxlPIitx%#$~97h5;n' + 'z!P{?0dcLCBgtG7ht~Z$?db-chBQsVDjN6&Bqqs=0hABJNy~!Czy|Hnpn3d(!-u(H)%%f0dk3NseD=uMuNd5+doQaYi7B|YnEG+x' + 'Pxd;59zy;Ly#t' + '+^=E>!={q%7@>uINmck49duCB(J>8A+m!`tBG79K>G#d>BHp9>RLS5@u^6QWP}F4gN>(cI+1}17Ab4#;)Z6nLR6J}Y#b3pR5Nza@VQ%`hKf*_#B0iSEX(amVlanezFJ2F^|19to6' + '=*IyIQ%puCoQMhY7Vp72{Ov;67<2C(VBr|)0HNM9$;*SuHc^U$ye_bD+rK~~=HVM!@Xq7#aOx3T_nF;ml^6v;-|NMD-pCOWBci*GsQuuuMfb@a&U!Lbn;M>a-W4b|}#KT|mJX4OCW;!_L$!m45|I~&}|%EZgC' + '`Z5yheraO1pVD9NqhBKY7f?$B1QY-O00;m803iUZo*-%W0RRBt1poja0001HVRLkFY;AKdWo=?*WMpY>XLB!KUukY>bYEXCaCwcC' + 'O>f&U42JLi6@pJ2ko7QNyX}ynSr;H}ilp0N!yvF0;|NnEO`_YSzkZ5k`6G6Q`sU}A#FrGQJkQrophGcZ4!sl=zJq6Q$gDGjQZqa}' + 'q7YJ-tRkr-N%U8Fo@d$4nFG7B#;B7h57szh_v`g)d3VF_=C_OOjNJzn-Hcf%(SiQWpHY^3^37&fX^k%JeUq{9EmFK&`B&;$&1w%^' + '0D(t}ND?}}15XhDEjW*T;89^gd@qGS%>yRnZ7zP6y=^>rGA_6qnz&wknSWb7@a@<6=9+JpKNj&&uoku8{!b_ba)*U+!bhnv`UxFz' + 'g(VcnW=WJvaE~DbFN~@^dDtPL_QrW$VyFO+G`cxQ>QG})@&(&4RZ|9KK&106?Rzj>U8Sf8`XR<*vfptJPZQwMDN!7s64!FShxZ>o' + 'MpRjGs(hCUKOn*BD!VJ7qeXwA3qAuCO{OhdYFp76+thb4f}OV&yc{2De;eF|hSx=NI}v^dRpH4}#yKd(Is3$Z)_Oi}hCJ@f2i>0F' + '(E5nh=0~({_7ROqP2f{n2~Q-}`m4t;{q+(}XtJM^IN8xj9E@awuWaXLB!bZ*OdAZf7oV' + 'd97M)bK|xV{;pqvs2@VQ6eIgCnT#4`sAq!e?De}RyzozxrhKqy0J=5cuJ10@f3rbCDMm4DyyiHCL+k;Jg|%%kf81o-^|Cn6Y=dA&K{`wGUR!*P9>bK*hHD+' + 'qVUdZuJ_x+Q%cHpBfXBW-HIg%hFem=3TLOe*&WTXirh|a?#>!E?>Ee!HVr~K{!7Eud5@`cG&7c%1y5(~tIvdz6(kSD9Uk(E3#yJp' + '>~~D@m?)EEkOcWd%b~c))A0>kuDRd^Xyk`9O+CMg7d^@I=43i0W1swwn6KA@D%C`_$N`sBAqDcm35E0$' + 'cHl9aE<^JrFlRM28lzraYvv&pM-RerdVPt0?+y%f$SFFzsVXf9I20a8fx606Zg@=7(w%*tY`b!a-uiVO?nMsp!k(Eoy3A{T>1X%qCh;A!mGaJC6s`sB?#V{CwBq43do8U@ixl&Gn){|n-1at6wm3?2zMYUJu>yp' + 'meGV%8;}_Qs7;Fddz?e%QIN4>' + 'YYD|=K{xCJ6%Qf#z_OIanECHn@hH{9(i9h6TC^-32Y9OSmcrJs2Lyv+;B*ulmUa)y0B{hsc5G1j(>ZDzEsp!Eq5eqF&VjvS#t&dQ' + 'Bskk;#nCY`x4V>Qk3WAP?$p`9yf}uQ@zZ)4#*AUIS>G^;k<@(-cEiO#pPaI' + 'r1)vAU(xf;@1p0&tzwsXxS?e;;JRXGEg@ZQkwOH!_6JzkPMoC0&6&l`3@`&i?IC((e6s}(rYFrRBBuYmG9L+)5Wtj);Tho9arphW' + 'W~c!0RfrDt;58e4X9mId5dgTN7LAdc!1#C^4EnJ4uRsg-MGa@fszw0hfCXSh5Nrd&4ox5|6n}!VGdVhQPysNx1Z46-KIR0OI8s)i' + 'pU;3eppPUv;W&!Y>!ARx=!&QCgynw%)5S&;cZKWgxnwUmsI^OvUC3+^(b*+Lbmq_@t&VhnHVo22>D8I|lr$H|skn4kqpbgSXEr4RO2HRaKB%ROrjIVL|$%M>9$kn3|P{Tuag' + 'Os*2{yl0$gulc5$>SzT8i0eFa)w{qsl`NxBEgD=jKIfR56Kw*+=$c+>PkGHX&G~(*!6poV)X}Q7<~75=c#D*Y4KTW?0|VvO' + '<=%r4>kv(sHt4F=5~35jc@@u4Dc`s}}ZYtYaRwa2iYx{u!6XlV=WtAqgs+zEl@8XAX3W*u*' + 'xbVidTFS>L-vdV=ix56Fg%|<#LGF&55yiXQ#GySJvZ' + 'TM|H3r+=)b`h6_h!Q#?~H9`uy(=Mh}pGf3dKh51nKtDj=G!^mB8wy(b7Ev@gY7G0GrWbO}k%e1Y5ttcTQAiH0$)$E0wJXvFBv))2' + 'cG<1?(mUvcHO-6M>z)?WH6#6{v#LwEh9XD(D' + '=un8|UKs6tK-ue;h6ihjuWJ3ba%x{Xo6Z6V^*+nAUSLTryI}+-a##kX{;adzTy;e`tYf)N`J}#_N_bq{>ip3`d8-K^{JL|v{}5k2' + 'XsE&)CL>UD2z9{5G|%})K<@+i!|)zOU!SQOb55}FUOJU@7*s#`zJS$~$I;?TCs_1_0xe|g2$=v~h7yw?ZHA%)B)mCbeO(a' + 'h9mRpQ}SP2G{!4DK;y<~2Gw87X8&xI(9j;?U2u2ws{VS>y(K(CRENmHkDkTRipCF2BxQx@%Yt4TWTaJ-sa5vgmJa5+nsk=+k7V;SP6EQ!_}!1dQ!OhCr<5)UILE-?i4}tUDXz' + 'b&v)xp@s~v6MMw>0(kE%UAxLx0##pHh^TsHu1oFoYrWOiQ2q(FIAnNQTI-T9W~NS8uT#SQwfcBLADeK6jhiDgCLqV`OF#L8HoG)F' + '0GjXFxu$X=6&ehpNYh1(Mn8FLHHmZe?;VaCz;0{c{_~vEc9cD>m!8^R5iSlI)jCA%XK`nT~Z4B^61>bg@|LC9n`T0CcCd8Cr#6nW>zhZ>eZ}TSMWcpO)@JtWmj#IYI$AFX7%cA(zMf?s@t^XrfycFG))JCdD|?LqL^Y7$kaI|ik1q1a4' + 'eyZL_9kc^vR9QAi;J>qSUTp|OceZJ(a`{sWBx{FM2!n-5ECd;gwwgBWtP@4`s@~M)qW-ng#oLuALbRcBw_R-52Mxr0?@ZtFdr^I;' + 'yG^Hx-`n*?-J-iGpZ>@1#i#8W*o&i3EUQh4EH@nFa@;`Os7`m9G|{9eevqNID7uX@yWB|Z^x%+FJ8ZbdS5~N*WVq#EY8lKogWuJJUjcL' + 'c>cqWul`zm`|~+Ldh+|^QS#*R)2|;rdO9e+JO2LJk1x-QZ=an%|DpKl#jEdL|5Tj7mltndzBoRm`h()^$2V_YpPnCo2Vh^HKKsjY' + '@%`)5lV|4$4@mYa45Qj)A5%6z=`cwJk<)t^)nHc5lj1$hKaqc3wwto`Jk9x(!lP3(HP0?4s=K' + '8j8q*`*=w=PBz>1qPmn7lF?{%mHcm-2~qoz->5)|{b7s*1+pGJkt+}gS`{D=3#Wr?kSkE{`*P7Co8v@_-t<(T(V!COQ}&ZkTaf+u' + '$MjuN0SDa4kGJ*arrCnfyeU_2QGC5eT6Z+*a<*cq=t;sEZ8k}yG&qfE1SrhWKejbYl;lD_0Gc-i@w4Oy+>R2ENb4%eu7}BLm@J0L' + 'EFUJfH^8~cDw)GJ)7*B+=0-C0=UoD01rYbhi(kBo0swob_rFlpwi_PRhc*2)l6=oYUNM?;zGlt@#dojw%jajLEr+!2GN_O+s*vw4{4qNtHBDHEh;llAS|J~' + 'RaLCEKoD5%ZFyVZ2OM+-ACX*DCYlX#ZrTm-ki9af{zNwg=4|Ur(SIc!;z>9m#N_<8V)43p7!!%a2s*_8wg*{Y^^9Gh?e2hW(y9PcI>TIT_T`(t2UT)NaU7Xm*eZP(4' + 'B#ZD4dUcRjjU(o$C9eCAC`#^)h5@~FRi9KPffbXeZ?Lt~)$I+6OPIZ`YTwg=Lyr>nDUz~7(DP==3{(-&dp2@6=UW1HnmO`5*UIGz' + '&T-;=Q6oA9X>Zhg-E=i7jHIo|(+Cu*LD|EA4?-J)LKJ`PmDE@4YGXb9u+{(>m>WcGIdtmf-sXz6qK%Nb7Mao2ykPcK*-WK&LfiC<5yB=ZUWItMMu-rkM1QDStizI_KO)f7~mJE1~ob)%=IEpTC3&g&186qnU{vFwfnQI3q*%x!#du+WZs!Z0;boY4_;HdggO}C)KSYu3|pB}@JI5~gz?aN~|>zTbVq-t3uaJW8(Q+5K!?2~7wKPP`Z{&|>4xiU-6k1x)X' + 'SFhp!e|-7Uu&n((4^dCB6pTw6rAERw!BnRy$YiYWSbxDi2slS-iGp!ZkR^tD>@9i&Y%;JFpjHb(Auw!ZPcUsj(bBtCSi0IU{p}R{' + 'CD_PZxzJq-w`vdH&*Y(>p-v@+h?p2l3qLc(ijGlq!0Zka^`-@5P' + 'B&1N`0Axobwx1M_pMD*tR%{t8%hg?$zIb(Ze0q+2{aP{q-=4ih<5KqLVS@jc!wEul73@F_c)F;{)>rlwn-2bEh(V`vwZL)-Ft2wa' + '4ed-N98oHedt`Y~^W-YU-VmnEa*1l-j#`VRt9Dch$T)u;r60B28-WVdEZzgyQP~ySw$AeVKjU$F(l+VPYYG)6sB+pR2Bqr5v|4YH' + 'Z_BParaw_4`v`|dLi8PkR}IZe?}{U^LYB;7W~+AH3R{u{bW?p0e`UU;7ZQ^GWjnJpE->2CZb5shzr|RFAa7EFrx(Fcr_gJ?R4gU-' + '^A5EUY(#XLarTkgmD>Z?C}5F;LZNX5GK^!*?GYEG46~;F#tsrU+qP@)R4O0_XqHFz&$Hu~$Is98CdjtE?@wQ!sI~k0^gGx#zx`S7' + 'H=$in90ppna*ZbvrO~|FOm9dgkdVv$EiBMAEMNrVFaGe!$^(!KYW}CbJu$R>TWxSxljOzSUl}}y$(NxM0oqLu2G_x1r&0ierz!(>' + '7ha$>5x4+yP!vD1NrMa%NKxJx@KwVP8;HbEcJIg!wC13m(yZ}+t2LS7p*pPo8C35w#@wEOrv|@+jW48}(9*cX=9h}yt}JDT_@e~r' + 'l6hFASagyxPA`e&zzRe;1>4)RfV}sl`ljP6Ivsd4lFMom${tEJ?Iv-B%h<34%%EiJJ*OFYj*Tjy+W(cS8mNS}sVTovD5&Hb6' + 'Gl)T<6iIaB)v7503%lbXf>D(=dvfIhdl}y4tRTxN!^!H70Op)q?M_f#bOvbJ!9kZ{)<^' + '<0L!OGytEZ7lHYRqTX2KtXn#?a}>hPA@1Fz{VHZFk(R7Z7}W}Sw6|nCB0dydg$zxy+_qhLhfuc%G9HMw`3MU^C&)a-^z|97jebz(' + 'a-_Rywu@OI^sx!21xgpY9ef}QyFZeQ=&T5)oj9cS`cM;359x@sW5YdY}k2I59{(Uy9I;PZ}x)#x|iY&m5-a_h<*&J|tXquHVb@VcFvqVuMe35S6r$pgn&I*dp8Q3a#cd=~W?xjwVuU^;@E2S$>es' + 'l|E^F08|H-OtG9bzAx*A^2X497V`B~tN7A7I<_(U}$t_MjG@}Jy&G7J~?@{LrBejR(vemdayb_pjX~D9lToT2ab3#cUEz#`a' + 'nr^H;-Kzf$C*0t$d#<@WLBi~79pj_=>t2|p>sj(q+mwFS-jfSK8nO~tcm=&khLX;B1!J(Dd^CUGhcs7lVpDXVkid0r)%5m9cl)qw' + 'z6a$#qaR~*Q6q*Cz6zpjJn;4j%cDU;SXCc30?cS#w&k({X$I0Im*rB-h}|N42C74S4Z0qY8)P^r$l_3pmnHojrPK9R-+&d`%}DGY' + 'APJnIn45h87S{2+e#zWGk^B!vaX>i?X|6-8{vCyC1(w@4Xq-dL_B?sSAx!TfK!{0`)}U)dw_^e5y)>$m!PARFKdq' + 'Msu4p=voN>1lK9Q4kn@|VA%C(8)d%>F(*J$g*5zuWYP#Q96Y_xTQn7z#R9k$K>g2te' + 'Y-XM-(0LYh9>E`GF~Gl}8&RqiQW+Gnkk#H*clkKdjkvGGNJ;sY_75`^#OOTK!%1^wU+)jdq0$bkzaZRkJKR' + 'sKX->HH' + '&?AEOPPk&b*@MvX%FF00A%;OwNVdfJqAB4Y)T#n}L-ix_h3>CTup*ymR}^d-' + 'kawU8w*|SY0}IPp(goK3s5eHeI(GMf2_FmHs=vD}+qXC(wS{T0L%W9D&KT+_zv^-!FzkwDxsHGdRP><<*>7~B2rRFf4{2SJvR#1o' + 'RYjm!ReT6i>B4=d2Z&qCf+N-pZfftFX?e|b1P(%%TdE3LfMkS2rfcMuWeI2ZGNpp;0xdOHatozT@*s%6VW7Ab?ZAQ6>cxcB3*p0x' + 'ZLC;<#7(ImVVq>CVSWoMQ(BU!$8j4c(a0GU3Ie@NeYw6E2p#YcBY`a`{=C~2wc4P;P+i;r#_2#Z~E)Ba1b#D-a' + '-6TtgsEm!dpof)&VK`+st?P+&@)kBxd1r6C_S(J=2Kn5&_48zoRx*tyj>4Hsp18_6mKf0GDUwMyBtxS4VGN1FDB`N~VKxwlLIK;e' + '_I{R}mbXXRygV~@)=+g+_88kxWOF`Ne4w+C2hvJ(O4!Ivt!GyD2nVh%fs>9$WP!`Sj' + 'M#;A|aJmg|)vN%!Dd3>op4ZKyp58$-9Mu*ND&(%Wxzn!DYSu(&KWwiVJn9WMKIndm6&B^Mcbvv57DxniS-#2Bay28P2Az#0(cL+q' + 'e--`R`=)BM!8izh5>hbHpj;qd6a}>Yc%P4;3LL=HIe{^o+-bZEDYloZojghGS3k8MZhb`LWmgKQpgmC@Jd{f+seio&7A7Ojy-hQ|+278bf7)^b`-Zz^Dh_CVU0c83s!wfZMgV*#7mf-LD>(X1AD?nJ%P' + '8G%=8C@koC46I;cArpG3mCou8ca~Y&M;X2*fELDjA0S362fu@9C<' + 'bbI;)$11e;GaXWa7&^DSj?dVA(NtpY}v+2^XVYm0__^AB&>b5dDIH(+@_b0Aww(Lb$O2TZ0~tiZ*cA*BFuv' + 'r|}MGy;yCSra9pArN1&r{PRJGMc`@7d@-x2i^9|)G(Vx=0Y{qnWQg}ruBF0W^$i80b~G~7L+p>s&!x+axHL0=p#d_^7Sz@d&AvXpyMcoBP^e+CEezHB>*' + 'kgem&1**e-;vht8Zp&cwMoMQCD1xN!xnU5!ms8ZM6hn6b)^Tb?Z7ohcMnAs~=ZkH_U}8qpOJi3UZkM(q!-xV9><`X*7G7*9K@MEe' + 'dMQ5#6t-=Zw8Dj1pYm@?+}BqkoE|=-IO#SRCxut$>i!s%WWJQuU2Gw4jd05m>q3w3JVz_l~)xp`L9gh%lF(Sk*4G' + 'O>W_I4yQD+iL`A~{Go1kG^^ma(gJ@;HD45U;?DNO@Yo5iLJJW$b%)Vo*@*!qAl~?8nC(NIIK=gu`o;N(+j_%i5ACO9N|?eVK2ooQ' + 'li2+-R(Y6+$)9Lq%iAO3v@SGoq`*n?Ka>BGnMz;_)E>L&@Fn}UIQvA+``c=TtJmZPDXuYljhJ_4L$y*8@N5K@b5PLGdbr;Z' + '9ZjlgX72GjUL;naFuu9FvKxp?xp)qDz7Sm?ol#~%U2g|AHPF(X=J{yR+*Zoi?wX5jTdyga>|r{N0;CngF`d8x@kwg>ktsnYqS8oA' + 'a>n9p1veD-`VnURjgBM6mVs@Aa8pIPN' + 'e1`WGw{5xZlB%5EU=|Q7M&R4mXG3*&aRh1?PLVh}lneRWfroQ({Q6981Eb{iYH^p8(4aXgry#5;I0MESOp9c}B6%6yB;rDp)74%I' + 'j+ok!97v>ik~~(vFKbdc&9*~Z-@L6Vq@R|_fmktHz-1Q`XR%DeBiu(i`B*=*tIMFDnLzud?sN_!la7zV5K}3eXALE>Id>pi#i46y_u' + 'Ta~k1efjka=c=YrUxq85b!r-r(n$Quf8pOt)fnKCyF' + 'n5r;q_ep$hPn*TV>gVJC%y' + 'Q>XFVh$KxEm|wUGV9%gKN}m}qH>LK`!`3^qus&n7345z|lu-vmtE%XSp%4gTm)i;MI8LdN6V?dT(pFln;liNe$lCTq@bTUBTqW&;' + 'ZEz-|L1<5Bp!61deV5RjIzb-0w|_RbogreE8;{+#+i2sExDm}BskkN2XV*&8A!|kAe5L!M?We@u9#xXFXt=I%Xw*SQ-i>Y0E8X*i' + 'sXcF2L$bXF*@1z{Bp%%Y}@cWBjD9073tjjY$a5GgGR' + 'bk4G?=RLRBNONHX>%+AlUk%4YGK^^dJ+<$~$y`Jpf@hpC+P3WzJdBT3aoq^o?KzR6x_`4Cn(c8vO1uq?>L9{sMs|SgH+v6j6(UKv' + 'IVa6+RlmL2bet}6$X;ci)Y5FeA3U#ez$t>y%q=%dN+4m}*e^ks4Z)`_#3OF~@=5Mv4I3#ZXl' + 'EH;z!Apo^p%8QKtVGe^f_6VI|Ly;F)`uzBDdYa#U0GUD8xifRS($R*m?z~{bzuwO0_<2i?v=X7@`I{e+-D;kf^0VX>W~ENZxldS2' + 'WP&%5c3)hB>+5m&`~zDP5_&D!4J(`5Cp`_NahAdMAdOc%oyELzpJV>#-|AlU!F#L^W>RJd9Aj}yGH`6ZsYTD' + 'k6}9&0u?f1&&4YhatJ`3_AdE|2w*U-GWf~a;r2YO6xSVXt%nB(Kc@#R5}qLo0UgQH`Z9{vAA!NUU=kX>VmMrp0h6p4ReRLju85z=' + '5nD2Z7x1h8lDT)op$Gm=u=YoUeTk;gFa1p+XNQEH-P=E7@eg~%Ksd1u;beq@QvJCI?N+jdF16&mmj3;(KEQS$GboCN(CNN' + 'DJQoNVW-f}qB2>6)>quLvcdfXU#CD-MJ1EP8dbS8;untQn|3?h2*pJwI8Of8+3Q!zNs2jEAR0@6Q*jHedP_y7Rpl$!wL@}0DFZ8#' + 'TbR|@L-5+xW!lYkeg!Y>MIf`~0~3NCk_hxSBmnJ6{CP-r!<|frug2_aubJJkZwm1L(cH' + 'w{FMvQ8QdGvPto@0&?I&dntrPkK<~*9^_r**iNHlIum!dC=*$RaPjdLw4dhIEkm-&D+<(isj5vtk?g`s0L0yoMMj`8GppM`G0_q8_w;`{EvWMoPRlFLd^weu%p)$T37n8OIJV' + 'RnBsa+SoyaBDF3JTo>&@0xODUcVB?zkv%bTld~^_t;TSGT9$>$bFt&8tD%!_-r5hx{+jZPf*$_RY8NGT11WU0U4I1+5)1l~*E!*c' + 'AHOgh?xXH{wDFp`tGcoRy7j&V>x;A}I63kBFCHXroLZE3RZE0kxBbRNBfLeMp|!V&8$|z2AgBiFVIDLFuG{ACGRfh%r#n{7H?CFP' + 'a%!g7yC>$wk;VbjVW@eZ)sU)D@ZK&UqEaBEfE~^kCx%rq0*Bn$#(|BUvykw8CeG;(O0Wj1{GGAgPUmN);;vFY-zx*PY#C77Hm1mw0Kv`E!FRZ~CNkVp2N@2me00*2K)e*{J!dq;nOv6QXhCZGwa!AxGnKA+_EXV>oO0#!CjP-1' + 'o#gDarYd1j>=jelhmH2E>ZYJ*!h~e@J4W(^ICkG0jb3z1>+wV#w#Dy&)nFqpa{^D4vV-|ddQeNVr|mT=d|7lM{q!F}F{Qw!0o8QI' + 'oV0m2JL5)l_Y?-ek;r3M=uAe!ARM{#);YPyCdVXGMQ5bkD657qyJt=?i84~szxH==Z6}a)ZsDZ03G=^D-XNAJt8**>w#90Eb0Vjq?l&N2RD6X{aly0GF}hIru~%n9LyXkb' + 'DLx`YjWpXmkQi=yGz6h4S86ck>9xXL6JciK_6Lw#PAg3EZGUhxe^_m1UIY5(wux{y1>s1hw(b@Kcma>NJvVJ++4lIpd5h#b0YN=a' + 'fKd^c!zCd%QITyqfcnH+GWJdz9>Zkfj^WT7OpUII9?)+9lS4aTPTsTg#HXJ(Pr(aFyH>^FS(jxBgl9' + 'X2V-g4rWX!!2G_YJ6RQK)8OkUEv5Bg8*p@eX?4i{>=`ie<&dz)Cs}-jhG6Lm(Ju2{%p4l6%7>0g$;i+d8l`+1E#5PRrkO0>55t5g' + 'j_e7it|Me3x*|86EYsa{?I*^IXzvcIZSf)S!Txr+5rLprV_>o6!R6u#%swXzuc3Ch2(NE)%ZddRMyHD~q8Ym>-&fj?o-7OIRU3zW' + '{LYp8({ExOgUjLE6U1t!!aq%L7W-gxQM-6fMV;%=s7|6' + 'pWw<-X@SEa1s@_4Mp*QLE1fW1;-Uy(MSw%o>&{MecJ1Esfl2-QVMprUfVu)F-@w;lq>a$z$AMD-KT<~&<8e&A=EwIKuj2_lRu*V)' + '(' + 'Z_x*aY=yDIF9C=z9f{7Rv@mNBX*}1Bjb6U%45H@IV9(Wb+W64(iX}^qO{5jbXsvz>R5dUGg(>oct>HT}<3`=u#epL@K}~aOOo0$C' + 'Ii3C(rP>|F_Hbv*!0oEMSxsh5)$zX6YGwO*w_Rh_vfvp-vWw)5VO(QY7OM54T!FGL##tTqn2!<#P?4OwEh|;f*JZaq%Y$AA(rFVZ' + 'p$Ig&GM@i03DD8?cKWUoPaY{pYjLBb-Uy|%$V6W8in|Oy)MKy3+8dGMYEC?;m6+FNqDl8cm)U+@18H)5My~_Lc;te%!Z%#Jtd5@' + '{ixnK3cZOGdR8ffmv}MB{dymDk=7}w3qy1C-Ic$Fk_z_wchyX0r0#styMwZJ*eymhdI&s9$eK<~^W=-TU3M*ohiNuUdhR?7K9R{f^zi(+EdykN*z!jJ*aW0F(p;Q#1bk7VYLTJCQ+-ooEHKjjU' + 'v6?|$-~iA_$7YTES}qDfQ2jGVS?i4-rtuA(I-bMRFu1O*cD{4f&=(uQ`EhY}6f0X}a1*;(ZG*GwRsuB}v#e`o%8' + '4ALpyk57IhW;>Rl>lHY3w_0oZ#paj}ueu(kbxhW1mYT3&~m@7Q_B>KnizrKI?jUfX`Pjnlg0j~Og?*h-cqdSn(oIVd@H@|BPu3A(dGQG{vBTsgEf0^oZj&H5S' + 'NSBY$*<*)Fx9`=W^4kzSV%`YMg(-YBosnsjvj}3eU~)LP^^`x!CHnt#tmQF`lttSTm%&C2ax)#~YFLh@CrrIO008q53wY`(*m|S^' + 'K+0kPlLvFl??S<#%EF;X1*YUIT^Bm3JE%~+rs+Bm4CztM0YM=GjC+9(fna2_2OUeFR;>*zgay6Q!HL?314CJ7&fBgQRo5jU^!TJv' + 'PKr15M`rbxvuTjiY93s(jDo_BX`d>599dN*Pt-|*k%L7Itl=Pui#Lntz!Y*IvuuyL?R;K;P&vq{{Rjc{Dv&?5H6gc-c' + 'fNt0rMSJ<*htx%>d68b%T{x)2d)?w&7l' + 'Y`M!SfQ7fNba13ELb*oDG?7P1@!j$F&whM)UVQuP{P_>XPcL45_xh*e1irj@^YX>h6%4p!Q?MqYVk$^{>Th(%U{*~C}71RRrXd%IE=*O^o$SWYt)b!=tj6166w&~624V#js=|8%?hWi@wySrm=60ouispUQwy^8c3wv?FIhxgT8FQSmVok3o*iR$2' + 'm*t(1+X}ASO*`|32d9y(M9tIdJ5ryBSbtQwCI-jkqxAEN4kN$!b^z8PqfQ|MV|VU`IP(>%e1$!`94s2O+fL*^aZb!e!Uw?YOZ5GG' + 'EG1YiMVfUbUqti>t>_rU%AujmV)~q?c45bXT!`ff|1lgKer{nQ@h&wlQ=!O<^rmSDLPJU9B|qU)0@X&ck$9vn>H{AqkTdOqD$d7GLW5h-!O-qE+fKy;Ol6`}>5WT_' + 'e;ySmEsn$vo$e2aHr{WT7@oDX9wxU_F6mdx6%w{8zHP`gUp-AMce`wbP6c;esHJmDB9sR?PuTkE0s)_-K' + '2FYLRwMqD<2uKDCd;yF(&jjPg?4kUd(b?mQi5-Ma5<0SaggXFv3DOG+!RND7TazQp-72j9g=VK@H3{cfV73qQCY(Pkwd6B6$x' + 'sel4r74O>dImTEH=y0HDWC!cplp_6E@y8;OBLQLgXP-DZ2|rD;T6M+ZCZ3Hcw+~5h`^l2re%eI0LV7Ar9rGv?6N2pVT;gp5fqct*' + 'DlkzE<)5vV;7c5K3s2(^#)Q9C)f#1qcQLac9)N@MzpBK=(AjU' + 'VjexYa&IYg>;FOfpJRXf-5!3MXJ~GhXzG`p>3!&CrJj`HmlisG0e6aHRKlm-X~-ivAL4UnVf*Em0dy5Jv;3mBEpSOYPR`7?Gh&XP' + 's35%2`s`yzomkTyBG0LKQce+wv7rD>H}D{wDl&%6^h+tIMhW1+gD{a~J>_KDwB@QRr!sg0ZCNxxy;$92x4JzMOW$lU9)$oxghbh3' + 'mn;fAZnR6C_{1v3L)40gsurJAz4-Jh#tts~!{4?QbI0=ddJj>W8W@Cvxu&I~qwV&dKJyS*<2RwQUvTJS*7=qD+_U5GWCM&QJdAA;_;7Z;#McZ_' + '&t+LvkpJWv7Iq2|_N2X>r0SYR#)hiA(mF`h2q;z`?>*xKz3!}Mt|M2dU_zESK?6!CF$GSDT{90nUbn-V{XqM)+qCC~VUL@JK)irK' + '-OvtnM2BCs#lcNB;*k9+0^6|crA;%nv|!x&_^7' + 'sZ;WW+>K$~@0*}6B#Esac5+Cc=-wd&F{2>stDmBJKkerPt&mTAJ-@%@o(jwX%8Vzp?Niaj`Q6|6(IcNh4`uVPmAH3K4B3MifgZa{O1KO88{mHpVWQw=ZMc|NgE8}xz75>E4' + 'U^nAkSoy)7LIpe$@Ev8wV-vI_Z?TCRW%Q3qRdvDBFA4dd1_kbg3jRhE6;9!R@VL>~$4R=ntH>A@7T!e=!Ri;g(|#XH7jhFx6wD*x' + '*LvY60B4wRDuiRxop5y3ARNKH583!c9TtBcjzc$`t0K?&msqU%zXsD2G|wm!o0rrtl(Jyj)3{' + '&16j*nUQ_hP;?{PCDAWrdOevcWtf0YJ;Y&kd>5m9lRD7B!3JZ9Od)N^XS&k`P?-WJEzE;mLBO{sqd_|2i3MR~;x@)vr~i@;U{#*?w6h0@Q*XT#kqGm38^Y)!;BPJL5Z$' + '(%*VC2(QkDMT`AC5$|h7ysxMV4NnIV}NlMQZ6B*^uPo4&)T&jj@(83{B#m8V#hI=k9v>unCBNX>3N2I_vrR;Az^Ak' + '7>4FD@)Y{nVKETgm>D8_oCmf9X1-QW)3WrK$c%%1Opo>@hJ?^rJnReeFkjl&fXviBaqj^$^Jc4i?IJO7B-5(mSErYQy}Nd;{Nh%S' + '-;fRYjjv5-?+Vs;*y@+Ne*IDPVS^rNVX^N&LG0Y+HS>8_>8FVE=pB_^4+mbYwo8nttTIN+UCL(M2{`Hw=MzDsiZ}kA)N_~sG0#KK' + 'O?BAEfMQw)VOvhmjj_0O6n>OCDl0CzV!qDISuo6-cGwuD2V' + 'bj_kTTJNKDRsr4a?;?0Q;2dKtm8P0y*w-fTMR`YVE($^{{*ZyBCTTe2uRpIUk6k0XrhJM1u>N^VhKA#1zXjgJC#$4doO+J#)5Ug%' + 'UR#%`e-P=?E2n<-LD%DhVrFfz##@' + 'AH+}Odw7V?;i0~UpTx)T)A<%kNR+hzngtUXz?ba;QyGi`h5y)=3z_MF@rHU)KqtEL-;776x!zargNH65)CJb8ztmGPp@$x|lhuD$' + 'wJU%%<__)Rn_*H965BjdA*05QoMRQ3TpTl5$^}lhoSM1wuT5F;(Dkqsjoa#dE!n)Uxv3X{Gp`r)cwjFOQOSp3Nz1V%cHLU!tvF8l' + 'g0UYx>IsZinQl_y%I|oLJN4u_0jY0(ajD@XwjU?a5O)4z!Z-u=bwu8fsgt-#pOp%FVdxC&@pBg$BrrpS}Ejg90`I4q4>do5lH0feKcQeyBjAY)7z;%W3E}bOiejbY6?Bwl3Go1et=e6' + '+Py44VE(qJI%MhhOr3;9VlI#L+na;^DJaKG6giTed+m47PQow>_z~T~(-J)rC=UWeOeGT+0zAqL(NFj=XR$4ZB2{kO5MAw?%jOT&' + 'bSs5Mp?^fxDZbIkoGrM&$($*Bw4yzjsZJhlm!b;ST~l()M=C&_9c@`n3j~XBFt1(R4-T{osBvA&S2Kjyo8LOnKt2MD`Hm{-0tpUFficyC^6+Q`&?JQO`OpQJfOB-Z3;Al*oHS4M+H%D0;h+1_dJ_3`7~AAWKszO2zlVZ#F}SbX_dq' + '(QuavI<-v;iTy0BI4P1N&F;8W3zDJRo`-JPP2kKe15Pr^5&+o^;}Lkr1%TeX7)YQLw8P^Pzr15Z@I8wT?YA7Jp=nVIK!+pye>Qg05r>' + 'ZMjVCaZ0h9THw@2oc;oR221s2UAA3ie-amG)}6Wa9@5}y@P7bMO9KQH000080000X0E(LO)KND809FD403HAU0A^uxbZ~5Kb1!9W' + 'Vr67xX>Mn8FLQKna$#p>E^vA6eQS3c$C2oF{))-?g8>;tNZIlA;s(jm5@mBEQMx3R$ji$!8Uh1yBD^vKP+}PV_gjyCRQGfbKuXTt' + '-4maj2%PEZM|E|*yDCl7my5cd7IocAs$y9!#_LgeIW3ZUwW#u|BAG0zWH4Kd2k$3&y;@FZb-$Qh7UOX_zpCH&(=^@LnN*8el4XSe7;!at8y`~cXrgX>%6|6mY4d^a-o0ywO-8i@A_}ka#eh5{#;e-(TYd8EbC&l(j)L+Syf(LtM{YDbPA7f%zl13' + '(hZMRMU}&{di)F4)=N%{zpab;sNfHm`3fhdKX?Ow@{85&5|*l;AIxui$p|3POBOdpHC^OmZnj>RW8DaUe`l6FU(c4eNnR)OrG5nC' + 'k97C=Q-63?%?H8tp$7d)FI!876ns3BQ@ZDp&j850X^3eX76y_G$C&_uuKa-+$-4E$94>{`KEbpY*T)h9*P*`fq;V7e6@9*XqT(@#b=|' + 'nBuzNA9#MhLttYFo+6Ou`K$mSBIM|xC>Y}7q?{)b)_uU}S>5U0?>s+zaq#x#S$6vK!O8RN^yohip{u9KlVpGI>32__e7lpqI(V0z' + '96oz}^87S=b9j>mte5tLsHoB0svp!wFMj=7s%^rbRw4#uA' + '*}4W6S5##_E&pBsRwQ}yAE&R6lf~s)TewP(Nle)QBzI%@^)3!op%GpHwkU%nZOqM91)=Sl!M?fS^!F1trzQ>S`ywDHQ))!Dam4zyt8QW4rcTYPUtEhU3a?u' + '(Q=LdZ+hr--h(2lqXKk{cP8kG%OYqRHdNR#dFt1|lu70)XFs&M<0EiUnU45M|i_Y%8IUMFwTzN6=bd%vQ+4HFn;}%XZE2hN^' + 'fM3Hoog(&_SmNdDm%q8JGTET3N$oxNUj4n1!yaX#qjN2N~z~v)6FgIEp^}_Kc3eH;<$)Ha8!7Lz~cF!U=qQsFu>3t^Uts>' + 'Ek_Z4^@x`9>E%xNj9Y|lQe{|(>b`{*zhlyS3A{mdEZFrsX87E(E6K&XoTP8(^?Hf&I-DLgS?=dfb$IWLQP#v-{T}~}lw8g78EH|Z' + 'H^DryweDD-ZKbI>9Z&OHd<7`Uo5NT-Zh^+@`SdnX&D01rC^~b553z=^9$;Kv6#%!P*5&$YPvzVGdI`*}=t#JoD-c}tK^a+$%`tYr' + 'K+fAA0}|C8k^?*M`h*7EuJ@UX(4j*iHs2kC8aTvTy}vF#@h?lMc~I5Ahqp9A=Ed}X^1S^saEJf{8xe&W%&S=DL=5U-C+z`uPX}q&' + 'S$c4b%-Qo;tpUrpkYJ&YGS-F|9QZX!QR)H9i_{B&j-kk4SXH-Tr)a4lf(cdY-1g5DxcD?GmaF7X#Vr91mFoh3v>YTCu6SnP9|c$2' + '{ckD+F3XbCqXTCKE~dQA?zFfmrf{mNsw@B~Bzjag$dZ420Sen?37n89-SoCk{oz*`G|z8g' + '`1!Jr6T4hbK%ws-M0>n~A-qxK7lh$kbrMl}QA##^l`&zq9d?;`vtHv0GMck95-G-Iww{E&4DBTy{6E@Ykl;zd!mY(0yb<^sCh)q0-I@_9J{TJQyyN(KR(wHT^p&e*(@!W`tJ' + '9|ktj(Ima&4?$DD?{CrTLm}&R=|R`LHLxw}#q_37cr>~$r{hkwSb!>s&_gril$10@IiB>AX?|Hu=}!;px@jiCuOBKM2Be1)sXn}>' + 'VPHJ?b8~9~y&!4qqxGz-looM+uWz(=Lu4lrYlswXPa{iU$sE<990aGJJp_-b7_5I)tU4)c' + 'PbukQsrFaw8h^zue37=o_869vCoka3H!oiSFE-|ytpR+4RgiPk-2ftuzOAOjx51#{Y1#2KbbC}poK1FW^ogve`' + 'fV^3xqw?1Cd|6*FR`Hc&ij<^XsYr_z=`I>eVLCw{L-)Fw^O{Fs+rhA%2-@L`DNMjjTp3pfE&Vp=0v{8t?1twnj+yAK&q8#^JyZfP' + '-gX;09DvQ{?dILwe6ScQwFiVRf=U|B&QPD0Oro{VN&lB3DNF|efTL8?bK9~C3avezR6x5cwLAOy{f@iOihwsp-7(lAkpHO^k631a' + 'Z@puwNE5EcL3^Kcst!n(>^>U$3~3S}%38x;0^H?jAz`cuah7weXr@H6VJe4ijG>RrrnB<+bMmk=(hkL|Vvc4r+}Vd56%wFnOpnGR' + '4|@8L3EVsl@IcERa%|vF@J1()-&eH@Pk&c82^JBR=>YoC6>q&Ea0g6!84?A#t$_%&AtU`4tG1~*cw3vINN5xU`VUHatuHV#cK+aNZ;SBhD' + '$Ai(1W7;E$-&yN22qL?4n6Pp^u8OOoYL#Q%T_yce`b-~#@y^sXp}%oXdbpvV19IHm$AGRIJy0A~MAvbPZqfjB>j2bx4#*x2Lo=TT' + '!Os(s$%E0{Wdlu`T^P`hRG;*$H%-*oYJ`T*)Tif6SSj*?%%SA4p02Xu6P)`xY#yWV8P65_@wxy_yh7p7b;+|H9^w%^gjLNQdvV;h' + 'U=?mR}2{R%Wk!I=vOAtRe5h' + '$%Hd`lb2Hz|0BoEP>KxxhYG|$=lJ3B0tCyNl$BsOX7s2#JFcpuPUDVP!XOMpHwdXNR_FMob?Rwlr6E;WEv9ul?VP$ByVRjz#j0Z*' + 'ez3TKofc}U<}F1a+Mpb2jHv8z7QcZK^z5L@;YS+eu+blPBHBbvIigKSrImL1pcdLNZv^yFXAGs1+8Of~snd~bV_^vN^YOT2=3`;0' + 'Lkr{LaajF_&m@>wSo#81016YCs~E3_Z=(So;pp&v96Ex**M_bN`o<(P6L$PUIZWMv=i$8v*++}mFRsJzE-fl5pdKR6p`N|LB' + ')ldc2W6!f(XgPyHfCVY#4CQ1W7PbkiXV}I(O#U!T_N)|zD>JVsC`?U^nsgrzFNR4IUTi990t*c)23mIM-rBe!Z?tIxqoIP!??^NF' + '{yX_n4GGWrSAV+rSX3RSgM5D58Nu<(N2{XJn3L}9KmGQ*-+lkTe)zZias-DmRWUTWT{%}>c0U(fr#~O;;v@qI#~M@yyGCktImLOaAEq1Z9p%_1YUc4Hc2uqH=y_l>n-kD8' + 'bjR0EA2GavLvwV6YXOEEo83xj8xPuCDNoeNOgUfG;Kl%9cGZXhk|GGUzlr7BJyTH-k3NJEAAmf1if9`u*2bmzi3EMC69JE8z+qrJ' + 'bm1X-+|ms$7jLO_H5R*sj#@PVGN!7F36q4B7Nux56muCH=WF(2nxxvRKBW^kaPi7AfO@g6KsmrNcN623A' + 'katf$gk2mers5h%ifSm<%r6~+>=8$Yl9)4XmWhh_2vo~D$;3ABr}|o5NB=nY`o8Gea1DS}QqE>;@;D_JkVe<)5*42;u^MUUOD%O8' + '6(w1cjN2tEdG;IKRL6?WRY#rmk~qFPFqp0msS$xX?aDJ#mPV`-0n>z{bX$' + 'Z9k*+Y^|fRa2Y6jqw;xfBjQ(!RX$C8am*;8V!*)$KR2~{q?g-=Mgkg4bsKSodK6nIxSpQI2m}~k71j+QnoIYKSXWbrZD5J)lZD{M' + 'H?GIR|Ad6%&^O+Ee=j!1lkOe~S>s)bK-NsauonR_M@DWtMou4=MWTr8y;QRDXRj+Rygp8Cf0&4|$$jtLt$PFumN7+Pqt8Z5Pn&oZ' + 'wfKBN8OEFTW*~$NMIktBCyjizR3fHx-`L#N>lM}(#6-wF)MTLdu%l15`n0+XTjM3Mo|bdEw;XHJyh(vybc5{xlK7Hto-9MaGY!$o' + '=7fI4JAHu07W(f|S{!+BBf%e`wPc(0lI<6jMHH5YukArf%!ZX|Dj^<4nda7OY^55%A_bBEwa|~|Ok?^?WaSO`{bneM(gWAr+L~nR' + 'Er1g64Kv|STf2`hlWEd7!(=}W;uZx{fT%%~oA8Q%pZK9S};=L!Sd+xMDk!5FqoW1zm' + 'l)U@-5-9n&LybKVKeNq@IBy>{8Y|{OJxTQlq$;Hyk&;t7aT3}YWqJ@_!rm#RLFrcPrBy*qjT_9qMkua-Z715eo(Qev5dG)~P2*fl' + 'v#YbOZ@+0sRM8O@c+=f?ue7?tH7LI' + '1sj|%7j=pB>w>mh|7G{OF;3T-08DE&bn=ZuaC96e>Y@}in%KM%+7+&Qz$SA1KMZA8A>+vr_Qj-Jp_AjOR5&|yaz=H0;@LAJityyB' + 'xG6DV#IQkYC4EVDll==^gM3D-v@-g1f%g#vn9R>zGmLZG7L!R`taL0J?m~fC4JwqqThCIrVPxU1(8cS{BETATN*Ek-XA_jQB~UQR' + '(^XaEv%ge0H)HGjkxhELoB#orRIg`b_FJwmr)7;nVMKSWJrE|6mvbjpMVS5^i=M;bdLk&pzgni>v?s9lGqD(k6D_&Sr=}SiIYX^R6=kgmwhS0Coj9LRP+H%~RaWP~laZr}b#D=#770;1&2PR$' + 'gqp&2yI;xgodhId4MH^6Dj%PYo5HvajGz$?B{Q$LxG-pCmWSRP42+_qG-e;hB}N|NAABpb$B39MKJXaP#F7eptBprwPkphyH^~gh~KnEFxn5kN)C|EjsdBq(Vr4ofqH>F' + 'g3{&W$-MZOd?X6XX8=hEo}gUxrCG}K*3j57nUg_ZsjBtT3j@S^RsH0RX^GINMR$N(f;dx5b1@?aw+{V0r>EJydK&k{bx18ct' + '4UkyMRr1rbzPANp_#x*_?9gQsubBZP|M2(GVeotY+oFiSwsH`+#jAx$n}Kqr{1N7#=z?ekE&!pDz6jX-?{hK4~idg1{$5i' + 'pZr{q$%go-;4IZN^at*LED5GYJKn?_)G?Ozh`Z8+{k1Yrx#&=0WRVAKwGu1s)gA*vgdW%ypP6X?g6OUcF@(meIldvfx0{edPX@%A;eodUsX#_;vpFsm7' + 'B6zA#RTjPg^e~HHdUjn1-9;WN#iTsDDvGb#>N!rrw$)T58qn9@0wzO8Q}&dBrbR5%O^i$j%nta%su+@DAhg%A+oRq;}athYx-h2&$46<5__y{sKMmYRjW@+H=Z' + '4M#2KNs!5SaSW?U1+txM7p(OHY!|G<^9{w<@SF49{fqOx3(uVwYLELQzS+MZ;bdd3?@d8F+@dmo0fzK=ABdw?^Q178Z-Rt-ubHK{VooopW6Wkd%qjs(>1?o+byA{tfC+nk)6WKln%QDixZVH6u)C~' + 'ug+EJPp5Hwts^cTtLSQ>0e?V?j_vG|+N0)bu(H-xCykq$K@F<90_kbdQd65gpD}Q_z$F6yHSs%uK8R30Vo%5=oBl&0w$_AuyClaJS>' + '@$Zq<8nopFyvN=+v}U1Gh6|71mO`NLuZA`vG2sdtuA!h(Q;eYRTo&0_4ZvX~6dSii66nVygOwJ%CPYzO6m6_OHyK$6)V=@qM}zh5b2vdtoqqXVV@H)h31PWV{bi@gj`Vm8ihw{}<)yPO^1*f-l1DIg(E`Y~$!|' + 'c?A_k+1r5f-GD(cprWZaE#|`phD4Z-U?jRT<|LcrGNQc3tA6INodcWrr1%7gRWO5rIqd@BRz7c=Ey+qBF3%B}roshn9_Lk$d)7bO' + 'yB=}&pQjq^RBcE95o_Ei&(m_61MXBuO|<@V&laYAHG|}-XSz~J_XZm??s?xC-`>Dokk`yv-5{_wuYJ??Gl+?tq4c75$5hC`c0w|>' + '-5?@Ms9PC1BqBE^q0^nl)Z^f+o4RA>w}U=|V~M=<-n#pD;SU0;eK)AEH|F4I@Zrw?`MNwqC%o3UTCdE{2DV#fH*SFqOA7p*)B9Fm' + 'Yg0jR8PZWj56uNY&;8(z>r`KHJCOIQ!>05gp2lqJa#o?E>Lp44G6V~Ak9+Nzau57PKAuk9i%?t&^2to-kg(8gLqP&II0=$)_*&cH' + 'j(owV`_7+k^uRY~<6v_5T*zU&t$k^{4$oE~_E*XK_qLeF`}ZgpaH3B?tKs~nn3sgRYLWefT*&LW=**^Xu0@KrNm=2I@p@q+wvdEe' + '1Da_4NL_2n1rz^@T+Z|2VApK3kwtSvBA@aQlWxZiPt=%wM1`e_Q)c5y@dGa9`?eWP91!kg9Q;D7vtKPqAlW9?fjYZo2G;WL6P3I-{AL' + '+j8zVv*!>}$m#alQfQm}LCP3UjXq{Uwkq*#RUR~x_1?AlZZ`X(flbi|&Bc}aw(@nunOgYIXE6!+b0^N&GBVc{y4NBgl>tgglT^KxV9Qay!L4@Xb' + 'U1E7cbDC(l7Lt=1zY(9-tNX`s6~' + 'Ai^ywR(Gk~ArorMB}{xD7(Bq{YMoa(h(&arCtm?P)fE9$&Gj_~`1$@l4*CAQx>>;1THeY&yi_|+fnh9j;~0QlIg(Dkk${NG&uR8N' + '-p9pyItBtGqSJ(8TMm=g;Fru`H30;?Ohp!Pmgp2=*HR@E(uvjUeod@4;T2_lyvph|x' + 'nwH8o+zXmxqK#tE47Gg{NEqIjiD7E$!}~9oV_PI03n$oa&8*#)Y|4O{5;H#{EtG|qIv^&wb8Fu5B^FSx=$^Pe;aGW8M1wuqebNt`' + '?PWs|6!JvHdITFpl1gHDfJ<0y' + 'PHs-}YyK)lz0lfC0&aF*2P+t4@;r%{q;i_iF2}k3dLTM-ZLeLSz_~2NO)cTfk(L_C7~fydr{(-ZN2M`xFSu$O0Df<4c-QL+^!dL{' + '$!Y)s*$mAA^EtB>z-xN*ZU32h=7q7?#(hxSM(dS2wH;~gFu&iN9Q^d^02fu1S96MGhh2Mp{J%|$XD5dTXNSqz!H+KwlcN{O@$0kX' + '@ZHhr*(p-H+H(pac9i4f?C{+g^nLW|;N+L&Plvzsf_HSuB*ks9*WIko}L}NdgFd?' + 'u956K4w7l<$CmDHYel}?A78%yG2RP$Hd=ZR`;~Nfc_PV#+xErllf$E*j%C|!a&q|M' + '5VqslA)|(Q{`wd;?ByYV_u0YevxDb{58ME))&EKxVDnqIZGz6!`s)Euw3VP#=+|u2@bYa1XMOsuv;|V@dN#*%e@E9479T0(pTgG`' + 'ABhY%Xi6tlltZ(W$pR->cY!Sf+XOvjI_-4tH6=~GlbC9{EyfodFEHG}%QM(O#tx!82hX1;&tAWL`|3D$%u{(;{W%!' + 'uaZ;Zw8?r2S`^5+bnjRp2-^kfgsc~!c$F2?+Eta$>wM(;slG}M=Umo%R$eKsP&71Vm+39~%6>fj>F78)diCn?`4K__q}#A&;*_DxZgpLYYmk8#B>0~JexX;lIH!c4T4^RKkOLPnn<#4RTp=>3ONE~7ilP^^T' + 'Oy~YJ^&Tvy+o*WHp$Gx5!kWfN-#4<&`PbecUtjno3z?-4<+Vba3VmZpR@Au>iO8bu-!(U?;~7rSh|Qe~mL-0Amma0hgNm*x0ac-+' + 'D!sNUXT@T@8h*P+*EEGhYnQx1_U*Rv2gQ#5x&Vy8Xu<{_olAgO`V=&kj4U4&F8BCEnRjzA+RY#`JI^JcVv3{f$h&7ES=B@X6JZ-hFQ1dgG#wf|3qv!4&GwgTOkE;;q>5|&+RX3Q<9bN@9>DVcXK@A&#iXd@=4YPhypc(;' + 'y0n;k1@F`t+LNKsaMKZgEQ4}ZWhz*@QwgR^Qim@4b)(7KWS6!KZC`>cM!9jq%%vNzq*P6AvTEmSAp' + 'rZu7DsW$T_C9vHh0KlW26g`%08>r=3GLgK}Lg^}mRw-=$nM;a$05yujRBWC&q`aeiuFg!q#UZNq$Ejwukp`;6+IXM+sJat2v3r#+r-zRS{W`lg@n_y$*An}nJ{?qyl^e@h-lN%P#}' + '8NV>3TGBO4PrG)z*eGZ;hnJoHX4p7>ag;o<-`hJ+w6`{!m5{}bo;PaaJeGI)tN#Db!#;=a|2Jld=o9?1B3M|;VdVQ$ajWk-zfxsM' + '6n=@EXn<*V+_7`hS>NzfrLbnO=Gl5C$owq-yT|B8i*1l=po&^=(g0<^pfvxNzo|e(vf6Z>oUs;hHgOn=a&upUP@HkfiJUAC%)YB' + 'c$cN`_4wmP9wbDBsA!;VfkC@837LVcD_b3`%`VqYlV)3%{4mpdv!iYUJmjy9_;Op0_k-*SY(3z(FJVQsvT(zC;8+f#Ce@~9|Dv3e' + 'trmspYB64q%FA+!G3e$1aHu-6$!QKpp{5Hz=(k7qDJ}A4V1qq;qbw;6#?l3%-xg`<;1K21jYR?t{p^=Vy0eMQyxfJcy~daMB}1w#sv-8nZHS=n_+umZBq{nD*OJ2at-@JTP3*=PTsv}DV$sH$p@O{j0Yjsx~&XG!U&fw^<' + 'VJ=s>)n$bonx%WAfpI{(_>_=uH`Ze=jxm}xoy$-REFWk;E?Ji=-ot#l=o4Spn*+rrm2xS' + '?b*1Hde;*eEqZJP+kj^3-tpq2$i2)?RdIWb%6-@xAkP0?7`jFBDhy1;vbS(bT2vOdV#qEqHznvf^ows$>NWfN54!|(YR$MFr?t;Y' + '#cVazfL24lXX1Bi$ZfF3Hx_J*-!;m|#*FYy$A^CWpplhn;pq8RLtCiIk@eFD4Qv8(w;EUMn|l*wi;-+`>}6;)rx+PGaGU!VM60KEZN3GpMP@Hn3l2uHnN%F*?Cd!N(e}DAD}yHC~Gxfx33R5M^)y%QIL-b=z?xu*8mqWwoj=PGVo|Vr7$OC#${^=~jJt7*{I!moET2YJZjd$LphG' + 'duKg)dwO*IQ|L-ZFgCyH3pLii@cWsSt{+o4d4m*fb~`FcD`yv{B@Rf`CbgH?();A~$@9aL^|o&9X@>34zLvqI~`Z(B=PNBDz*K~Lg1v4?-vh5lDv=wGES^cOjn+iAH@YES2oq6TZvXF!J~J=QrBk;`w7^yI|hqa~V09a)hy' + '$%!@IoZdyS$xzZUy?U9t9*$HnL78DqvN5W|p~jkZZECwY!;=sx&^U=GwTmd#OBPFWt)~>}VgP=WZG_|T6cIVZ+$_*gr7Y$Kv-}pL' + 'tS~A3v@FK`E;B+ydOQJfX#TL^c-Lxa;d4WM((^a{|>lYx+7~ByWL~Vu7+PkVA81@g+P93PadA*=acA}iY!cs49Sg{|q' + ')_d)ZF5_gZ&5eKaq$_D+-SlLbU}Mo;Ikp|yS#+&Gk6LWim6^5a&_a-t%;u9Nu-cz{BpF;G7@xt@zPSA0R*sWYCu0Bq-T' + 'jK3Be_UKUTaxM~NlbbIPm|ce=8I4ugyHnyM&W%GkLKXE{E`7_9>y!1HlPK=D_huT~o^63=$ggsx2DAHWEw>3b;TYOrceRAO&EP69' + 'T-cw;+UokC$ne@2eS;0wOG>Crv0leb0cevu%>C1i^A9NGL+M`EW&)?u^TGU<)9ZRRl@^gk_n)zrF-W|g!i`xLCKFPlwA}`!Ggw(o' + 'P66f|)eT!FgJHh{Ej;z*q-b;US_!%j6$ipQLn(2boB+eT1w?4#8b<0CuXtUU(|S**~KZVl^uJ&M_J`Uc77-}d$bo|w_~dj0`Mc3Pivj|0{7H~8&E' + 'vuGdQzPnybF>NjarI+@RVRq6#rZlaLbXx4cxfKdfKo?iR3VPedhYM4=q^oEnzw`F(J^^{8b0=qg-;HQrt_CKFbF{fjw38>i@NoO6' + '^4`3CLK|YrnKS{gx0PLtENPwnq?KxLtEb(TyD)6_9jmxA^W=W0hmJ^2vubhUuJUYC0D_15v%kF4GE)HV|E|YoPY5fEiLb&LQxFcW?vM}H=>ex~;&D%iy' + 'IZ~dBX0@dcfZORHKoqQ1Io-gPgXE&|4G{)uR^HI7M_3~LS@W|?ae98Lb}nP4;!tb<{dm2kl#y6S34UOhT)I?gA$KuSyQo*YZ29zq' + '*)|oa@QeJAcTxpgFO}!Xy@vCohgNXlMC&;wuFjU_=)<&dD@e4u1COxs)5=C~&wyrOi7Yf{C-2|)msIQk?+>V(ImRWpSgv-CE+bIw2NkNms@65vFZ1HlMgDsfj~3PFnk;h8)gpjVHeGPm&|x}Su0e@S' + 'tD4*Zvd&K+I#{RI_Y8JTP4Pf)3IW7YY1$3XYNz(i26S&{-MgT=>fvo4xN))Bh;zT76ha^GyBCo~BPAV%@TL8*P13h>lp{z=)}#l^jpd`2N>@$;+}Xh#S+Fx7moqZua>bPXdcH)6qMXx7' + 'e_y8h68uxwp#fj%s44pWhXl|{)uCs&{T{&fP4Zpxgc@w38L#pw9=_%pEETz?;wz5BMn&+mMvP!48sd490}Gp^zrVv--P`qj-*py<' + 'MlmY!j*BEo4*$U3s9gH?%P|gm|E3ktff?W&l_Io51MObkX4>15S0GCBU&%0p9p`pitxCbkACjT70U{&bQddkth`|BHOT8SJt)ktB' + '@DSd8|6R9>U)w1$Cjm(KmDG64clFS}8Jc0XWERcs)-+1x((sL}I9&<2ysC1nM~egpwwTj6c9fTm7bQW<6CLtDnDkREVN09raVh@}fClt=^$tdNb|05E%qt^_?EhH72K~mjMIi!EkM}TF6kidC2QxKusM7%FJIUz;' + '&bK+(gBv}s+wF|p9Rs-b^XtyH9|%r}!eq3h%Pwo4l0NH1}U`j!8vj;k`z0fQ)8}qOW>ajkHic+ZXSS+H=AVR`|bp*(jr$TM}$umV_Sf)m?@cny9' + '|DfYD#GM`MM&{7je1tR_Yw6uuKo^E3*I(ggi6+U1sO19n%Uv5K*H!q2J{' + 'V-Hd!p77o`D%=^P9jeae`}E#sLQpf^){@|*J8bu5AxDS}J5;t_j*D5kpU2' + 'bA_9LL>YJ%%H5;yiifl9@n3FU6{9Abo$7NUzfQVdO?H1s(QYGqHLvq}RFsJ<<~s>uZh!J_oAq<(r_)d?DH4dyUn$zx7(aZ#h5PxsVoEP' + 'k!2>rA#I|v!us^oc63U43;#luz4hy0c?SJ5sAub!S9x`rUm*pgO5+a9Y$0;fSFk3Ie(5=toyQD3=TUQe+}9H7`c3njjH`**1~<)b' + 'g6G(M9U9D$OZ3BdE=y=}vGe}`P)h>@6aWAK2mk;8ApnGvSOz2q000sh000~S003rTb98WQZF4VWZDM6)WNB_^b1!sxaAk8YaCyyG' + 'ZI2T-5dNNDG2JhhpuHefYCoh(1*KF)qAdbiMT(-0ll5LKHuhqB4?=qXy)(A=HJi(UD#QtqU3=a<^URAkilSHLCKp?{WqHZL9tv(C' + 'QyNlcnc)`re#6sL$eU>tMWa!s)gF>0D{ZNHl7QG3N?Tx3D$A@;(gd2V%5%PTD^s@FR)o)3p0iEPV|Zb?W>)DK{$NFc3SxM}-=cX_Ov~Mo((FW2E}T<@~KK(L|FgYuxi>M0vRp_o;V59rqR0jASOv4Jl-t?bxikq5UVFciAd$|%SnZxGe!t?DQl6pb3uSB~q_9acHayQJaQ!pI@o|M^_f4+x0nN' + 'e#t%^JUU+UjO)fIjMY=HZU;pxjd4|_H6lgre6REYGa&^69PP;b03UW-f+iD<w3?4t7B1^yrjfc)!zk-ZIUx' + 'cuKU+i|!?ji}H9ebwQuNcfneZf%`L~vrB({vpTlynDNMhV^`;04E$q(#TyxqjqByX^^zy4?csI>EA}<^k@C1I%hEHfvxj`VczjEjNgWb' + '0hCNGmF~W20mhAC0fYE&K)h@4?aq~W{x51^uR|Ij{%)8~bB@4O8Fg~I!=i3?!r&HJiLMBJ|9Zo=@2KZv?ML&zc2h$Q=b|zcjGjh+' + '-WJ!?HGi*o?tf0IM2j3tz)5;j=8mg@e%XueYSLu>K-Lq9aRUT8935;K6&R45r+$#8`9-BA0v8GPjkA5Gha|5Jb{J@x@5ImGtZ@x(' + 'rFvG~QI?n#FLsU7LLnh+hc@=}%8T_<#@>h)o%p@+%XENkb^XP6R;WE99M2hH&YNQcr-C8pUBHC!K;HJgJsf-b0CbY5ifVIMU?y%P+' + '9P(D&S^3q&leytIBL>unl*cgQ4`%kdlDTTrs?eh>oh|Wrl=p$Z>!9}?SeSVk@vrZmxY!AfYW7FkcNF2S}i{7Kl=2R3|' + '|2Az;YZFq|hK}oDx;+v03!N9;)V3D)<>9Y4uMACrR4Cq;;+Ey~$->`A;DfL`;3)NgQ)2kqgVkHXvsZtIf$(cjfWaO839KV{c@nW+' + '9~s7|Nawn`!o-6VRXMBZ>Z{5iwGZTcApYHn^OXA(oaakIhji7I)fsx?)>QOGciCT<-R|mv_Ju?MN|wW3r98j3>ROjlV#}e-&}?hN' + 'D6n2H*6Y?snDu(Ri6M_6o!~kRzw0%^O{67H_RPE+B1Xz-5Qa4l0ZO0Sk#NrrP8~y^N+$`1--AmC#xQ~1Iq;eYI)!eRBg4o4KA%l{{c`-0|XQR000O8001EX' + 'R>?1I98LfLMJE9O6aWAKW?^%5aBOXJFKusRWo&aUbZ>2J?Y(Jp9LcpM_?^EZjs0cDngD%YBzqrNTfdrCl{6~89&39hgZWCP2q5v0' + 'i7HZK^WW#3h+M%;WP(enBvGcS0uqrC9`5(vbB_D<>+|xuU8Rek+wFWhyPo{pL_WD}uhQj3pI){(e*e!WlgZOReE;>gKmPRXzs+AK' + 'W!>*oRoRN-vozA=-lYsN$(m&srRS)cZo(N{(oIoW3K@g)*#+HXb}FW)&v?_9|Ni}-zq$C+i$A~o-xojr@g*Md)A?0hKf`)qvM$~k' + 'VO(@2HLIHMB}pwps9MGD@c^k@@Y)D%rLwUFSE^Gv@fm-4^5j4NGc6YFdVzPlc++mL+vWT(e|a*!y18su_&6=bmwwtW)BN?t=34di' + '$!)ut&8Lgm?K@tu{PJ0uU0vN>PZ#f=4_`UMSFVSPf1WNo@M+C1Tj`BKZ&WXw)WI2}iiov`D0*=3Xr#Dn6nv-rbBN@-+9$Sz1W<^dsI-Kjsbf?i(82MXt(4@4b|^M$>yxu8At8E-e;Wq6j{3zo9(xhWddwyuCx4ui8bL&)Z^ny~;1Q' + '`Bk4?R&`NIz5h0OI(vPWpMU$)58v^U-S*3tyJ*@3nXC+^WhcF?218|wk4*=YP4Lk@eR7i)ulWr;;r!;!<D4u??LG-XS(I>a0sjHt~yE=tMj4zUO?tFNk<_s#ObfqJ$N7Y>f#7B+2lY<9Z#2%xAn' + 'x~#L3trevhm9x^WB6dluQcpr%l3N8Ms(Mr}>kI<`X<<@6gnn%LfAe_}9Y&liz`nrR&m$' + 'EBA!?q?)uqW&^h|R=X~MP%%~6U3Ur2k2jLBelE``y?AfH3h*?YtClco1NBj0n=?P)>lRayQ' + 'v48sWAOF|4Kfd^JC$=VMAy-UpQFvXtOcfdm`o&9&$0X~bjoBy(DFs5U3WSq^ssbNqHhR$x$JQnf4?h2Pc%|pJGXD?pewn3FVWENlmFJ8X<^Y>r<$II{k_#=NEk?JS|SoL*i9Vlt7' + '6H!`YD%{GzdJ*yjeh0_WwPbCePP6vTrXX_&#vvNYoLi9NFmAPBb>rHyQd!}P&O|6#6X80xIH=tq@I+^V%b_Z-U1HQFhO' + 'fBEso1vA=p&0DWv=&r%&O#|#3Gc6_RI9y^v>q_x3~7~%hPuYh5iFrQP9zfls$DbLw#W0u+r_7K^{;;V=1iY`eVOj&ZTs#Z' + '^U#C7t1f-bzK2xt-qk0KU$ajwr7lu-AxEFG$+4n`UDwGc?@|ln-GBJwH{XBv{nszp&$}}t!gaI@9L;!}AdH|p5i+_&GXscP3s6%@' + 'gd{AwsRBE4*V-X?d&tprIF|G@y?t0WY_2#QS2}`Lp3UAsWwvx%UhmfrDuX&k3A?iB0oX{2(pV*P#7h<6i?c3+(0+2jugO(dj&4=T' + 'k{d?7DyfQBHd1>!52tZVcRY{Z5Z^B2*=O^HI?2}0=xC_Y0wKFbu&7qn6!x^nb5Lx' + 'uKB}OFuQ5joO3vA1)D1#kp2I4neyy5aYAPOh4;Q^gNiDNC?*wxY-&BH;-C!SyT!K-W3IgvL4y|fr}*j$DD40n2W;);cD88K>$AmT' + 'aoF5;E_%S?t{1cGvwSyyeK&1Cliu)KFJHcV-|Bj8Do9^yK=9Sjw7wMYN=XQmvH@j!1Kn}zlLw!ig=`)OYz+h&sI~Oct7aV#7SXS}' + 'kbID#b__ZdDnY{o6J#|R;M^Z$ZXf-u!v;}{Gq7H-tu8?F3Rr;<$XdFROiJiH+X{4yv}6E{a|$Jb?^-x?Kogb*F-&t#+x#2|q5krO' + 'U@={^vbeh)I{e$8e!vK83N)aA!)k=nj2)pWBMFIvzR&TrD~bn)(des^;-yIq{WcjedzPoJzO=!ds$=@niE' + '6nI{5693yT+uO|~{`krLm5XQlCj0-gJ-a?kuIu5-t%>&BrMqZvTe(}W8Qf@fftIJM#wK2*yTz=eIbHR(mBE|F8`jp--Q@x|`56~q' + 'ET-eDuX_6F6NKyX=4Lj%UR>PwD7f$TXIJ#qU)uE5>&5(H9K~S`<04&N&VFf(8jIVz_GG?Di*~V|_F2xAtBcqDVllhuXeIRQ{NiUw' + 'AdJ~^9pVC^1kr_hmtXjv*73W^;&q#hQ|Xgg@0_W=oY3|r!!sszTG)Ve`|f=5a`n8SO~0F@o14pb!wo9C2u23)oR-P{rcBphacvX%^00e{udrxm+yGnE?dIjj3zumsKrlsSbLkwg0D0-c-4eXyB~X`T;jqyxkgyV{nC7RlT{BsTf5)wWVJ%^' + 'Y`D|(dg&bJlRr#h539buemA-1@(3*A60Q0L&1sX>g2p6GuG=r0+fSzK0G^Xv0_ci4#>YwMYBe*rIb0pv{U$1MLayzJ&zU@`xWppBStL7V=!$^ScffnUC!UH9p$QBI7Of4*!RZ!!LihYo&0WkCD;' + 'c78Q}4Nu@be&9uqzQypZ?K^xoyZs@}-?aLNA0BkOb1<2Y}ioA?pBKF5zLau}*$tZ3}R)%T~tE;o;Y_1wI)G2VP_x(+!%=49*Rn(tqSl-~CBb&(3aP' + '3rC?#f1nl|_3|jZ@Yg+3kgvD~u4Q;%tG`|Ry1U-=!OH(9!>dnWLv!5f-GEwoiGaL)znxv-fzvq}3eT&DP)k1owe&wBTIr8RDZLZx' + '^{6ypIwwQYp&N|&a}OKj9%PXBaiPh*fqxTY?#KNa&e!vW&{Mg7Y@5l8|8O}eOY&etva4)FO(9O3y' + 'pI%&}CHL5i>n&#<~S{kj+oF`T46<`bJ_XZ=|OlfjLa=@jBLFd3r9RVM9*$JRSTJmfcNB{ide=MDRxc5C-bwE+=bjZAn?~6Eq7o`^gb%;l6z`>If' + ';dyIPSq{CmAC=^VwBnRmNBK_&Nb+OhNN!psbH9Su>QAj$ST@Y!UB5WiThvfW+Z%^A7#e0F)aXlKKwqG1Xk' + '<;yncvvGN3>uJC8iXC6}vnjMA>vOLfTxf-v_n~sMjGX4v`L1k!w`kuCqc}8uD@An4q()5s_Qf|pd`m403z{x4TEN(wHr!}ybJywx' + 'mkb3~jwVm}n9&G$EB>UGlbG}0Po`H_cjNH2pZV@+$XX%}BeZJAPeT^;^PQZBd$X!-JO2W|Ug6nb19$na2#ajg*4qt~Y|{-});vD@' + '_aF?Dd^<~IR<}cAhsoHLp503n1`|GwDvXiopKO7$PJ6!_qQk}(cb6&;u3--S@o`jR$dX^<=kObi?KHx1;LDGt9S3gnDagm+TYpLl' + 'a`-8y5fQbYiKtIOMP9C1$JWdM9eF)9yqXOD1#)s7tyVKr?DWg)z!HXmZ*NPxSuE!oZn&Ju;xwTqH!fJt>a+1UU658)g8@ARoC$HBXQYSwY^K?jJ(hB_4f?eJ}v7mmoZb?cWKrm;n0STo^$4)fy2|M~CtDa=q^' + ';nU8h5DZqhf*)6xoGoVS<=6?(}9;gLvty=?&`*AtJLq+qPWLoN;%8#&aTVZJdJ<&>eP$Cj' + '9q_BQI{>hTc7Gd-T%#DtB$I@u4>7ZSicK=(V%rK5uQ*!OM0&' + 'yEn@=u9(d4BlBA4PU@_UNQ_g*~;~azH)phs4cw?e}UxB;)bQQuL0gz*bo^_BN-}8Jl' + 'kkb(Ip*!qN?;4BT?aluEZro?Se-r4lt=;OMI!yDXG?!&g?{0`*tvN5(>pn0>FW>v)Q+~(0`~zhD{@MY2**6zxZu%JAv`hf}0k@ef' + 'jdDBpYS`y7UCdXP54%TmDsM6Z&B3Ir!)+Tg9&XlohrAtC$b7RYW!;l!vuoUJtxi@?UClbL?RaUb<8t?;^S(RD$wNv7L+kZ;YrMv*' + 'HbdiX-%ave?`{2EK7M)Ev*pC|(88rK0v}~0Urt${A(X*e24%FRyl&eSdVG|SoV0G-%85VBt~Rgo^5x5M-g8LnZRz%sR&+JJz1>+N' + 'FX?)AJ?se`lAO19>kQ*GqSSzm&}8cco3H=ln-@c7+S$s8CmT~|VEzF4dE(ZcdAmy2%m*(+IK5jz;?Vw8A~y@M;{DoDpyUuu%M$%>' + '{_xE=FM*1%waZoR%>N;tP2~T%3#6;gVIL2W`q!a0EA(<+zh&vmNJijYFH-Cm#D_E8R`KeDUm)Aa5R^8PdIw_U>8w-tcQ=DL14F+9w>%r?Ox{lBH$c9X' + '-kkq_0!%u)Y(G=N&2E25V5+Hb-DWGTd_{;q34KtXhCis^7J=X&hKJs)?ut7Ozq9ZyYf0Zuhv0E}aqpYs75iz&87<;hVIC`0bev|i' + 'R9?To!D#S|`}y6Z7+VvQ8@6%E9@w=`10*aIi1Dn{h5`U?BN!' + 'dRbR3m;^Cizp>3_DjoXruj2;RA&YM4{3+MeUv47B_mgeEz#s*3k7Y|Xq8)5u0g0S`PSZ;+P@mQIGi-jl3dbdb*BuLz45iwakAyU8Z5-{z' + '?2Na^%h5o}yTYw#%r_XQX<`OUU446(c?@)QG`l*wM1frcCsCh}#^TK3M&o6|Mi7Y0bJHKG!Dcu@`sv^Tqn(l@UFP<*1$gXuCJsnzZ=O@mt%65RBDg' + 'c53#kvAX{S;G(@+9!ap=a=1w*xL9q=;a{(3i;KaG3te(3-anAOlN)gVS*699x{Nq@i1+6&$>wI>-uAdpbZNXL6%T(Tk' + 'kh@CGv-}s#D49@-jLMo4t1{W8k_C@3kO~*GLMZxewTI{6^X93y-$@H6HclI(Q^DqZ>7JhiT`t#O-4#BpPE1K*882=t?1_4c2Q}a>~lau}0cp68rk11ZA2-n6^E8S-i@{rXs8s!o?Vk4>AgtfK;y3)Ln=kZ_pfmH@3+<-hLpMu!Moh&gmofTAP#T' + 'Fdh~X+KnaXCTAayF$SrzSgv!8+ad8mE5zJbfpxM)==+FI?Ea$Wdd' + 'y@=Mgrh0%W2Eo|ym~9r_*1q-izZ{RL;Sl8f9LDAU}S@lqotPhYCCI^S{H;0' + '=m_|C`!vJ9j;H4{1j5U$BVKNyvpBv>p17$FxmAy9+dR&1-Y*VWFVA~U(~-CjqrNOqJ5C=zf9O`5gI)|?K&TXh7bT)MOBF4J;1Muo' + 'hkJzNvUReBjtGliViXy^!>YbAM-|owy};|?^HHDh*2~GWi_PTOrUN#gz1Q-y_b1&xq+dSZ37hT;-6vzMuhx61bM!FJW`coD3*j3A' + 'q0$(BgpgQ7D#j;~WR?ofmUCt$`DJ%WE9D`;)%G`mxM;=>2NY7X-3amA1iYv_?t=$95$)$Z2CuD~J)&AHiI0_kphVDaUSI#vQe@S?3y?vek8jxG1gYfM3xc0lL+w' + 'P$B}D^{Q*!ED2GwbV`{Rz3keT5Slp_b!Ckd;wiaLwgu@UC(%c07zy5' + 'kB+jzdkYwtlrn(Y!4kWVH9QJ-b$3Q0Xu^_3tFj<$sA96^!3cP-P<*h;hknEWKr|hxRcM0e&}l2tE{sYw>;aZW7r4x4lU0s7Rh^;&' + ';q$l#Mq9)e*gXcj+XnK{!290KHdx}j0>?7pCri*tepKd2H?PE|lCLfmZnEZTSof5&|RM*;Y%)#=^U=R{yOWCwzafS}a|Rsc~84^6TJ' + 'iC~pmRIMQ-As7^RB?2y3KGdo63Q>i{sTE{#J_^CZ6Zc`T(15)t9q>fL{{g@thIp*O^J+2DR7hhP6C5+rtUbtKH3)VoB%}Hf48~*k' + 'fpGM(2|6~ULlZ?{K!XMw&=f9&4?M&Nmre#@U^j$Lf;v&zR}Bc#)*Asz*QJFzMW6>5^a_sY!fuE?x9=#1%$PrsXdIs5F55TVQfwywClmzPDX5<`+~y9PpvF|pY)JTuQ@oQ$q@jfS`~0B^js29fHjJ&AovjVV(>h87R$' + '4vapigxzUKJZxn7oIvdE12GUpYd8xuXx1rrTdOjNP?av8MU?~fCIJUP76{e`x-y)~0ULpd90g)`2#7U^4V*XCDzsCfIn~ggg-2*6' + 'i1KC-izF=533z&j3M5q#+y$2v3CSEKUdF5W36gGJ(L8RV-Q=Q+rSmY8ZAEz$LmZ' + '>6jQwKwSengVgUly|2~UV%FYA$Eoe+L~OZ97fne<$`-XV5bcepAV=8h7a-P}ruHxpi@+%^*YGfj0H>Vt_(=gW49`oNSqw#690vGAcYbH=cYobHaKg*;zgDn)NSBhYe-RKjeu_xKRFU_H~+IC@hK14o|Ep)wVEr>TCXKk1D=8&DuCfS' + '9G2`oDNTk{bAhIBL0n~z&PB+djV6Y#_=9uO`2-w(``Lk5=N%Zf!sx8D)&(vEIcE*Ro8_6^aEdBZ$tF9V;G`q8V#=wfAgs|g^Y~E{' + 'j!#MFz4{NgOu$?L|8Jc8=1fDd#Xz6(v?(55jj$%X=y)&~V=s{C2I0*IAao-LqcesG`Sj%`5qp_eKKbdJKaF`uL#i(KEAs@HO|H(_' + 'Dl2bwWXz' + 'ZCrZ)Y3jkJX%nqUDG8ekJO-h_Hg9oP1-QDijsTv?L' + 'WQLC6lQYCk<3AfeJpPES1P?@VAzI21)+R`<&4&UuRD70Is16yYA>}1pLu7&2E`zIJG`@;ng4wJx0SOMQn5>V<*&&EqXGs0(ODD?~' + '?Qq4Mg>pQ7UKbEGgOHFTs54w0u+*w4)>4D2(vsFjrHcNEN@O5XZ&IWqTygcteXy=I&`xyGYYhl*(ubD84kSE0Ng%6t$twru7^+T^' + '3mp+mGAUDepsCrRi+^5bT(A1;cOzT8x|?&O)}p-LbELhRrm^(vsyG{XAGcTxfBTNrfM2Y0+|`iwyi6TGdzBVr#jt_!rdQm6wb*`%WzE5NXP52z)v(VEpG{|M3zN%l(lzXHxdm=bY0toY' + '+mP4XhDzom51!0A`LBx4CR>bn{7`TB@Y%?RcfModVOD&$q`li;0QDU^=Ktb;i|ezWUZ?Bpp=RCIO@%P$lb0JBJhJH3?Z%?_H)nFl' + 'O`pGpvCUUx`0l#khOfYe(Yx*KqcS?Z}kQ})GjCNgV%^W)AIWXnzSm0v)u1-wsim}>#L1f`a>Bi7z-Z2*7E-6lmCDR4}Lq;s~QB-(pQ)LjE~>*nxW9)j@NMS)#a*s' + '+XJ#_6^b{3`kp{CuGl_kh-ScviZs&MY?LWpCa|O|MBtLy$pWc%)s2BJk(LNtvD-GrT=YFvMhHOOU~h' + '+Aekoh5)Do*eN5R{z3%d2In|3^a2vShTfb3R@#Q1g8?dh9rq|Pp&O*FJlnmv0tnAlTj-D?n!xoSy}#MjoBwT~Re=2|rTZ$z9^U%gB~9aVImf&pdOtY|suN^H9>!O9$_+M+w>Z$bC6ZZ933~JUu)tY3$' + 'fH>U}OoY((26U)|WLsm-bg=RZA%n|3K*)ecV8yC{l_{4a95D-PdrPsWV6rP1KM~EN2pOn1#t5CMFcLjG4Q3IP^GQSn!i4_imb`L|' + 'i#MaRDq3_ft={nNURc>c>5~^aMwbe}SqU3NHei)5L>Q<~x*A&}2LE51kh%N_Hum7!iC?OjMs(ZEV92Erl=K{OUiiNn6c(^**aMwDE-lCmo+0aBtUn`G}!NQ5@9lH`+%2lEEp-dQp&stiD3!$hgrRaGn$5S-H@h7i2M' + 'Q$lOG#3X@(zDm4<_A$1=?5cx#1ACvAd1kEkA!RER_>Xi5)UI(|DOa!{*YP*tYzn%Bq5MHLL6R(4n~T&3^9J@l%_9SB4>?pQ$gvXIg4ha)OfAfjEr`eJ`>gnZ={Ye' + '#}=!|`U`bukUo@p!AcCtR^PL5J++>z9p*Z7Ev=)EtuuIu^eEyES7ur@My11(d$ySx5IrN|T8f*>TiKV1^Ki7tH3|uN)vZ;yMm8c-MvUWU{yHyaLWGof^4O0y1anYiC+mwW5LvcjNh6Zt-7fdJT1}@VE51Xwh8+J%=hO' + '(ZQwQ#Kwc5DDNOySfL`@sv07s_lQIYf6yr2Xx)45O=R`BXki?%KxS}OgxQ>Xg*x((3I;+qCoQb+GHczE(#%)L&WfbHK>SU)N?jip' + 'ErvW02l)B+m(}TvN>U' + 'NMH%3=ft%QW^`I(L+fxzxa47yr}k=P1Gee4gCe)Ff;$Gm(i(^wNjk&HJdB4eRl5dvZ-vS80FYeKMY!^Ca_HWtWqeyS)1u7i+AIW!' + 'N2^tl5W)F#utsw`dSoh{K_Kcr^Q9$2IZ*XqoGj4L&*oQWEZ}~|a@+^6OoC9T9WLoTsptj5B$yRcfm^ALhn~AUQtIAwjbr7d)EdMb7({^=J66;(GDU!y' + 'wQCB=_v;yWA(Jx+vWDe+8mAN$Q)V&BJvyZo0%?)9Kyun_Y5}`efZ`IsQ8S?8L9CGns~I>8GKQOgJ7p!DiU<}dLANTX@G7`_CNtgJ' + 'A}(plPXs0DJ=hMc3lixt(ERCk)-Uhc_2TSXd8n#^2Tejq1yyBEE;OWwtOVo%pnxDHxW_IbE}p)$<5XiU5!|jMFW@Jp7e)@SKOlyz' + '20bSI(OetvV*~RyO>t@fm|LP|nT1$lWPznnrAT;yw=qJGgHwX$>R6a{4n?{@AZ~i(tLg0d*eh3u%H5fodkYG54a3nOrXm6W@KRtP' + 'pc%Mj&-?ng{B=Mhly#J7vT@ug)mQ;Cx7f|E@oicVV-fa47H_);r69dXU!d3(BTNZ@{47uxG6&b>bt~usJz2}-1wx)_mm8mBd?{5$tpj%z`ctzGZ12t~y^nF6zmI$p41p@xcDi$h<>PeE&;E*!n9(T?j' + 'b4~P$EFed$rZU6}n?%MvS@j3x5>yFo&G1SVbxcuJkms&4H%cgjVLQ<^M@!u6nl`C`HlXi;$QkllaTd%4*GR`1oDm*+){n~+liWlX' + ';giu}6eYqB!mgEFhvES7EV05+e57|YVgvVb8Pz!rV;XoMTrZ(HklaI=B~`5hk3-N69X!Oj%z7`dunbat*frfLuK8It+SrC2&xAqc' + 'CP9u>kVY1eePcn8K#jO(8j%M!BFOh7w1+E9_3xabOu9O+V>{xR?to{q&O?9=CgZHEV93^j0(EXjfj$Ce8ezdrydeD-5Rp|P{MDut' + 'Ai_$0qG!7Op6N=+aQ6~4_&p#pI1e0EAxj}!@4X6q;Eg)jGeMhd5n01@5jqghy=A>#C_W*bHvvJff`uI0f6iS#Xp`i8ssxI7-J4Yy' + 'p=g*xX-Xo)2S=?v!CgWuy$Bg_XXCk&dZK5#2Ru_jH#0@eL$O7U1}F9s=b#km4SdQLjkxnD&kRWY}7$%gT(Fl9Oap9cpF&=FUO!RfR9`;S1^L(&8' + '7qG{Zup4PXrM+PpV3->`2;MJ%*g$T_qNk_&B%%uf2|!Cp%SypqWJ;;Z?m)T#7A8r2B92%cY>kzuy+i-zP(j2bvzt_96)9Hj0gwg&' + 'LSbGp2zP0u6A8WLV@a!Jm7{YP;0j%IUwwu%M{X!G4V<}yJA0N!g^CCwNqf?)1Nuw|gp$To2fm}taB2-#Yv@PlzFVHqYHy>Q0NL4>O&Qily&WSjv5<' + 'yVbk~aBv>5gU=|9pir6l11ck#pQONQs*$E>E*FJB;Xov;M`P)s3QOEfA~+=h$AhfvFmoWRlD!A5Kr_MrIZhqg$5XA8Jgle!F}c>`' + '!;x5epu&>iT!Ta4E?^}pB*bu#2LP}OvNXUV3_5yudc9%IYCRSLDS!k>&9e~@nZePZ2N3oYYdAy99Fx6Gf$;epT9HL~CZiU-umjgt' + '&_YUdp@S%}Fwp<3N`miVgupCwvDCxD9G41Nb)m9&8bS*o38qlEdr7m#7`L82YQH{+IaE6Slfc#k;#+hOSYV;OHR}*o' + 'L3^D(7E2)^n>CtyB4UH#v2+Gxed01u)Z}NPAUFlpphF7Ji|BAhYSWg<`2S^N>29T*1CjLYXAGtHYUCuFT?K8A#b+jfgZo4bnyjuE' + 'YXfAVVwGqI5maCr37SYbFQf|Guh)(T()~4ZJd}76!E!1xN~tyXQVX8Vf{@f5*9EK#2~0tI8!=c3)XRdD84dL>+mSH3x6F%Y`OaE3' + '2CS71k}aCUcxtBBBq8vx#7qZ!auD_1A|OQ=Q*IEL4v~%v4RKfi1u0Pt;khaGxH2!WYeZp)9Kq7xPYS21(J~WYrZo~L+Sr5Wm' + '0q-EBrAyqeu7xe}ab;eFVL7bcI^tkUp=bwb2O;l(tt|JVlIdXb;KUguxI0c66qdq(4{-JEab;d|L<}&n9_Luix&SYths73ZAQk706^o~HSqb_y%MhtCA|4Dy' + '4ij-0SC^oO4?Ruqe_GBhgZY^R24o{6+;H#-;s6vkVjPPq;Y*pRFc87uO|T4n##L8k+@YtLy-!p4y7UHeIzm=?pt3eB9U;WrsY`h3|q{}TtIj(yI>Tw@;vwkaj{gPqv(>PAIkj?lbF4X|pzvT$a2AJ{2d&X76f{fJsU!()`6P$#3Skq2()3Vk' + '37jOcK4()IkqMNai9l+7VB8*uj$Sw}?fb-v0JUJKVbX}ty1}&26Fk|>_mcopJv2L|jYW?JQvqtx16x?xhl|%45L9CAU|!X0@hFUh' + '%9YK69kZ;TsQ^e&4~!hR64U>EPatW2^Id}$aAZG(kWM?t*DiJ5Xuw*MLJbNWy(HFGWiEXxjQkGe?C#Q' + 'rv!NAaStZ+2+nMwgS^$K!TTDN5i_f^$QgkWBMsRkn-4jgheP>+xv>ngV<8X$6FON|t%S!x=@C#ek(5hP' + 'IiJ4vr$LOEYnqib)v#=R&0Hr20nH7xH6a{P_#Iu9ufQ>GpY9OT;5NEv9x#%0*A7h6AviXG8+?b#t6D2~!V|P_?2zC3ODKP6ats>6' + '#q12?ICQXsoXcpYbB>g|a`LqyJi5o#rZIy*qS;D9B4V65hl!3wL@O-FV?szXH0BK4}psyiI' + 'X8$-42VIzv$-Ix!^S^E=e}8uDp+eICZL(Dzc`T4PnJ4bqy`A(l9X2@j*=?I_LOwnz>5kigFXv;=Uf}uD`Q*phbvwr$e;POJu6t|T' + 'i@H8kd$rB*UeV!Ui{aumw?%HwqCbQ*YlGZ6hy24(=iXc%&8`W>{Ro+d;7Ao^2eso!o9=`d~t%8' + 'L%&bwuMZ5~2O7fp(+`$G#kf|Wu=Tw#ZSteTWYKUhV!V7rhUkQgF}SKjz6PjVkwprL)nO91SU2_HftH&m?gIeZ8_?<=}cW-N3ul-yXeo8?}gfnUtgf20>@i`m)g=gI4q>im52;%+hffg|_b?DmH=' + 'f79w8ewehkH!~gp!aL3zo`SK$0A42y)Z{zBHsF6gnUf7P4rZNP4PQMQzRTBM)jnAj<+$FoY-l;3{^7Ky)EZcTB7h5mc*<@}lda)D' + '9v?P?SmRSpw!Ssa>uTHt5r4BJgZ5#A3=%f)|HN`VdT=P{vylYZ`' + 'B2*6#lZcDmuzr^_4!0?Q=wf)PY-=L76W4qMPln^BJk3MRjly8?=;Bsk0h%4b8oqr$;(vR2onF04w|_`^aNDzS-1mY$Fr#Edc*j$r' + 'Y6pjFz(~MlMYyo|74HQXecTi;BdbZ;Wouk)F?lE);68>uW}8?l09|gBIx;U1vr2(8f2Io%SFX?jeLR?Dtd04IIV)pBfmsExa3vIY' + 'Xp0_AM^G=P&InnXfk=p#0#49)24e#%n9QRPtPwS_`i+lA#-CFlwt>rm;dGY$)xeYx0nV*M&S^wQX^S`w;SiU(EvIRC4Uo44TVRt4' + 'VgP{GOtCyUBvTB>p8-WW(N1$=;>GhB0k@NMNdLLID9t9#~o00rSe' + 'a+gJlAh+n^*17A9RbYJPWD~3h(ay4dga(^Xn%p0D`vs!b2g^ZR@i@}Nd=40QFa@ftvBrVRx?3)|a3U~*GiwhrrIqj^@-R)$?34!~' + '>QDtwjYrgu2yYNvL31g`a+O^{!9sshR(NRdcrr#QuXwH@OF~*uB_8qyi6Dw{QAU&Ccqa5(j_t{49+A374+@I6?=6A?ajyW&iU2$z' + 'b-7aoK^&nhGMW*p@B%VbXw#Dqul@~U6!b#sUGeM}C`Q{^Ljit`7v}^9TEj{fc9^_41*4V&zdy6nFxRf;o}ht5;z4onTyJW}c%i{T' + 'RX|vBk1Ge1G89hZS-TovZ_ZaA1T>!K-HXb~llHhHAZ4Y`t_55TW2z+^$n?(bDB2co25~KG&FbsAL9z1?RX_^}3&LIwA?ANvkgC2n' + 'p>lF8CmCuih~^S%TPp5sswFl~D~b33NU7fdN~t|C#i=v+84tRZV8Q}{C9p83Nn+Tlr666kZ>HA_W|IquCqOE|1ssqqnPV`e4&#)v' + 'y@AHY&Lwiqx@(9aCNjg6YzWY3lS-axCqo{+^$yI)W{YTLvaMfmYK2oadhVl99j+#MIvW0M<-2T21#XX' + 'dl0A8VW0x}Y_{=i=iIB;*&sCX=raoBHf*zw5Jv6v`Zq4SREt;Adc$ee#LXGeh79~Gu|OL*NUNhe?i)|H=4s(vSkBPoHF~%!RF&e!' + 'LJvp}!ua4UbUAY$6Z7TcP^&1)FJfv5RO$@2s}2>M=C-$2W1!q6&Z~&Xe)f7C)pqu>wPtj{6~H' + 'OEtY4A@}VP?>?$#ERUN;2cSJ9YmVib(4)522F)JR5!h3;+n~D&@GGgn1Dirr;w+!=-6)t1@Hnz|q!ZxP@T$O_`mqQMrW5WT' + '7xwU6^iuY=CcwUIu~u%$&iD=OK{$ag>Nb>qKLmUFHvoHj5A30HGxN1IsVV;P0ruP3M+#cZIapdvmOQLO+Y6G~TsdXLGBU%CzlUK@' + 'KX8Z$*b-SK1@>03#{){UZ#{sO+FHO}B*J^+`7A&H1bh>OFcE|_yG+r;' + 'f)O2qtwezl&c=ZtyT~_ro$LWi9VEdg{k>;>VhGWET|Dg88kCO~vr5lql+!zohAgv6Bg4(1RfGkLkQ?KIfa4+=EJnw@}FP>95}XTb_eUR&sX5sXy21ZcQNW*2p)cXbc4fbnDXKOV@=' + '!kkUFs-0~C#8)JJ8tz$cU@xrA+A`}~gurt)&^0V}$>U8^P_VCKbCL?CA(IB76JSg>D_Il#S1l{9#>{NkL=sk+57`Rq9sv0oUZb*j*6Mas4D0#O*!7YOJful-LobcRV~B_Bl8x0fft>D$kVr*C&Fe}cCIAe+#-8HslQ' + 'L9*3pa`j|93uA&MCWH8dk0j4^0hKZhfLC-e!5s(FxBF|TfaV&@9k956$qwDvJmQhX;8$?I*a0s_gZb$$olF*O9OhC~sAxp{r$f>q' + '>uvq{pWeNsXHVuswf^aKZEyK?G;NyOvIXsJxxA~b{%&YmzMEe19r5s!;^c${Elk4`&WR2L9' + 'tw9?b@Zn%ztC9$aoN-IeD#RvjJ*=p8S0QX5_gFBc2BRyusezPWa(qAtsR4A#KMeM7_Z4{6C3jVnq86#n=Pq(Dxt3~&UCo^60)(kY' + '%*1Lm)`yB{sguw$htQddno<|0K&Nw&z_|X%ZJ`k$*FCoggoMzKdkJfGj6L>ZAjcxeZ7+}kE|d1;gy_!#GnISA^CP!~#@?COqqB|}' + '3P5kl8UBVCpt(I0Bf^s4OcsOSN{DGaW89K1lp{r($7Mkksik6N1MUs;T_A!3WE;3~u$^-k%nM;UROdGJ3XaDddEc#u!' + '87+J-qiL`&3A$lPkRiI66q0ne2^_?_T!m=NpMTuZQ9Z=yVTVkS#=VCcS-?H0PoR8g(3GN8QN?Fh^7s8`o8WNI9DKYr$@AX}%Ynp-' + 'Mhee5_CxI@9%uy7;6;aD3Zs}hptu`7hU2MKJ5SRXu@qPvnK?)x6O{Szzs^GaUclTyB?^mv4faEu3E2hV5;%=tRWg1cwQA>S2mmQq' + 'ouSyB(qL;gIgPiqDXGZX^(|<;G0(t2kYLp{E>uE;;J>YS`-QxHa`KjEmy5rtZR)oAJSsh7u9!SdNI+z}8$vumE7H68`T' + '!-2r0+D;aAxx2*=i;<(Ok2C7iw|L6+-4z%LSi;$M3iogx5g6F|P4R`>F%i7Z+Q^&ma?`5~McO=W`$l`Rz9;Y6;(YS|%x>R|$26tI' + 'rV*1@>1H@QYqEQ0{ppAQ{@;g=!cX6U+p5*MLld4mG(ULM&{u1#d2gAR%eI{CYf19ZGsr{)m5dk@G#Ig|2)5iT' + 'eJVM@_f+B507O^cVB5tfZDr@OcIqZ@F4}>(9hrszy>~3j0N?@yYtFh$?&5ALRlrDwRCd~ekyouTd#m-F*Qy4~V%zC9^^D7(orM;ZXgGf41%m~E5>Jq^+5B<#e1nCsv%$~@bg' + '#dA3&n@c`iV1|Kb>E8QxBZk0}uB_mkE{vXq7GRwwgJDWo6F6?d@aog69-{FAbQ3BCj9aiqZG_ufXllNU&#_b%XnCZXfRAVJs>0H`' + 'f{AI`fB>5=72LPb_=Sglk3~}$f!6zKdR*dc<)$T#;XYbO*XJ*n)8;2T7kv*tnqlOLyK1JJHbdF*)Jkl' + '*{UKa@mN{dL6gvil3XGd?px@1Bk)TuoItb;l~6&nGuQl0SEnxJXmq?~^+83d+d#lr52l2gpa3Br!ReG-Wt1Ob9e7Cz*%-5xJOPPY' + '7eV28=5FXwga(#}ZW7{f3OEx4BUr-XxKAYr;U=^JapP0cspkiXR|jekGeQ`D22C4!;$ec3uUQthbi)S1(53!dN%m9' + 'vcTx$Qdc1Kt4MT{W;x&zE(`Jx#>HyEPSs9J@5kmc(JHSokPwrV{LeywXsA~sB||6$twEUPDa4QgoFio|$)Xaes62HIPb!H)wor=T' + ')vna;SPDD<2r}5VV6|&FmZ+y?My5J!d5B%+#4JeDs*e;YwU!E~bXoKu)#1%FDR;;^;~^_p+7-c+ibAu(P9AhM>#Ul4)lkl7i1?T7' + 'BF*P*K{UCYT@I5p%kwCnKDkMY*F(bpaQ)ga^jG5<98aIz-CpwVuNRA(`R|@Tub1a|jp?`za~Se-0QvKtrnhJK^7DiD*-FHV3I6UQ' + '0JeFL&QP>z=KeQt5hP^S(RvMZ=6nEpA{xlf0&nMWe{RXKai<|C(jv^5!-%Q&K<1}m=~wf_zt`b7SDvBzZxh_3eHfOi9Wd?O;R(|&' + 'Mi5m+FFd-TB5HVu#}-|v#aNTU>69}z2@hjuNeQ}sR^kLORr?Pl_L`L@CXL>tI;nSLkB}gr#O`5_j4ViZd&4I#%!KF?WkD' + 'VWBotHEY@!L~(C)NV>-^c-9|4>DW1f%l_CKer02wbIoEkR&vG1NiTumPTGQjMrh8UH1TnngLq|81il(UBoqX=5UqoOafeH?wE>0%' + 'XW4&^emU%~7aZkZ(`kFUoPVg-4vpP$UBO*^!f<1>2Eh3YE+U~axQ$R($4yI7HUk^C-BsHS547gV>FAuuQ=U$6-SMgWjl@Re>`qTFqJIGhof=os_' + '9>J5Lf^oOfphH>3y!ZiskL|^Q)Ji-HH^jg+-;PJtvlbm?jDSJJc<>#1#2}xjiFs+3w' + 'lvK*1a9ged>Mt|zP1gWpP?saon#D^KzKTIPufJXaf0Re0Pkb{^#glZ3KBe?urn' + 'E`14*ypFewR782$WBY6LS3(a^WewiPb@i`@7LT%YzPP!(nx8L4%bKF@Ole=NKVEh5cn7%9dYS&gyA8i1EL`k$to2>D$8ESNed5$8' + 'HyHBYC)9luk9p`Nb^@Mc#u@9FNgmT`bl0A>escxnXpFk?LRhXv1*U`m^+aXi)N6cvz`Ng?ebdx(Jakmp>Pbc$k3aTOxyt=r' + 'wkfV^D@k3W@S2isI7r+lwb*Xy61%8fC?$t)!!0DOtmUz%OLPZxr2WCk_Lph7<`Uix*_d4s)Ua6q~MeZ099%AjO99Ubex`=lKu?1=c{Yb^5' + '!MQ`NTnMQwk>nVaN)7^jrJzq(>s*yzqtpJe@6aWAK2mk;8Apmb<7~t>#002(`000{R003rTb98WQZF4VeZ)9a`b1z?C' + 'X>MtBUtcb8c{Pv0N(4a+MDP6-VNMF`e1PCV1VM1YgEx^T^kinkba#d%Bg}riS;1@7t5j+jhIhOk&}5?eFk|C-K*vV5VIi1B;T6q4' + '-p@9sbiPqgDw>f2DuI$o{h6&JdpN^yr^l1O)PsDcxe3zc;f' + '?rtY2O6ZhaR_c6>#*n0KTnSdvPBnyBz@4431`%HXmO_;jJsjP;bE7d0!;sRf(jez~_gyJx;J?=*6Yc0M-BQXKYt8u)eBUa4#fB4%' + 'Z=1QY-O00;m803iSX00002000000000e0001HVRLkFY;AKdZEs{{Y;!MVb8TO6Y;|*RY;|)lUtei%X>?y-E^v7R' + '08mQ<1QY-O00;m803iTBIQ!xiD*yly!h^1sxciKa0ZAaduVkb&$XZKE}f9xm{mpB(N0!SNxZ~jQ_uRQsZ{>DX#29Lcy52Y#!ic@r&#UBqE!v`O*>m!KhnA;MMvt|O-)%t(9NR40?#;nTGq{4HQmyp' + 'ZHxMXipHBuT9^N#tt#+Jrnm)CjX^B;kv(Q}LZ2I-4SB)fl1pd4zx=T8L{^C29IpJwo!%TQd-)v|-qijmbMM+yTmvz9;8t?7(?d=gO!Y3d4x2{%QTJ$}T9n6m175wv9' + 'W<4op0Chf5IZvu)c8R^!Ns?q$r5EK*S?m1#g7*2MqBpr}F<%7prwBY>SQTMrn;g={?r|9' + 'v`7xqn$L==;$_DH^wQOo&gU@WYO0TA3P%t*fh9=W3F&A>XMMgb=W|-;9jt7c>)HX(obc1W?na!|Mu~V^5~jOw' + '2~BxszFpbO@u|Xq1)ux0E>@I$l)-offsF`~Mt!U2oE3phhm;i4n3' + 'yYkS~E;#Y%XS{L)232u2gSBTcx' + '$YH(wPmRGmcyM`Lz&f3{>;|};#yc6VVaPR>>ZJuE*FWq+#tMT{#xJ{AV4*?rK&oHOT6m(8m1b8l*B6vFf7%7bYJf80%g*)|7!>r<>?;0J' + 'gF1d`E}FI4S@ps@(g4FXSn97hZ5#b*s4GN_+#<|=&!J^A8RQWS$f+rd1YBt' + 'i{$1=sFAN=*K9yahBZ&yiWXNC7{C-pA=@64{V%MtYK#AY!DG8K2otD;-oSuilllj$+W++k_7T{k5Dhh<#cWCX>t=s0_h~2(s$xx<' + 'DlS-w*$IPpl_;HLak>ZfU%PRPO8gqXh{osB01dX()Sj8g^raJ(l3C=O}1+|-u@' + 'r}(1oKyV|?d=71`K<=5trrtLK1#Q<8L~crsZ`Qasv-A!mI00_GnO5bDOzEpgLm-BVQnRIfKg7mhgr~3zIYk$x&&EC^#W({8`SoIq}k*GNwDHv' + 'l3UW*C7&4}kETbf)SMD&nkuWZzvVM1uz6k1VSY1mNFE^8^i;|PVU_!W7?bTt{N!;5!$Ee=G|yxt2F`1uic0649&Is~sP#A!RL}Rd' + '87Q$mTXVcc(Ix04s96=xgF#o2dU|Bk1G4gxjD@zTyEhw3|3#C7v80RuR7iE9dPzf2Zv@TaCdf^)0SnCdE@I-_8_umZ&jOO5GL#+d$n>ci!*=4`x9j@11>K+KPNSG;' + '_DTHC7sU+Fe1xWG_}?t5(5oRs3doH&ITFqK(?hFYd{k6V3hXiEjPIdXSYwS|mBvW&p4;ad0iwm4ip{$5w&{' + 'dA5r_4kx0TxF}SOnhkm=6OF!VH>Tz$aKb5`{|Ys0=r5jj;0R@anhF2Hp`gr)39OF^ri@+TRUt-H(|BNVteLHHY#25|1Q-Va0Lv}r' + 'lyHW@agmuMqB8RtbO~);uz$JZ$6d0|*F_6b4D`KA#s*oL^{_PClWhf%H_*{Hd4)`yZ_1zmyFGx;^GsVruxHPOTGn^3ge_gZU0XX<' + 'Q`qq^$JShoMm^o#piyMibh;_4Ipd6VQ||-OcA%EPMy|NtEtzf>3slvB6P%xO$mi!uj1gOtXwlG2Y;Bzb$rZtv{Y?0OnnY*?5JzQw' + 'm18eP$fP*BWLFB%P)fiZ2;;C-Bv$6Bt_WMZiPG_Km!eQohq_Va+5wbPOTtqv37(wGjKqR6zRO~|z)5RG-xJ;SiE0%VvYOkBD`48(' + 'BlTg9EdDcjG%;A!p`@_P>LjG1Mw;=8jD3B2IJTL!r}!qKy@%vc`WZ?&q7~3FNOIsL0U@~j?oQgThZ_cHB-o_Ib=>I~Tv+Ck3abpt' + 'V9j@401Fd9&2EG=q~Aff+lN3BiNW;ZYR2>?Y)F^G&4' + '{I1dBOl#Vr&d>X^->vBkZOkC?ogAC;m&Azw^s_g#wWcO$E0Run2fRpl0p}PWE@_JLU-fVhz~Gl0~}5&6uB5Av6zTjqpqCE__jmn7IKt&<8DjFFd}Ts?$X!?' + '2`ud@PXa=*E^F`{Z~wBp-8V^O_cM8vJAOrZ_VDpM*MepN^N|)<_Li#O0ApzrHSmc!E|Y*Z>p5B@a9Jto*P$c$_YJHNPCC1&MDbN_' + 'ioyZe9(eo{CY3UJCVeqQ8{0K*6}We>5p-x$W8Ea>9PLj1Em;;FnJtk>=D~f!=WcvRax|jHj+DspxwlR8;Gncf#SMd!U)c-PZdOkn' + 'qF+@Mwwk6(c#^@`VTPPykmMD!B&YBZn#)Y_ctiC8Dce~=Asq?Fs5%s>P' + 'bE5mil`1`%)xaG{Q+|&P!{GkGcy9oui7~GagxncGQ~L^eM12EEplh%q?hzo+7^8~ApU`_eZN7Ml%DI>-*bZ^v!0S?l+AK8HSMCHKC}ghcqpB6F|zX%4byD-v9!8+$_9CQ@|yMN>FX~alX;0!EFQNNQ#|%Wrv2;$' + '_N~{?PoMz>0u7XdZE0wn(gFKWUll%bUNV%f?ki^PoGb$|CV#AWB&ceD{auIOdf>JcYW1S9NsF;L+A6G@SLCwd4I>6?`_LuN!&P%K`' + '4=k{vr34t{y(TKcjRmxFY$M_Sa2&$xP`mA9c@<' + '%e(9HqS^qaKn9r!_r`?YtP6Hdku%RWVXg+s&6!gble!rH#N*XH$Fv|oQRCE&P1wup@^8;yD_KaO7$78x+aPQ&qNy=?NDlw}XMW)O' + '=g%Pn{?(^I9A*+Y_Tf3O$ouk&Q?~RYPs67M%(OdbGLLLDH=%tcsGZy#K2C3A7RM-Z?ykKA%w=2aY+&Q%18~&bk`W!2N3w32@4I;&C;3!2a^CmkH<3y9j1GF7Y%c2t|' + 'L=9WeMX86gT~rS{qJv_7q7C=W0|)WD^$Dc=4No-je@QbiZc`CqR>jf>sgLT%r>?`kGEjA(Iz' + 'Zqj7b&DRC|53Aetb57slfX<|6zq3}V?-Q8G-rV`{i#LnMM*Pch64U-deNc02QP=I@K&{KSss&hHGH-xPUTsR$JtK6m0_3-K' + 'Hky)-BukLHnVFD)L{8?gB$k<&dl#y7#Bd!L4AN0G9qYCb5m_' + 'w;Rh9dlLfmckT!W3OafTc4J)A$PWikN2Ap~+p(H+o8(Aen2Ocl{`Q(7bTNkoQLqERH=!~+5skFQa@TE(oJ5vMR*c*DEGW4#<{33m' + 'v|_QVX%o~#jbBXVP`|Y?iiuDFX@bnhm7|70hHa?GTN1k(Gc(vpLTd{gogkM<%w~3}y>o}Ts$#K$p&Bk+u@13__~clXd)um5C&7a%' + '#sXnI-(`xtV*#~ebMl+;AJ??JSoX*rkQ((EAU6EUB;Q0GFag=ufk_e1!w7I(JP6;RhxM!!3qptnfYT9!nnAE' + 'li6EuVoZd`EeGbu+>ERlvRSAxS21R)W-V60n7@05{N5RIxd`+wU?3OEaaGitq6)0a9hu9-NBYDXwPQ*0Oi8xzn|b$&)8of~JO1Iw' + 'UALOqbxxGt{*#A^pG}Ldy=tXr3QJAG!xFvO+5`y5Id(_?0*LMO(~;>A-T(;@xphr{lX|' + 'Z1>a5-sj=Wv*-!KQewfKkdd$`apRJ(G>yi;B^Jq+){unp' + 'dr=UgtH-ryun{oNFPwQ}K*kbamo`(J&GKT?Y&yg0jKfp4L(6BNE+4t7Z6IQ|`Zn1O#TW^x;4BnKmzfCg6>?EXk20j3i$dmb-cePb' + '6`QW8TnALfZ)9{Vc6gQfVm0f=UD%{TW8jpdPX6;m%Q96=lWF#@MY@NTu~v' + '*)}!Kd}^@F1)c?jV1GCL(Xh+K!UQE8s-9*NLFbo|9X{9e`T{c=5{90j%Lfvg>@u5s-yV|taR&V&e' + 'xve6@$(uDdcl|Uv`QiBG)5p(`^Y6bI9r259Wc1|ucTc~`zlDTP4}aciUGBDPdMh)aF8yA_!ClvxOCZnqw9NB)!|Z|c&CDib0+YH!Dr1qzguopfb>#1QToguBBYr)do%-`dQB*RnlRCE_KIH$p#=YD>u9J;kQ#nWv*X58gAK12r5SrUieL3+tDARmwbY-|kD|oEB@qwwuS_j(yJ$b?h6fB!KH|bIKie5A!BC+d_rg$LkuSqs)zL' + 'mK*Z@FhMX*%1JMn0_ypa|42mQ`2IZmS3Ex1^&Hc;!ruG=4==x%b9Xtu' + 'S9W`U)$xuwdpCGCFOwL-bv0`jdo8?N&o9C|=lK788=DsatQ)$C(jzQR2)B' + 'Qfzobvh@AuFXXW`+Yn`FR{|Sr1@ddL=rMxGl9I2UG10b#Y1Zg`n_l028ivOi3$UETG7O((QDWZ?YiVIbNr_rkn3u&x-E^odz8flr' + '2NsMCodi=2K?k#cz45r>+DiD{yOrM?N<7>E~gw(T!!J8eU4EEtzG2QJE)9_(Oy9VuajVA9XrpB' + 'OT3wO$5lZgL8|?H90nrhBoqIK3q@s2zxdccdIt7W>a#7`DZAiV%->cV_ubL+6^}YgnC4uEdc$krGE5^s7+u4VY1`kbC18$B2g-4e' + 'wY|t-VUiP#SZ3OeqRVZ(y$LbtKX8>-9Y7D!bC{F|)`UJI$GPI#oD31eSHO{PNn_$+#IJk)zUBMfq;%=M$AaVD;WW~bNSF++YJ|mg' + 'Pp;om-W2a+MQOKMG}Zht*dJdf@T9pTFRU{IfJfctk(r(8AD!u;xiyU(zZ%BX%ccI1>~cm2@@mmzxY6MonNmnPA_X%3byZ>vbUoDu' + 'yb|QAO}}i~5>HC5_@%$qrt5{=N`xUA1OD$Hq%M^~eG?dL;TX`A8RKEQLY@OWj7NZ57B!C&*71vCOcKC>%$5aIKxnD$38nh0u0&$z' + 'ybeUpkU>Fb4dwi{tPxQ35e5fUmo>d;33dZ%jaICWFp?z-_RN^?l;HM!EMvEMjP)Mt' + '07lTrG%ttd2G`toKuilsQ1l)OUcjSHB-PMJb5=sVBu0`Op~o2Zx&`=38yk^IPi9ti9}Hz`I^uDi{OKadCIo}sRgdcf@IdWe$ZBpgF4' + 'q9m(OS~7Z?Ki4k~-I' + 'yp?OH*@HlhatM@n+UTQqU?5j7bE3Z)@7-yu@R^PW-)vkM&iwNz`#~2MMCJpH_ad`?Mb06+EJgu*$~6`z!9bBa2`#q8D{7=Nqq<9B' + '#WA1@(PW2klRLx)^{io@*m309KJVbirH-V7iSGHiaXjstIy62e5(VRk@)$C3nAy8oH?~_g79&$zc^i|4Z_e=h`SWm;DqUOzjQmJO' + 'PIbc)>aJ#1J*}F$u@kkuU>9qE%cXp~gOD5j_?vM%wg)5>l*2`Y3=OMQ>n&=Fjgh^O*!ZW8nRX~hp!@~DeQxDL)bEkB_|?JhUDXK7&*`uC?8X1ctkgw0%cY4CQ;c6Ns|e*Xr+{AMSA$@uw#aemD3}6#Z!EoA&P?qM6t8G' + 'XpVMcNX7dHTf1j17b|w8(>F8oaBMz6G6jz3=)EI^avZO>nFf`zePTw{1WG{-{)tFZ`l-u5;-vA#pJgP}wFW!v+zQ~FV@-Jcz$m~QKidoFCcoB*-grBqKcZko)-2gN)B!B=rZZl2Z^+rry5pgfldvsMMspgpgc(*a' + 'L#B#cv<4~bqF-i0;0j+w=l=5BO4?DyBc{sT43!aHBp8h;kB683_H`Dv(V?U=5*%i@m2qSYK+&1}nC!hLuH(?g-rhU#F5y&k((>2E' + '>D=&~Vt#^2^&ThUj&cW(eBIJTiMERo?N$#T^7iEJlSiFE@RAPqDg^DWFF{Nf' + '-ODlX@eyIpU_Tb^>Aak=hkYmLj&TM|j{Nj9f9+5;qOXgZfJ6ED;notLS|#K=ejyJQOztj+FRESC*E^9A`Ao`Gdw9!K&cMlKFJ~' + 'pw-(19~xg03iK{|a2jm=jAR5(XA`)g&}XVWbz*nzsgdPWv)' + 'v%*C#-mV8m+Fo{~YKl2UK~lax!@V!g@g;)gA~((r&Qk;4%gb`juT}CMF%w;V3*ea2CZRDWlBxyq;g+=rEfuoO)H-G{s?-8zYCMS}' + 'AGqQEX`Z;JO1f0(jbaq?8E{?zz(;xY`(5%mAayOsfdJ_UKjkMExhS9VX_#Ov@j64_OkjI+ewU4NX8+M=&ps(Yp=aK(fl~GXKsBA}' + '0@ibmg~Q`gVJCm_p$P>BCB5Mha-SeO`bt<}wK{p~w{ym@WV*QyU`F{#`ZV`mC&w%YKspE4&_=7g2;UOQ1-!t%WM2H`Xi2B1QPJ&s>UX+Qy!M@cv1BXmz@9b$2KHm;1c*p#LX_PX9' + '>Q(@_Gxl8o3Sgp7{PG6Zc3yC8$Geik6|1<;HPMeUGiA%stvGP&Y6mRQDX<7}<@&qDqMYHoA-tqsSKP0*knf{QVVYg$rdJ?kGf`Vi' + 'd%tX(&Bc;IJU@TD=^NI$^YfIDZ_scl9b`8$cm-u@8sX#Z8sQqN7BxOCB~JtBO#xCrGg$AVAfz0ANil=nr?XpR#&aX(S<}i3S?A|}' + ';V+}h*5%b>Btd2#tocnHP$U$ul@B>Lt)9J$vhUJ6YNp}m7$&0PkzXUY+~b6h2H}R*lCf*D_otNNtTL}a;+-^9vX{' + '(zgRe_9m4_u8`*R$0X6rzqqs)xDk{2KyAB;yb3MK)ti;E;RWu^NLL)Qf&k7loTw<`W{fBuydYy;j0s38(RU=32}#7>cS1J=hVD+C' + '_oB6-co`csS`e9Y)4=9zid(FduHMC}3$=256ebr(FT`F52ESW3Ec~4AADr1;3c~{NmtGr*Zs$CpNY_>kV;Qr=*dabgNx5r0l-+Kl' + 'm<|ho^f|rZfjRIv%mZ7Flw;1$y{E_s3YDIzA!Yovxh4?~mR6' + 'hT7H$o9}+%Oj9JYRmFP{zL^FkLlG?S!n3U+ZffQMOVkU!`eK!RLyWPbHG0s@*{cNF5_W!WOe4sP-kmTe*vgA!M@${9=>^XdCp_A7' + 'gFlA4xaB9!osd5F4-hcVLI(|DssU;EYvZz*(IuN2d9TF%vI)MQGg}JR4bbd`z;3JF+Vc2!;qc*%a}sHV!EB*-sAP+N?wJQuY)EE!' + 'r1NvN)R3{7jQ!j&s5&pg>N@829czIM#m6(Az)lm{fs?Hp-Yz!^qECg#x*Bd_9O6RkE}Q-ZJ+OWb;bqg&+$g-aT`m%C@!?x)L2`RP' + 'lGJulO3}N!&iJscEaPlU^XU8+5SVtltn=Qas&|k36W<=QaB^6N>wu}+`tELy&i?GAck+D>)Zfh=I#6-gLpo3)8bV3MJ!EKc)~j>|' + 'g{-u1a_$nV-^n!WA%@9^<5Bn}yrNzI3b^bmac{-*PS|E#+Y4IP_@idb8dX_#RcU#xbL&lU&{@<(-tTWp#Y($>_Lv-v_haM' + 'qOh0+?%w!xZ+!Ya8J_@D)pR0G&Qmnn9@jl|`Sv+{Cc~C0Kd9v_;+nb)n6juB&3kynZ$n9UYO8A1ZP5^S#yU-s*L4^}4ru' + '{jRKD$nBV$FFQTO>&>}5%2vJ+ELfb1Kz4TIzb4~M`Pm=Ms#X`AMTH+_DsU_~z2=c#Xv>|#ji?w%^rn!)!>N)ElP~7AP2(w=ZBE?+RgBviIdF6#s3?!_1($7x06jTY{$EENQMt#_;W*0^LWfj*gCkA' + '^PD#CDJM8P*fTfA9p3=P=ALPf*A_NCCrEU_lK0Lwx;030=%9!W#O(1kRv=wB>ttl*jrg@LO9-(m2bZ1;Uu+jM6&^dcO90wVN9a@!' + '?TxOAc64UDs54h;?SN}l=C2h5Z%e;|e6j59_SjwNDZW61=eBgU?r#|s^oZ{I7S%&5{-NxH1!bFz6=+gAOIkOyMLB{F?wnLYP2-*L' + '2nr_L7oA-uF_O`R((1zp#Syv&uZy-XM_#*k^=x-KB1CA3yFam3{xr1K0$%N-DaD8J_(dU2huVXa-r$pg{CycSmJVnXxWizV@WFzJ' + 'zxy@<&lu`#!ZnzDnDM+%dpR14)kbC*k9nE{NTbKI>e^2YdL~}-00_Hw#yJU9(v45H!~86?9ehllqg|H=&|T4ai2}^JZT`t4z;(bm' + 't2Jk;F*@n-20uw2eCD8X)lm_x{pE4U_&lS#8L6kT;FG7v=yCC!ZNs6GoW7v&3Hxv2a9(wNu`0rq^+}#IohxcT5' + 'GB>`_euhMLS~OYdTVd&@0(o8iu2A(=i*37@+7oBF$1?-sN`{~=|f;&Nvo&Zb4j' + 'k=?a*Npv_?_L1bw&O4NUHE' + '=cY3sF@x6c=);1)OMCc-FmGFL5x!5`Hj~HR+OQp}{Xxvz|2F2dNx)hbv5@_;1~Ob&`C`_;m9OT#!Rj|)uo{xt@9k6f_NjaO)E(_p' + '!3ThU1IDR)t^VI%tB-2szC+G0tDT#IzLXi>Mi&`W2*M9?{~@&j_RcpCt;qzqy_d(|zdru<>2dz!cTXQb0le{drbquhtGX{l1lG}i' + 'uZy|;~(27oouye*^=E5R5r?5Tfw?lL++$Sf;' + 'iEax6ae{)=Wv6Eojp9sw*?`hw4)cYU{i5ReU2>`>VWM$AF;G6yn4b6uKC>HGH^6N{!J2`F*RV^1bhWO4SDf%PymzPtFlNrl_(~-I' + '@tBwkkJCcgitM9DPyqg6MexUyqXY5dz^GvlF0-GMPvn|c-Q}k%z(2~l%p5z;8i`~FkxDGntaK)WIvYGp#oLs|icpkeeQi{w;e}kG' + ')$_Q2Tr|OufM$4YcCST7);=96`2~H+Y%yhY7BDbGL~u<`78A4y%uxRrECAus!sVukI_n<*Mpvfv||zY|P3;Gx%))btAW7wL?5K5e>lVH+&!+v_ET3S9#Q{{$cZL@&e!%C{wZ@1^Vs7Dyvefv0kOL-%79' + 'ruVi%K_<5gAoU1`56i>fAi(Kx$2?%$I@r{Jx?W5s=h~e(Wp_cvBP~~zYAkl|n0Yty?cH^5kHWPmZ2&fZE3DF_2}Sq*A5cpJ1QY-O' + '00;m803iToBeM1Y0ssI71pojf0001HVRLkFY;AKdZEs{{Y;!MVb8TjCY-BPoUtei%X>?y-E^v93RNrgcFc5y%Uvblu4PGAh80es6' + '-Ve4kW$mD&6rsfDOiY%HB#&lU|M#6_yK@p6m|u+VzVEx!cV}_V%bVqUtE)HAc5AIRu+h?c^aZGTV' + 'J_aW}C~vhS+>CS1SnX^BLeyR8911b0fHcMi8Hs+t^a-ujBcy+c##Wui9us!0GTRYw`~A8Ui;v~v^QWs11zegPW2!z3&l%8@$g_<#' + 'wc2(rl@;ilI-PS+8_&H+D4>HJMkYek5^8GXMU7{71s1e' + 'Y?1aqI3Y448=Qj=E{E4|;~Mu%+b_O|S+eWMz!}jKv7ZdB{S>Y|Tv>zj=h-`{Ji;Z_Q|m5Z>6~?0&3g{)MWPAu%00mknw_yAM=b1P' + 'jxxM2^-Jn5HZ=5~9sH)*q>BPZ*}Q;%t!}klOYO7#KE@tMDJiC$L%Jo*5EzZBR5U4hqm>W$WYoia3is(+$~-ypES|$3h=C*9@|V>WaCv=H' + 'O>5jR5WV|X48C+XczsB3TN(%@3x#e8P3a{BA=~4PSXnX}xk;fvz9WCT`{Bc`taBpUl(=B>`tt+x^I!z2B$jYsgW2w~x1F4s(>`^T@KjJGXPTUlVy$y3<^b*D{W6x7sV3=(6m2nwPp' + ';4_mq_#cdlTAq)T8sFQAFNGz&c8c6GVkb3SfqYy-N>-)T)x684tig;=rK%7oov' + 'N_9x!M!Ey>-_7wYtk$r(gYRs%?a0i-pj)lek>FD5n9%`Jq$=-bpY*@rp`LTrE?odlZG|z$w=>W!kazHRF#I#WC1LAjmZi0Q6Z|WV' + '=nQJwLT0TVPO8`NR1!AZ6MEo1$fdDHK%|)%zf7jWdDgMa4kNU!*C;2jeJs4K!q*I$xYgzY$K' + 'kAFQst`@GSm0;3@ZQtG5`PoW?^%5aBOXJFKusRWo&aV' + 'Wpiz2Z){{TFJo_RW@%@2a$$67Z*E^@b8TjCY-BPnaCxO#ZFAc;68^4Vfv_JeWn{*75;xrj-RO7W?w-3jqB@PP6=>5R0WEM+Zm8uk`!F>;u{bB!B2yq$hio3DtSm&X~+wrmYhs;7Qly{' + '8I#|6Aw`ywBmaO5k-)Q4-snAWc*s^HVGktD6j@0QEQBPHh&kck1D-1)QW9iI9t)NRoUDaf>TN6&5QF?_S!8#L0W|~q9KJ;^9brlf' + 'jQ^HOHban~ATr4HtbWYCZ9{FWDKbwz%#~)|czkT|0P5zi(T}>~q&t`M->580wy8Li&IVCyVSGFH%s4XQ`;H~@Hz6qY7riUl!~;LeIjh9XeodTIrC$o@ZwTIDgn0b~N0' + 'RdIVVzyJbgTygM0s@V5_q+jp_M#4jB%PaeRRumaXvEYg(' + 'S;%8r5*jAevPlQYaAY=OiHMP+9QXAI2y^3sDI!FjcdJQ7WU2kiJenm&>Lq+LhR_=`0Vr-rIrK`te#w?3E2&7x0eS@|O`g7I#4AS~' + '0dG2mxV0K<7nDpX;ChXx9bF>N05UxL}Bjtdw-RE!6u;p{+Vzu#dyNIfR$YQv$193ljx' + 'MCX;boQ4XBArgRzzkbCG@bxQ6S;A}cfx1G9R4S~A!=`{^E!wCCC=lx?Zqbd%iwPNe-q<0n@eJ~=xB+uxR;yEG=p}=?l3Il-D^|FB' + 'uPZu3dti2jr2n|LchWNG58rF-FscP_&;c}>12>+H)qtg0s?9ao$e7wxEk2gYGstMpmgcQ_Lcn8hg5K1=s%LN*s{IqGy|yk^xd6-i' + '1R8?^{un;7bTrt_DUtUjm8(40OzZktJEUcS$4ZA6#gF&IYcZ?jM3te9dMpQAS2Y5$;s^0{38kQ{d!VT;qeIUf_o@n%>QQQH>F^sW' + 'cw*|W8KO{`VFdqw<8rvwf#!~!bVP>c*I?y<-O8?M8p_nv!8E+j?>y$3*mVFf1ZCHC2%Hnb`jpok%C5TTR)H#poW(f^NWjDZJ(e^#y6K>wdiRhCRkjJ7nGL%IZ!_SYf}vo$~&T7(+U' + 'I%h7S;*-d?9Ip;uvr7z1lR8h%kgeMoC1`j7BiZj-+^1weRMQ<$6nJ6TM)||5MLT=p_8ekDRt9mrD`&6{M33zw_|4_yb' + '5Pc9)v_jilWZ^32Ef{?$;h|3~(0}D{e6%OSAj=BC7*^e|WCCHL)>(01qeqHG!YJYe>(Z-<;hkaK9JeYKq^9{l=>E3=z=9&Wl0<__8;Kr7XuM1n7' + '(Xb+oc){@+4DV4e-AR3w;$||QW>}3Ej=jr$@bCz0PP%huuQ@xZEvoROvM3mYAv6F_BtCs3LQydAdTE~en1Nom@;D90XI1JU6D0zF}(1v^q3$wZpPTmaW0xLS;k5t&nU`jynYE1mnegIy3d51;kIeGKUx2Yw&$(' + 'nqj^V2e(CvhxRSCbUOq;GB|&g=bJ>bC(l4j0cAej-ui9tNt!K5|E6|JnAen4cMts=cbN42qT8ZZ=3QamT-RcsRoM^zgWfAN7tZAa' + 'hb4sLtK#x+1otlHh!gr`19oIf5+1kWEX%38Xp0aXruoc@fbp$%sWR(6?p5^@&&OD8i5%*q#8kbduD`BQ4dkRP#pSS0NDh^`Q{BvS' + '_CS<{j6TsZqq*q(bb3C$q90}-r(e#m=O^}&!+Gj3_bBApVgx__Z#Fa-~' + 'G{F*^982O*K#E8A;3yoO-Ql`2N`{wvNb;_hNl5e=h99;4ITDzBP!iSnf+=h+$_Z#GA@$YMG+g2REv;IEE>;PX_cRaMypW4|v@jNG' + '%xL|zyHI+Z8V%^>Y<@laKu`@@iGx50uNgI*p1PU10MMkhbeUMV9N31qwHI' + 'U0c;WT8U3vZD1q6y!*AavHcD?s_)nA&7gk6u5XNsU?~*Ws*eenOu?aatXrbI;DOLrB%2w+JhtJjAl|@NFKK#i$s}~|)JCAB%+zDN' + '87}Zzu&&iknI4muFYni^SV(;t+)=e-zF@%vEgw-PJ3r|dw*G>vp;uC-B_D6(T)AT%*46*{n=~%e-5vF2O=52}*nIjZ%yXN&_oh5>' + '@@tx5(A@H-bGuv3tI57@hE`C1h9kf7M(GFJRaFg>b+)S;4&f8Ceb{&O5|OjWzM4FG?xVlE?#FZAqav>fd$W&zaJ}PX&+^?vd+oWK' + '?v2R;gn6uFdr|yozeQDF@tlp1aO1K!)lWa|QqourfdURFETYX~$6yU^BWEjGyktMLhd$v&FVT5sCqT*~1@H;3xi|0zMH_QT`78@;Y!?!>h>_fadRjH0QZR>((rd' + 'PlK)Xy4J$psQEC~u&}mf{ivf=a4MN^d&05q2|FK<@!ik<7@}igWml#-K*#7i`NIB8LZ4Ys_)s6s@fNI^aoW?(l(RXFMl|bnNoy(D' + 'yX)>+He}sUk*t?Ig+iKrgKi;o6|U^tQg+yKakm#5MBR$p=rGDR==Bp_uj(D8sf@o3abHY&C46=ib{jKcDp-;Z{_i2}1YDo(y29|W' + 'Q(y=}4THx%sk@@I&lePbC5ONJ!>Wk>9=3tHK?uoBAD)~G3k8jm2vmJl(R>oF%?MpN-n~!skJE' + 'AIy*GhKr4y)99b{ld)Ne$1cj^m{M4MGidyZ#Qr3}^txWFyDf*?jdMBfw#((%j$K+0IhMY`?cjeMmOaCwbaZExE)5dN-T!RbroLKc>`8v+j=kR~n~^et=BV3=Vr' + 'v_#u%C`u!#HffswzT=Bts5rs$A&Gg%ZDLUpvsY@cG$gk>3<~#3h{%yT4s8E&kfwC;?aH!T}(XPBEI^U)J=g(9%T&E5?' + 'E{G~spYeSZMFrg;OhsN56e}QrQ=IPz6N#e#Hn2`-NNEXs' + '|3sWAy@b54W)XrPQOS~U_5(6LN3D0()HRjK#Cos|IO&0-+h~3yVTz(l!W+7fvXV)!EKmACJraU5Nfja*aSG}}m7u7Wm8L)u7+BHJ' + 'EcSN8sO;Dt8yb?H#bY!|iyxEjYl)S$;d~I?b|Rxn6?)Q&g=@_|?i(ZI_I=5lqK3' + '6nnPgv^TY^+pcDOY-Qx2lc5(AId2LI_GRqKZ)(*sR=0{*gI)Bd+Z8JE^{4YebSKh>OEs~V9xuCtA(yOVno2dci%LveSeCD$hlh9X' + 'Bw)IV&xlLfjuizE>~hzak)nLl#|WHezK!>0PX#WCx^AuLC;Dh=Y;ONJU)!vlxi4i<=a~b9-Xh!2udb4Hig=0+4^s5vx8HxATwTGv' + '&_C!q(>pWFYT7%21K4|!`<4+v&P2gV^vfK*LVzqZ*>KD0ix*XiR+|mW8R2krLCqLGJebwJ#yzNc5UeV{;JewLzV+1JWUNjGC$_{rqHokIRt%Z;r7#FUljJ_w&1p;`H05DuO#KGTR-q_E-U7qTCD' + 'ww2itZpPm7$#Fihy}N7aO!Vxo24(5HC8uHw+L)t~+$1Hb6Ie*wvnKuNYj6Qm(pox0LBFw_+6MwK;Xd^QiMr946$#Q2!r3`&@Y!sKRp&?6!6s>1eJILIL8?3yMWOTBklacnO9Kiz}w' + ')`}gzCY0a_ILpVzUy!3vmmY7rmrBQ9U}_Pkk8;0H+`doyCp>_DVrW0v!bdY9?8M36^X{N<@6aWAK2mk;8ApnxQ5Rg?7006O1001oj003rT' + 'b98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDVPO~scD&+6lP2qovnstwx2!6gq)E%7W=XMS$!?bxoHbQa)Vs3EYX+a}rw>2B' + 'FSG4za&d7n8ttkwXK}ncG>3}EF-!A(Sv4@dXhF#_+oVZ0SyI=$ZU?DbG@^Ohr^QYC@Q30lVjp<68;#_hrmQx%a;UhR6h%9DcPKUj' + 'S(34&X744+39s{=AT`d@dZWhf=d(CDH036#n|M4)J;-s' + '@DXdrUImr&ZUPvK)R4T-Ks2#{05PoAfgW%J+w+rWGdg>OeB)Nz*Hb4d&_p{6ex-DdCDl`;Ri9k5c61alIGerkz?rnbTWW*S(e&m5+S' + 'YhD$w>?g?rR{HL615@HUy(tt04Gsyerx6>gf2)XX`R8=Qmm0~0?pVa>r|i-}U{Ra{3qi(rjZJAJw%u)W>QeqK$T>Glr9yYXY' + '6;HD9F0Jauq=vXMRxo*pSOU74fchrSiu*&c25m#q1mDEm8pf_+>>9?dJ7bY#J-F3eT29!|_kq%MeMKaIkk$`u41|cQM{)HeuAaoz' + 'v$%SWJX0Ro8#(1cT;Y^wHRZ`SfAc~)4S6k(5Q%7dN~>PyfTybi6^ZmlS>->0UL+%sS;YWGRM4*Viub2*J2YGE-8' + 'u+2%9KJ%?#8%|aOJ;hLlUPT}p^vG5#m!~Mh_$4~hx>&V(0;cyi$>Vx+o2T1`MDrLYk7?~w' + 'lEN6mJ@L5Jk-9A' + 'Pl3vo9d&ohi!x7(1W2|$g9LVYISVKCArH1`z7!Vvp7Xu9{#YG&2ZDUeglrVmfS=z1N<1m58dd)+@ja!cs^U{Nuv?QXY3bg(F{GxbC4Vu0AB5q4M$g$Cry<;j@JMaG)hE}dJnXBRfMT^' + '>3hVHb5OwTh~x%Q)mDV2%~P<+fUx>D*$eH*5Dbt~2o|x+OLBDa{X&iepfz#%ZHV_FzJwq4Gj@qTx_ST@Ow@+}LtF3SuQ?R}Ef+ye7Y814Ysln`v!TmmVZTMo>8Xz_c*' + '2-ZVb&G_tz$$pBa{IwVw7G_uxp4~nkbx5A{ka>tvU5MK4&dG;$34Jwq8Xh@H!dUvH8u8^gw1>7{B)xz3lD?w<5=W07a_2Y?iaaAd3kn8Vu^hj{w+s3c4uI%!@^1r~X%' + '2b0Rc!GJE=wHOuPMk%;RHHGa}UqPX*wUoh{wCJnj)SI5cK&+fX*DHwqAGBXWmq3O!Ez(9=RWZyB' + 'j*Q6HF;7}{kf9fl`Wjg}LW*n^U04BPm83OiA3+^|swxo41x{i40mxl*ApfJ7D;IEb5t_(w=%fH)vE`Coi8=t?LVJA;xh4DElD+jI' + '{Flgs38VE?{Mnf_+$Nu~ER$v;{0CG(h!FGJh}{6Mh>875#RLO+_+@fo0S*VGq(vK{Xpf1=NJ6xn#6+GHrie)4jL}PD6>WOd+FF=N' + '4zSP@H9|58+nN>?!{PZX>_$JDf))%-eE>fr!BDKefOUEY>j&u#-Zf$|(67Z&fE`+1kW*t%KM~*!KpbKT>}u!su_GeO' + 'z#Hmv9b8H%AcRarE~3I2hAV?MO?5Z!bO_pYjDllpw7#B7;gHl`7~uN0&pA=HXCDAEq}h;FDAihHgGG(l9VCdSRBLdfKWk1eZo6ru{+0=I}Q(16Oa3X6}`N%`;Fq!mZ#h8};pYqX=hhexyl$vne0QGF1e7' + '($=4}FbG#`R7`B=r=~6I8XfI}*7TJrD6xhFm' + 'xL%aE@Q#k_A>RMa!B(Ov#c_vXmn98A4n(;xpk3vM|7SK6d4malw!plBrk$HS7);u!17!*XRA7`8!csk?TiB@IQ9^OyNk^rFsKoHC%41&nbThE`ur72V$%X;0j5L?u$z0O#ZkB^;Tgi^+dy042xY(' + 'J$7g!LH0nK=zy!Gs}_?t4q;f?@O4{;J1)n%E`6OB)c7+vl@`>cur0{9Bt+N_I6v}tvA-bp|5-=1r2IX>e}5)$MriB}t(!8QKhDqI' + '{I3`A=U0$i=2uhkF@uk(_&OC|r{e2Oe4UA}vzZ&Cd+G$PVX{Lw*$(oAai8L3jVsqV5Mlx74cq;pJBFK(D7+C|iqGj(e9mUkk)dN8' + 'IvyT6HlUs~bUZ|WCsKCBWs58{yM3ZXduCpPBBy%L)e1}y^AH6qk5u~}UCof|E6<}TaXdW>ph3^Hm!2uD(9}=Qn;v?GN$mk>&@=0&' + 'hmcOs6apdb5%`;<@|pYSIkwl_811bn$!H}4Ti-1|T}`H;2xPFovqTE0*Q0AubkC_mZNR;yTS&NsPAc{Mh6N&Rzcg797Iihrlg9uF' + 'FbG=n+IuJqCr8w&TGCT2;aU?XkYNI=RgAn6k>FYd9&b-NVs;PQDr0DFt_AFfEmkVAB+wLl3}zn2iHQ@FP4XXuF6Gq{&WWomPE$Gko6t%-~u*DdKbax-9==8JtHYT' + 'El3-pSmegvtMjY@M4ivPkcCIenSa2;wDTX$92w8tsva9H1%Ayaca}LOeNi1ga4X4D8YH)ev3Vz7^=c9UvqCkj;O' + 'rPes*wA|W<5txXWQ(G8H;|Wum@2RrIV85ut$_kD?vf@ynnY5dC#(k@UH$OWGH^kA`z?+|#Z@WYdclJ6gEF=%DnCbS*81kv=I%!~`' + '5vXK17kB!Z>tPEC4Zk38bBH3@!}sf?`7RN!b{GWVzV=xV0{Y5FK@h0#B*@oQXXH6GaQAvG8`;j+h>h0mKyt4tEV{-dRxB2$D$v|^' + 'j&-r$lp)q}U#qt<9Y7JE*R8z}N7ZdVpyxR?2_yTrjeYk;0f)>;O>oQ{0R|F&?e0EQqKk#gvJ5^w<553+6YzmObCeCJidLtbf_6-94wMpEp~h9QFUzZqpOA' + '`H0ioo_l)Rll+hcKzG<7mMX-CG>5B!z3YjDEe-Opqp)mYA1@uYhobtXP*Wxh{b$m+DdahzFnM1%6Pu;6JnspO#S_24^f0o!YX2cIEn*e!WX)h)3A@AKfXFa7AbFgB{7}7CT8DCKmzRV' + '^bI*O((PXk;4kw21Au?Umny|6RlaF7JwnxC1LNx&zqnB|ea<5)N*b>OIq=c9y_uST0qScpS%_6i|0Th8Dn4(aQDu' + 'Y9)Oidn2CqE#ahlv|OytwT$p(DB|T&zJ~<=&ggu47W0Ijr|k!H8rpl%@!Wv3O?9sUt=Sv(ztV8r)EOzd`7%SKOFA!o$Kg2cZcno<' + 'K-jN3MBRacu>N5uz8le{P?tv4>k$}&(B{F*Ghd4sP+#gCYLw=S5d$sn6W!O+EVi1O(^vUFxwn1h!gi=}qalkjq(Nc#uW8%Junq(n' + '&Xf8h-SQ0fC8(uhvo+`*CAQe`YKmZ|L4&4PBf$X89E^i}Zqmgedo)(r!ed%cW~kXTo+@5oN42!SGNCoZ?YxcNEBDsrzmnWvPuwT%' + '601pO?hiahD|ZJP0z3L*zfr_>w%KKu1WsFA18JVr_Y$YLbl+MMeV$m9l}_5Fn7_0WtW79j;|nyM2C!l2Liuh>w~r39jRv#lxUvS)' + 'cfjJ9#Uu)i@IuzaTJ7i-a1~;z^z_%)ma6iO!go6R)Oj}=n9g&AK#%QmY4`m70DCR@QpB!AO$om4xnReP$VR7aL3+EM9yRyaNIC0R' + 'Y(8%1PBd4lgzs1;{ON=BAz7^})9)j5Z}FF)jv=8>c@bNyDY' + 'VJ!B9vQSBu88w0KEqyz!ccM$(5)iUKFnI~NiHr2uu^xSCJ~B3>_-%qUY+0$60sRtDS=1*Le8B~868I<1@w5}I_;MBwTN5X<-VnUj' + 'mrJ&ntzv94pfm{S%(UE}^yvCOTIQm%Prtnc%U>loTmvAZ9Pd&Py)i>uE@i*2=qDC20YaHZ~N9B!&Ak(5HKMBX`GS8fQ*7~N)zo)F5nahxE^;@6aWAK2mk;8Api>r7S6f@004*z001li003rTb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDVQgteYvw=lNIu!dt=we~~>%OFE^Av3hLmsDZT1' + 'vPLjtz|?u3XIZCJPblq1w@oSG{h+iZOiE>$A4Ut5bsd-ZCtIp}(`H%b!y%7ODs~Z2%=F4bM({#?e&>?+W4{oP' + 'OAETga0zP~7%a5paw7*1b@=A7Kz|F`IuJ^uaI|xJ8A=aPl2gkYdqVUoh{s{Y|eIw-?^Y6=6QE6K;7' + 'P_|mOOlx*Kkh-ql0qB`|eaF{XQz}4Gnk+H+$s~)aJ(J^{%_2{$c||U2lMv1CBvLtm~E~_RQHgQ;olch9kQVTEo3N#' + 'ni*>SUh=KC;#8!?VuWMxXw~RY>rtBTBf!5cYj6O+_rzFJMqgDDDabR$g&0d55*q|MG4_G1^B~;ihiXa4X8q_UTZOhr=1Io|rJOPl9Ve>KnHR9y@gx4F)`NiCLX;*a&3#Mvivqv@MlPB1zeSE50}klL?=uQw$@Y$CHdj#9|N5-' + 'nR&?>H$Wb7?rQgf-?YZ6&nNY8n9YLqqj9rNTE;O!S5%hufC)3a9RW8+Y4bAMsIurqgQWMZIWgQQ_O9C$$ui!JPOBK-ME$s5jZU#u' + '-P`Z9@7%N1K9}XHRO;zyc=#31GCUa&^Uj%3tXS_Qtlo)gz3&ObKipX3c^_j0$!^PZ;^+QqZlN#Ad!yd-jgz|*irFOk)W-=kwG$f#' + 'ar2JVzIC2GSb9u|*pgoZC`+FT#0{3v%1j6{U^Ti=5*H^~Cfj#5&Y{@X?wYOt&+JUjm}x)Zw&%qg8Qh&5r|H$_b-tFziP!Vt^CD5}' + '*%xyhB6Fj5RcUpQInXTY_lvfC<|zVBjNZ6S`^g);jOP)h>@6aWAK2mk;8' + 'Apkb_WeLCm005W+001!n003rTb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDVRUb8UukY+Wq4(BE^v8mQcY{zFc7`#R}4Pc' + 'u(v+FL1F*&Ziq^R1n^=ZKhR*uS8-jxXpOM4c9;c&A3#CA>1_001mFJQ##$^uKVJ~uf*QFz8HX+zpp2OzpjFyMh?bAgVyC8vaJ@dQ' + '{J*_$wodnc{&Kytd;NkJ&?sU;4y#M_e?(KQKDdFW&yH;QWO80Qo)u051Rl0A^uxbZ~5Kb1!XgWMyn~' + 'FJ*IWW^Zg{GB0IwZDwz5WHMi4Z*FF9a&2XDb1ras&0AS>+eQ+8=dYMx9|9yGVmrs~GR3XPvK*=6NG?gqu1lptKwv1M1OhA^vS>&D' + 'eY<-GbKsz;#9JFZ*d#F1)5q66-91Ccao$89wk(QcURA6v*x`z=(>Ti5VX@v6InQhMDvj&3$fI)0;v!!b<(ijOFqvGgI9u|lZc5HJ' + '>4s-%&RN1YJWncCK=P}po2kfA#cnzTmQo3yF{r*iChlcVJcDn7g6bqF@+S=f$~^8;S3mqnJm2z#rYA6927hRr' + 'nXn0dt;?pWc|y93qj<%`vM6dXfc?%+(U<1*AFP(}B?}Q5HOg9-QCzzooBqpEl~@KKf6f8vId}_a0I+lk_?UvVz$McJB12klS+S(o' + 'k_iUVO2h}`GJ?Uapoo98Ht^FohU*2TU_vlhU0J%jCKCKU=9`-R#J5Lf2~G+qRK5sHWpdYCT7tbnu<-lh=IJ_FOE*M;CepukWl$$1Lj=es##YgmjmKm-laUrX4CZ3+mHTa$hIR0>lQ' + 'ZA4`!t`Ket2<_n=FVkfTRB5Q-Q25+MKxzt9&M^sAwp!7<`}p5J@Aul' + 'Nkgq560dBrt$8(qS`;nB*p2LHX@d-(xte0_^vyeP9Eh$_U6*bF`IK`OzC1ZSIDhB(?3vepdHCk+=s!n?^wuEx' + '`@B2w^bGd4H)sFWBNJZ5Wx4@>oUs%#MSHjDd66D27)EWnhDv?0bPo|q`&FAEkNW;cj4~' + 'x63I`+&KWq7-)ThOpt=-HxTvSuQ0!bT=_l3rv=C8DApLp8U9s8Q^u_!60)AQ1VE%*7|O7Wt?+<4>nf>$?D7FOv8!#9#5TcW#8t94YzuoXIklYl@digM0I`@-^HQd3XBVVs(l22+V8@UkpwXe+>QDj8!PXdM=?5xmaI?%&RaLVVxkUvv9VE!>rj8bw-e`R=' + 'DvLVAD4LgilR-ki#%dDqkmghR-U8}`m)Yv{0;#HI10tvw}' + '6dFTTze$sePtA)b~_S3_qqKuWxc=(cxLet+!v2K$^DqiTrd&AQa1g0tb$2Y;nLQ^3jt2abH&ES!PYOaz|cPoZq_7yrH0o`9G-HsO(+sr%tts' + 'UVQtV;{_{zFW-72v62W`v6AL~f@pbhd;o-0y!;;fJWWOInS#Y-fek5$Hp516WCk~FV*zH-5Z(6I?2z)dR712wL^cG4MwV@@1licz' + 'Qjw(h6JyvT19>7hLTkiel&`DTAUTREQE9}^OjQlM&KRr0&Eg78hr(KpX9o<#Xpg9uxPFb+aJ>O0Mu^X&PHufO^ByTAYApV1-)y*k)sG{&Vl>roG~;yv;;>7-3P@Dff@ACc@c' + 'iupPv%niML^K7b@jf?d<1qHQrbq2fAo$P6VqRca^F2c_za+eBquJz835S!hcJ+%|a?Eis$5xcRJn' + 'P^@o?Dh1qbMTrE=ld->o2uQ7)YC8RBXt*66$nD_W5-Bx%c4otAQ^0L8t^u*1B+Ub*t8PdnL4Q2=0wm-eW;ZYf3XGkj=n}' + 'qYW1ki7%Jfsl5{pBi;+2``8ugVMm)tdAxL?p1!|=KrP2q4t(pOS?WS;Ms4A2ll`iL7Mhard+hPIGYFCpuc)jA$l?F44nCgYu*SU4' + 'MkZSPc;3FcSE`q7f;O@aH6MGar|gM39cx;Jj8WTR$?tmYDlOaaeW}gwo4JgV}CujfU+-RxDb;xHRelO>6AW8zr*&mU1zca~doNWRC4dqK;yb2iBDR}Pr3bj)UO`>4TjSZ&vVzNlVp|2O72k$^N#5' + 'tz6j#!+5?d(P$5!bi26N815cl^#TQMf=EW?*>-7$(ltZdEdM|j1zQo1R;VOAn>99)nOeqm0b#~' + '40p6$cmj+;?SQ2au0A#UCwM&GHc+Oba{>bJ%5Hm5k8S+Grq?+4`yG$LzWI3Xe6AGsSZ`efM~Kf*fG62NuYg)tXhnIFNPrZBwVlR}' + 'TLxO8-8o0}A7=CYO=2wA77Y}JB78>)Sy~seQ00p)-~pm6(Hl6SX;Ua6o8bmzQL1Uc;20Aia~7jSk>QLeXEIc)=DB' + 't6Ln2y2TL~UD}R!&RhOqM>68euy4}qAF$t=#%o@$ibU@amgl*tGA&H7Mf-Qbq7lz4RrvQ7u1b_1$+X6%{IoRfq{8GL?j<+h@okmd=$he>$s' + '(%BL78`v^MGf^(+bxd{3xAQDoFOrB6ota`oYOkGmMX#@s{kLVYsSYZ<{wS5~pu;*9_dS4ww~sYg&?K_WPR9YHj->}k{ZqZaHL&{D6hbAOLDr|fyp8|+y_R1^uf!-wH7kNbyRfEXpB+<<5HMyRK^o#bxmY3WY$' + 'uSTf)V$_v~y^+x0DbIR5TZP?#4_j%YW@&UUCR}1a1aISlu%`YGuv~S$;qyqG5F+5QGX$S^y2Ky_+3+y(T1C?rmMcUzUp=0UZlAbo' + 'rg_5$2W$$weKI$$2TS4JN^QHX04K@4>vT$k1fz#+tkZTsN^goLDqlza}Omk^cfuU~Xdoot9*nE=66)Z%6_' + '+hlbMwXvyPpOdpduR2hp7m-LMkBc&?b~@5{$K*-gBpM+jjud_pPmLeWLmB+w(CWa' + 'xt@OVnr`SH(gl5RkpcLUO?$|di;duRV9vgf#ctdmh3pg2DjI+PH?;mKpwbU4aMu_`q>6j9$3giR?t6tk6`D5WYlO%r`r5#peSJ+&vO3v2-L7qI@8@E6hkVf0' + '_O5Ca>}QGznmcfb<$oCLQ?aWC$5DmuqFHi(kM#ZLq1w2aw9DNP^j' + '9qyPnnM}fq%d>O*9nDBj&u#C|v_I@}|IG1(<2Y~dmtK@c)8VDn)b(ZegMRH)i_0?a=^|v5WjrzNzsS;`m>1a99WTi+{@4Zh9oZ>|*-WbDUdzWTB#hM%sCCF@ezT|>xLQHq**0*n@BubLrAyb!qv`~8hHU)ZV(s03Tn+l6++7?qM_(~k8' + '?8Q;EV9DS{U0c8*G8Rdx2aev1lGq4g^X@OeC@i&$dHp&X;k#3_y(nqcbmYEPfj-Lc6-ae0LEd(FRVk>6X^kP{9o^Va7+J%Bf;?gz' + 'Mg&?=qu`IDLM-wpv7ngQ4-dVw)BOx^6By8K^C11R$Yyp3!-w_&u-q`R`OLt+Jt!' + 'kYv@WV^PVFjq)D%kJbiS1fqj&bbV-stW0Si-b9eHjd5(1Dq|cYt9Ev^PhO?jqd9m{y11iT%N)=5aKF;*0+2REwgvE@caDer;bgDN' + 'co@P~7lv4y2v&OAnB&_oEPCe4e#9`}?!@WHKr+_AyYZFUtO34XPyP!~O9KQH000080000X0IFhIHarOc0IU}P04@Lk0A^uxbZ~5K' + 'b1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMi4Z*FsRVQzGDE^v9JSW$D@ND_Y6ujtb~1ojBE2=663ReM!Jl7TWvtR$QemrEtX*j5E3' + 'N)k@8DgXETW+a3V*xB528gxswQ8q!!*pJCr+DW&6hj;q-^=fHzD08DOuk1GFh)Qy60iOOF3;*zLgS5+*K6+5tMNG' + 'IedV87)SR!%PIL1XOv@Rx6SgDhZ{=yQ1Lp<|h(__a;qRTMuEfGt07lPJ#L' + '-iJ#;!H_@j3Uio2VgpFIlB6r1@)Z_`j#btX>S6*7o6TsQB%gO%%EP-ge%+_Z2J;DEmFxl&rw1N$Y?iN-k}-q{p2Bss5=07fiZ0J)#Py)S=z}=x0Y_U!XQiwg}pe3oY+2ybxXt2Q&d!-D&c~?L;QaMKG;JM(-htpsf_5O-jy-cGi>c*;-dB`=O1Bg~BI#j@GK3%ul-w5qQ-ui0z0iKQU2=IA' + 't#%yr6=E=hGT2?1HmCecLU()_?hpZg@idW90ADFC;0pBn_L2c%GcG2C?c<`?6(*c&H>(hw_Wp=G*2-saF^y$nc8m4NpaB1' + 'Ek!XE0@6|XL}xm)$SLfWRH6IXh#B`&FrRtV}j3Y@XeTcj?X#fGZr2*j`+M`eSSEx*`H?sIu@3+' + 'v}4vevtD3znyd>ptXS(aqp`U9a>7wI_#l{VOFb~0O_UeoiYtAO8=%nH!J{aWc%r0;|?N4>aspYe2lhG55b*|aSVwW#0' + 'EB9kq_&!pM5M;AckvfZKxy(CaagMy^k6h+A=A~7RnA{or@Den856$WWo`8t&<52V$E?fL~YvhdTQrG%~U7vGU$<$Vma`vatwY;%8' + 'w*uc0LzzvgB$*b8fQd}NzsmC`M;&UFQymJ{JkNQGL6|3-tSycvk9Qkc4_b}G-XX1xeEG`SH|o^0)LTG!oyqdm+e**ezN&-QG(|y%' + 'd|_+IX?g3fXxmctp)prGl!oyGZ)tsid)MxW#0`!={B*~WcZ8m%`ge-?W9==~)S+B8hw{H6|NCmYTJL>_`r65Q=IMHUO&RL5I;F{U' + '@Mhn4dcu8`=L+wXsk3z1vB!kN8g6<%(syu)I#sKgp6~RHel=1+rT;*HGHDyIrC;IRj8NnZ$v#USt%f*yN9}KmNCT|@sc}pb$Ky52Rc)z1-$Iu5y&d8I`JPx9jTzmhcQ&_(`$ymq7Ol*' + 'fsrV{sJ9r+oD1e?>}@&fSECCtDh#1|)s$CFO@|DRMxShk2Q@%vcr?1SG=#eE%0@$Z?8mlr?aXq=jyX00Z!sM>6R+vr5sfUGjA>vIsw~+kx0j-`M1MXVl0G4COf&LeNcf+wL`n7x' + '^2c;dh6^Xb24pz!0vrNo?oBk*X=st|kq5nybIC>5=+c5dxnvJ$>X2`cJ12KUGm~`LKH;sw!!sb?qA`7hVvhzoO?1&%m5OH&H)%E{' + 'Z%Wpjh9>y~G8Z&*2#L<0(qcf9IgKvK*XaYf7C9b`U7Go%gFGaELbCx4uq%gjpWF)aB{1rwV{?F{(_FaHnpjiI_Qkmu-h>(L`LuFr' + '_HA>QteJFFv<#5>?s!^x@`&0gnVZc;!#f@76`_EnQVNKiGD1puuD}4oz;XqqLaM@Zv?ALp5Lk*~=a@raIlIAqY7+sp&wwLKzg{##' + 'x+)7oZ6NYW{IGu|JWi-pk5%J5Q%q}}Rc*b~j_P<)bW+`Avi%0cXE{Xny*&iJTD?=Cixeeipc_{M$F{2N?HOm}%AO9Pxd=#B;(&?<3Z{}PDFaVP|Hezit?g_Q%N+1jJl9yaY`1lFAC|3' + 'cm6W3)8zGk$osBjf0n*E7k8q-F*@V9f6vpwXSFQ5pn#X5)PKiqUm1M6R%iWw|D!E?ADlViTT48-kmf@YaNBy>k8|NYRg4LE`oW#)*C`;z72DH(g-t;bRi_Q^=OyK+*' + 'FPC8NUA&Ig~~E7*6Nj{S)G1tQvltd;o0BpN}SvBDo#SpAqk)lZ-ro4' + '!`_xPY!hy4h=qF_SwrIde!eGu{xLXlb17Ebql?5#plH%i(YRO{J=V!t0xbyF>ma}ZzKO$4@p>T#u++mkxeM3jtAikRqina`^0d{y' + 'RsIW5O9KQH000080000X0M6F8GVcTc0AULN05bpp0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMi4Z*FsRVQzGDUuAP`' + 'GcIs>jaE@}n>Y}D=U43Ugy#~*cI-6iG?~GmoS7ux0?zfC$q-`XgegWGg0{J~|NT}HG`8cbFEg>_-TkC*SG%j#bvEbGo8MEpcV@kZ1e}H=1PZsd*3};PmeX_o9cjJaNH_x=I!`lvz>8' + 'Benbc6`quHvu3OO{@w&U;2DyN!XqtcmDFgqtk?=zg@o`&$C@)xo0g1cb;b%vid5qzU1Uk#o>$R45i0(am0WI0zm|En$ZBDX-lepp' + '_)*k3I!}3>JmgeR$hd~InjGDt)sj^!j1q&CeJOI5q+Fsa%31!IN{#dZ63%Ij%36emRT!6SMNLiDbxo5##PPDJn~KJ9XOR>Is};xA' + 'rhYC}Y=!zdDKS)!vxa#T1uA?f5B*QL*Bz%;C0o5!*js!*SGlN?yUy+S5Z8wP?u$Lbh|csh!4s8Iq_@~-}tsQfwOKiiP7IJ' + 'a()i)$|S{4%EY=UKApoASnvnjKnMf^h`(E*WBZIdO(@JbUG#@l9&q' + '%FFo*7<;ukXm2|!Fgcr*%nW1Gm)?)wi+2-0j3fV&c*4WiC*LYh^}83u%yRZ1L{!!hWbYXwg6jx-YPsGdAlButf#26b*H*a4yp8f2' + 'CX6i4u>vQytoYjXeht6+Po(wX*T@fu)NbD2Sm>JXkcsbK#-aO>p#7+SdfckQ8L?gKN^KRQ?wd@uB3WVf?n3_#Y`Mf*l4c?W)XxJ6' + '*-QaL7va$lK*!qP^_CCVLT+EyQ}J??#B|ts{2i?QY;&yw~w_&mkACM_$f79gi_oR%mUo>1$zlvL$i7hy=61-x3ETbe%cbl4_Db' + 'tf=DoD8ypgYzwVeHGs`(?o_gFHFZNL_!hkTV>e++BPR0+E9q@sY|bRh*3`B6Z^`-B<$L;{>Rjf$RxCNsXf}#PD!h1VUgdN' + 'Tw60VNV7$4{F9Z&_SRSo)9KVE#I~_8@R>lvv#4q))`jBjxS<`8$o*)^(wO*j(Fj~368lz`V&6PDkc*MoSBq2gSS<#}=18rEBXg`)' + 'M?>?pwI6nX@u1Tl56q!jz3F#$CxdRk(Kj<3M_#{3a4YZ57r&@qY' + '-Pj?sXo~8S?v2HVGfT0>#A@O&42_#y0h8<6<0zn|hFDOiCNaxwp8X(QTkO)Cr*tQuZ-RB^A@6l-Kpja`kyR3oL)lL1tARZ=`sLp&n~FdFm+' + '|0jT$*3CjpSg<(={^Xd6aZRzz9vjw3dN`ZBx@?riNuJ}RhRH9V9q|rz@6Th-9+DjS3b%yN%y?7ctZ4M^w0{9mO9KQH000080000X' + '0GFn|p3epV0LvNx05Sjo0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiAZg6#UUt)D>Y-D9}E^v9(SV@oDHWa?=SFk!+' + 'fny*!8c-2;CT&wR(;$-;C=5ZMC3>P(TSaOb$Mt{j<4#J{%;eG~hl%z0`1a+K;yC_r>PfjqKivLw^PJ)%K~>w2jG|*Vpx3u=zwPSs' + 'B{~w!#(|)IAUz&1tGad*$8i)Lhps_+ejNF6o};SiyMZCxwjC2}RPhvDU6VpkHuz9z##{2oNZNu(rV=w;)RZ+B4yf8KD1w^j~H`4RdH-E=L9qUe>iLHtE;yRte~h4611A@~Qe*rCs4=$=|SqA0hBvOzr1rm6`Q&&oKkbjikdL&r$fO5eows?=BOin2ZY' + '%%oVA0;{NT`~!W0-Ss(}s26uA-Xa~$2kd%(v-?ME3aGyR_f-E_-Ql);iuT1En{qH0p5QUCc6S4AuDZ4qr3LjT4Jbb1s?95>yh_cy' + 'g}&LeB_0NR3YJz^{7%?hS7c~65wm+SOhuphm!&vZ<`EDR(CB~vt9J&M{VA_toXHcfb1<%7akdHv=nC#|-n>d+~V4Z}zyijS$55TDN>E>!)l=;>7' + 'yEixahhN{l2I5(W@rDzMd-)iz#i5xHC>~m@h}V`_3gQJsm*j|YN`?y8)n}4>eY*Oh)?G&ldj7RM=lPJ;3F{@(9I9=P2o~E!tT>T%' + '1u1Q9wAAzd9P7y*LwzyQZ1nRQ#n9853O_Gm@A4-oL|;!f_gK{SO8m8@BH|?6@jWpfRIr68!@w%9uH9-)X6~E~L2NoXp}=HucOxIy' + 'K2KGe^QxSjNq^-b{GOb`ekX7o*TSOMo?arSn>iO0lT%uoJHl3+EL}=ii^q);aZ94#y$tu0LQ@|=veQfhX' + '^)|?Rvkr1mbaIN>^GeRZrS+l4Sg7v-WWsR2Cjy+SitY}-0VGGP14wG|e}j9Du`Mk9((>lf*oUW7BF3Be$>-a|F&nweFYYOyzlPjW' + '3Kz1^FLJy03hEvYbj1%7klmkF(oRkYndyr&g0dxZ<)VJvOvxAfVHKgBK&yDWpkp&(oX%ba#Fp=C4tMY_cojwzk9!%u7f8xEcKnqK' + 'QoDt$6n$n08`kkXzDh2hGMOUo!kVLvB=X' + 't$RmfmV^T5;kQ8O{I`%f0CK`8@Ij(Lded|*A0iH#*z@1nB>;=k)FSX%(A`bG^7`r7GpWZPG1}u)DK(HM^ZC4bP9JNbWOA9}G+clV' + 'W3u2Xf2Wwasoy#N6D}@UUWP2c{y$i*(EQAJ=e=WPOjh4OQm44pJ4YhtlXJj_l*0N?1@j8VTMK13%kFeteE^m5*ZFY6KKNb_i0VCo' + '4I?EZ_EB0>DmXR}P(%S&0OfE3OlDWj9F)m@opVc|)A@fgA__w!4wL%=54mw~2A8$DHUF^SSJ?z?q>kkM46;mWOaqNnehylxX9W=D' + '1K&BGOoLyt%q_af2aZ`x77$0`-=mW_O#}u*AwD%K>JbYq^3%SzgPn)rt`-!|+^fypT0|XQR000O8001EXEqB(fwFm$J;U541EC2ui' + 'W?^%5aBOXJFKusRWo&aVWpiz2Z){{TFJ*IWW^Zg{GGA?FbaH89b1ras%~@@4+_(|`zQ2N1frI2|6|Y+t=puVaU^o5J7Ke*lv@i^X' + 'mbh!e5~UL*$Ie~)+dIP-k(9JfkT&Qo{34OV8P3czGvsU(MHh|eY0;;oy1cwf$&Y`1cbO6@O7iE+tM%18QnS9}g(Ul?BeL&?q8~a&' + '9%#i&+Ve(aQ53CK`>v@;p6>^o&2z%*w&{A{Bbr{3$<@k?iJ@+v2$e*%cBpT<;x2f}gvj=TC~zsQkov8bp=K@nD<)*qc@r6}+fnw=' + 'HwBeFA-^U4Q_Hr5A4JnJv)RnjF3nx&4?Un+NZfsyqah`*iAO5(%9t=AC!w9;J>F!e' + 'fD|iVv!b`MsnD;i>y)KUVBboQ>)LtP+W<;dZ}^EPj9!t`>^#XZ6HKPE5(krU<%@;2u+y(zd-h+f{rwlLMbn;UXXhq9AzmcL9a>|(' + 'NfW~NJ{Ae9Bp_xD0c8OIS+Ryp@lhpe1TnbfB{2c%yVk^Iy>n8M#1H3?(~0E~2lzPAJd4GdbBEnHkIIb6X5#' + 's3ek88IKe};67JOk2o$JCqq!A^NqP79XUJw&kcijckt^ibz!peo;AVZ380|gI!}aA|LX|$L2_{*ji{c;#hO;_9rZ%r3-W^I93tZp' + 'IL}VBnbH>)vF;*Cn(f9$L?4Ca5*Q1bF6=8jdHNC-s9lz`gg5_VxK4b%^PU}^Uz=-B' + 'lOH--#>winy}!g!F_bid6e#Fz#me1V3ieEb2*C5cwol#M@@jIv?P+&l{RHQzJ9~tnm7gtlUjS6?vvN=uwk=*1VJf<%eQ_5p!}twe' + 'P2fEHPdLi<4LtnI9C3|K>L%&|7|w_ck)st%NuIhft)Lp~%zYDPn*r}|yW^KChbY6;Wi-vup=DyNMaQN-e!(H0=-GYDiPnCHWLpna' + 'Jk!#ulX#e%XzeDH!tQ5zKU=b(*W&S`b2#eDou#t3<#r383SBPS?3)IrKVqXho-AZv_' + '#)LW9IT|=L?u^1-g}B^v!Fv{KizFH3ve;aXzcNt*CeRxtj+%0R0IV25Ns&qaSqNi^YTwHQ~0Tl?hb1AKYBM=x2|s`jaMtb#-A<+#a_RCa~w!b~P=E+lKWgxmICO&{%m' + 'No@-WYu*8Sl;Mz-$N2l5u|z_?BeC@sj5>&E!z||)&GNgQ' + 'zt>l0g(AX68yC)8N3?k5J1Wq_M}?6VG7YGonLKkhAB`NqSi!M#60atayHqB23Fmb!rP-iDu' + '%Dd+L@cJ(){&{(>zm|Z^FCd(0$Ef;F!k7YdZ*D21WpIsu@mY#OY&kraTt`CfIYpXdu%~2({d|%lRSb=_%~^O}-~CA&Vfz6}d60f$' + 'Pr6rT76Lh(RQinxqCUsX<6$&?4{g(s#757=k?q_5ZCzrTRV{M{6$jk?k@zAM9POXsV$W' + 'N#i-?42z&rv2rC^kcjN4jt~^DSO)(_;c}JT|i$_v|_yjZJ&+_k2ud0l-Y+#)3xQpuwVZy(TM+ov1)%j4|-QG}Rb#`2oVM' + 'H|Ivo;S!E6B^QSsb|@0mVv8HS@nXO+3<`oDAO>0C4s1zS$cmcI88I;Gbs#pK=yfw*V*)0+EN?I&@VkRsFh*8Xa52jvxGh`HD^Mxa^?&ZCOtX(+%i%W0F14I;q#uUjCq-|XuzrFhaKtQ5>' + 'NjiOGlGwy!cd_qX!1MY1?Lk(R=vA$oE)iP&b5M1z5+UmX{;Qi__GMFxp)aelFO?R9R)yFe#jCepe$iCLWjdR^yH?^_Rjul@C~MJQ' + 'ms%W}VyKjmof3tr%B|{Tud1WibxMh5hx4iu8o;Ymygsx|*O&Dk_NtlL3cq%0*8l(j3_lNA)C~Z;Epr8gRw_rz5|KBDwyY$7G@a*?c~q&30XL5Lvbx`k_-8P7YOHlzD%)CY9UVuKS~^@sP;I*FF5Ut$>Yh4MXkDx>xr-u)}V8' + 'vDJMi^Zq5zd?{-b9t%dAmUP-N$BdpY!8|6i1W9L3Q_DaA0D-ZX_7I|w?*2hF#bxq&EK@#!q*s5kNjTTi`GM^y2kb*=KKFz4%M^_T8J)O{j%BgX4@$' + '_+qF@6fc}8XR|L~{4IO?_0`p1-@JSEdG`71x9?tj_41Wii3{}+XseY844A`5b2m6o#I_mgVr2*7*=+XdId6#T0>qYs2w)(x9F3PV' + '0sny7*%1NC>nv+gl{X4>G;g{>i@UPF7E%zfMWt@RBys|&YSg5Zlo!E$N=pu^zitW|0If7jqrB3f_}lOFQdFhx*CSDzSS&vAh+K*^' + '1$&+0=3V>zGLr*PD0QD1U5gBCv@zoI5=^v9W&~$iSJ$9Vo23xH6}-OO*I=`j2X7QuT#ZNMO<>is7dJ|^8kI+NWe)!52<)jf_?KHH' + '?walfy~r0=&o9UGmph*-v091wq1AKdHU85vBF}E3_@SM7JDl$%y-nqzO)XO*Z(kxnBEZ37^@XgoiYL!Ig=#3|FUM8jj}@=lRgLVQ' + 'UruA}WT}<-MplD*)pbo5?dD(CdT7x!z)c%x$=km7Q2aQLjT#)}aSLPQ|D#(3gdVlNRb8LeO$}2`R?B(`Rsov466W}z4ou!V+9`ez' + 'U%_Ih`Y#y*I9SoNXq2*oP|(REt3TlZ0H7C)IS@3@LJS!}dc4CyyRy_Jd68UM(tV=hv%R$F;mv40F-N|tioAIUHV0MrC*aAh%@k@Z' + 'L5Uv_HbBa67?IPLZZoh6gC1IDdtOzpYD=otm9OmG}69l__-1Wx}-HG#-5N%W+8qa5ukF_Oq1C%(i8IkaEClXf4PPLT;Pgd5u2s5BD@6!u3lrzft(XUE+0RLH30R)s%6>dnJ8|2$U<$1vc^eL!85Sr5^?eD*|UV9#;0f275eU$j3HwYWm0FXD+C?}' + 'm%`;S2_L1nhj>w=AyR4pzi!}n0tP=viI@=YwmXi#Xj>2}ndSoY1M+eW5gYSG6d5JlWnHNI1v3L7Qy$+~!$ppayoJaG1xNW_6&ca?' + 'I90&g$Z;Q+MX)K}kWC}=m=qc$RmJn3#>utp_6r=?5TtlEVb>v5fjv<;2n0xoMM)*y^^+h9u5)Ef#E_6s8gX@=~<' + '3B6ZcbAmqJOq%*Bjl`{Z5<4Ofmv1Jl`4^CT0&9YU03g9sk@@9KSS%*Go*C18o_^PqwOekvP0Cy(BY?7+Hu_tr9oNC?r2TO)(&C(JLP`lQ4ofxn5@*R7IDM5C{PJdzGn<115X~5!=tW=NJFhQ{6=9@' + '#L&!X`gQ5*vF#!bl(;*Uw-c' + 'c*_jaUGHN}-387z@&BU~{$8pITS~!plf+qWV^cuo1J!}VdBY`-|2^XYzT&S&5Hxip1T|KEcGU^Dk=b@?l!>pdFc2zf?B~L!&Q}8G$ExkRIW&7m{7~4_Y#M`BfP(K~iJST_y*%Sh!w#g-@Eo(Qv' + 'c?8#Fmfw~N0z;4}nEm^aCmBsS;BaMy!Zk' + '#k1WRR}RH{b|`E5Au(0Ww!psC#YdxOc~$34p=^MWdx`uN>Q-TSrdz0w3cv}+ti)DoU{J2`23f@*tjyXaS?V)ApV*aYd~$3x`^RyH@WNEQR1@!N%ebB8uoBFi7XaAwJ4P' + 'uSDIq(IWD3Jb7~I1h$oZehvBZUg9-|DGMQw!gQ}Ztf0)Rn~WA^@U2(RM)CcTwm^Q;fS*DaLB%Mn!WJoHgLl_ug&kyc>FCP9r5E^<' + 'pb2^JhfBr)cuTe%lbcXNmvyqn<^bk1NLfl;9YdzPFM(POC&Rd@s9TGcK?NPHT`z+}Z2W2j=aK0wBZOszgFksR^5|IbnuWL+?J1x>' + 'm2Ha|jp?1jrac4)pe{~tOLZ4LVe)x5@CLh4Yf4Qc3cMRch-V$?N9hMA7&os`n@(n_F#yA#K-R}T1ir9of!y;I1Tn1pp{-OD3g9Wh' + 'j&1ExgPB?lfi%K*dx=oRUY!h{wOvnj1_6qll6cFdxjiXCC%WbiGhE2M5a@!qe%5$3w4W&MC{W*Sp2ws85}o7c;?i>ifr=yV^~m@O' + 'Yvjy}o4U^SooufeW!~WZ$O%uz8R{2J&9<^YV|Z}hmQqm2PE9{aOqU`9dZ^li1lMu-v!SF6Qv!GUMXk#H^%i+d91w%1#KK-K6(t8X' + '62qa?G`m;LLG|5nWIJ$W-(RD5qZ1p$T3l{CHjJ~jdpR6T577Y5T3KRF{8KctXPu_0O~scR@q2MPwKOTZY4zRb)lh)s4Jeg>~_|ECy^XqK(-2XS@x|DzPEUfaI?@' + 'R4*repeTTO7JeR_gg}Q`O7De;`2PVM>-=-t$b2J%G{c`}IC4e&TN?>-N+BS(S>z&aNVw&5T*a37SXJB#8C@AWk' + 'hGYc|&)h;1yt}d>2LcI8#EvU5UM0v{JS@gyY2zk2B`BCpOU<41u@Q|IS=p`ZN~{7i#$+xKO;5-5tGv?K_q7}A$@vLDMv0m2tx?My' + 'j`NHc6`VNpc5b{*b$uqI;*~6f?6zg!VKdEKM7TssW0M+!9C#m4=kfpvT8||n9dLBe&Cs_)pV@?F?bMdynLVi&5T8J~0|2pH;7RuB' + '!7BjFsN7x<0p>oC1G=U^L)&-+IA63IK*Jm|pSqERkh7-p7<_n?ycw|F83t5TRucnLQ#3vm7a!I=9|o)VQ=3(C7IV4WjM{^qkvX+;JUi7)vwK?a?Btoo' + 'vAA8xe&*EBCEp4PWt)m)2}W#)!?MPqLoM}=mYi}gR9L2CWWKR-luIo*ZD4F+J)!&J=KoJ8a4Za7H)>4cC(Tx*mwR5ijrnL|zS1bQ&+xb7&Ks=?Zsab;pYR|QD6Xp|-<^c#j' + '8HMd0lc;X5G9t?43jDjd2&NC?GkED+{h+-+bJu&d{bUoOsTdo!k^gP}$$B(_h2xxIVZ@y*OD}Gl4^hb+}' + 'HWz_tN3%jL&4;ctZ>Km1jaVXyxKYPdB@f#|;)Ts&N&m+;R9%05S-^Pkr2N}@aj}85O$(+N9@ZdaK%qJCR%;MuVH_;MYm~Ac#$IyU' + 'K`g>snw5un7DB`!&g#Jq?FJ?QpHSTUV+{y=B(Z7Zz~C5OU`XEkWws`7!0`S2eeAEWuL&TJ7@J1~bsCYQY<8|ARmrWYMkCvI&CsH%' + ';3MlbzBu)j2Knbb!lpYvuT42VQpYPUj{!~+2K;)QOo@Pv6*o)An>OK$(h1cBL(`-Q=av?90c!30{RU' + 'Ju9EQsSD4Cnm5EZy#5)GMc@;eVIE)b4j5Ir*?E8KL4U9T{|>dfZ|MilZndH6eefTDJ7v-' + '^sCF{fdh=%U=1)bGB&xevvF^waT9@>m=}y1-;@6aWAK2mk;8App~FYO+}m006!~001%o003rTb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQD' + 'ZEa&|W?yh&a&u{JXD)Dg%^O{F+s5&ozXHWfIzS6y?6lJdPMJiO<-}7<^4Ll<9uDUQB#shl5I_LXF{A3gclW*zcK|6T?bHu4aoF44' + '+ppW*JA`5QqNw|#r)TSI^O5GkZg=teRnTN@S5$YwzJsClA-H^h^{OuOuY;~{_nUs-(x9cAy3M;Z48z%M+t#~)knJ8{5fT);rf&Nn' + 'tE#%s`l7D7*-VUW_tmDa>#~yrMJ<2T9Y?gOtDb)D%VI4Nb^E4iX(!(2S)Xmntm|ke0o0I!pnb8U#%uPhxLEU5r+PhE7p%L{*J)9w' + 'O}_q&VEDfTtoFO+fP+=TDUZ(-GeEo-b-CYF3I2Que6t^~ijoRBrV^BPyDdtcz|xC+PYDR7>$9rwkfJj%dEd97+t)V$|K{b(Tk?XuI={HRc}Lz~oWHq*dC#9cOP|fi)fMnXE^glZcy&!)US7PuI)C#XUO&tN_!nMeZC{bk' + ';XL?DVvZb~kzb9G7o_NFQn!Tuye~e%i?5T}=kSzUp(^z}~I' + '{_Hz#e4O+-2UB730DoX(l*7QiHXc~ARoncdBUHTVq-zskyv`Uqd`HpTV_n&m)95HD%gQG)hA' + '*Iwb$2QI-Vk%4)zE$ht3qcUl-qy2rID=vS${>SyrPuD1moNj}*-d8!LgfUS5$kd2J5!C1NopGs3}WCv{E3SvT_fhaiL(-tRq?5W{0S1Ql{R&{WVtL9g7DD4{x)M16oWhx8Var' + 'I!_?+*=K0Aip*^eat$un6EnMM#?oH1>rEnGs`hAvjNLDzVMMF6%xnlqB%UWP_V5*?^yb@7;r^ld|ag' + 'h%Xmq!z>lYmhiBWeuV}TEZGDkS~OA2q`^j%G}5jqi#`gIFh+e_;44X+?wbA}V|>KO4I&65CdMK=pe)dbl0p1fgb)=))L5`+p%!}2' + 'q65K=hA^|{LS{b`5p7ufFoTI543Zh+(FrzuA^;h0BDQYvrsy~t3Ji%cHnDFHmRp3lUt-M4))>6N$NPO3qEH8Z%Nztk#a-PVfUa;;' + '@9dQ|35h@g^C>4qRrEzxlAJa*SRqOf!z807QQHKShhc;e5X&RjR_K8k0ixK!1-x){81MM6KR{6ZHgw72zh8>}Ugbm4KM|hoaIqg;' + 'D<(BJ1K?fm2565B{@^wb+v!&y&c526eU*pt%of5b&<#%Z}t|@8rI?yMMBz?b-h)l9jtBCM+0Vxh!q@!6wz0r;Cq+yVB#E' + '?~}zQru~lc5{n8EtiS1iWHt0cj@J7uy)ea<&y-!' + 'CyYx$(HmWz-mmSMYzF{Hu;Z!eD4aI?g-HhgXXrXhLm`w~luyu`76#xSOPCremcH?v5nu4!hHoVNTG>' + 'z(?`~yHuP$RfnhqK`as4kQ$IYBnI~Xnig2l!(7}W{F5}4W)SbHTyh{Ji^A_b!Y6e*93(w_;0R8rC&YH*gil1)~5QT+yAM-wB7#f;D4J+`bX1#2aVF9Ic2K_xAej~aF=k+AYU{a0O7k%?PW' + '46*GXoD4VE$uMABs$zI45um}y(V5pCgoSpSd%B~#ei!l<1iaBf40=^y*C#kHazYZ*C8W?DgBcANqCn7qW+tR{h`aPM3|GOQf|6D$' + 'mT)|=*;lbwAqI!!8Cm(xq$nv}kC4jtkQBAj)W+96YQk#48p5S7X&S2&nvI;0o_IJwPjpH9AaOm+aiRVkhac!$zmPK5BbI=Ky9}&Q' + 'mb|;H8Cv4h@}yA{S&X?Xvset0PRO%itYJt!!K(5qNrH4wpi67ZMQI3NqGa->@h31BI|=Xffy9O@XPdgNy2b+P7G;Dx^6eyHnY?XIQaV_c6>g^VmsCna+$R26}uoQ!+M_MiYRMdM6Tdwyw(5E}th@%Mw{#kPDoip$_' + '$znR?dP5gflf-}vt+Il;&1p+>xzR#a&=l3(Q{BqljfcfmViX>u6`0vk8Y8Ao0>PfYaO?Ftykod#9)Jf#*7DLjYV2xOnJNt4nvOZ<' + 'VCln%49i)Xb5>S9GjxWz4j;&g4lu(L5VtdF1|$}Tvg(dT=$``?ZFUBj9*x~h$Eq(~R#D~j^W5iQ$Sr$2=HD0;Xf7jL=VeoDG@KoE' + 'OjYk1D3+;eAPCzRdy0HI$@WjPYkS|3Wn`kuSl3KkvlM)R7$t{hfY(UPOlw@_l2GhMY&AhNKLj~9ii#IjSR9(66nd8DL^rv&Sy|#;' + 'nJ*E!!ZHumU3%WvyK}6a98u%ZFf1T46Inh{dwP`z_gRr?IA5K_S7jgO(71KK7oW4?7lkdBeqK' + 'ABCInN~Y6Ut&DOC_0qFS$l_G*!#aAm?*q_G>pe60uX0w4qgFVKP`&hoMGKKhPoDGd{UWR6H9|Mo6de+k6XL&~3b*{8D+7VZZGuq#*6)dm_V!uU&K6HM4PB+2TV(m#dJk_x_)T=Pq' + 'XHyFA2^uyIJGh8_Nso2hq|kdcYFs$`sl_s^RTsJ5W-Z>U)0gFp{b@)0EN6Efbu*pCN?By81VLTpVz$F>RuBR)uiPd;QYHeM9R^D9mNeCpj{NWM' + 'y~ZG3N}CvYtrc4rE}lCFry7C041sMH46t3nE+KEInT(q5;^}0xc>i{IPdB`-4KiDHC$x|^' + 'a#5RM>}%7gDXeez>{McxnDLv-nxJF{12gX&_GQ&A7rdrT-To}+MR#wbZTKn!f_c!_?CL!=D}@J!;W3N@b~~IsImRH2Y{U{!bu#X`' + 'Yf`bxb2(5>OT`{-OmHny8S^+BXe@qKjQYWEyk-bx)tcvOke6Kv7?0}7sUzKV*w()D+A5qZP=8Hk)n*p_bb)r9-bA%FQf>I8`|y0u' + '_9j+jj1Y+9M>i;`fb~1r0Kb602yCrR?vaqh;=RUb^oB+3UNl8ecB0AqiDNhPMqB>InV)%3X=af_5-VFFlcxoWt}h(9`@NKaqvFZ7' + 'snfGli}k*?1>lGAsmIsU^*lT;dRnaG~0C{GryFH#84_(Dm}+#6HfzXviCgZ?Ukr@qSV=h-FNyM&R-DI-YtSjZmH(b3S&{' + '0h^wYxq?A_Vh_FmI%tQo_L=&!J>6uGV&|l3YsjPfg9>60y@*b$@bmd6#jh|7ud*hffE`ht2~bXkX1o3@+8?gA)hfDt' + '`FuK=yn&Jpe6H$iz_Tv5QL``GqA$CssiVGmSJqur)a$5-I;gWPqoQwiQD0nb%V`w7xv4sYJ3vA6q3$OA4G`3p`?l=Lx-a^wsk5l+' + 'yJ+7weOc8Hn|ga2UA5Kvy3C^8?#c5nqvyZ;CX2ehJ*@geTSmLGZ>v?8VY3sY9D7x)`shQ|BC+kR=%MN7eFWrIS41t~yeYbP_+byY%VIbAqH1mqR}tWPS6r9>PKEobu7OA0;Y#+Rl3WvX-bU+cvngBXOI2(mU`yXkCvhB4CYu)ckms8N5}oH!wc9ss' + '4@B3E=yx}n$Zu7n{%Sh)S6d1U>!L4K+oJ1$;|fK$u2y|!ibTzQ(ceISRjrrsQxpL4z};2ZX$-5Ia`i564*mYni#mO|+ixoDpQ!Ps' + '#QzrU?Neats&Cp`S?_j_qM&O1<>j;d$seCR`P1_kf5@WC`d0Vps%rYGFQ;bQfd5zBVDYqSru+5PN34eb)fGW5ww3y|>#lKl)X(~`' + '+utHYy;q-r)RpY_a2v92GKt`SAYxBIM6RKanf}mh54&3bd' + 'A0@Pgl7g-W4aEG=m-%+RhQU|A+FiHy0C%fulkbbRV`KI5tEW%ja_$|QKvmJab$H&D-yJ~tmFGiJ' + '+xrH%xIPN3M7sh_`|)UPRG$59ULD8V6=l9D3!HA<3?}`wUKed!+}c&6=;zU&N#A!j#lDP^Z5GuUE}aspzVFopn?C^;oRYU-GbcVU(3^bT>pXDzuzkc=_N?Q=4E^KDsQ_cvW4XLSC)*d9uM`AMUPzat^gGwKN%Wf2+3M7%Joaf&gUF)FszbuIJuXDFcNT#!6_?na0)@RaZuDKrWxPZPO;Psu=AK9T<0@Lx_&3' + '*+|sT60A1RpOlc`5YirF9sy$q`jVd1b6esvHY>*nwxJl=FAlqxpy3f-5epQIH3`jCyXa^0=riQ{Xid@wi;LM3tDC`joP`fi*d$9$-iSLe+c|SEk)<^(@s}43FYt&1lgE6HlVn_~PakS@-2yDq@N+ogqd_J)I>*' + '3g}aWNWlu0lT_%%;#6taIAjP^cSl>h$L8*2bN7)@sLqf}bxXPkSbaCBh-Xgk?BbA`b|F-Or3k(rxt9D' + 'SV9j65QWS3}cvxv$%)W;mn6n4G4E@6V<3$=h8kl&CDgD#*(@5ACN)W;o7b%8sb=CoSp`Mr{DNs1^J>TnJQ' + '393W+O(?!cr1zBW57EVB+z=)Onxj&(D=^(lkJZprvxU)`YXUHfK096c+Sokh>#EwskcrJmh8`#Ilk~5H$mn0N9?R=-1sv)ZDPS}V' + '2-?=b9-Gw+4l#I)y&J6*{FRI`FI5c(CyE)QgqeZ^6s8%g&5XpM>2K9n_G54+r`}SoCkEFOWCU`79^}@Mxp5N1#>wzyN)k3EJtwG=' + 'vt);q`uT!GV5c$RgxMpAILByV?T^?h*tDngKw-Ci=Pd~KEL_a8=+TnFH2YIPE|%63v7h2zKsNZ>=n-uX^jAdrTS<;z@;hZ|9g|5{' + 'f50BKM~{|}0iYD7Hz02g{ljMS@CqH2opd$qi~8ib&3B6-i;9Y@`D*PXLm;B;oSFL*T6D+B7}7JIiTUYEuw_2#z4K;U^xDNtWnvKJ' + 'riLLUDHop5c1M_n7>yQIi+)e)t}S4AE*)(XtPg_vQ+DW$z`rcgSb0wv0W' + 'L`7Fkvt?yC8x)k_2f6>?pQWs&CyLrA3x{l`d`UQEN>W77=|uL=?5ZsRS0G@o#XrQhJm3tj>O*O(EV<)ywpZk^D!O-2su~o`&hhKQ' + 'rqpOB<^3}IJ`Y|O4I)8S&_t&1*n1c&6u`QvzXv`iNP;8lT30}W-ieJ+3po8cWPW0hr%MZq+36O0Jbz5Q{wxgaGPPj!W=w)=iqK4k' + 'kuemOTbP#dlh}qQ>GFW>IzrA=r7sIWWGaxLsWP!TWX{m0=|nmy`$Nn1FfI`tO@@TIUTD#q6Z4y*fBYF!!wkS|vPJ#o>hI;M57(tm' + 'o>EctRMo^O6|P5}W|YG4YC5&@QhG}Z9B5G-!)jGZHFm@cXsI)r$vAz<-}Z?rO_3z2Kt*7tF-n&;8>BwaUOl4?#yo9OC1@yrUceV*' + 'T>iLkE(m1FO)H1S5yLHh(P+IZ9f2D(2(VR`m$8&VWxM1T5`iCT{%=aOoiaE*dy44z)+)->hf*%' + '4#r23l@8No+z!Wx5;eytGA9W>U&*))1Do^4!0NVd%lB1t=yGM5%<%lpI`G63koJA4YjgvS5pH=+9O>2$qe#`QqwlM|;jBZsf#?)S' + 'M^my|_47CF!Siw};&8Zd88Ev?T#XaC;Y)FV0v)2^u)z3;dOY%MOkMD^d4QvqnpdNtlsT;AOwBum%-0NW' + '5aM|H_ok{7t$VC-F|(D85ff5ZMa%j-c~UFLPNZ7KBeNgGAqQw<{Yx8(ub|^iTauBeHf{o;I@nc_RJOGmspk0cjBALC(-r0rY;PTN' + 'TQCSkga8b0%a5ya-!p}21MczsmSG@heP|`DCJgo' + 'm@r#YDNh$gYFPp!4tO#!lqzK_mK0uN+hBvn^0p!&GIidq%umnw)+}(g{~7J*k8S7~cIFBw>A=0z$`?;#Q{N`W4rcc0yQe5U31Pfh' + '5TP@qbD153V7B@Y6w|k+z%kBi4EEbr-^1LSBLFjRrw7ntZIWX27j(fkcgYUXpwLTVl2s+RuTlJ*7VC9lFr?m&0Vb9hQ' + 'dJSVSgh!h88ddQ0z}sLf9Esy7aP%$qduV76b_9c70f%H?=$G>29<5;|*lqJcn#9H=Kr7cWTD5-XF%xzbqxXmF$o0Ulz}AOQA%&5K' + 'V^8cV*iXBZbe{>W33(cx*BB)bmAgGG8^{jetc(uo%6$Q9plQjAWn2w*psZd*YHJ&e8q~JW1?y>2+yxKRls>KbdHBN%d%N@buhL+I' + '#3zAEU---@VheZ}&=ST^fUk(_PzxsDx)cc19&ZuH%jiKAz~t>9V(3LTPg+#26$0' + 'LDm?}dYl|@GK4WY>iA^5G#|!L(keHA&6a&FC33A(ZK*}BEsJ%&sy0d299p@g(NZAe#AnK)Y`Q7N=TFOzU@mvEdg|Me>#L7b2|GpP' + 's^(<0Qq@dfQ{2?^Zxldkk*K586AbjmK>wK{k;sK@x1hz^eDL=wQW|oWmA+*>4V~$IHd!XP{!hPt(E|=I>lDs?D7QzTzdS~ZX;a@2=L*kirn$f9nsL$GM5f9Qt!%gFE*mkR+pgFCNPD!pfSp6' + '{_^P;Imsj{=9r8_)r{!_Vg8pfdIGpoT8J;SS3=Re?TK~cEz#L{RVPO|Ah1CX7x5HBg@2F#;Qud`UJ#>CwyI#q0vlO!;Z{{(yGz1N' + 'jx^+-Dxki2QY##5GN4^-No*g`fG#8}o~Acm2AC;B39qO)WoTdD`)X57cTa|unGjp2?lBWp4T{o?LgAGJb($d0+S6-OL%Yflo!ZfB' + 'jf9OaGoztIqf71X6h}dxwFa#%bPLD_*2ZL96-$!u?k9c+R$EY1bt)D^mWf~YnG@`02wF(oOMcjCEt8THJocb3x#FM-Qq(?K46H*vv1BOe%*;>bBFF|SIX@`^fcAms(>e^17nWd`L-V0}-' + 'S9d`;mYxchGtjQJV5pX^Gf_WFxKogyD1XPA#a9Hu_7~pB)85~0G%}{>87V4$ryjwg%ove3mS@i2i(?_XtL`#DTzq#HSHT@J7JFpFPBflKDdVB%6' + '({R*?4C$PBqnSJPgMw-I7T?Atfbd?1zQfBMGY}XLi' + '?Cl1CI@42QC8qg!l_N#WZ3igxn$z=J=S7fFv&@E3jy{nrV#b@%UlSDaxlAFEkjXS>&KeoC%iR2cI5*C_V!c+%MQ+h59a-i%ZcfuY' + '=Zd7%RYnHrWG!IGQ5a;fWL)kp9EN0rvXLu_-K' + '#8e^X>ad-Cv%LkK7>fWQ(KC$4m-L7@(7LG~s)|lXkqE7}<{KX))y^h3#se3Kqn8oHdI;9CFye7_&SviD&f9RZTbafl{i>?>qxLrqfWaUoe(0^XrjA#;!@i5UNXlKE@6Qjx)*ux3<;vXYMP>oSdGX#bc=)' + '6GLM!kG4I4y9hbQ?Rx3pNQg~3IaN;?4qpA!$3%H({i^&toI{{yOM**MYS#2fX9QUu9S5cUxp_y5ddR$!z8y=S+64LLgVhMPtC$;+' + '+g8TJlT(c1JFJ~LtsvGve9x{F@0no#*B1&AKb`nGKRt3IV@U0*;UnkpJff9lk1pGFnS7PnS5#L8EGgBhKxjDpPcGEKcm6GAEd' + 'ITmX?{Pcr$o!y65Ux(~!(=)j^AEtHwdmrr5#%c6e*Z)rAR1H$DZj`A?U{9cTA!DH;Ak#(KXvh;^n' + '4W&YZF%nu8aFPZ%!E}IAn~MCXYPYL+8i5o!=W$MzqgRW3s1sNyRm&(3#SD_cRVQ3vwwb9llFOXHy;we>q6fjl(219Az(s79KwDRZ' + '2FE_xk#plhZUWdUBBFdO0qDGy#zfk)V+$@GN;SscUVI*>p*Cdp1O{(OjY05lKD=Hf#2;=k&(6T`8#Wr`zw(je>59p%nedZu0Yxi{' + '2gZ)iwHzEn-K*SGC_JKg=_dbwV;i?<(?u*`w;qI86~#Y|Ff)L`GTI!ZwzE(H&jeLli^}hE`Q?' + 'U<;PmVOVo;7Jq8ddaDOp%JbkH%n?fs@Cnz2SwCk=D`i|$;DUngC*' + 'mZVEG`WK-QzfM9U+gH0uNq}>6nv*C&kCAn?G_Q;cQV;z`P>^!2U@@m=1Nx^Rc?oc@hC*P$cuJqU4B`xDZbls1g@%dg%Zi=4gG!!fnWzFOLvjef|1rJeEiCMI#81' + '5rHBZ56-na(Zs!cn1l?Fcrd&VH5g@{pch;cihF_mOky)JO{a&tgFS8eeVIH;vl9^5{5*o7=%i*B{8HA0C?xko06evxM@U45d*MYL' + 'cpXAtlDbqm&ef45?%~XX2_*8@hvv}nN0fxw{xYHrB(jA3KUGc7Fi|S_z=H@;^hPuW=4ABtZT0pom}`XV?c0|NM+;S$J#yxMyjU*Z' + 'zMTSu+6N#Ob+Joe!v&%^^f%_@`NIu9B&6SJdHYtc+W?Jo|Lt301Heyw`vk=AP<1!L)l)Uw=9-R;)Y?rTV0o34nNP(j%IrB)#ansA' + 'u{Li@1X2(E^B$P6S?fYRw;@fg;i;9;$_0B81d~AWX&W2j^%#Wm)XWCoH~HW4R>sexH#eo){PT>*6LLbZ%AGPuBg>d^0L+2EZzoRpMlsPsWdr8MFG&cqWn}m1I9w;f%|FLiJnZ*89%Qw-+mTfar+*{-Jh3c0XEwFe~' + 'jc`0PID~yLxc;l_)J*&Wf+6W8!v*yJO|#Y{lX>Ml{+6uJ;kRG8X?gi1b?hGd{zk-J&Jg}Nm5`I}XW+IzkP&#lN=GxnF1&kMudf=MV)U7Tyo@7|TSWP-?4ZA25^f;1EX9r{ef0#;#p_m14LbCWr`krIx9mpYOjdjD(AVM7(d*DFQr' + '{I;{ZUab)we~E+JUGv2m!C@DD^O&48Y?Nd!L40`X#h+q7^2t8*b;ZJ}z8qDUf$K%SYV*?tBE)5WuKpNQ^v)eCS6jPxt}L^~A=%_XJ(~lWa1&StCq-B`x&fATp?-Hm%MvR{' + 'QIU>jCl&^roArh%YMv|yv3dC-g_#!2-AHnQ149aZFcaKAi8cQJkSTt`MI>d3j}Jy`SaPO>ffB5)^(^b#wvp' + 'gIjAA1)0u{w~>So(SHF%9M?B?|(gMsQ(7!m+{Kg60oPp?2~DoF22#' + '>Fs7=msj;(h7C$Tn7#2oVx+6{LsZ#1uNk1ULa*gp-t)n{V%B8^>wWNoXvmtHH?rMphM1BP^lt#46kjSV+0IE' + '=!n;`l9xq)L)v-T_2n**ViL>bsh)zT^+G(b8=thhHw_OM5h3k3u({4z9_7&;cBo=tn;ojAO%gWHM>fw~$)3DVKFu@a8|Q-==mJ*^' + 'Yy(%Q^qDj}*)>lYmZ9%>O5H!;OB%F8(LS)NrlU@P!O^rl0d6hs@W*3XbBlNnGMP;HjzgZd{=@U6Fp&@3Wp>XsSOSKs-e9J7imbCK' + '4dh}tE5(B8seAMiO#fiaAJMvZ`PK4j2>Vd`GW4?+19!Y#WZ^5{w)eog$Kc$wkPJF<$y;11Le3@pL6UHiR)Zae8~3gh@TRsq2L5Qc' + '-Y{LC%y}(gcz7pxFJsiYfQj53`u%|-&RGKAz+J2lEllS`)b5awv4>~FzXra1Cx@U0$$dZc5WUX-8_dBgqB6`mm**)5}i)S4k-1g+iKF>#L`$?o#u^;FT9kriJOcM0Zi*SMF8~dMU^x41`;_j=^nJ(=ne!' + '+wHeipTvKw<8(HZ4)*G2B$q&;T|SFBRilN~E}mXKv%!2}~Xo%5mO^h6R5GS=G_-9lo6jLD$;9W-~y+&Di3trqv(pID4q#W%nYn&CVUisV2XSZ$`uQzzMXJFG6#*w7ft^q8eoAp(M&g99e@|RAyrRd' + '7@{}wmJ7ukZnx!n8ok_>1wGnFUu362_2bjt{SDL?j@oG;|3*ODht~LkC2D~Bjii*t-bR}O7_l~sj%=BJ8B?I$b!)lu58hme9z1yW' + '0SY^E)si7V!tO@T<8v^ZvK2`v)Uly=hk`ejIZ?=S=jB-uGck{py+YImR-w5hAh7bx4NU>p1G!3sPv1W36B4?Hy$9}&<118oP9g)v' + 'm5~D)p%Y|YEX{LtLBOu!C&YFUy*$Kqq}S#;2))?j?SV(=>j@5ksimDkLxZ7e4vfSe6Oo9cAR!|Eppk&-i&ikk)aVVeoz2^NhmWpV' + 'QSJAIWNB)@q^Ckla}gvhPBra&c;z8MT*%3%GO(+3NKao$3|4ikO*DD<$OeOd%I>|HZ?Mj_5&~J#n' + 'OFs*(RN^M!TI75ZuTh};G`wahX_nUqDcmRtyN5MNya60QsJ>QkBuN-W5_w+%UwR%91a|RxI?h0q41isi<2ygBYPozcblC{>)opLR' + 'u`HmjV3)G3rZ~r8W_8J~yWSV8chmJ@C;dB9Hak-2?su?B1i_mB`sk4)1Eh1w^{Ssa^XkZ1BtWRCw&n*1L$VB&&XHtfq_72L&h`BD' + 'vsceAzk2@HXHWCXSFbMr^4cFqXhRK%o5A%*5=hp?d9xsB$N?(ZAMwlrJIuyMU{H_8_oE#P%BO!sBpbr?@wR6^J_~zb3ToWc2^~?8' + 'ML`{Ue{OU9j0J1P$`emFN(bHg4<0y`t;0}5rn)MN0zYx7V(}6D_zF*0saSat0sHX55Id|PqK?i*(gi9y-~jUwxA@|DWB9jfreC0X' + 'c>WFiAVqOxV%pP*P-fyVN<3qnx-Sslf&{IVXMyUc^k!L3ttC$GSRc2ca1b@sia?Hp^5+s5&`e#M3b3Nq0VC%XX}Aq?U)7r=ny1hx~PRRw}4u1<{kP)X{1^7;DPJG1WxDe2Cav?*dBkwos!&Ut$T8vl{-qB%U|fK??_hXq-?sbq+Lf~WRn;5RTl?AFqy2|rXp#&-u1hdrYR|^ZPWHJrEdDHFPgfWOvJN1' + '>$6pvbsg>GqpoiO6bG_u$`Y2tDRFkWl8{~t?D!x9264gi5$j#O>YJui?^oGsL-TaoHe1^Edp@e@;k!Ai=*mJa((ku`m3;H8-YXL7' + 'UA5hltRwZ75B;WS3`C#*z@K)jV!MyKEifP}ZKa@A6r@8LNz' + 'GcD>IKy_2H+3*Q2sBPAE2C?5|fa^uwxBDq+X_jmFQ}V6_uIUFZB>n;^PL)V(A>zAlZD|?P_gzmbIpN2w-~7;ivsG$dL' + 'm#;5gy?Oq_%kMooR_y5ho@|&!Z@w6?k!B$pgzb+iI^12NkH4WxDou}sIQuO1w5{i' + '&*9>HIb{!bRky1`dyPl{VcbZ!O;>P+aSni>>ZX2A+a~;He3fk)!lB3{)*-j' + '(wy9iZ+Bu9P@0mCVKtk7?xB^_ZNC{A|M~n2Z~SGG?_oH5|HXXC7u~`T^o@M+PdQNbigC|VZ3MEIvuR7I&cTP*vmlDW?l;+%h7811' + 'faFVWAVi?uCuG6Dk~MfF@wKQKY6}K*zKnCqj3$cNgE07K9YiPtK71)f0*Qly%RCKZ$5-TBi^5no2$mLgLKTIIh=I16Nh=;zHP@3I' + 'l;GS75uR|gX&B6A6RmmkF{Yo*G{*%MmYHxgiD?&t=($Rrn33?1gm}-jmQ6KnY;w#NHQWijxD0~$m!_yg#eV$_{|(zs+&6+tGlL~l' + 'YbIY?kF+>4I*TEGpGjO0mN*1)p9!k_hQo?5CjhnN^a?&k(%UNmcnahR!DY6om7K?m+449IK7tYj2%kCVA;egj;&JnwqmE7qbG_{Q' + 'utH}QIMbXv0S+o&Z5jZ>p^!5&6F~S^aM)$GqRDqz*-=Dm<|uauPB4VeY>S>fJfF%nB1HLXl@Nwt1@CE{ylHndn(!PEbI$KyKMMzv' + '#7KqmXElK_5bT>45QhvrBmKMPEH7Zcg5EYO$;(~!hm|N=1!^#^leI8W;!4Bc4t6kTVOQ1-QCn$@|W%W9+Mp0ofdxDvv@to`2`4ZEH^TBsB{%#04Bj#Ce@!xs~-%SRI' + 'BV-I`wA%K2!WJMhet^ke^&7~Uu7F#&E*^JrV6EX3DxE*1&B$*iFt$Ubb!ZNVkPqwuw~WJt1;E}*2wKfrMHfa2PY=1z0j@J3U#q>O' + 'R1o%FD+bt|kvGWH*AvDVH0~Gg32wGD^%drunmP)BFC`FRCRf1*rO6d2BiyY@*aW(B9oz4u={}hDKK@S!&kZ1QAW67=5)c2FrNZn(WkKOJbW5Knunx8^!Qdpx^>KP#MdR7fZ<-$-k!O63Z7Xa;gV`pO=987nQlzE' + 'v=XgMi}}<+U}>;H6ctnn;3zLt1|m3d8V9uQFgJXH1F((TNDs{k$VYm!WGO29iW#b;@)VXi$%{#`(tMMb683ZmyF`4Nd5X(u5#=?%' + 'xzOqzd)Day@39brkr;)W&QUk0UBQTZa0EV-cxd1nHPd*uS)M|wqkZI*=v-wKeV&MXdAbK*2Y+K#M_iBejR0`VKJKvaQj15RH@6z<' + 'ogGWX=Ifp;18(qaDIqd}((~0YWCX9QioN?8-vAlDob3T{d09%}c6|v$Z4b+HH-PACWo#IjO4%_bQ)oi)#oyWtS*~' + '-DKLg(jn_)&l&#Ko6qE~P2qE2e`QCB@7YCA85nTigzS3csZ_D>vC`q8^r&B~Zjj!OmVJ#bc5PM?Kez7l(oKg=MGk*kM@b~*-8-8k2GiB#Jt6pp))e6g@)XZAnsbwfbmT3cy%(iM1i|HIJ_}luq-sI_u-iCIT8_>sg=p83~VY#Qb-(Vi?U^NY@&g-7DMZd' + 'CBQ6m`wDikxam8@ZNBiipQClMOrg|h!IqdWBiGFw%{3W*&8~&bgEk(MH9)Fsh#mkjHl{Z{qGiMLaWkyMF$_+guz~Z)-AJKwgGT~H' + '^ww}B^EM;)WSAib6Yq5ZY2Ymr)?Hwy+>kw=cB3#RLASM~|d5>`zGyJcAee4OH#' + 'K$xH994m}rkx43uE{jP|r>R9zcTS0nxkzKhV_p^4YJv4di=6KvDamjdEHw!Msk16C--Fg$*3dQ(kq$qeNLI6Duyxm$9V9qQskeygJC(rZrX~rz(LaW+GLXYCh-~VBt5zvk~S4A*y?MAde;o_D6SfKpYk0%&(F+FZ=Hab}?(0' + '-MG729$8gH+Iy^N93vlG@gXI6kKZ3tfIsXQ>~3' + '(ZLFRDr;Fr3GBL{??Sti#=t(Dku%;w7is{&rv*AOz)?S(l3R8(C{m{_4?iB^Gh4cC&8b5oT%%72Z(T`p)xMh(R)0v$8swC`' + 'VAd`k@@4O$;Mn;`_Ou$=r*1PWu$paj-WFB&MGfprMbSf-e&xZ2SNZaH5k?lj^kNg1j-6Llnx4H&O(o=gnW5c_7$SAx7%c?-+`y;P' + 'bHf%^a$7jmZ)W1<>@pO(ZVVdawbO%V!5u`JZ)44IBL}21ZyU;R$8*7hX`ok@yHARBdYZ-JsH~~DxmG-qAn_=AA+VVHk#kC3(G@MO' + '*-H!kyJ3hn-GVVvDF(Kn1NvZQVD90SFIh8_VH!A}3@N8*!{cQsYkU*~R^XIF)}_~E{nICdWdgO&QXmuc?f4;U->lwln(}w!YS`9-' + 'u3%?vco5c}9kcUZ<~|@iD8hmxzQnL_EP!&@XfA&k-*0YwurCt)FY-Ggzvr}DwFRq=+y#S{iG{|*G=^w$dZ^%&v4AH|L{&Sh&r^QP' + 'Y}PnMpy6yfW8~KB6P$5xZe59xgmhdtg=cl*;JAqNrUMeUv60pJ|#`!`(TjNPUDG<}npN|2QEAg3YFQtwi9z>gVj130lq+?+7h@I?VVD' + '6XGk{hk<$+oFHJk16ptWuq_6*gF0^~f^%QT*bl;2~ed*!2s6s7+xvRM`*->-;cXLo~5P' + '2V32S%A97ql~FB-jzcvo8=81crpRrKtuL!r3k?zNUJs^PbsalIVdt^z)a9f+kP_@&RPB7fa?j$ZpSOCUMtrP;w&8Lmj~}BQQFzg0' + 'Vb!4y^+1lr3>^p#^ePTy#E-_{6OM)b4VO_)p88dLkr>#8?nB&h4iNl<=D~9H_(AeVNL0qh5pl8gM~&Gh=eOx|{?&S%A744#>(`E!' + 'xE<2}FPbq*m7yTk?ed{^wd6u$Qf}o4^-8cCNuF)rQE1O<{CV2hq_sU-Q-_KX4X*H$W_Xiyxn~cSE;iL|1X5k^Z=T=)v3e' + '_QfBu5NC|p7cH;kk69W!L*aIokT1^l3%q12pBs-?&Go=j1BUsuTg?ee=HdC-lk?bkcUja~yH_HVXa-}(tgYCmNcAM^za!vQ)`~n~' + '|96xYXiV^bZin)90{__$5rhW-C`{l#`ynSS;UClS62h>srDZ(4Pyc_mM_>Ac0xvJq-;ffnFzhZO-$Y3jVhW&89bD>5HG60^1F{-6' + 'CNF;-d6Xw+-sd*M@A)Rf3AY&NP2XZQ)GbZ~X94-t+L5r}m+F>3e)nKG-mWsR(Ta|CuGmE3A1RPWjF5-m&o1OE+uJ2s)X=*T_B%Rs' + '0%QF>1*1Ho7L8Qv2(b^bd|!}2gfwC7^wUT&ub`-WxxhMyQ5RBG8ZC}tdNGj_P6POJG`' + 'AC*IAk$$t313f~|fzKIhZSurr+gs=QFbas*1h61!gS%t!W@Bwu2kguNG-_bR$xianSQ!O%~};obo`Yywr7bWim1U&I%w|1JnefZ!cLRf|;+{ffsyMs`iR17<+By{(L!rc`zDE;l=aih`K#WNx}utJRttlW&Es' + 'a;~+~>x!?x&t|iNmyoY^mgfz#T0F;0Yqq;Y&6zCR?*!&oljG$K@Pkp_a12fY1G`o8LSXa;17kIiHh2hx_wo3iFF3L|1v^xm7u<>ljO>_X12I)Y1HJ+`V-oE1RPjtK&gB' + '1*bd78qQ?Qo`qRlq)^yh%NHn7){5ERehq$YH0Q-G_t46PECi}JPJc?(My^M64zrL#qY8cr#%j!1)UfBsIa&*2@9};))aTYIhR?Ux' + 'pw-r3S$dN|?hPd=P>>OZ9gb|peGnv*`TE?bE&Ri^0$~v7TedTl1|}pgGPt(D*}6i1cj59e8t8jYLo;6?ugn%13xX`9R=h`0j;oD!1*Eh!jlh;|HbOAw?5p^r=0pyu_umk$vI9Gy91{X;)1ufN->#oW2+>E4F>))' + 'G5Hz|8VBDei3u=FVWm`^kiQ418#xe&Z>23Tj+v7r!Z%!xX&ab6yq$()Z5~z{k%xK-xf7-P9ut02>ZP~5C8kM@ksEncQ%XfF$wIAI28yKHdOH&qI!(hYJWc*%K2rTh-hT' + 'Y$b7M^~my*&Y@$U)nObyvf6MTQs2qAni{fxFrf#}+$0}PUa21|{kG5>%=b`)ML!UMqoHswqz`vn;c6xD$04W$KAgzfnqj9FG?u)L' + 'vSt=riC`2D0VT)mxmhdigr?EmkLs3iwr_sWA^Edg0GyT}_CY)f-Mga;IX*O%B+F4p(3-Kf<+6xJog>d!kMVZDFh|At`K{_qv>cVG' + '2cK~OpTyknWP0l;&GatUO4-=0Fj{H&vlNUmxMF$eH*85ad' + '`1r!H5$|`$rC+V++%gR}qHwzyefs@8?Gas;f!34O>gj+okqZ>Fz6AF!K-&D>dDhccLNa=!^jki-7NxTvEc=@dH~Oc!=>)#LJTonK' + 'M9f$Tw*-gPNZVdm10Sz%@2;WZ4432NQRen@c#&pzpbuwy%+UZ#eo0V4C9`=K|N*Y9rc{lkR3)jTy&0Jli<>CZp_cC1l(i#vO~Is=!JtqRy7{' + '_t9i3SNIl|wJn6wH``Bcs' + 'IL&&(xg0_B=0B#mFI$R(zjc4^JoFZKx|?i;zMZhRlN{oo6;^nt9Kj+pZ7pmqXqzU78{91cy1T!)TxRtCXsOi*yF?|x27@^6h=fc`' + 'TSLyr(N&ZzXa50EO9KQH000080000X03%f4O4tJc0P+a{06G8w0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiFZ*py6' + 'Y-xIBUt@1?a%5?4VRU74E^v8`Rl#oKHW0n*D+VeG$SAFD;=>jOyg-`vngVT66fgpYwnjD)iBw4{i5JCwdxxY%OOCg#4}wH;-n@By' + 'GtTq;S9y@tvNobcP};Pjk)MT=T0yN12&dcCN7!p^Dyf7+TjqJ5Wwp^Qa9;PWH^@22w$sLeP)fTHZHF`N)Jb(1_kTZi_=hlAHhinT' + '?M@&psBTvYB{c=ZEM|Bx7?tWRgZj6shv>HdenD|Lv)G~buAUHsuAuke*ctZcgBd*FD7P%&B(mSXB#;q=B28H' + 'Ut|NvMV4h1)=@j|G~bE+^G++|L1kO)cD&P82AS4l5G+5X^y>`hM?&9w;-=q|vJkH*6!5^{ekq#nC>Tjz!965AkP4oj{(O3h_vCR@' + '%En+XrXT5@3Y?NEd$p6QDk|A-mbZ(5Vvw#{C5ojd!Vg9qMa%8}*viTk`>$m>Qy9e|czol#6Za}{MWq!XO*GsZW-_z16;6_9qze2E' + 'jkIn{uaEHn(Ona;qiUA7EQ^v(agwgTVS^+p-3id`w%U;Q3AhS5$U2QC&k8l#U%W|s*3*)q8w|uLlcY~?u00lKg3J?)x`l5#w>|GZ' + 'f~;pK*jSvD7ve}DxsQ?WY+Q!3)RJhnKH@R-s(A9F;>h&UmTF!1eA47pXdF++Vxe)TbVI7&4T!8Za4-B`M?T@36PRMgv^*CL_Wpgbu(0!Aam8_i7t3w$yKGETw!%JR#cN3!z6`JoJ}gQ)7gMel14JJt!DYhp!JEbP' + '22(1-rJV6+?ir0NRXrBK(oCwy' + 'yGg-(qUW`CzPO5qG44vUhC))uQcjT68_97|uyi4#wf@BqO9KQH000080000X08F@ilhOwO04p5;04o3h0A^uxbZ~5K' + 'b1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiGb#!obbS`jtrC4omoH!8v&abfe1XqI6_H-Xs;&gpUPnEJsmFA>YtCi&iZt3&>?*221>q#GNZ-@m0PIkL_9d+f1uQ{~GgevHUB`xYcT}_d*p|(mnR?S4gFydo' + 'hPpi>(xav|L!5Q_K^vLUzTOLW;2iAaq~Ugfi_44k#nmmo`e*(2;s)PdZvE~' + 'ZX*oSZ>(&h{Ln?1{T=28eW' + '4%9ZbqnL5A@U9w_hy98*bmi&BkZ>U-W~hQ)iOr;zPq77x<7tKy!VDD3(ws8g6d)^__M%oCKKJ`8d*#wS?{&(>k)ZYY7il8i`KJIOGhg$(?(z^xpf%Je)1M4lJRA{(_P69+4w' + 'Wu6CpjNFJdJZst%DoNLo<77(-Dxiy?0wBo^r6GkF97s!}vzG~a9WUrLq?xHT6GkiBeS=84t5b0*3q1LTbcB;qnnKsMSidWuHGLvwNL+1GS6kasD^{zU~&QP;26UotsI$JSq' + 'l{{$k))I@}6YnVz9eE$=k}kUu)$69WvcFdfb*i6FNSD^6zh9c{=-!b1)U=H%hW&SoYisf~duB>Ebb`8aOfp9jJdit}O?@vrOt`ER' + 'v_fX@>H-6{G`$EuqG|zj_1pr76(O)f;t2dqe;%ZydBhyQ3+vpthhtQgm6~3B2&Y' + '*pewBaSI}C4`SA~hBap`tBy5v;YLvlzomClvq5Fl8mglWOe!cI#cAFS@L%3+B9+a*08E8koyJAbcbkC7fRh%nCA1Q}At%%@Fyz-g^i4om^279MT0OWRM#007%%^g*WQ@7YnN8py=4CQQqo?taQ)No-~Z^i|-bA21ROtR^Yjv6`' + 'Hxu@9xt7*rNXDIK1p3(A}8JhVZaN_R)SCcwK8tfgBIS15nfjTF^(Pl(%6#Y~;paS9ZUF|}#9^$;jO&2mRd16oJ>A7Eiyf5Rv' + 'g-giNog;JC+njGxcXDcv_UYxvzr&?sDI=SCah}6ew=!|_f;QP_d6~tvWP#th{kZLhX?gbBEe0HF1{%z+V6te4RHxsoyaf|RmxpJXbo|7;zbI|1yNVxkM?J8O>XA6)Z(ZyF8QCHg~Sda=J2S=zP*pI1}?aGcGBI9M)@Uft)Cba&{By' + '!Eig0ek?yO(2TmsY596q;c`kA?7=K6&WyX{a@lh1w6zPQwzInOYsG9rW%sC?hZ<*Pn1W@0_|_UUYh}mi3cr{NLr3Tei?EJuwG&u9' + '*l#?2D|fL;yXCztiB&Zg;xC&S-)cufn(eiIbLolMs$Esj&h7*3gKuT9%4WORpZZ&u3Z18zm9=xb*n&2UOD&ra0u9Hq2YQ4jdQn@l' + 'rW4+7%zMzHiqnm(N>`id>3&8}tG1;l!fje|T1WA(ceVD6o$6(OJ~GQ$FEaEpcvck^DZtX~q=BY(KB{WU-F8}}lW?^%5aBOXJFKusR' + 'Wo&aVWpiz2Z){{TFJ*IWW^Zg{GGB0EX>w&`Uu|>zc46uQNlmS}s=`%rhe' + 'NmQS|duPbyb16${jWf{3B4>v4_|1cqr_d^*t?W_*ia=`ffg*PA8Mw' + 'wy8*--}XqF=cK5%P0IxNTazyXuVu+}n3y`h}0OWJiHdf3uSnz-((?T*ln)Z5{ZIqNDs`c=M0wQ0>Ydo&^N13KsbK6{^EUH)`_' + 'k-vSDh}HKWF0antUgW1&mkW30!}-r|-~4h8^!RIj_3Qg{m*AI+pRAqOSEK5vQUH#gO!BL<<*5S430~!dnC(o35zXeFK' + '=y5umrek1`>Bngr4D*vzqm}M80*7ejDo=96LVnLE_%f3Jqy)kwVg`|tfvPB9--BC6$e4GGw(I+WGe(o;uY{EyBNyN;TMrNz30d>I' + 'w&}OgA{H!Cd;vMqaKR(FXA7k*s|xVo1{_)6@%yM_zx5D*7SMD!sOzf;lDwsBbo}3zWICThE^ovMdShO6W%C&fk42yj14K%lq41q=' + '7y>S*r&ZoIpJk>xiX_g`plgb%C02y(FopeOSY=F^Lj(umhKW_e$SM7A~8CM1?=7SWf^$~CIOUeFb$W$0Ed0|twhF#0V=Vq' + 'Nm0MEw&}*QH0N#EP=5MK@~)e*uWDdC5g&`XV=c;pg4WkuukZC~YNv>ySjsTjTqZ(>Cv@aDOR{hXptSO$?wNDap!RYUB!~5;W%NO>' + 'D37lscLvr)4!Q4cS5M!*+0@w=IJeUMj&XxqNd7IFo&+=8)ETGZc%E@h(?Pea5s{5Nw)k0Zs{mVA&;b|{P`NEJXAX%3-Nxz7' + 'X{2j`L=-!jH{*`-=ko`;#T${wPz=b~={rKp;;yb37zKhFB4|5uef{zJTGWf{>o@S*=Yrpp?w)QLiK>K@v1vOVl(0y8)e5Yc!vSpp' + 'AUetBrm$G>!jjUZq%1ma8GzwgWlp*tB*dh{({`9}*3sz*(TP(7g7rAT%gFb2~NKQ' + '&%j`{eu!+zabngTSy-Bcj|6OB=N4;n3Eii;WLo~5VlO4vctED4vUrS3-2BJoX~izI|JiUYmL6=;0DT+J4lkGpUdJYSq(H?e#h' + 'dojf%3=dW^f7Fn)FGaJ-GrkSzn^Y)6IV3|F8Uaa6@o1rKhY9^JPh4Pq_&jygV' + '5IABV99}fVr2&MZU7FOrBL>ks+~`!`1X4_Xn($dI-8fbFQwBypW#qRdy?d5!KMal!tpJUJSQ-p+GF%D2>w5l^AxxkhCupgR6N-)`YC=Ei86@$k%$L_6Z36J>zACd{H_@Sm0E*~wbNW>w+>=EhoI$o^' + 'I49QWinpO7V>P^7V3a+s#X+v{T!sHjq{M_+3QHvuV_#xI5Jx4H^0djJQkz~Qg167jxQaeSuZ@n3ojS0ci1-~&1;+C3c4bi~wQ9we6}ba9#Z(?dQa8=OTu;lF4F' + 'OIBUB??phHg?(UvrV{ZeiGn!WXRv|A;3zkI_^hO)+VvX8EW6$C_3~x(|47%#Vs1KdF' + '&`x%p{@R}GKy5=BQn~5*w&zy3z;(QCY6MPXSmO|rW1Y=sY>I=gZl-zMa0ucLNZhmTJ2Hjll|%sP7WH5^vu{L6*?U;OYYH0a=!' + '?7P$NzYPpLX6w)N4f9t8LxfC#67l9Y40r0w9`DN?9`E^^f@lB2K@6Gxx_(L*lks@pZSLlg!~i4qy1etJ4Q5OVe|O;0p*T~*lK_SP' + 'P``)RI3@I5&Y{;ZHHjp8-U|%dj$e{-Z?x7l-rOaDGj|E$WxoWehxX?tudh%<7&UR;01~eUs*Lp4hfGw;{Q*!*0|XQR000O8001EX' + 'q)$S_>jVG*+YSH#EC2uiW?^%5aBOXJFKusRWo&aVWpiz2Z){{TFJ*IWW^Zg{GGB0Ea&u{JXD)DgtyfWR<2DR_&#%zD7aMSmZBIjh' + '6zC1t;a;x8R%{q@fuY!Rqg9bTNnYCC?!S+eWLs|B?rxYLT3aG1@+0|CNs{FH>X+Lq)^Tlwx?^0`?4*UI_mi*(1`nMSm9WfM4gAR3' + 'lLBoHqAQXlS*;q~9$8s7z3nxWB@@T4)fUaD)^aOaWmc=OcK0N@;a#*|vudS;%E=XcUk&O%0v^I`t8@0w;@e)rAg?SuSSj{{sOqH*' + 't6rqz_7Ls=gr9!HK6VsUSbxutP~Y~Qgq+=?^1GW5OabQDw6gxL?5&WdL^Uw6wHy7WZyM06)vA)*7C2-1xL' + '=0Vm_wYp|PG1K__GI9fnQmzYG_=dpls;Kl!A798N0ZgK-*3Z)T%$iAI>=s3LD74))=(sA@^umk(?jJVR1dL*Gp)<#S}5dWWx}`vDag6VLO8cNI%jwu9sW_-c*8m7rud^3DxJfYWit!Gi;eSn`|Wy%erwUCbE&zB' + 'qLRRMBm(j?;(2C+cO9rYrH{;K=wEEShQ)uwAmlnWJRPUGFu;E0vWK@?w>nL(Ne(r}NDS4IP+f16EHqTvVpM30PEUPbu4jE7>Hv=x' + 'NRj9-+6jU=5?SHd;|(_uRg4BCL>04BQ1NjPNg*P1%xqmWZmTeOvUn(DdqSr&w%M?xxO+;@>=UOJ6hKO9IXkvBY!YpgoME_ajlH``' + 'dfQxlpZEiUo;$?p1&u`o4-i39jsPx0^kw|>Gg_QcqG6|lkif7=|024nIF1Ecra$C!)?(HC174-5GQ&=IIf&cMib8`7ui4=' + '(C+TU(H=rc?Lf%*YM=_|)xE*pMkp>Z#U~fY2yP7q)!oJE9N}oQK--{falEgEPW^}35W!*}6ZB@bz0;o+nd9-2a2x@F~$lAC`' + '*TGl-;j?L0P;ZDQV3#4COQNoF_m7r__4WeC6#Ht!_Q?m8IF)RwgIFu#B)-H+OJzZoHxF<=#zLFZwH8uxV2W?H)g+f6eMx-!pgs=u' + '@X0B}v%*R1G;;eh21X?PMN=cvJ0Bg3n_pNPFHD5fD-*#15VO+owjQtRTT7SVt?w+n@9lE-GG1q`^&1^9%?2tGYO}rM>i&W{=AzL6' + 'QH$-to&Xejj8~zUt)ek;p0>qm@^ayFVZw}<=)sJ&uq3w*za=91S1@@NIMJ6-H9X{OybTRzGU7B){gFm2Oo-3#!A|>sEq<5tvc!3e' + 'Kko3(?lUxvy(IT5iv&AtE{>^TGaAUl(!vOyZJpV8R*<;7Tm1u2O9KQH000080000X0N6FVt5pR60L2dg06YKy0A^uxbZ~5Kb1!Xg' + 'WMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiHa%E&`V{~O?Utw%%XKrO=UuAM~Z*nehd97D%Z`(Ey{_bBv_`@KjG8-q|uo-Z%B29sP=nxFq' + 'FrWw+TB2;C5~-0?(iFpf`;KptC_62N)ktEAJRa}mx#up+jrX1c9qQ(X{R*Icm6DvG`F?{60ia&_}Nd3*iGFIU%S' + '8*ZxD120*&EW3W0FBXf6)rgd`?RvvVM;U3U{-`ticN1>R(du=0+$<1$APl_fy(lQQUw9{FljUI>VjvPJ+zb6z&)8>{t=2jE?%Bt*b' + '>Xh8k9dEdKLSTmngVnW@y{M?-0GSy32JGx8ME|^q4i!in*6D3@1Fg`y2*z*ymk;VAugt*(+nqx%Q8qbq;e|>tmMMGT`Qdv' + 'gyaKL5XDF0r9n6x`>aM7(i(57eU66o+y{TrFSYuE>PE)d4}4px4Z!h+O(d1-*g2>CTz' + 'tda}*Co9OYy&4fc9lB)pn6CE#XaAGb70*l;D0(TR~-9A4v5V|!=$#L2=0}Mf>6JEuQQ^`5WNvE=NB?TnJIKBvgzQ6zT{vIj~>QWzQ$4~|k' + 'C{W{mL=N#8_(qS}saP{L&aDmDx9oOIb+RDlX3' + '(+}4dv#V1r0qS#Qc+y6&C+pZZ8wpVY3`tdS0Wj2At!T>t+Hs&e062iqlvkJw30XnIynyL<=@GhaD132Yn%#Y=Ze*sWS0|XQR000O8001EX@tzB|%5MEUl@Is6pzP%hP+&Wp7' + 'y{cVFE=ikn!eXHy5|R*u00V$pYjw(RzwVy*10W?k$)#>nkx8JZr>Ccv%1NvGPp~d;HFyFLH2o7Qh$czosqVilJ$}p112dErOdYg%8vfdrAO^EN!dhPMcmAS+GhMi@dxY1;LM5wt`QC_O5C&P67;ZmDV`oW%>!e;^40H' + 's;F-BIrNa1i|jMB3F>u$Z?lFW{Urw^>vm97DIyP+)gmi`U$Xr6u5Ctx;cz$@+|<=FNRpd1(wHPczFbvx3*?nmOJivU1O9B8ws+$5' + 'FKJx@%7(R{S4EN0z#4&Ik=>;0qFvBF)168`_w$@{~zx5>%bho7&Kleg;S&u2ecO^$y$K0Q9WLR{F($x(9l^C^FId76BH#--7vvc80fFnYvT`lne-pKho' + 'L2b*v{X~MPJvglv`3>kM(sgWvb=78hsli+rC@#6e%s$i^Dh+7J-$sK$^5^4=^W?+%WiWw-JNh-Nt0p{%g9VZ}fhRYh;@X4#XpkJA' + '9i6{ryh+XuPmhzM!=oRL#{uG;g8q$zB5&F$*ofH-usuFO6E1a15oXOBY2rEvoRVX`j!76l-jK0^|4XTncGPqo%b)7y0!M_C;WEY#ebd?3b@@PcB9>9-YK(dMn87S%sIJL7!Z%B9fUNoz8o+ayiVLi{AmMs0{N!Gd%P;%yL@Z8Q}iezcC|T;u8_Cn4mXM{<+S!L&b25H6yY+s+9' + 'hd;ATSUhY?t~AH*5`-y}{5xGUKWx6P!4Ji?Q8Oo$OGv' + 'K|Rt{l6tD?CH2s!oc<>`gXPL#qG0EH`Z_kcAJMQzj66jd{wD@$J{6Qs@K5Z#;uKH#zb*hHe!~87U-My1LjGYiuT~FX6y!JJ6==Bt' + 'R1%E^x`PNn+CB~(xo8ok4dy~=f;A!LXb)UyC2c(3*TcyN2|xW' + 'Zc|Ua4^6G7p)mEf^+GA{evxN7z$^1oBZOZl90sE&#1eZLtsU?S6T$4>^}|S8WplqtB-b}~_3mt@Ex}ie4l)*i1_JJG8AT?5VX;=ee2-q3BnkaojXr}Xgut*ouOz6&' + 'r4Bt}&5>t%w5cher6R>ruOP}2|5DR(Je%fh>wi|90T40`~q`&B&O*ufdNUuWD*SFf8;k*' + '^&-TFFreUIpJtO_U?LI!sQAmeX~Wmibo_cYlUO^*nqm|52U)K?e1UMi6i*mmF-ygrBVukoPs;T&)IB|7Fz{A+$>|D$3E^9z=Dn^k' + '{xX=!knI4nYvB5QHBYbCMOr`j7~2uf6Ki=d+jG~OlG0I))*z}iI9$PQq_MGsh$(Ni1utP0-DI_3XDDS=aS{Gy6qi-KWGO*FD*;FF' + 'K3|1$tms!N$g$?=Q0K#=p@I}p)7{x9Zx;D&-iDFWM1$L#VMElC>R}XoAM7z{9R47Y+b|%s`xH%TqW~fGDdU#Xvg9n!>I`9O^`gGx8u3^t`6H<&xnVqU&=WF$o6f%0Is^$*ATNT!kl9#7c~+q$fj)H!LO^mydb' + 'y(RBWxVOY_f>DNp@7HZHlX@1-!+hb1XP@WAdLg9TnO2|-JXYM~kTd%(1t$EXr`DV0@};&YSp_qu2xpVlyJy)w|C=T&mXvX7#OyV?' + 'GE{Smeh)wht{bx}GO)>t?N2erh>8MjYPZ#C@t`Zk<7SAQ2P}+XA%m?6oToCdF<+64F!8X`z_FFyRnZMkVP^s7g4CNauTfy{c?|)K' + '8CI107>)x^' + 's_aQ$u?D?j)k-;_>Kl`ldFk77HPfCCyXbWRoDBoBcQ1@#)w~cME=*0#Gq+*1AyvDHG(8jBL!ZpX' + '`n~Us>~}G~dSxtxu2d;oF(7j#ZWGHVqWT7eVPjE_VWT2lRuvj2bzt{0bp{YxP7`-~{7-k8o)iKf4p}L|A^pWa=R+PHv>kcP3GaXJozw_gcTiNws+Pf5stwffs2mF3F)-jwO1r)Ap6!FeMXgL`W-cTD$4vf1)!*E=bw{VfSf)yTn+G$EoMfT`shsQd_nD' + 'Wkdt($ufRifW?s@sw|w`UZuqT' + 'f^em2-e)DP-XIO+;V|-71fzm1o!>E!i)A^g8-Tq$J^AVQlB*`Ea_Fc{z4(;3;(*Q3UADAdjqsT~#a0vqA|m8$zAkW__;7Su74M+g' + 'C7U8PJNlMA19minM=QKbp)HOnSfw?*WaN*7M~3u?>jj&D!^PrILn0^D&H$#G8w>{B%ef6Z4C+30=NGW1bwlSA{H&)W&^nreEOC+W#RK=Vr~M+zfR<`aC2?LxcbZ-c$a+=?%mZxnnVyxypaxr_)N' + '(@7dtSyoi#EwHb>OUvLjZF#}Ud&E~ynwBPlFSGVTU9G|)MQ?Gy93g_KR&0k{&BF23k8j`KAB5YmoVOm$LG#x~ApVWml@wBjOHi*~' + 'SA!O9YghmvqZwHe1Rro`m#rnx3_5Yk>uhn#=TS~*ERbzOGg?IhIUXf)$yztr1yOcXfnYS^V3FpG|AXlJjY%IlG2)DDc(DczyJQj*' + 'Ytj>$!X?Q2`Q5v7pkxVZNfNmQNtO}hV?ruV789LV2xHUFCv8?YlfB*DT`QRJs)&nhS>0!jme3RwuD7%G@<<-N4JnzryQRXq6$)CZ' + 'XVerO8cYFA3<6~D;chheqOx0zqD+OwLS08Usqu;0608msbO^u?{9Pzi)5+p<7)74)U~ln%=q)@)JetOc;!Y@`2)iNPQQ6%Qd8j@(' + 'U{Tr-i^8cG=m`8{`T0Tn{OinfytBV5{%gAN8=O?d>yFbWlU@JOlgSXgzM*^eq~Fur(#zc5!|e1fJ3Pxyuaa?#PShD&U8c0*dz6qj' + '*==5;EQ3Am6+F@4xLj-kH;Y6l=&)AV8>Eg0AhWGJ;Fyg_Q~+VrBQnRyr|coj+iXe2^JWD>K1BcRwg_v{wX-V8)e*7l>X7raTFR3M#+eJ@Q2_e3+cu}_Ds+&{7)%T#o5d6${P}qr8X6PO%|BB5c' + ')cbrsWn@D~RGFyCDUIF3t44L!KRMEo;Wbg%*d=6kAoTSjAeO5#_7*l}==<{j-MRmv&MJ6s=xzfgp1Xd9' + '%r2YMpKuWuq>S)`YkO~oXBFP2fE8EwIVM+7doe-J7f*4pZg8Uz{}h?J;Rf?rL(dL%o6bKK*?m?__PcDPP;(;3-`b~^htA=4UqHTvdJ+nd&MVmEWaJ*6la<1d2ftcPzZ' + 'h*1zJ-3Gwg3W*jn46lc=A);FdrJc++rU#yyQtsp(CqjQyrHQFB{Ym)v1yi??V>$vilcqI~NpVl`kG;yANgry$5iHkD3&X$9f(*h%' + '2xf3H@U8(uD^1?@IxiM{{0SzQSOHe3Zk-r+-@ww9U2{x}yg@>kl5GS{-*Fs31wRp0}99{XHugoW}69RPYs%ixk' + 'UPXi8g|;&eelo^lUf(%#0}Ful@D>oa{PKwq8po7Z^T26guow%)Bix?OJoaORNentkN7jQs>SUZmJN9Ja@meB%uG#rO=@98B10A`T' + 'N;o>}!ti|??D=*B0=6H@DQmq{uXh$kS2P7^;JvUYeV82^vZps_0bkb{8' + 'ge%T8df!$*HTEWK=-7-GAXTIVj`g`hlHp_Zl^_-pVGKMobt8i' + 'ulF6v3%%?eRlr8|!us_VGxkKBL#Hp+T?3pw41wSWpZl8{4|aupK0w@U|6}74CS+|7OL)kw-ZKwL7=DAlgc1!I(bVlCFp_aTc`}iM' + 'ny6>i<#zhasH>iEW>`f;E__>KZrIId-ovgcH#zRF^TKIT' + 'T~ilFP5k5aHe*vHI5G)B?QW~U|9L}M;|05yQtocBdP^pgBXP4AdCg*%+Thzvpw&v5p_Mor7c@a}58(b#NOJhy>{i1jSi_O*F11WR@aK9?UU9}(U*=<`vi(7icMb5io' + 'ryr|;*@!+G72>vO9SL)41Q@=80Xybs%u7)u2}r|1)P;ckrC3EeTRqf?xTc3hHPS7J^YBN^JD*HJluqJriLmk^{D?06u4(tHI={_v' + 'GwkL*tIfru~nm%3AKl37Ag=`#g' + 'u(Jmu5s8oq^oUd*fXEa*E(8!96-8)FYPQN!<`{*#G@**r1&?M+e_#Ua&kRLp(DK{hZ?c!)8e#z`*|=fu_y1jNg^1Hx%sb*yX@6p%' + 'Pvo&pIQKs-0U!e?4Ha|KZ3)TWfoSyjd?b#XD$e`viI98x&WN{Rjeg8g-vFU4' + 'b<;+%R~Ek!HFZFkI#(cV`JMqSAQdNyOiKHvxEH1vlmCIU3g8PC78u3Dcc-n?zd|' + 'GmYICn6Z9hOct$Nv#`mG8kl%)*L2^>TWbAs1%N)Mj`MlD1%yw8Z3Zvd#i*)3#Z)2@U9^1hc`w2uMBfN|kZR=mvF>}Yay`X2+M#DI' + 'N@@hmkurexIgmzD&pP#2Z%F)8v;SHD?~+)YFdOf0Xw20FFNaRl&Bi^eSh-+dZNI&DSHnl(3-+xQVWDhg!pELHX}jZYpP+mQw+qSo' + 'LWE6`z2GkJzTFWAx8pjD3gg~U3&K8Ix4c`vo>' + '3@HmBCOrD^V~+$M;}?hdO&eolP|$mZ}W)M!Pl;f6ZVGARYoTI4hp3Gd#=-<_-Lvd#;|t=cSFhW3i*V3V$&e%@gkS' + 'SGRS#WWEF5e8LtP3;l^ek$^n;TKPqDa82K&>61hmx-B$iDbkNyB@xy7?%HYg?J>VlBby`VG>SdMH~bjBzM*UF-!W(T3pQu{8?4xT' + '|3Bj3z~?9T(oDOl{(W4HPP3x}@W)@n|9=nv|Ig=k_}XHUNkHW62|pA4{+8%n)DBv?q3VZd{8ectkeMnnnu~nlvPl#5qngc@2Q@k}' + 'ICeoIo;%}*)W_OsN9fZi?a;uq!wSqz=A|{e+n)z`VO>5BMLahHhoq-0-*EMtsYdk7UNqaD_%p|WyqX%`^vt=`O#BCY9MPE@$(3+w' + '95b6mzVn3IxdeE+p={Z|DEeqMej>!4S^B9gyX2Sfl}mO|Q}YTTIQT&ACqkB#_o;Jxn-e*ao8xus0GZXnez+=n!;(HQP9' + '@ql@0bc={TtSrW27)20@A~LV;QNNPC0;FJ&XyWvr%I5x5LwwMn;(_hR^9xMdDlvc4FdA5k6RohV7;=5(GnoLPsvr2TcROc^M)It(' + 'Zy%5L@+|G{vC7KW$b%^p6TNj$qHjOWJ-lzFRkR!?j#}Nuy2yZKAW$slkGpv6z6^)g;lN!9s&kD`vA3GT9HNI0ddUg%WBL%8@xwFG' + 'xk57OmLL804Y6P*;)A{$x+ykQY}0%#&XCBT_Ryya_wK#+5SCu@{_sO`b^hb=nf@zA4u;(h45pCEn`E@6BU-&vR&1c%A)~{v?d+R%' + 'QH0^15llp}L!>i1YP;DKNWHMcMeA0V2oaYqGg*sry<9znI-siW6>E3_' + '%i|637p5z~`3O$cW#?I843d#25>Q5w0lRgE9hTLj%&=64AQNx6iI=JIE9|iM=QdX*c;e_j`MZ9xaJI~^>D(4}tga2gJhX6E{aX%(' + '1@dk^@Y5oQ3So68{!0%8S*T5$*moL+ZH=w56e`!U0R<3^huuOUuCbKPSjr;1PEQi2^Hjwcn|Q9y%$j^AOQtuG{5C5xd2|0w$r' + 'Jex9c7$#WdJ>tQ&E*LRdQYKk)fIV3)zO7700TSk}QZ-~5a*BA0OnftjC@Ep461TcdTpKPf^xSIlR%V_U%15h@r(jWKP5DiW39|Ve' + '7iXaG7ToPeol<_$kLMC+b=ZQh$@Tmbn1Ww3{gjImz3F&oZ-(b&uEV`(G%wQS3QKCGtnKJmH%bwvEEk&<&pdH}F4ZEm)6|Dfp5=+X' + 'y-is)!S+jqo!g}DnS4w{L-7Gb9-|072XM!+%tAQLY^;8^SD$@jd' + 'vw;r9CfNdH4-Gh)BnwR4)*uK|Prjc7yUe25bu{MXp=8KSv_k1iK>D@`NY0l9s1JD4jSF&}C>&VL#vjr&=5tg>tp1rlj~pyi3no!y' + 'CEl!`$XZ%eJndeeA?4H>h0Z_Y%#%3nq|P}h!FA6&ee|VCWCQCol@SRlgk>s3LAK+8hj_j*Va0BIPsrjx7wjOY?^F&U1U^Jjybm?1K(p^USqHb' + 'i~jiX^d0WHh<%!X_mS2QGL6JbQ^iKk#P8W^CQ=7A{1{Id6zPYd#Kwc~K$&%ZNF<)MzPwv##&(W4g%qj$A5RC!)Cbo;+oUJuEXC%o' + 'h_?A*->B4Cz;WPDNq7!gDHZhUl{qucjjvSl2?Fms{a~F=Gk3ilOg$;z?m`r85fQ3Yg=qyru0*n-oQ0AAu{LN!#Poc&oGMYEs13cR' + 'P{^&98Zv3*C>%AzkI{iC4fTo}n-r25%&SW=^8pBkni8mwR~KhfCBcwZX-&WX%eGFx@*R{N$|bfHol-YPRe7J)E!9ln9AdH>%s_)@' + '*7@6aWAK2mk;8Apqk!HHs<>000*)001rk003rT' + 'b98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDaB^>BWpi_HaxQRr-5P6;+_v%i{t8z8P^~QsPWo*T6?I&@IH0~9Vz*xw3z;i%' + '?_}#$kvgC4_`i2%_z)>+@0=7#9|jU@$(iAB-ZOL*MHg*9D%KC8=L1(&*D~IgtQ!?Bm!jy(s=a2XZeTB7zk1o#<+rSAPht>lA<`&{' + 'Hk;GXH7w6hqZ$X1=d5b_Zcsp`?G#az2BzrhS`@UB@?&8EU-7<&Gc-)e6)$QorI2>tEoq3JtDCwy+TAPosW;R|BxBc~wI4Sd^WBb3' + '|B-RY+TJdyZYV4sep<9QqLtlX7*2(3PI%?K=voj#BL>G+-SILnigFIwba2P9<^$j~eJ$?XHSNZ8z5b(+W3A@sM(_sbI97GJB$*>p' + 'ei9rdF6Sh)E=69o9;N3uylOAt5(%^W4uJsYR6v@O3SPZ_mcRwd>Qoh^(u7?Nof1eAa1-`g4P$}aTn(bENXHi*)ut;&4a&(qfi?};' + '+C)Ep{=?;8F5X=HaCve0*Zku0>hEvz%V)1%yf!3&>7gsfqB>S}r9N6rDMX3dQQ}T*Hos@z^WyynK9o;TRKV)77I5L}mMg({U4adt' + 'DXSY;t=d+UU`_*)yAgE{+D+M|_`nJdhB`~$pGGY?c0Yn>|5gc3_=eR1Vv{gTOs' + 'WO1*y_0_J2ZtO+*fMtM)K7Y}iort+KV478uEM$_FxvrreN;lE`qL4pTy-}lCq`wDXMcdUx5QL5nLx_((0XqO*z8k?E5o+%i@sEEK' + 'r?<@KKXtl$qEAQ|WP}g|7PbLdyp9in*I7y?Kmpn53w)Qc_4B{Odpewh`!aG&rBGlv91>1!M7cj~Q-~gDlU4%T&58y>ivu73l?=@b' + '7iSI3gIO^w$$tZLg4498Lve$cmdik2W^qrD$f{)!^nevmZuKVPwojq%YOh6X3}|Vo+lTeiYdyqEt&o(}2x({bp5_+lB3q?gcbg@&4eqDtv$@un{4*W_?zHgNv$5uZ4;&&^CyA)lk%ex3dN!rvLBGwgRM)bDLT_&`!R%>#_gT&WcS#18hOp' + 'g^LvWK-@hvK3KVHfjbdOcKis=t)QIOjj7EJcmSlz;=NFqAuw{!vIMW_p6bHZ-=cgm4BZe1VLfskuptH&cu8@b3V4Z~l>}t8cWesA' + '9-@)pjpl&K;zl$adOF3Tkgn?>_o&+n0t_N>YDSHWR#?{|mNsz{A#9mq5!1*(>rU9Iq4Ou}}Q8h>jq^gi<4()zj' + 'Klvnv&R)u#noh*FT9l-8Ovn^yp}+P@Vv4iqTNGkZ-cBn)?xd_4M)t3Nky4)SShlPP_&W{VMar(6m{CFLl=g' + 'Z*o7F7~u8I2{|;YY|YlfxSb!lDsJ55Nb_%h`Lz^!w?-|+ZH28iP03RI3yN@nGNt&Ul1JQ~FTT%?;g&Z*D!xSn-%}l*sxH<D@Yyfv2vh&cexv+=CZnO_zwN<' + 'VW|?+SfNgGjXYMOozK{0EYZYIwDXwrAXaz+(3RE1$j4W*)~%N4ii9=SR=%h^vo)z2<*nyIHU*NGeUC#>F}M=pO3yFt%&qdnQo@E2' + 'viK>;JBsA$YSnS9D(dm' + 'WdEbe^xVmc2MP!%6QPg>CoZsx!0Hr?x_IA1Ev5b-X$*tf9C)E7Pckh_kg9+|XfwD{LrkJd@g1cC$BgEXQKH@Jo?>y1l0XU6)!VfS' + '>c;_+hI`%zK0ApG*KHYD@(i}EqK-@vKQ|Nx%OcM5s%FCZ(%x8mV%IGDH(*u<>ML&NuX5(x?>M)-)&M&ZA' + 'ocwT=N~ZM&Taroc&VtwnkUa>u!YG3Ap&OO8sYwCIe6&jV6b*ucy<-)z^}~+s2}yX8eV$+Dcf*%-N!1%)oQXu}_GuW)EB*52Vvr3h' + 'nTakx&unbQM9ts#8n)DiD_#1ok9E=wJ;{W_)vNhQ5MR9@3I0FiDEnyg3$!)~L>CUG_&X;S{a(AXkJy?$U4wd4p0yHt0pzJ=H@}-F' + 'USfv?>^B1XUAQXgP0W+r`m6IKslF~td{NZ**hlm`I_&i^2QSmjpS82Ed!3GFEpj(Nr(#djr{r99*y7;UbaWCR5?>kpKinYPhRgSv' + 'J#in=8e|V)+S!kINEJZWM-H;;-ts|PIA9Z(KDLF^%c{S+wC7Bb~i?hQ0IX<{-RgRb#N>Ib>@L+R}sfWzy' + 'n=>r8;bV9?MW>Yinq2Z|`fSH0FRRmOlo&C!B&AN{8T;mJrokyIB-k=~Fy(+_n|~j$wj1PbTv*o4l+0&ISxYQguWb?&788%@u7Vv@(^~YS?RUk*~GXdZw2j%>huJBde$m*EF8OAKQhuXKg(3Nl)m339DyLSDz9jc9Ft93?DTn^@wyZ(Gq}^Dj_K0|XQR000O8001EXU@?-xNi+Ze$>RV3G5`PoW?^%5aBOXJFKusR' + 'Wo&aVWpiz2Z){{TFJ*IWW^Zg{GGB0VZ**m8ZeL?)VQFqIaCz;0Ym?i?vFP{x6*zR&F(GrIeb`pACp?wa%64=eJ))Hpr&ufqBtR~_' + 'AV34)?$Wx-Z@>MR8O#g@Aa^A@$*rhL5&_JkXQroL)7>)|3|^N-ouv8U{3=RwFOKSHTYvQzl{PrK@#O)=)JsYN}Hm9Ef*mA%ERQWuE5Cifhk{EKBB8Hi%|(*~E)zy@o;~?=ty$ljQS+OCyl;' + 'EUK!cQk_+CInxI07Mpxt7e%Hj%%k~L5{K)uSSMw@>Eb|&3`9r-$IOO~De5*z@ON%Ig6@9f>h3{nOQ;s6r@G4j+;R}d-`pfCH;l(Wa*3N-(Ey@vMdl8@S(^y' + 'tDJs5BUFJO_h_z4>d=@e<3)%?xut5JuD3x23j(O8fGP5u>KYLpyiKB23r&S#RuBes97b9=Y|5ZP4qA~#_UJ1kvWVxhT' + 'EfA^0GO5ydlZ11?Mrf(75YX03QCroD7K=1bPGf~6{QRs{E=!krQmSJ7*eX>Qt1!*uMS>P>igasKCTM)c(cR{C2BJ3A@)' + '%^RLtekhAg9*dUeglyI5z_)wOVf~OK;d&HhoaTU|l?efKkZr?78Lg7AF1TF9eJla#)=c(POGK!+xT8N=y1`vZ&~QD%*_Ib9huU`4' + 'O`T>{2n3`75b4UR^66%=fR#MB_|y6Gm*Mkg;l-<$mv6nXH#i5PdJrcI5Bm$RlPwM)&1?KQyos_+Qk{8B?*MrU*f{V3US)vc6R0xv' + '{;&74$P;J_|MR`W|E1z*GwuROk$_=ObHMv>KI46Q!F%#0|927b|FZ(9K$hgo`s&PEWJOfNq#Ordot$XjqkNeq`sd%D' + 'JmJrA3e3HXvgYIANpSq-DQs=E37#Vu-o@c8sUuG(Bea1pMgS5m;l*K;t*?wCEcZ}HFdjvWhCiMG*ADVHD$8g~bK5+gX^T$N{G<06' + 'KUY7O^^pEY#x(rV4{-huk4Jzg2{5fyHGW%eU=x4g9Vdss^SR&Kw7$aHNm&Bk)yXi;VVcu;JYduvXj>;I`WGQJ@D5>ytq<}&Q_VYA' + ';;}X)QJHrEzc#%RX*IWHomlK' + '`}*#=h+w5~32%UP!WMZX#zgyt-;Lxe*fzhU>mkjPd|46o0Kzu@HUe^?b`9+%oMVAcg3BS>Vf+HAGe8mH9G^xWvCuP<-HJxi_ze31' + 'l2-v|OOT2tF?T2TN4j8g+R(M8r;*W74xOmttu@T6@eS5b!7sI-Mb4q2y72*3z!X%%}F8h;v!+f4bp2JF|j6n%3Cp4s0ce*1$-~N' + '|F80cgg(h($+id*ixi4^^9O-gRRb)&Pdga78XpwriYBwxd*k0seSqcR42?b5q(kTIO=QzkhFgtu?CmIj&}^r5QKh7cBB5Ic3*fNB' + 'iRgUPRs;g7gaRIN$&q(DB~q71x!<(8imK~}GzV?bz64nZUyWy=!zy8lDv|{1Xc}rF6X9y`5xD%kj_#' + '!' + 'EvrMX0(W;o)X~6I0vb-?st#WkS+s2GP4y+R(2=%q2QuzLNGUt-jhL$C&*(i+!J8!W7DB%DkqZ0>24p1{{x#w(ZAIwDV&YurWU4Il#6r6nwSLK+}1RNS-6NuW_|3m-yG;PS#ee&E=9mT%GrjVl!ve&$C=B#q3931>n*`9}N31PkMfL?jR%zi=2xKu%($r`NBA*iQM&~Ob9' + 'LJw?khn*97Xkp6|PuQWDur^w#5aMDRs)%w;fLFL6kghF@i*q0Z-hc;NP!-ouJUn1B#5*JY$pkt4RKyBEdtCl_Ct;bbATZg>?J0A%nWBV' + 'i6a!tQ7(b6w}NAuTN?)UYE6fJnWvp<5' + 'vWPZWeZ~_RxY^^7EZ2pA9Xh%s{TV=H%1?x)S{HeYfCI}IvbgIjc{k`_r$ag#yGP47E3oC}n=F!CS6e!mfII=Q9_I3$g#H~Kc6jep' + 'pYM=2Dxx^TJ%ck)Ve2?ci?pn2)wG6DznUQBw$p0Iekk!8tX+O_Sw3{G@EY};sPue$0uPq0Lz_p^)gBs=MMK6XNUOKjJ|acAwYijUUmNAUEmEm' + '8u}|9ajI!M^WHnB=EE*|PYA|iYk(64!_<^WV4}+eSrZX1q6MU{<0E0SkV<38z^ni9utASBq7uYa!9mHw6Lg9FtLSd%7Fiq;1q^kZ' + 'MXOmXsMA@OOTZ3$^whVxxk5uA6)haGcTE>km?qD*{I1kPo=#;04XnNJub|KD)@7tKV}L)}Yp9Js29Yq9{3Pn`0Yc{G?i8@x^Os?cq8GMe(wdhuTzg6OY;`Fb<-1M+10H$;$&T(98JCd8hgiXrnH}cMJ(om-0i(9kYcmMn?*Icx5KKe' + 'H|SB+8f;_U9(1<37`%4_{lQ(w-)Sdt%rROwdk$FJOR728xrnvpePs0Bc{tX^+DMmRvSw}>WQO=zM^Af2lWOLppsb5_Tq9-q;w' + '4o{zqywhDgr7bSF0l$h+kQ7G}R2d&4F*P8' + 'b=c^vp*i^s&IjHjF^GeMz2#O-2q))j(a;K' + '?%pJ-cSiN27pL=wzHIvwCbQ4IsgkjKOcyTqPI9<;v9LTD3`nyI8`mbrQ-5k%fEp`Zm-kWIAB>xFIfHd=08O6j!+xW;=Aj?&wQ~JP' + 'uNC~H7{Azo^4h2xe1C64vo}I_Oj188>kF_y9&26QABlVPq1~Dg+GG&B&P3G4udf*yLdd_HZnRU=nML#K8Ejq-W6~lWOy$SxEZ3*~' + 'c#j@k-$v!Ka-57@lnGR~Pe%Ai1cL+7NGv;LpD{fQqrs-PS3sXA$P>LXs&uu^wqA0#hAmeK$1~!?cObXnUxA-iLgoBWwzp4Jj' + 'tPi413$9UP9$ZIpoHTA>jYx-MgO<~rgr;r4BY@V5+sfNeuN&BHnjVU;<~&`{awCPUF^08F@xAMKNr9pl)Nr#p~k@`&ykL6*@c90_)^V;p;Pmj^A)4jr!tN%rPNZ6&H3(mi=qhlaEpnWS>K3wI7rgqmpd' + 'bUNyKyuTv3V-B1e1jt@#t~0US}!$T|*~C(hd$>=Fvg$Q;}LH)a_^#%r$`Vho^OMjED#5mKUp}gymAPKO=AO' + 'qvNbzloa0AO1VY>N>c>8FaWGb8NMkB_YT$mlvN!Y;jEWZnIv>wZHY6(PC&$3^9n' + 'xTxII5oLwJM`(Y=5JOJ=G$A+W^L#t)(YJcm6I+@L(s(fKSTimlAMIkz+HpDcY!_50!sX754cDPvp%C5pxqx+nrz>PSxPdJO!anDI)OxlXXw>~5@JK=0T~N3JWgTr1' + 'WSv<7OdvlRfubF1AqWJ9nP2-dEAF&N-ZEJgXyx3LlvxlXH=w{sSOZ45*tA)r?YV9V9-?@UglG#JqDG&jsIz}j+$Ax7Fm!FUyKp*1im~TOa>qZk@t7<29ICmN' + 'c`*JK611' + 'Nk@QId-8zm)4Ptk4)unu;h3JK?|Y#C(05R3@3Z~^KX|lcm3#O=U88KEz25-IT80BeTG~%Q7aia~g_;SFQa+mO0F6F=aPTd1#H}_n' + 'Dll{}oM;*804LdckRvs^r$inZ{n9x@yB|EJyJiE7;2jU}o+MBj=Hr6JL6N!Z;2Ii|5s&W&KMX+SxWs>64LII' + 'JlUszT8~c#18GE}`{TX3XA_j_U$^_58Po$0X(v;IqwwM0U6_u%)*&<>E4Pma$l9-y?k!47?I=DaY' + 'NZhkiIaG!Em0G*ljsp)>yf6Kao_f41F@Uw<#3?WUDf5}b9tDbvZYltgPyTw%;UB)6S~v+#ySLamj' + '6ag4QjO7pZi+n^!LEaq0j$E8Y8INIRM()qjAdNw766#jg5(3J!P`Q1PX=29C9cAyVv3rZzdv)CHIdWVVr{p=Jxr_Yp^811BZjoJc' + 'Tv*pSr5^WqT2zyCI_g-uWR>LguF8n2&iWD^$K(Vtl#h5NK=(~I_Mc)t->iW@ZpX*L(P&R30tnaZB#()wwrswDUrqXh%5PFsN{>{p' + 'E~C6+J3Fav$b0bs3gZu=I9kKRH|AY*GILyrqcIzCsgZFa@LhV7(2Z~~>&DyRj&9LWnf#BCf#' + 'Yz7)_R9oLcJBOF^t5q5UAZ2}3EYWjPURdKjuQ9uRx=hJ4RP`Y5_fQ$28!J6Kt6EMUY7T(831@K}wtUUQamGdnhmEm_eIR2!$16Cg' + 'y{3$n?4PmY;-VpU&_2*-kQz4LT#Y2e0{VTX#?XHPB-fiSOXAkp!IQugd6xv7pI03bJ8jQcu0B*i^iOdAA{oGYZ{;RPVl!*$Cs`)Y' + '!-tI4wAolv_1n!kZ=@1yCYHeO^Oon5tq2!&K@eScCl++4pSkb2zs3msQhUA=4_?Z8jW?3mmck{eovqk{ndj5O#-NJ|`5Mf8hfs01' + 'U5Yb=eo$XgLAAwk|1Z)zz=kE^G7_0{_$w%`$5#WTwN%6#)lnP6E4()isT#_8={%`qgzb`rpMU5v@d|UQ$>7ys$|3Q1|IPT=_wnwZ' + 'B>4qKc%Zc|?vIgZ$QxleISRf$`SJ)o0)wNkk55P5QE+s8bgDe{1k&0ew`z!eXkASvgO_Ogx;UqQ2UCEQS)Lzk@)-pj9Y0Aa<;l<%gGdhn7;&e-rz=K(q(N(kxE9L-08t%w@*+9eSVnr=+V~mAE' + 'IqSXq&}~$897s#suEPQ#N_U5ww6JT3a~on6&TxB-rU!Ma-NwPWMOid}9}Exg0$A~3um8=%+HX{n0Bxy<8eZnfZ76d(-;*?sTE1lB' + 'cZ=q#PIle%m*3c{0?O^I=GAgS@jN)~lu~#s)!rg38BW-^}tLXE9)TMW8k386xnjgVu-er<_n<`q8#knq@!2&EX?`51|' + '=Ydx8s;<}7+2hAc5WqGwK#vh1(UYU!MZb$rpFBP~IsER~' + 'W4bFbSZ_c3^YNEQs<(@$blaY|G>$K3!?E&paN^oGalu<_i7uYnOru$Yk^UwG`5;_o+c{7a(t;@S%bg-mpbC+C}9|T-PgVbmhJPginwQg2234VytjfEB8@6BxV1Yn*@_~2A&KL!t2U{wzDt>Yz@nsq`U@U' + '9-u=3zf7V^fCBAM@>Gn|s$ibxSJG9Pql5U$-j-roF;o>^OPnI@zeT6k9CNb{}|489u(5racI4tfJQ`#H~=4m?2$?@y?EUB(CgOL^rHyqlI`*' + 'GQV|lG*DkS78cn?y#c01`AR!llR$I#pbrf8a(NX!?J*G}Jj-D$oB^x3R@|HJ-)Z?Ly0XA_IO|pBSvhc^3|JXwuQvQ!cb@Bu%NO)Y' + 'zIj;*cewKq*8vtC;Tz%9vfIk^zgvfOah(vwe&6r$)?|u0Jholu+um+^Ua3}Thr^zVPdgTVMpNPcvG9A{_Mpr!Nm*2);?#(y>+`Q`' + '06fy+`_OO+#5fF%S3UpAqdC7PlXQYDn3M+sD)0g<&9mKXA>%HKAs_b(9+B`' + 'tevlcfBNQ|^Y6pAufG50WgCz9u%~TCbH(>C{1o}v6||*6#9_f~r&GR;F7nh7GchgkD9e@g+)*l1du1paK0|G' + '?FywyB!Kg}ayX(BOBRWMt9`B~XggVS^#-&4^CrchS9vi+Rzxvgw1H}$9tMK^r_*CXYdM0BdM=KmI-_sC_6J_TD$0pc!J#LGvWzTnBA#(p^KH+*?f^`M!M(nZSZ3WSuM8Q`4^3^r|c&RDYAFKzN2)yAvD2sWnTC`7MG^+c!&>' + 'fUL;;8~kW{m}o^Yc+n^=u*p%uF$SpWc&l7A%|<>rMxhRR`O$H{L!}yO$f;FAO>{%EseVN!Te#@;nTK#pIEoKACNMURUJCEf@Jc>o' + '_X`CRo>SW4$0UvKEJrYOHQ>k-VqNeHUtxY^X17!$mq0d%qGuIObBEjT$nM|Jd&S0`n0r%>GjWj0C>lv%$b%)h)k2GuMY_jnrDNQC' + 'ZbeiYBC=!r$Qc3Ji*uib{tGkYVD{Q}t!5G=z756LeM5(7%2~;E#QRWWkNO|al~aVt^lN$C_FgRdhmrTq-8$j8!}km#ynB<(lk|q(' + 'MbU?Wy&w%J2iKp|29?s7^XV9vau;e4^q' + ';<&bQD*GPM={8~@McGo(u8fFbLpm2y3LhV0=*V!' + 'p+xLs9CH#z^o)e}Fgj;)P}@CiUzdUAP*tA&YHAWinf|C>L95SyV+5wT?L5%ceB_{@{X!aSNiC{ri~f0yG6MvH|2tFMcHT(Q!3U7AF~6MlRqRQT$}PaZ' + 'R@zfn)+Yi#TyN_uP$D?fUKmyS0&1)d_&}Fu$${;Ed9P$3W`>70-ms?tPDyW~2Z!@)SIw50+bzt?+9MBBJoY4futi(F=Cm{0gS8iT' + '0Bculd${VI9(p!d6IlWb{atX>X?WMPDe6zz(Jh67XtAa#wfpm>b96g}' + 'mTY694rgR_AMHtp#|K)HQOy1r#%V;}g+o~ty&YAXPX!|g~0Ku%btxF$XC_cyDiFVH0alui{bWh(QKK@' + 'a1mJOi45B3bFFSotCyA4K%p9|v|(OoRr@ZH+tHBb^eFNlQ2s-E0z(d2Q^)xIbB4GD70&ibzd!(qW#Tjnt=7lwS9_&sz)zIZ(Hsxw' + 'Ea^fCZN(Aji|5~3D`?Cof_*n!j67>zId@Pv%Zj<6WX5fupW!GYKvogpS!96zB?!q$T=BhqISQj|bawV0>*5+@Be0+32hrhY5up2L5A' + 'u;N3IJsTb@vtkxy=z{}ehaW~SFms`^6CC$FI-zyKKKRGZK0dTKfLKkmnIZp(ycjf$6fN_3riZa=JLgt$i+QzKE9NW5CuL}_Hh5xy@Z&GFWHBXJQXqIFk9Bo&jg)OZmaV)@uZTQchg%_`0' + 'UcQ}rj~X^ff#dRw=SyrEwb>LFBl_eSu+y' + 'KncKpaD5?z?`v`GdMHAy5CZ)0Lv~P|qmjDLzuTDZdN){rR%I=T+Lj_aLlOb{TQ6&PMXhCFnJ%*E|0>*KE}SRMTq^-E(a%pnnAvgd?7A#ahQKqCf=1G;p2V%xn&-t*5_' + '-c_+io7gRSbmpJenz0-pTNShwqTD-q^r&f}LSG$Ce~9TY>izYZcSD1@9>EXvPpH)7`SK``u3wc|H2^npl?Kxf&RaBa45p3%Jz1F*' + '(H-uT{QH{23A?10wGj`^++QZ`whbE$d?xPK4jTtX*qNDLZ&Xk0Xu+X>>II~&HX&T=V(o%!L;zWNndRbOy{_X~J@3elv{0N>;D%$H' + '+^#5;_g-?x?)}#Bu8&e(EgH>G368-=BOE$v^W^m75sMhhf2p{RzU(~Ie3$Y;YI@xMo*h~?TiG}b1{UHF6@#he}s1sJK1AO1r*g-ZiXT2|lOxXg!ACyqRc-r@yJ%Q9>D_~9^Glr`JLsL5pR+FesWz%4&Sma}?Ww$1cy6;WZt4JJuKvZf>NfknfOt4Z1+vt??_{ecvIHRw4{^yvhbQ_KbjkLE?Ohg!TC=' + 'cpwsMig^^{^2c8?q*ER?<$d8*YamS+1xqPrzYtUsevnL8Ch-QHBvuK?TH7WdQCy)?anSo$C*M6jej#50wu)gYQwrvnS9OSF47C-NlJwc#IJvacNa}RTS42=fuJe&Z#o=>M95RREZldH+)4EE|?Y^L?@VKia}JYsWmAZs;H8|Y_qL&' + '2I@L707jtuw7E?8ty^x&a=-h6F3&H|HP=YpRUTdx*-awuxzJtG0r+r@ZgDDI3XO+4?aKI#szWnUOWVKPtUPG&kQyYAfFl=5CmPIB' + 'B70<)2BHa%b5LOGpyfnUmf|LoUEYd@%gNwrTHukg*QCP6OUO8~wb9JfZCRC_BlR=yjVSc|Sw%Jo==zXs14&G3BkbBanl#XKvB@_W' + '6N*mp-b_!kErcATQ&F6hC5ZIi-x8ILgS1AGkBL3-XhMuLcY(7nqnBQRqyfX>%ntC5m>MP_vwZooCL~{*$6yuRS=&=Bznqw{?Ev1I' + '^NCZq)>d-Z^a#tGV5077Y~Y!Xo=Bsvb~wPmIW&h|e%iwZ?a@vC(N5OHAzND5nuUfml~y}Gx_7BL-VZG$QqhhmHZ@>@Gykn4YlG!x' + 'B@#DSFdcM#r?`EMSgk{H?~#FM%Vn7?>C8!8M~F2BebLbu0*Tc&uQ10ZP)V&d&U2PmcroAb^8B@L_)qa%=*2<8N4rdAk}7ruDpqM-' + '!B$hBlzHU9$~Iy&S#6*`i&{70aD+XZMzHhnnqQ;{e~vc5YB3d0767+DK6XcV;=GknoNjP?eH9%2Mg;x!zVy76HI%Z24tN`)Tq)B)' + 'Nf}c;5Xl*|bsQO>0np0ujQh~x#V$fj!-_ph;)(9!XsI^d@okRAnm6*ahGz-=-8$z2{}i' + 'Ku1)an#tcH_(b`ZX|@RHGN2tjkZRIEnjV87}8ZSOXs+A_N_;seuXba`f=p6S' + '^~hbBq9K&4dIO<9b(zLnGy0KWwj28xFrBFWnu(Nt*=sX77`@!wz_?D^iQ?1abcRXK&#LK}%FY_vi*T' + 'K10{cu_zwExXLYbon;|D5Bc>-qm(0WJw6GJe1nL>CKMY}vidMfualuEAlMh&<#~u!7Bf|(0?Hy@RTPDJzHbjrjfv<5XUCj8`JpXc' + 'AkPMzEq_J92|@eqB&M~Jgn)exhvpz3J2pOM;A`)*RaMTr(;ixX^?JxayFXVoUsle=6(Fd(+' + 'du7?#_i;z|PHUhOH|PxIE@-B1d;S;RadP;(J+dsCt1$UT7mc>Xb3;Veo7g3w?-tTuf0T^g#!OpEZ;(?o)Pviw5A_YCQ_=tRj;5{~' + 'k$^Mdc_araVDS2MFRRSH(SOhL8<>vRkha;#hXEwb%MXF~LZkynskK;iT<*)8Z^4Z>' + 'CW#@TIg>R&6M}g}`X5?cjbRdGl*Gu6GutVzu8TCU9}*9$x#3TuNJTcSWT2VleoyJDP0^l$v8J8Yu=9}@O1Q$+4TwSvD@G4wV<3TA' + 'N-X>wGNz%k^q0Upfv&AnqSL4RRQ+7mc$|=-qtr5plX`gWYoTGPIuZs)f*N%#x9F&v+_6x4#|~unMNcKJ86JyryN`7s53IFcM{CTB' + 'M#fNqdr6k1>#9g&bgU{1x~`4Gg62_%j)tH%VVYO8aAO8wtwo>*35KeO?67}R8Ks1$Ag7rpo7*ksjL+&66P?1&JbpYpseSxnIKINh' + '6W4WAO-{{NC4oB9yvTtgC%L#-gP4n$Zg>xGE12@eTtPQ|KUQm_Rn=3ssfqAV{7vOWMK#{%hNDUIM4ZpU1SShmUsHWL@W2VRtT%WA' + '6JW!e$?+6e=$i?7!O|1GJu=>&m~W4#zV7)g=T>+ZiE&^ruqtg|dB%iC?TX?s2QB64+&ax+lz**f;~>l*k*tWfPmhm};E!?m`)!)X' + 'Xbi~H)n+9z5706zh$1&r2`Hk{8uPZ&X`l$i!!0-$B(p(D!UCDgGOG0M;m`2yaP9|hy+3;A@Ry!&dQ_n+P`c4~3xm_{{)8I%>t?=Q' + '<1xwviv$?DW=77^$Kb8Lb=c*&8sN>qUkNeVT?i)6x3GrFO#!aB-(n-Kl7>fF>aD@(LLfrQC)-PIlJ45)UK5lf&s#i' + 'F$oD7w~jS)*G50F&Jl@5yf=JaR?HxWYaUs7oImrvzl!Ep=@vM{dA2No9bBypEia^ttubb7C=th!+Okt+GteFb-jlq2sak;oBt%L}%;MCFp7Oy#D=d=r?27bv=@?HzhC{5>YR&Z~%elJ@!7H(S9pUO2hK' + 'WB90=#7diXxyetk-N#Ka%l{si@e6$&o3(7lkMwcKy&ifS6Bt@${A3#iv5`OE?slB$VHT4f_o=pd6U8eKxA-O!zRPr(=oY}Z*&j&Y' + '2*K_!NEvSq(LSIvMiLRRu(#6>8V2atVl%dLkzSBcQKKOZm*=&4?8QE_X$_5D0~Y-Ne__|dT+-tPF-!ak-WuIqBp4nkGc?=%Ad#kO' + 't$0i%a*fo>CYG>Rgv%}OHe1=bk7c?^W-nV4HXJr7HTls{33A0_69^>Ac>U8-7q#l&V9_;UjHi' + '&ij$ioHqaSjM7C?YHf~aP+DJ;VV#u)hOjq@C>H^oFr2Ii`NSu!aaK0#!aVLXrtk$l@Uez+G{v1{d7lOjC`1!)x!-#>(V9}ugE}Hd' + 'TMWq4Goq4KBSr%wRaN?uY0{mVh`ZfUA|@MJ(B{Hyfj>;N?^YYFDR2hwh)pHNBl26*Ce^m_s)`@BpV0X@' + 'oL?pL>#$4~n23bV@Y>k@Pu_e&rBzcE;*jsL3*})Wq7SBfZBn|Teru+0pu7P&9hQIOUK~;*emgyc(0y8avtv0fWN}KuOYh0uoA$1r' + '#-J|#bvJv*623`TO=f$@oLtz8y|%dNLdjr`jC-u^e-;AGf%=OJ`m?z85nHBQ|n($DT9~m_@UV$k%!gp%<<;e;zUsL6' + '=ATRK`;!_u7K?^O)668lS)YWmtQ&*G#P%t!Py1#qbsZIT0kjvri6nSU`xWl#cbcQqJKWWj*U`yZL_!|9jaz%B$)O{zb8*{~bxLC<' + '(iwF(#dy$eOeZ-HY>cv<>ui?@zK@F15#j8Rc^iLV2Ad=sl55g2lT7&<6WaufEUE!EJ#cRclk3jU^{=WAejGvi?%CV$?d5|c' + 'P#4|k5kskk{iV-;Y5Kt<5_7i?(QxlCo>#7yp_V`mcWR@Baf(O9KQH000080000X0FeA_' + '{aY~r0As=c05kvq0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiHa&L5HX>MO*Z*6dFWq2-ddF_4uliSFR=k8ggMRdvPHS3yPw5*%x#ks4`{4u`|RVA<5`D9e`HZriXdi^}z;ZaSz{)m`2Jgmy3x@5)*|b*$;?' + 'dezDo4YX3P)h{NWZu6!ptIL)*v#3`qwjiKsK3~YTUIDaxzG9>39s7C9ss-c4u}_N?_Ni59wMDt;M*7nT8r!ULS!KDc7KE6ru*erz' + 'tjIP^y#bVWywoP|u2$t-miYmG@(=BH4kZ_?)%CB~;ySCh-DcbIG96oQmgS1c67L!Q&70jt*+A#&Ml{mxHqdif>S?u8bexxU2NX=(' + 'uGub-?9i=fu4p-jpvU6S' + 'QeHg68v6@>l%=1)digx>*d@?xL_g})c3siq1@SXH+yPw6y3C@f^&-Qf2Oy=3y1HS=;2dIh2@pD9?iMD_nooyObZLU^gShR=l>`Gd' + 'oAP7U;EWO#+C{n9r7dt`nX7Vtx_I9(Cgx-+-?DbS#QB;vtSyTz%K*u?%d4)%K6)=jZB;8@F3XBNE)*y7-xprFRe4#lMit{fy;5Dh' + '&dRD_AC02$?iGLK7DuU5F7#{yX|?TGc3A*ffs*p~s(=@Z+^b!-N=US;asgww>#pi5tLGnJs%3dA=7C#py(pL2CU06}2F8O3{=LRe' + 'KQ;LV#KDN(ys3pOB{t{pntEFm0?dM#vKuiw1=;Xi(S_x{iS' + '`P2XYum1ybx?s!8tMbG3YF*WvpPRPZ-rRoN{c?JC{`i~Ue*5HizlUXH0EHqVIm=Mg(p<@483O*qbtu-4;|NHRL??fUR%P2wyDcnW' + 'Q+PFs(lnjT#?(((n_l1=R=`RWt=Aw3A_X?muz(&Bj3w{}|57&vYxu&3P!K6v3XAL##&i?Y{~ZM2hiDZ4Y2?eI9qST6rRhwS!gUl@' + 'tu?k!FkvdM$}S#`hKYtn1GbEYC&LI%iVgt8v' + 'U>`fg0XiOp1QPL*#b&b)qca0w!$1aBHcUPuM64ExNlyfpKb7XVreal(hskgR%#cXD;RTYEs>}?CPyqyY%VLg%KmbQCR?12W6Se^H' + '96yMCrbuj5IMf9CBv>1{Z0D{8*%JC5(+mb_W`E4MrlM%jCW^Cv4&f' + 'kVv9hJGS^%&!f^9qSj{1MGaG_tYE!D(7bgNMUS6ko4PHz4hKl@Vw;V410d|!#scp%z3KzN4^VC@pv(eOz(mbjTVvy!F&=3nblGGt' + 'u!jaBX_q%@@n2EpK@fX20&?C;&ZW1Yemg{)7%e6iv-%)`_x`9f-2weytt?#cf6claOK|cskPY1~Th;Y7N;x7U2!j!fL>K)HrQXa~' + 'a{OYd0IhsuYq5m-Z|aHx#qkf7x);5_Dq*bwJ%oTj_dpGfx~m*jo=vN_BQX6zBZV)FrX;fYwkko_7Yr)Ep^U=HvR(>W$f~|=iL174' + 'e#x-rU)$TVTU=$b)L&DoE`MgIR0wK|&e7tcB>BwgN`S~0_2T+J-huM4V(%7p!#H_i%*q=k#%3HD3zs4sRUAjlRh@TYy$0#C1b)bx' + 'b!&bA^$I1B^L7bs*uU$_d4(8WF{{?gGxHbWkm+}Q{ri!%+f~QA*1-f>Wd*$c*#sqetU3X$!n46cFwD5iG3l&mVb-G04#7qqnP-9z' + 'eknIGS4WGKw}Ub~PDTPD>tzyM!mBpg)mvh)O81Y8zjk*I871=`>y^@fc;D_2EL+iGb6ugb2ZnF5m`pT&OxrRE==T!)8Y<6C9G(fXafj9eQrX?pnVQ0s{+Gn^Y*ENH+R_3la=4' + '&Rj8DC4|;s0l)GZ*s26H#N>|F<>eJ9@K-tR$|}()V8)T!tpgxLKbWjMXT}PNquhc<%;5_W%6CDeHk-f%C$^vh;L5V73+8=xMRZ?*' + 'b>3ck-#{T-mIWuP^GV3ZwUE&$C`mysB5ojnZoGbtnl0|T)957#xpr5<>#ke~gU&6BiW>NH&OmCiCCI^!OFId4+_NBBzVQph5FyRo*FE6MoK{vK0#^Do=dVQh(fCvMO%bYDvm>i220?cuPeiIU-28W%-fuDO*Kv' + 'M$z-9@E>)sO`}%`KPo%a3{laB*>D3LE|k86|K8$u5;ug%B(Rr6v6JDCc#p83WpQtWMc*rt*w`(Fd9lk;R`1w=%' + '|8NGF5~w&yx78dv8W-gnaMo}Yz@~;SK{F?hjjvPKf+4P>XgpITtGv=3y%&AQI^x}BgL4qZw*;{l&3E+j`O}l#_mXbOiORDQS?p%f%id_42S7VAhRq&N8-P_F6?w4n1k39?HO+}khj_8YPSHc9Y=H6Ljra$' + '^Hs|Z2{5t4%dttZ8>6M1fHyE*oIS?-&!?I~?}rf`}5K' + 'u7>9osZsK(>o)E9(W6V4OxrnNvwn2>p{~kDveu*ds-8buo)^FU{o~X5H&4#llP7c7ge=aN-#mUYfBgGzzhzJIC#S#5e^)$y^62#Z' + 'dGIJcj|HsyqKhoX}gwj=AsD*}9o_YgoeOqrEv=6PIh0VfM;0^OJ>>u7d|^(WHw;M}Ux)1-JXnKakB2V9rd=Uy;_|#iADQ>K~#ro9CMx@C^GGs2!CJAc+U*8UMOZPkV0*)9_nIrKd@E}sX^m~mrjPw' + 'Jkd%?*5(!N%lYB6>RtvL=!|c#2sr2C8rI2X4;QHcz-`xWCiH{qB%vYrPc-rK0gyfh=4O1`idA3j!2#T5cQs7h_Oy($j5P`c`c8N_' + '1-=Sapc>C%O9)KD2X1IkmrOyb5h9}c#jU%Q;q79akFuY&_=>i2kVmXdMStI_nH0;%9n%`c?Dc7BDFEXm0zmP;$<4>ssQnxckPZK_Ko_Fti@A!zH4vM(24|-h4w0u+@>iNL9!3W99uMr@Q>xPmxCe(' + 'j7D__4oozZ2p0oK1<+6p%Ax=#ZYO}E%+x3_8-u' + 'iwyto6S)coH}E*U4A7T4NW|oVSCf>H0l-=t(dJSjF+ANjk6Jn=6HR~;xna$TL{GzSao+wFzDZ}Xzv9eA#`uQ|c%>D?+(MTD+qKJ<' + 'RF;Wc9?^N5KT}4bJzJXHPm@tUG>*Iv%oJ8f19MQLJ?^ns+4C>B(cq?fq!2l^-~$?U*(#>({28aNe2s>ebh*kqKrS|f8F!}pA6eEb' + '%f7pKpS^$gKho?=q#5s%px`kg?4KrQINKYN#+?V6wi9bo`-WuC*YY@-#I2f%Z$S>6?Ae9(3Lv7&-BCV9@cm;jCLGvD5CAQ=mHk?B' + 'dvaFVkMS_mwC>4&|8kfh1kzp6CyCOXXWV`;*C*Ao};iew$zp~$Ah{BDcAkav*#n1_YaC0t2kd%t#e8Z=78a-o+e2bw0' + '9Ubm~z9Bq&4AAur$Td8x0^VxNx$^HAL9P33^z*g^$nC1WMR0i1#FpC?2_AGH!EFP1i=hR8Tb0pp9j)qmV>Z(8^PDSwAVh~w02wtK' + '^tB{>;ZqvD>X1Bm=0vV#zl)y#@MlB#-0QcZ%D&z*G8*AQ77q_WM>V7<3LMk*QW=`)Gzfj-Mh0{k-d(-b5+8ji$OGnN#b7WS>{7!v' + '49&K}1oG+vy%Uidw4^H|Q89e~9zPXMoEK' + 'di1xazO8o1ae)r;Q;xQSUch{oo!V{+JbWWMvFfI5YLe>dFyXE+%B4{1FX@pcP~p*~JBL%ot}2|A+IX+6o%>3)wwJd&4J=36qn6<$' + 'ZqV0sv#>3fzKbicMj8Eq%$+^H_kl`btLOW=hACO?q)!#F{B4614j$0RjV?sPB!mIB&P~&j6ao8tf5Sg9+RaD8c;3p5h+uBbl}IRJ' + 'gAf|pt=&Oo5Wp7C3ivsJuodGEA72mGs$pOHR7K8NwBt}=1jon3f0o;fY' + '95H^hk)=7()fm$hW5Gw!kY2%Wr(42f(k?4r^3t~Dh9cd_`v$1@c$DCIwtm8`kiS`}yHHC;JGzA}1-fX3tS-pKq?`>Ujrs9GYUfDN`I$DJL8#YWkRP}DD~;HaZoHBU2XBP*fw|w' + 'rQK(_A1tqIx#Ua4sp+wE$VA&GfqqkNVsFl%%w2iM$X2w7H_aSs2y#Tx7*FZn&{)EANT}gzL96O|gXe%Gd0-V^2p2%o1z-hQv9?W9' + 'dz->mp@DdhzQ&b3Uhddh=Z&XZ&>9FG)3e%jv;@vH};|);oOJ6KqcpzgnN&ih-CJw+Ko!8?E73' + ';H}1ZdNz~tOU24X!{qZ39p<8GPk%jxrdAzdo#W8)v4O%;GW!i<5T}TWb6XfiXGc(O3J@e7dQKBW`8u(VkPeLNQR8VT%RFS0CRSmF' + 'aXhVd+y^8a3*%@_ZXti-STlA)p&)TFb+~4hjBsf|W5hTrFDroL0ivcRoKS^SOlAR_h823FE0pjnSYAiu=)&KN!h9jETVkOlJp{2Sj7M6!s11+@XIq@?&Vpqhzu8UNSXzTo2*#f$lxEG{Ws(2nlzbfm=6>o>PqhvHY@8kP7KXQ' + 'OKk*&VAPHZSaHE484;N0M)KLv$FynDiO*GAwm{I3%f!k6#V3u8+0^Ll)ZV|$X2~Fk%N`#hRV>+au&WA5t4|VaMjnN7cX$J`udo_1' + 'U+!UyTB0Y8mN5!!C5&jW$sT3Gu4+Y#O+4lp^$4-aCy!m@(iQI0(;29p-rKWTGB%}K$(^sth?qjQV8Q=93Z17<^jL&G)A{nmJd+*`' + '0ux3EO5SxDRr8ke7~o{en-YB+N0jFPXd+^1h=p%TcH3&_FtqKWJTJQ}<6F_O=V#iI4W<&#j>=7xl%t@B)q4=RV#hBFbH6H3V{%fd' + 's>{?^A7!D8`+r3fSL`oyU^%ZW26;e-#=5c6aX|NKnI5LjhdaW{=otDE-*z9M6o8D~KKkOo&>>|2^Ha_8kFTf{hM&|o0`^ufm>{wQowfJ#*Jremtn-p)Eq&<9jq_' + 'wi1kfGj4Isc43j&$G68zY@^@2E$Tft4_ndtAu0Z;2jS0%@zGM;P{=PCCVyKCbsRP$#mBM8xnjCSQ3^B5WyyA@x6kgWbQ?H@Mn$9E' + 'VluS~1f9&~BSRgcIc!(1^}Yr}FUQeO$Od9-A(Bea3WMte>CD`2uTJ*q^U3yY{`oy<_~5^M(VNC}=i0r7AhGHA|ki14UoR?k0=NUwx&yUO6#z%)XMAjy>_!?!J1qo;?ZZ-hDN_aNXe6MAaTeV+@53!XO_-' + 'cbPY9x(WEaIDwD&G)sbO<6JK2Iq1-?`28L^$6&p' + 'yx?7mPYLiWq)cIX?vgYH05ec+IBJG7kZ{~2o+K?cq~q$Z@pAZF9FU=!g' + 'LOpa`N0^$Fi`+=uH63^!<-XS@+4e!YX7y4yj7hCoW=~7aOqkLP?lPtcU@}qt(}|c|&&x`;kWxSt8e5(mKEuJ=>Y' + 'CkEz|7OXQ7$@!tLb;+&x8oQ*A+0p>_JCF_5^OQWXOR(DtyPXEyIA1#n1Zk7C2vEwU(h;4oP@103j5X3YAXc(Z-c2wy`Eq6_)HPqrD|KMzRJ4`xgR@@t^?<4V&FFIZ-NQAqf`xWTL3Aq+y' + '`W?zPX*6O64~c2zwG!euSQL4#%Gfrjk}vVum)ks@tP{daR2IK!K5LJE-O+#TISCq)_oUks3VcS|h_yN@yS+xz{{CuH^FOV_GF9k5' + 'rOWm<=5!|Ws`pEEp91nuk`y1p-PdAS$zp6pHZY2|bhE(yr}uGdF5*ynTtQ)uFRoYYhOH*&qa%B$_jP?;v{OBQQ6FFMJgkR0$<5ba' + 'vzK!Gq#hJrjX`$D?r@Ky;ZxU1Pm;=mW|2sPGif`b-tAk+3|yh<-j=n76v!h);@UCKiLGzZyZbi{3gIn%*dah$2znd~j@w8dzj3~s' + 'Vc@RjfMa*!b~y^tn%L~%1a94Bo9^wXF&}OP9FNxQO}(lwcXSZK@2OxP(MG&~vxO1*eEhD9tZNwaw$t~x>d`Uozb>7d=i3rg1Ab8q' + '&s2!l1?WV``s+C?p@JX0BcN(nzv<;#yWkm*kd@_g~la{j5X7C#<4CqGQcDTClpk94' + '@Qc1OXm8SAbc~=7X)%x@i>=h5#lb62^U4A5-Bxug' + 'GDA2th@KJ#ZpsVlZNqp|`;tSb^qK7*CvMLN+(rq6bAut)Zo*|Ad0hBbo4(|r;@TFaWi4F_rJ7Dj7@At9)SJ7cnABj5U4NUmGCwtl' + '8=+BI7Bqt%vP*oim-d3-4$MDFqb^c&x}k}FG;o5+!f#09-oe^t0>AHIXW&W`zx?MA;~`|42fPwW8(c2(R{JOiIgIqRy|xZ(pk|s7!+D%qbJMs`$D^6F;HLgd%' + '?9xpu$`CL=`JHIxkpIP-T2fkWOC9MlbdvEaV$EBTBw4i;!d%|Ep@#@nSbp1NYRs09JNoqMtoN(lyHUd+GSjYo9Dwwj##OcP}v)b$MAI!i-6mhvQUYF!Z4*NL*JhUxMU' + '3F8Y|HBN3|m&VdUJ|IivK(~%qRX8}Sfq2g^>I{7;Si5M-jZ8K_b$02RBXCeDp>^z%E4^mB*2_tvF;anUT@xEN<4|eBQ#k_{e*n}=hP?h}=8UlUGkzTq3*=$Zr~' + 'Du-C$uCR&BM?uGB$RxGZQqVYiMDym*d06=N+)-cLT+%;5NPc^>1Yc%-Q-wYTCJMc;+OEh}Wp$lj2EUuK^y|$b2j62RlJ12yjW(EE' + 'sbKN<$uLmf>Yb`r)uFN)Wl^qQdd?0`H5#Vl=Nm76^rSPx-+l$xI-_uBj=lRd)q{A{BsGWcD|FT3hA-`gMj@z-Lf' + '`0T4oj)0=4)qd`x=uCZj+b2)xW5K$-T!o~pMLU@e=h-sHOld=F?K)&HoVVr#HtZMybBo;aOu46ddc~klJsb38(hEgB^jg1dlc42O' + 'L!*PJamEp*$Fi`gp4`QH=vfX$;x}Q;4vl-YSZxbDo)zb=ZJ%Ay8cxgq_)Z2cfDkZh(aC~`3ioPu%Vt$=l<{-XD#IBzB|`ILE7oNQ' + '`u#&HY?*@u!&)X0%OGTOm1i~~4tIyaG?gBvyk|?DZ@qmsB5^(2Zu2*Q}H5cO{w&' + 'WSYvB2aTSccqt@Nv=y}xXhr5)(2;X8Wnz2+$Ty7HbbJZ!VUXD(uQXb88!X6CKs&mG!BH@am0~+2`u2NP~%ba-HfXuarp2VF8' + '1yML>sDB|+SRD18MgUIJa^}0JiY%l15@p&6TfZQms4d9ExyG*%O-mR>b&ET35FXiB?u}wm(r)N@ak%c`WnCA+qa%!N#YHk2yIw(|' + 'YvL0#sKRq?j0_a@P_!~0Eu1|=`3^_Y@_}{2N!W~DNa`9*?(CP9jYBTNQE`OB0%I?4g6-NPMf=yIkPNitePq(Vhu`0)dYT^UD(f_pV-D*$7es0' + 'PjfhmKD2cO`>>{@ykzqg6ZsGQ0Aqs36OqENdILfM#sKT-+ofmYcJ0DWV*2P&JG#JTftr8Pf6&#W7xVWTg&9' + 'HTLnjdkeuRdUbytUDcFHj6#LY7~_i3EnIXl7l`-s2M;s^OfPC4@)bO_>h(Cfu~L!UR(tzdkj*qDV;ir' + 'igkWX@y}%=;_cTAQxSkzSg*HTPV|U=s+((^Xt@J+)08(j3Y58{ttrPjYde0;tW@sR1znqbx2jggv6819ksCQze>i--AX' + 'FCxCAM!YulCWM#kglh6GR_n{oWM*bMkd%ohM!4aLGs26|0`s(^TVBk)zyjF' + '#fXP53%7uVALOB=6{|qQ7KRC85M<%jV_WO9jD74W;`@7fWyR_VRjf@EUhmw$4w9ZY-*u*+s+69SXx(0dV7*R@?Rq1ws5TZ?3mn>Q' + 'xWD^E)Z&reDa=?*5aexbnQ7T3+g)I3qz#f~>4I+m2fuG2aH6n9DkShGP2FTlhf&+uo^Q)}r{2zpTnC8Ak{z' + 'dKk4l(K^Uq6l)KK%!LQqmNcx`;#n`K;dP^3hnQ;n8o)9Ou0dxfrST_mMFsqJXG' + 'f)xUvHT0rvuT$FKv|E0;aKr9nW+XCv-R2;f%^eI^(>T2bo_0f5fh3%SA#($fQC=BBKoTqaZ*}mkf9$=HO8nf;yP==1g}xMCxNqMwN*KVIVg#p6xRTp_~#<8ukjqEMge^#Z-^o~X7g7Y6m' + '(17bQ%-K&kyH-5hj5{8x+Rmk8a_Ymz9yCHUl&OGWmDmm4K@kW>d37SV3uYJQ6_eMJ@k>_pl|tfjCYcJ3?Byh^a7@T%CBXk?Z=3-Q' + '&7Uhd<&>!lKVXX8j(zO#7+{IFW7>g?@sfTBpxG4kr' + 'E3$Z5xeG>iN5)n^dS@UyQ%n4$en65DiQDYVn&-|x71?^z?UeVsNCt1Yme9m(YEY_Cw|I?ayj(hC' + 'yCEz@f9tx84>fwl@!b2@x' + 'V-%ex=KT(vjHh+~jo_(?pKL5gfYfC#|TfOyqVK6rJcPbqdW01xv>`F#tViz;sB' + '9oGIG2YVg>1E?6IOKu8yO{^&y4f){B2u>MOyi9`>6@;)|E;uJ}q2uptc@}N>#Jw{l0|a)tdTBuIWX&3Zi*v6Xwswx8rt2KDHlCdM' + 'pX?j*KpO~`7a#1>{6Z3W#owuStZpBvi&c&RUydo$ojMtSA&~PZkKFVv52guD`8~1@K4?u7xoLs8CUVaMa' + 'axpFou~{mdt;O=MbV$)tL*FxE^wlr>x$nX#cVa)Bs7dX0J){!dZ;cCNGzsJ|F>~fj{Cl{R@K9&p!E`ab^~FT$sU12Xm>k+G?7JQb?Tc=v94_ENWcR1bks_YXt?(YDlsjrY' + '`|-uoKV|RV{^`Y=6xk!TBillYsH4_N{3j|cl5-in9%`#!gL~1l5;Mb;i+pvW++*0sOfc64' + 's(zWTI(Rb~l5s%zcW+yk@hz`(wxxaeK&+({FhIAvPB=}Ufi)GRt3' + 'Hz5hV;Ev#23K(fL&8|O{lkL?-bpMk9?DKuu>wEX^MbGoqf=o_!eMuXq6&M=YXi$&c?%Trzy' + 'r7)u5&Gu{bKW`1j!V0t@GMtaKVO28Oh>HRsup(6VDtNm2SwA{_Z+L{#ST&D~h)@3OhXT$d^lgp$mD0Ci31^VfnO;3E;iGF' + '&dNFs_tk|K8pwh5CIfA?8Oga#zfg|g{p-GdaNH6;I#iJsPizU#)zRDNE|n#wT&W7yG%tq_9Z2L-p6L@u$IbQ!T8|Jo;iIiOE' + 'X1!s+HRS?lNn@w%G3tUQ!LO)Q?~KmJVhEC8@M$!$Mn>IYK6EzS_oA1y`1yQ=3`fU@8#6;gJCgS^p>hH`ij$qgl3ZjN49O;E?NLaR' + 'B51wlHD@l;Px-tEXk#WK#;nL_Cb>$P@VK{d9=Tvg#*iez4X(_JWVY$i`5EQrbf=|e37-?(veTT45BEK}G}Ek{&Jw(4$g{g>DFjtG' + '`QgDOz43|)TVQ4bQ@v5WJXX6o8~`Lis^!k@ChnUlJ1U2fsZ~?G;7~(n3%#xs=wtP_p=H!n27vJ$!k2h>wlGEsZ!3N~lVPqR*I>Ji' + 'BoP-|xD|2X5|wY!J0LcJCUWl-u3#R&O(~JFTMk%7pJ&W`v<+Ye>N-d0CNG;?80y%qlZbd!F&19?' + '-J%tUX5^mj!*1YaFSJ8&A3uqG-RD4BWTigYBi^G#^+Xa*%UPdVRfhD9{@_Bp&xx^RIU+dEsr!EcP)h>@6aWAK2mk;8Apq?IC^sYt' + '004C%001@s003rTb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDaB^>SWod3-b98cbV{~(Lsj*Ykn>33)v;|skk%eK%9EqACB2iP4a^iH`-@bATFJ#vuK^js@+OZ6Ax}g=q4c)MoFqxsB' + 'fB5B9hA^wpzg}G~E`LEgPFpJJEfM*AKA+8M4ibu@?q$zOQ6SpxIxb-a>m*h}h2<%`#;OrHUY7&oOWZVg-4Meh?{<`J2HNG_j{J)G' + 'Y-XQX-|p@Z7KrVJSF+>fHh9Px%j=$LO}J6qudQrF9Yqr+I_?p2-0nu>d)bv(NQC~TEF$L!;?Z$pJ;{k^>#nJm1+Ht#$XPWkyfDwN' + 'V!oD&(czx0Dcj-lZOjB3T8R7yQj}fCE6T7WVpv64McxZZdI^5ZoBa=G;Am~9&3lfPbmWylrXl8WQT5f{t+^!b_H_&pJYUhuuL6}?<2WBueoI?vaT@t~C;' + 'Y0~xP=FQDbZi0EaB}kOG!5m_J_YPJf%s0L2Kmrj~b`>lG9%VH$US;M%yP*41U^m1Kaa>W%1nLE;(E3gbD7U11yXz>EPXv-2Q>MDj' + 'hIB2F{4O6nF;=l+*9oc$0{oxWUt+?aq4|$cVvrxJ#WFR8gO6SL@*-Y(^vYgt6RvMWjqg1x{jd}kxp31`UIGb#&D=vc(|o$psb*Zx_qED)2LHES%W3c!_5vvEm`oM>HU#b4XTN1%_FRHq+^8U6CA3O&`u-igGO' + 'Qkos+gr??my5&R4dW#pwtyV9i)yDp=t_4v=EBZDWrQX8ug}Qs{yN7CcV+dLB9_r@<-6cAb0R^6fM' + 'UNORp0tR6(?YL>XHC64%Z(|bL0FmK4T?{Q2Z->|DzvxG3EQTt`Z#{GjQqXEb_lA}gh{FGt)6F7J8q6h4JdfMIu{*Q2X3jpF<;rDJ' + '6^0EqWt{)Xu+r0#p&hOYJpxu1&0v;>l8mB2(xCe|N#NLMcHD9zm863o@@g' + 'i_CU~$qdUK5fTR=zIIWc4@QcUedhAO7wR~jdBIkDG0A+tGPiWtUiBMx+=CncxBYc(;+|@9q@j|4ERq0)25E7=yv_n@r9h%B;dR$F' + '5tZE1lB|aG&`*vd{kUdD-tS;1U9pbTyk`PRl)&_jw#s>8O|TnMQ)ml+1_Cp5;te^QhsUzJZed5xZ1$~fYhAGcte2#-Z-;YRGB5iI' + '141H9oj0o0YYaJ_>WnG77G@6dhL8cIkI5SXCj=?>r4Iz}n3*)PJ(z5V{$G#q`z81S_*<2t_RWLf7IO;&x;4wq&3{Jt{k@*TXp3(F' + 'hN~n*swSE07#RXv)e|5l(?f-NoomQ$QUK63w?};wtpVTv463$p{B|PGj7V*w)K>yMn<_p{BOtyvX}#Dhta~rC@PtJ97bTb{)KUED' + '6k|sitaopsWO6fCV=hfmw+f)8*Y~W+`0HG?H0Tpe)4d}w?#xWn@A5pq)^pAwB4yr=LDai?tuUv>T}K4' + '&@Dhvvi|^jw2q;9+j88~vL_H2(!;fd-?&Vs%F<~(Ajf~dC)$5ZZWxpv{mvL;_*6r>$3XOrofDpnu&D;ZR8!RvP<|%?$-aJuPRZiM' + 'XTaV16G;L6T$k5h1$7T!Zl<{UHI)`OcT9E$IBN)NBdhL}&1W;+(d&-4nygDk*Q+bSzh+;DcpN?zJ%j**+Z&VZIu6a=fw+j>8;w*M' + '7I|W}TyG4>8Fnbw-$9Id#ST;Mg6b)rM!`ck8l%A}#*JWw*q~~B=&RTH=;uC&;rZdWh6e`8RLv3Fy>}0TfwCGW$>es{L^CrjWfm0X' + 'rh-iupEB+aRp(>o-AiND@YPw}Q{#pcT-}WmXL`Q<$Z2s2>Mw^mVAObWmJ>P4N6C3Uk@Ngfa$Zd2ym*WpC1nWIv(eROqp`y;E&L>3e(6_~CK&*dPH~S(EOW)G' + 'IcNU^P)h>@6aWAK2mk;8Apjf($pGUX005VO001@s003rTb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDa%FRAWOZdpm=|USzySUm' + 'JBn71ad3N=GP`5<*yKyJT&F=4dhz4s+*|s!znN7mcDVfeI`)F(E?{rG>nQfpD1IzfJNEt6+aO%9D}R0$M$70n@DuhXxDQf)QTrP-' + 'coWS1F!2{`9WMNsrFT9%TzPZ&B}!WCH$P4Q8D_UTtbrBkqNv{dH-PXMt=YZz$igUP>%<2TLBei=rO*7YbAOexAY}9Cezgp|aPG5*' + 'Aibk*1duj>_#i+;*Qp1!J*dBew;R14^HM^X{@tbNYW(xh4-XG*kFae=@$Jt`UNiam&FRtP>~eAd7*m_~;nGhM7W;p$gBWPNeq`PX' + '5Sn|}facPBU{TDx+t`P)G(vlz#vijjAJH4#4SBICUmo0$t`{~um`THyO`{BjK' + ';n~&c=_nxC!P!S9rVjZhdweeevTE$YCo#' + 'z7Gqqx~8b7iWd6=N&iLaB(x4%Tog$+%w0MHT?ErymLqI=}dBfIy#' + 'WF7nWewYFdvClrEj(iD{0A+zh#QvASe`tg9#=#eWdhaEllU5DX7_drO>?Zc^@i!0vN)g>dh1CkE!7^pb2=HA#vTJ`CJ@A@P' + '23X8LgW~&Ny?F|y-YuwGk`l=PA%xCe1<~F5n)pmO5^LJXhFa|=hR$5~W}N~7u8WnTI0fjS`kaNen)r6J4(DkUEfd~o9xa#toO4;!' + 'Sok;IdYJ-EX^VwG(IQ<#pN0Bu?|LrVyyb-QIx8=|TL#y%>>a%Fg7gt9$?u2ZV~fqbB+YOO*Y_~Gyo80TT5ayRbC11b-S)6!cSeKm' + 'pfl>%=68V$+5>`hk;4g8_*TP#S3i7nSRb?LtR-GAUgK+xiGS1jS-r*TM~C$D75#i&7qxkXTp9j6&ebr>;OE&SSHmpB=Z~^RhT7#B' + 'qE>}bDJJK~R(+-by!$Z#M;|UL09?LSwO+qD`zu&@KC$u~dj018Kdt8vx)zdecDVty%N%O5<98>MA7kpfi}UXQpxR$fE-KoeYeVqrq`H=d%+1|*tm_O^(twypV+j+zcw6ppcggCGxHY;=yDFC{l#0Z{iM;X&1&wuQ}^BE;`IFZ^vFFrIYs9ojOjP)sU_{s' + 'HyfY1hVbv4J@Cx2UcrA50z}{<4XjykL;XWQ;D1GA@*TEs{j>oQQ?OY3F1pqhYXAlQ5~3uak4P~9#6XJQaGEpfgU2AMbs4=tCWP6+' + '!X=i_H+K~!0lDlkL$5xCaHj|EqI%s?Y2RoNdsMI2Cwd*rDS=19OP|+6' + 'NC2-lC{%F(Ed' + 'r^mJuC-KN?TSMY)r(@;1KDM&D{ce{!?~Tfb2@f#0Sb%34CIeAv+bHShM#*rOQF5@6v7;_$Y^e?!+_Jb1mTC35Apx69pu=^b)9L0p' + 'dsG^WWmMYF$XTbdlFnF5npUnnwzZSO7O}fjj2+lCX8XNvsdAN1D|A643#{j0L4PL}2va>6jD&6tept8q79S&{QZK2Q;Nc(}+$E-U' + 'Vk;w@jc!r3FuW&LR=MAGIGz;(c)v*i!%P5woqrdjduZ(=ft>_kZx(>?lw}UWU}%Sj6{B9MtVH8GM#C' + 'sOJB6rOwTPtJH-naQ_*!rMKT`-N6$GgVo)lD@4JdJX%uLeJCW`vH8$Y#$EbiDPGG>v92X8!^3ZPtDu3P@z%=s=2^1-lQ?j^;7Vg*aB;qzGtXU7>+FUL~*$90)ULEXV%i-8z)NSm$la7i~2' + 'gHB)A=F$j4^ORP{<}k~b3^Y&K8Oh;mRjw}ZnuC!H(z;c_npo$pkZWHoFj&v(R5uIO;TqVtM6f*C;8Xd;wcpaHd4-j' + '32Zf(sPiZLM8Vl3`n*zMDx@WpQCME96)reg9' + 'd2}LC=)#0)NKoa#@-@W' + '6N*687?*B{CVavPO#Pfc+BmT2arjhw6@?3Ep^{!3b^KJv1Sq86qO=?A8{h2gZRv-%=^Z{L>0;r3sn41O>kt!jOn{uAtZ*x6eCuW}' + '*aRulyQ(#7HTUvraxpn~k4`3UPmd1YxGF=ZH@{Me6NN`{6acNIsgq@8|E>j1pA@NbxV' + '4Jf;x{l^9=f;VQAMMN*x^wMMp|G|hzo?rw&W@Aony?)_CKa_bM#UP^t%y3%y@FPqa0HxF>%&z|ec)=&^tWBxS*gocsMbEO+CZ=2Q' + 'Pwd}cijO>(ir2grHNA|6*!LE$pfwR`%#GCln&t_hyeK3<^Y$DM' + 'Mvz37krIfhd<{(+1U{F!MoDX}J-*hI{N6yXO4qUj1DK4zpwxc$C5Y3tw0Rvm' + '2TMN%DrNQ_8H6_bjrWoXmT@~mCmmOCkjvs~~*{5~LG^vRKdYC`4Pbv1V#y%haFp!M4FrwW$G)gGQ!N-1U$Y#-&n4`~IYoBclh*iOf(MXU>=BL3#l+' + 'HuEnz%h6h)H3e|cj*r7e(`=m?9`;g!6IF2(WVH>n+lilw6BW74&*`k$_Eu;F8(CBQhrS_a>RMb(4UTM6zH*$Ks^=26P06(*pQf`~' + 'p;)Aj?!j9n;~aC*AT0endwKPRk4Kmi$z}Pz6Su|hx#T+UXB*!IVc*An^33LE4X^U=*A?IA#qV5r24|Y=XJ!pC1^5LESM60$)yibx' + 'mS&q2LoldXJ%kC|WAGIPPZ`-|VgbK$4<=rz&@zfXuUA<>DgUXcgCYVID79GjfyYYCF?}&wXLdV>MKaZKon>vFvZsYO;q|hd9UIlE' + 'rgu;DX5&U1Geoq1lcJ|3+NWBCg>?;L)fYkS%mBm>R&Io37a(;@0D(_P@z9m1CBw}e)@Xz`-RulC}F&3@weX2udT(z2b&}(Jk}mW@u{IfL8g?Zk5?92w$)L^uhN-h-wmE!aH*3ZivPccLe!9' + 'k7La$D+}u_+Tex--wCw(W*d;JJk$Yo`8mibt-11iu}-(wt#0AT4sc!D8Hg4Z;Kd#2b(EwZR3m2B-ot*h|$1o>!Z' + '6<@2Tto%|nI#y4rdA1zG*ZAb40;;~AtFM;|)V-}DLX#^q!!jDb#C4RWR#~Fa%Py%f}De1aUj^4lz^$NI>!%JbHxM@TyI+jDPB`fRtL9T3_iLWGW{lDtCjk!FQTfQq>' + 'D%)XU&s=abSL%$i6U%%x*6p@D=NIueFh?D?RSWu?nItw9c7=Jq15CMLumL6|?C&Nw~#hM)Ae^d#QgTsUo672%_981eWVVw&%0t}8Up&d!N{g$rX!;_H{N%%NA<+K2e8{5H' + 'EMm?A%TxLAMn3#1AKuD`U*yBPEJ&6QSMuR^`S3wL{6=A@3=gwNRD96mQ1K}jg-YU^Pb$XDBG9a`whlkLvYelAf627^84q2&iOI#%' + '=?nP{82v{*hoh>uKdPcFV0?X@<(8DG)vR{+xQL_GU34AHgY+>UD0kYH)i=UjoTnM#B7Cs>juGu0@XSa(SOY*qBi_Ph&d3Osuvu?l' + 'L^?d>Nsm~dQn%YPd_K+tyN1IyzI+hPY-?b6OZd_q8s1Kk#$5%ru?%AmU@XTnEH>u!^bCs&7!HPpnZhJwV~BSs#TG^uOvZh8e&+?D' + '>m^>E{e7oB7j!lSWX`pJu-x0^(^SbHpF3d' + 'N0dUS3&a`>+TDKNkcb8J_6Lq36KmLS4-BbTmPOfJx?F~o`#UlOL-j@jLo~KMqBASHaO@69qtiF!W7!U9#{eZnmv6av`Id`Y%;szo' + 'yB@atnJq-Rx@{*jiHL>Mwld4ev9dA)3Gba@rgeW8ydP%f5_vbw;Gy{Yovir@vAf&0GZTs|RP1__d;gW*6}oTf-ENOE^NQ>qW$#>#' + '?5sVC4T?LN$;IM+hR*NA-$8~wYK=AOX5IZp)OE7L$;w=-uM%%98svR!8IQAa1J<~}zj}|`jvaWE~@eh7!UUKKHeD>*6W7%RbN|&oU@7hnBpFWA5()YYOwqC9Lc&JW=|k0lfb2?6s%yw`2fW@k}&Ao~jDcn94wdJyn~aJ5ds1>EB`YVh{OTL!cZkluMIy9Y{gyO;`X;KaT6' + 'wINuTT_1yY&{$M8x=#G~i@$Kiy>HMUiQ?2>G!j28w&N?1!A-H$5Cu9-_~&tTJ*^7*GbCmJlwtJ' + 'o49o6S_k?3*C0K-5!R+gcK0LH$^aHQnKh&i?%>eZ-y1hSZ#9i!IOzzl2iI' + '$yGDkLXsENv{>?Ze+^n``0lK|Vh^yjpw9*&Pyw%hZd}Tz$wMv9QPhbv3zY5e&c|j>gJlZVA535' + 'IU{A`yv3`5c&lvuPBG?D5HPi6yeqsZhb1=kz*e|-DABxh{^7?_(#VTP(?wHOUAw8no5EL*tFHk8S_#w;BTHX{7cIe_$ym-xqps;N' + '$hT`AgZ#SYG03TF9)ldZ<}t{bYaW9fx#ls*iEAE%9JuB($a!lngB-W!GRSFbE`uDl<}%1xYc7KvwdOL&O=~WLT(ssh$USR5gIu%b' + 'GsrD#K7(Ac<}+wcqs>a(ZW`fc)weHW_L&FBpIhupQ|yaoQjpuSPkN{h?Fb$MlS{=@z11|H@%>*7Tr$HeyEG^>*&N4cu#iDJe#r&x' + 'v@2N%+NoziObffAj&HdRl$-dE_V^&mu3H(OJst|n$Z2XLZxgwMr%${h^Nw' + 'cdX{Z&R9)sdt)^XK58I>{<>n3;UK6atk|`gU!O;tZ7Y' + 'f5;ca;6NXza#rek;lc&CT$}+@zTK?$_VAs?*sBlkCdlgZ_ZQ*~8fw|570}x>2XExXG!16_PQ5@~K&Q{+b5J0@$2))v$=}Qq1yJ?$f_^HCN>4_}FAGIG$@o)LM$GDJ#MA!4ZWTu_e0L{#7ClyLg' + ')o$RFvQr2pdE1|H8ph0GQ4o;gh6Ysij#m{7ZAJ9dG5If<$' + '!cknDRNyI4JU!_cqR;A&c|wxvLnJd%^oRMye9;CY(MN5=lysmVaw%geStVF;!{p1tg^SZ_87q;L%a>qI6~|^Xb(tk%!TT&i`3d*-' + 'E#o#!QKaPpW(<-U6p|TJsRqqjX+1T9ocx$E(<$)o=7!>Xi&9KdZ~nQF!ECluupkM(_HnbJ_MHH;;dX!N;fkl&7b)' + 'YG9>iO{l%dqcNGv`-}bZ8&!?uJxLJ1ZZv4A7t>Z&bfB3|N|z%RaJ4J8C$PCWS{ak3iY|PeNzHB;i1NOgTzzs7kYmWIGU%qe4{#P;Ci_^R4Hi7~dEvf$qdJR<PKGf!5g^~t`D*6$%VsWoMMIoFhk>W=7' + 'C)Y;&{)fSD2mrjVLGrTGY+n&fjiZ>~EJl06$9uy($x3H&o2~8ka2|zoP~Q+$xKV<2E8nd`tEtVADZy=kb0_U`-^$2`d(14tK{uR-' + 'v@vqZwTA*qKE@a+2S&eYSi5C-BV71@D`?BvhgQp}rWGJFzx**0amRpfqW-iP}Q;Uzc?^C-fDT!1uBXs^6q+k|a8' + 'DluZ@#-|nrX5G#T4FGAqfuuo9-Li(}D&t0@Q8qD@moR`QnVyrta4M?+aKtfY`_c^q_`6Z?_3_iTVULqKRonZ-O{U#RLQ9cdFy!VhL|P?aXGS!*XX%JNt4vhv(Er_URw2S!-j1&b|%oS=Gre<4;Po' + 'Sjo&xu*n+Fa2Dys=}Rj&$HIWr_33E5;6E<&GggGML9CIGHT2&tar%nG19TN>H^m' + 'N~Q>04`XR6h*H@LI#-COeFeGzizeX-en<4;NFu%5L_UY7h%H' + 'K12N4K>T_i#Pi6D#yOU-moJT;#--+?lt3z2ddZy&dKY2JWk&K(8%Vy+NCqcSuoT6^nA$ij-4*b1EK;$j!fk)3*JXWdgQ}1C{cyE>' + 'tFu42N+bzfZO?PWc+j=XNNk%GDpk@3teE2Ejy*SvjjwlTY?m}92|_EXlpQnOQDA=WiY>SKd0E#qFp7AonuYn8<1Lt}{=FM7%?S%Q1TC}a5;!{6_rRSfmUg-^cyNI3B9{o8N{n1Ex8A!*2xUOLHg+8btz>FWpBOsGv--Lpko$J!' + '>tuZD&F|C7?eONV+bZ6gVCaM9;`zFgp8Zp`SNwbtLUua}5K)2dE(TBD*tCzG6Zb)FT71bVO@1V8vL;&>cB&L|-%cuS9FbP5;y#kH' + 'QZ);yQf;CCo=1d$tYKo{%KW_s|E!Ny8VgV-8w$rh#jJwF1IiSW`=m0vl+<5cfJItaxYes(Z~u1`6!J@^!WUB}iu`kk94|!vh_biN' + '+_UqGw=^qqdLv7GJyKtLcF#7K#0zVqoI~4kRCs+{8|U@qg(YuGlNF4K5Z#rd{kcWjlT|qiyQLrOIz+c-BIH(;zljVydd+ywv5sogAK?xvwrJhrdF@UnggniuynG+W!JjO9KQH' + '000080000X0K(ai0}TZL09X$I0672v0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiKWpQ<7Zggp3Y+qzybYWv`VRLgX' + 'aCx;?O>g5i5WVYHusX>LMM!%z;9}Eu7f6flp`HExWUif7XPX`QzbWspQ0!#Vb5}5a|@r3pq$_`@c}GDWNW0lS+J`g*-mS@j2QkHz=SLRAlPP@NKP5Z?!YB+' + 'fGQwP*?TT6EEcVw_D#+og?+^jk_~RiCd6^kOJ0f!Jzb+0IwkhuAZh@QvgA5t-!B57=gx%%_CdSbs+Le;#8AF7Mw=x5*k#~MkoaNX' + 'jXiLALQOpHIY~OZ`n&62iP^&);y$R)>kwAhI' + 'qtOozo!~ggV0Lg%=yr!!gC3' + 'GoC;?z+6nZgaeq{aikZW_yQL0$R&k(sR7g3xw}}LJE9g0kl(GkJfEBObM!C5594#z$`g%$7q;TAI1tfJhk;^WuqAu$GxUJ%s8Q=5VIGq9n>L?>ly#E6c;5_Fk3zxvRgK&6&EQGUsxV@4Q`hq38@coD4^dm<' + 'o@7qrnKI_2w5jm*6O{ST6I~Mi`-X%dY}>Sn2sf0U-86cGs}Am1TXn$n$&$HX*Ki6bkSBW>3#@' + '`&#o&ukvPDY}mee(|Byv>TipQRKv(k3W;kV$p=WehA=b=!HAMdg9Vuv(yus+r}q!7G)Rl%8>Q7%Ux;?`2^%eTz+M>Zyz{Zb' + 'nlc7f(Z#tjw{~SAG$ir)h}Rb~a@brPU6JC0yG8Wh1&FEp_WJWV$)_sxsaRicE-G<&^xxeEO<}NuhYm;1dlms06+!+04o3h0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiK' + 'd2@7SZ7y(meN@|S+b|4$_g4^pii0d45WoYvt_2DdZLoEFX$wQK=|-q5dAh6(y8ip9ixWF3YQPuq@JJ*N={(Obxj?fs7CT_F0pm8k' + 'Ry7&}cGsf;XRv|I4peWsl1zZH+SS%+1ZTP6mZMqbd7fphRvlDT>pWOhz`I^)OS?*CnJ1epOE#{Cufutf-!r=vd^3>Ow2S~n!{yUp' + 'Ka;x!+);pMf-~CJ`(5*ATpf&1`cUCt+`~U^^*7hHsHaW?l{~px{!}JPRMxW73`c{42VdEei1={}pRz*+x+N#+E(d&gaiBqkyA8BMDX43$%L`@5$>i7M4cy' + 'hm*jdR$b>O+}~W*!ZkP~O=C~WG)y;9W5S_jTo9CIJM{q-hr;@3D5m8rr^_@NWq3vbt=@gs&?Mt*4^K5)Ozc6{@=iOP+DVg=?jz&A' + '(BJmgM!}ACTZB`yl=!p*{T*lWF8U#F8i==&xz5RR~f&No*Z1?1jF!tnoq|MKhuwF5e(zkqc_wxg9acX0Uz2$xRARpx0=})0}' + '&ku?)ZV;$(EblM`F-u1l5Prvjk^KiyO9KQH000080000X0J0V2^fUng0BZvP04e|g0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0Iw' + 'ZDwz5WHMiLd2nTOE^v8$lFM#`Fc3xe^A#hnD;C{nk>(*)DisRLsdAB03rkc05AXm0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiMbZKmJUvznJWpgfY' + 'd3BTDi`y^^$KU-|h&)|c8`vHL9qYjk1{*haeLITJNtCNs#}2kU)?{A~53{_)P2-TAd$niZwrwxiTngM;-GC^ZvoAQjsbD1T-8o{*<#JpoJWkC%C4<&yrMNt$&bl&wKWf#*J' + 'q)Zu{CyuplN+QJM({4VwFy3nwsoVsQc>1)s#t0sNLYzJxS&W0xigV5#SF02DNf7u2uLknSo>LCK_qVa@(EoX1j^kU}l}G$ZpYCr}' + 'Kq1731?46fM`3^T5jP@x;4a!Gb$3|$tOW4GGdY0ApxcN$uPm#%jsjvx%{Ys@nrpP920{NoXq@vbh9Xuy&ow18szy_#rn1RWc&gGO' + 'Ws|pOG_u9(2IM3~7VsvZ={B%?57|sJNP1I#okq|J_8YQpHm^xQua(g+BP!#dXW&`-r3Z2swEBg2Xa!A=A2a3JWhUQ3K4mW' + 'S4x9c$`zOK{zqqHUo#N@S0)w)o96s)khvc0USYr+c+1jC$xF@4x`)T&ZzYQ@6!Tup=XraIllTo#O9KQH000080000X00by1oajXW' + '0Awry04)Fj0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0g!WMyn=Zf9R*b8TjCY-BPnaCz;0Yj@nZk?42*3Z`?w)5&yv}mh(ei-%Fb=39c' + 'vMSeAw65oM+0|84LeZwLr~PJKXM1~p+%(g7P-@*D9`5Z$@ZW6RE~0*2F1uN~UexO@Y8I<@-7EMf2%gJQOHpor$`C@aTGs%iS=LoN' + 'xoq0&&E?m!)FjEKo2rbHtbSk47@K1Ddb`&+s)u!*#Xw%iv+qV)tyes;!L$IwawC7=!Y8iu+m' + '*LBhL>kZ+xAYh_owl|qf_Vx&^MKRk@fkGm+TrS(b?3;Gk?d^$oS5x`Kf9B1l_aa-=eOZ-#DZebr{+b(^w#&Z0#X_sxJ+Bw=ktQ~cI`{x{Fl(+t' + '-}m9W3;^Bz5gPp8jM&g-r}Zx-Ay=lF=mGp}E?{nK`{td7@fShRr38Xvg9X2!p=' + 'Wx1%MU**w6wm#tk_#d>=)w+M#=k!Qm&BK|b2}kk+)fA-}XPY&lsi?cfjOd$Fm0L)oB}`*EZ$8vma#}Q%fKo3m>#8zVs;qb{KbURG' + 'R&c_QdiEWH`$pD{2@hw-e|&xX;_-3uE+|2AJe@=' + 'j8ZLiW4Mcjo)#YF;+n70q`?@-M547kCpz&9r0QLHG4ZCC7' + 'pVZ}&-@dB5XVvXdcei*}o#8*nups+8{5x$cRrytWqZaFF+s@U8vzz9p=dVwmpYqT1bpw5y*C%)qt>(=XR`L7#^$MtK%_RzWRbRDh' + '_%tsTsvkHNFi<>2fIJm_`>qC(QQh)a`EkA|t{cF^5{?}J)A84<5>KrH4kVy7a0*Y`c3s)u7+&8}0sgWm?=I`2T&?DJ#iA>r)_k*A' + '7K^fbr$)zRy3J)*BK5*@`~i;e2FFyqtJlkVUR;(_XrOXFGT3F+Alh)@i8B~h^!wuZvlqv($Q+zt8_rKdKUc7v9`ttc-PL5v{&z`{BZ};|o9X+XMCCo|$grB&iUQAUQy*xc(' + 'Hem&;XP-_7>TxvxzmJebVN%p((c^N}MXS1e2jCXSuf#O?D0+CC0Xgat_0xZd?&_RT7iw@|dU{HR5' + '!FFBrqFn+e+Nf%}@^W5t`$#ooJQC`)=wNq^^kE)d!YE$8JlThNfqDP|_=gqzxNU$FspogmcMl&X*=jX-a-@ktG2O=Xd' + '3*W&s?ES0eGw5GMm-&E>jl(+O!w49ubK}6?$Grd(g1zN~9(o6iSQTB~84NE>M|}%Z*U*VO=ZVRffqnZHTJEi#1iW3-5rSt8@*cPn' + '02(lPojkF#=-;pFCA|E30~1h-wf^?4wIKlx@?CHqGBnzE`?TyGaM+`Hd&B*PqI}d2H-1ixy&#ydcwwHpsA}N=>~W+maJA|ztOyvq' + '>-W2M1}xxhq|e}<_$Oe5Vd=ho%P1_==Pn1@i|Ck@zzz`{fDVML>0x?dSUs%BsGULopd(8T*3zZupC>At7;bzRXsjy?fiXR?Et4A!(cQ18RRD8XP8ou$paDp@' + '*6qBD1*(z3K6Dvw`u2eTc(mg%x&+=xG;5V{cD7fHsY!vNsPi?6_J1cfHoi~k>&2msU)=>5Y^-D$9z*8!6D6R)OK}{6_*MI#31}ka' + 'r$Eb&=mfC(XHw*=Sq{@ZBNC}}H&qKqeUOm*beqL7SWU)hFtOp%;gCgP(vHL2EW6DLS^m1pblt?psWoLIXtm0zG8)4+9D!t2&P28k' + '$?1}@N}xlc8=J1SJ~Xq)`f1Lp@xfSjw4~zjz?0PZYJf=OmZD=DWl(od#l{{ujIhodD3Xf(d137)!Sa(v$~q`hy~zXbhd0>3&JtV7' + '=DT3K@g5_E4xXHcTGuBAW2pRI8lwvy^11GAs!CaX%wuoBsWHf8a0SssYRW+ve844BYthiuS_5#F20=0lWFR0B8e*JU=1&WyI5<05' + '%{LwX|EMZgz#PZRWyabT!K%_?fDtu)QN&$6pQTbQt4kv8vGz+MDcNv3Kh`V+v&q`q~bRj~gGJU3M8?^LtA8K}|3phS!W4Mu2J' + '_EfzttiBSx4}3X9x)T@$FnlcL;v#tr94*L1SJyq0sE)p_jpG#v$vyu`3vu%59v}aVRx&c0cTb$+lDW(1Ph6XvO2nwX6W&t^3' + '$k`-uTW-3BRIt-Jw$U!`2zZYreqTbvg3%5S6UqP1V7PJkIei!N5#h24RkO$+CNAk^aFS7&w;<|CWjm^2<%v=Ny;5pf-U+17=FF6VddMkg{qasZCpUqJ5*S=v%OtGBk+8UHgHFr^LW6KRO#Nxx?i' + 'tBNoM{r7GKVh5W%PhGoNPmz0k-*j|mQ$SrHAj@V3i?1R9yh%kNU85F+Vx+qlfci77_<~kElrdI5bB#{V' + '9gOo1%CJ(D=g`)sDvb*=)F@+tXi=`{N|f%7kpJ(1k6o9Gy2pETl-#as0M_*&B@nlRDq*2XGoZCIyw0uH4Wbv}VYTmmj1V!Gc#1d6' + '=(_ECsd=6?>#ol<3$9W2X2M;EmAs;f|2UZtorw8;@<~m-J?G(@{J@kOjU^#;{mMs|Xbt}Wn=N{h3o6A^lheNp&?e5<9yHITaevph' + 'J*}ZkV>(U}LG;Yy(XI*o5>#?;%Q5Di3$qFFwG0nu^`iy)Q%d^gDIRfEvxWyMit~tNMWM`=)GbM6OwhogD#HnJmTV_eBgcrLRe|49' + '*4i^Zm=y8_vPsHs@(NS72PjOVJsy92-kp^V9n(7iuLDi`VA3S_nG298-gB`)#01+NkWT9$3}`^}}IY*};^' + 'y<4_7OuhKas_mK{w*=6{a(+|Zb#j`@p8BkaCSD65g_T{#Mw_Kjg_9t$mW8gA;Vi;(!h^|KC&ATW)s)=MM~}!geZ9z-OwePEm*B9Eu*diU`)Yt9X2k-c4$sNg6D&N~H%9oM%K0Wl' + 'aA!bq7Om7JFae1^I>3Il{v^2|+~krR?TZPj9JT07>s!^4`cdIsA{2lIRHF6{+uOr#odVrrs*+2)r#F*w)3<2Hb~!uv4*$+p-#p^K' + '-%l>A>(3Z=%!hi(qFvO@+n&X|MBj_*CeDCu+H?jvW@RL!XQeuVg5%%3&ndlilv%JoGV%@Q+6cyvYHByAnU*p#V!7dSeHo{69fJ}!xuYT(AGfRQ&?8zm2G2dKB60Qx1yA!{k+3JoM' + '?1?G$PMg(z!LSul#&vBh$IqxXp)!@{S!xmL#IbJMg`t~G0tlS8n>i~jE+zVKFMLF%G^PenM~gOdMezyV)2mMgvEW0HUQ05F(5}l4' + 'B%F23;{#&w6z0AE{_$qL1}5YTPE}wnCdux-4|U$XLJGB!MmN_D8d7%djS&)qX02=ylm(BVFi%gv=YxNMGNS69N2hjYT>!}|rsb-<' + 'ghRByi$mH8?`{vdPM7JtxhA7rr(XdS$X-U%av7~ROTEgsODAF=toMK!0#E`con+?~`_DW*P}MSe{QAj}1U)h5oJWa`WKCC;@5^S6' + 'Y-5}ZHc6o9ZcddJAgw*RDE|{Tz@mY}P^qP;9b}+{+~=X)-cv_iSFceL4eX}WIFo}r%j)%vyRF-c;7~!?6h=UM$J`?VH8Bk#`duFV' + 'Gjf~aC-5=f)A_|C!>>uQd$S)2%BSojRsW>7gShC##Sua$u3S?343c6Kb0(s;;s)hyrW(8mSL^n&W=5CD0X98>#ew@oO*!xict9wj' + 'nk#wisx<}^_-9b>40)3zb9zb$AQ>3kh4avvGvZin6ED#R=?PwD-%oYAh7*%+d%}%NgT2P>17C' + 'e=R`vX4?Hs+CFME&cO|2Xveu%Dbr75tEd@-4$G@+DEQ{#MPgjp^iT*@f^;s?_k#<%bDh^q@g|9W7ah=^R3-SzbTSF3Xa<@Mv^lZJ' + 'fvPuzDNzr0TnJ^zwj-N^sPjpufW$nuK18F14ok-=iZIDBn0maP6HiMo<' + 'g9qrKq)-Y2LsS5c2_~PkRg1i(!I=T^O+XkC+h!9jevo(!ix(xUb_T)~aZs@Ul6*>|D**dZwL-F1;x`Tr4|MpGxk!Wzl#4Pp3{=<>' + '2x<5sJ{a|p4p??AG?_4A}UmiN#Nk' + 'PQWPg72;`Qr+Zs4K8Ect!?dDqbY9KpOx>j7CDz78hvY-H<;GK(NGWUvZarDvH;H4&`eCOVPeW-yw)Ohn!2~Zcrlkw>PR=kcGH)fI' + 'tOe7#U1AN6Re&shl@%L(-!5B>_M4kZO*m7Q6GeqMNett!Fc5QOYI~R!LL;-jf7Zapy0r7S+(|%z&;-1@mXb}K2$oy}A1%k7h?cWzk9d-l%k0&rr9cq)>DdkUy|59#!v%cpz;X;4;)xYU3nvFEoS__Ve%7#S7Yd_r7N5%8709?_4U2ON7vM' + 'vuXQgtZnfoqaK*I&;V+v6>U(V*53?uE#Ew%TYAEGrkRsma4t*_xcntmIQY)Ig}s+)NPt-8&Tq?}O^qub8kKh>Sc}2kdknXi3d8??~SPdorH~=0vQ+FStr<1ruq)l%0Gyga+0RQH|8SHYS{P*VN9a;wjhKz;y>lcn%' + '1M&y1zwIz~uiG{Tyw5%!s5oC=XHu_LT&-cn$8_*=DdfFaUqMS)IrNbDL*6{Vtt-Tax7zoHQqP5l+f0aWh1~LDHwc$;^pNapzHZkh' + 'o|1;%gD+N{8)uK5F$B5fkSW4PRgt*I=rNB9u<#M(cX!~)CsY3X!I^M2q$!u}vT^R_Z^)CjgPtr(5L!_5aV;1IxOeKEOFXu4m7>;g' + '373cGMa@&`A5Zeo7$~YOsltX)fg1AYGJkqyUD^lb-B(^6bqE)$7UZ)#QZjKUI^R?^UNNA&M`Ypa^Ye4j' + 'E8(G|H`I)M+Kr9X>8lB}M&Np>(-nQeN+{rtvnLJ^0RH)jFq2JCpQv6LSe5D(yuW`}-;w5lj8`?LYzqS@lu=-q4r8Fie)Ep;33gw@' + 'dIQxjv|5W;8kYNJ2j3ANgESxEb+IA*fp}-$H2WBT4Tm2RpQx`<30r0' + 'Tx?;#Ucx?nC+#*vzSzd*_56J#!wOriCzC+z5@4QU2+&)MNAMY5KzOd<@wUv=Kw{CDQ*t)LRigq^$WmT!a_4s%T{dNx8w{BOX~YlU' + 'F(}naz}4S&E;1qyFowdQLKtycMwdVJ!}s)I=*l^o0P>?n;XJ5P0|6xRHtVj-RgD)N|C0M74_#tOjK^w{;@c!K{ag;CIglgam5waz' + 'n+wgMLZfJb2n_!6s*WF~Mi)}A0}ky)ge)3p85Xo#)C6R^SahiEx_xs9;1BBy*Ow(LS7<2`2Zq(nlf+XM3tdau$55GlAP)4VBpY(F' + 'M8XI!p-l-i2oH`fT0MXA*unXJJ9B!i{ZOZBuypil8VD1D' + 'aYl4W`Nk+hWX33fzm3lvH$@lV-1mweDeeCPj+RV|Cea@G`f$e~m4t^OhkA4X-' + 'ExGfVV7T%QKtvQin4>S59x>J7<-s4FIv)lKht}P?y)o1UD-Hn#RRe*c1cY2;fP0_oRjLiBXLPD%LPjXab>BJ8%};n!VT8zvl}2U-' + 'R9ZYsoU0DF=l-`w2YwwPY3?$+O%WnwxqY7@5kU&wQ6vy@>4r*=z6rr&bppdYm3|wxdV@*UhkD(Dgy4fVmjcEM+$=a`2$77$P!BlG' + 'V>FQE&_sJhg*@u7W!WT@VH#yFM+Z)|dy~};O;+2R)X_$En73a?kH}12Cx=mN4*O2xPmjgrTgkoRn!0-@O{K>CK?4`' + '^_pUi#lbph^l-fMvpG@6LWWGbZMt|XFMgXXfn&t}d~36|mnp?^g|Wj-Aiv7YY={BJA<)P8GJ0Ut@>tc|or43&v&4@L@WFp|i{f?V' + 'zt`*x#dvC}TfYoPzz7E%FfVYRJ}RcM^uXt>FuJ>i5uzY9E>!0Qdk4E;hL;`2*)v7|IY+!Aw<3z!DMBZ13y8qOzigLPw^JR*V?an$' + 'hu*x=u}(au&l<#?;sx}q!k#0R%-{;*50;nx@F2sExE5IP@YM(uv#k(X?$b)B{@!qLgOiIL3?|uT+5Nn!YoKC}lHFiv9(N4d!LHCG' + 'Zy7rg9eiHcYYt`H!&JEK9NMDpgCHLq1R?5MAWRO9B{vqpEfUV-8KB>ZLqLdtDmX!EO68I;Stu6aK3Ijec7;kK0T~77gxc5!6vL7N' + '^&0}|)5#%-89FG&ChOU~3&RZ2)vf&`E*Twq35L`-;q(ek%EOC2Yfv2A53LS@@mFJGVpEL;8!S)Owb2R#3f{' + 'PpTFOM1j$;77Ml+*i7e5MNNc^{Tzuuk7BDs#%A3=KvQ7Cvmxha-3A!TW|6~ZcWT6@pg&)YDgt7=8_vcm#2n)vV>@54$wi#ti7!6M' + 'Z7x5VB919CMb|LP$OWNx7q!?Ft2`nGXj#tZcTu%jb6Cq+y(TZZt~FrQ<@B0W#J}mH89Ugm8{j*ldU;h|0pg-(%l*~7oYq>UT)uA`' + 'SSsbx1au3OGD71J#uJ*!PFcaMN>LzsUKg-jGqm=^-YQe4dWi;76-C=Zn<;!Po#|~`6GwMQ$gS#mU&~UKAKFqcGc0C{-f*J(v8IhNvxLsLT_Sh}*L=JlLRFTRmsf' + 'IV=N|sd7)ORz%9y-QBQ&Y@*w%r25VCdPeQ2W~b8M%a!*>ewwf}F{XWDhWwFhSy;v)nk@nqruOQ5+5`w*T}-Oq!^+$&lCMi^!-3@W^kTcD$dw~^uX%#gD3{V1%z0k' + 'A+fuiz;RyQ+H=oy9SZX1Xiu3o+`uVTJ2CTQYZ*CUxtMd38Rh!URB3A-W?ieU8rpP=ztfCWsPHgn<-`KNCv&w%6)ZJr4R3XR0?V(#KO$?B#E~A}Qp$J6' + 'N9LFI4TcFNvzw+OrV7ysV7a1IVv4b>*Shniuv^G}3tL2iNC`q}z7ULu(FM~|N=oy@$>W1hwhIvfzY`^_12T=?)%6OWZMWX)A*w2~' + 'N8chv)*#HI{GSu#mwyYABob{H^*8h)`(A4)7ql5K=`6w$Q0%KHHqr4lv_uddS-T4LN|)H-rk|v2QBJK' + 'f!88N0r9zbRB4>qhv$R9YjX&qSrE#wLuUUTSnxf1XnqV(K&uH{nYtmgN>Eyb_h|6m?S=RPsTmmHj_=JW7-x&tEttbFPP#F)xLq7S' + 'D%{fJr-j)A6bpAn8{WZj8yGMO8+BMJtej*R;0XkFa#{%J!hRP$~&})C-@@aMmD(1Tj|K6r)Naqu}Td|' + 'I~2F;K$~mHb{*XcqhWgBv98GaP7e}Cc~#6@7heg0Rzrfd9{O==k@IYp8~aX-diZE-4~7$DwdX)sAf6X1(b1w|-@MsfLPcb>PG$m=Eq%5M?l29SV<|%+$Tpp`7EmehSIaJh=OQF2=dt0T(' + 't<{W=MxATeF@4B+rTDY^zA|f9L$#}#Kc{Z+B8#(vGR_+pPEY9W2XWkRiDsmTyYy&S_NzeEPueLO&6w)~*#h+Oi-}|(MzrVXnvD(ZQr_X4Bx6&(' + '-4WH+r*pV2TIQ+P0tU1s#F_Cnl*aVhDSkZl0BugO!nw|#dd;+2IlrQpr)q~}oAD%>_XsYiBK|RaazavkRDD}cF_MiJwqU&ZPopP=' + '?}0p{e3~EaQ4R)XWjdq_Vw_bc`!$E#To|rUqCo7#6bXk}QitFXrb`turOgYgmkbr4=jyJh=as{j?z#8E@=jR9' + '{v+}r?}xYC1T*cm+Bf~D<Y6y#c_FiVh0Z!qn9#R@esiq3mKt+__QWB+1QAqn7@`Pzsp2II7ViAI' + '>14W8JeMnWpA)H9RV;!MR$bHA3_jjq&iPU>di!Ggiuf#?&NMZRFn}ch_K%|%!?6Gi_j(G_Ln6BHR55Pe!|d0+|KTy9gVE9w@1n7as61g1Ip%qRs' + 'R{@6WPhqKi%xM>zI}UuXwR~??t!lJD' + 'zXrBJyaCP`y}!i#pYX`O#&ORuA;4v4%yKaAF)XgXk`4FUw}%)yv=ix_yC{~apaI^?)061yJ;RDgr-M#7od7O;%Ib?&d2A=1spQh%' + 'eG5Vy2pn>)IO&ley>oLztI5EwiTMzzmlYlbup3N2OtI@P>v?;Fxt6oQkR5w>vZDPfkajdxn&8uPgN)Dn5(hnnEd}`4Q)ZPI)s7)Z' + 'tNSPxhPbpPm1LDTGKxs{!W1ueyy`f``Mdg#M9=1G*lZ(HBU!NHtXg9yR' + 'Ee?FPlYlTStwkz(C~fDq_u3cc$ke;fcJ&hVKikJ^jRA#P#KEh&dsf{Zb$5$r)fxVCOj$qhFB=)hj#gHgJJZF>EnO$&k{AZRrb7@|' + 'lQ;lCe{@v4mK#(+p-Tfa%%v?H9qfRdIgpJFFqkSg#dGn3&fc@@#<}gTiVucc7zZWtq7kuih4AYNh3pv`32A!_xjv98*e;79KkNv2' + '1BG@C%j4AKKq*qKV8%CSs^Rh~4vXpUGcpJ>9Lu4T!UkiXehO6zglQQ>fE&C)4hX|0%4p6qmz5Lt5hIsUjLsw(hx}PVhK9R15N~L*' + 'S#n^fNift9%YGbt86&ykc|*ms$zYUDHtzjfhU(NfGa{SZW0`Y8MRqd1+<ZvupE3knEgn#cTlL$&7ZRZ)()F^bCO^*&Xs>S=jLCoJ88_SaeW^SUSNudk!u' + 'W)A}u=6xwE>?>Wq2X45Z(rSB~6D?YL#nO*7NduP-UY8PEV<6%hCPVvC)W0NMN*H_fUG3w}tPGT=`W)ABD2+YF28uWU&{H{@WndYV' + 'qift<-&`-ldc|ifaNplL+W(^e9N%|K{(^x$kuuhq{6g_Jg8ZfSzZmvG4vKkhyat*j+x~_caN=S>3r<)N*)46a(3Qn>cBQE*z7U(=' + '$q=nSxMUo|(K4J7I%wBFIXgP$A3ntc-mEKg36XxKfkHus)0HQ3#eN5ha3n>eC4P|((_mRtEzCes#C&HB!FcCaz(V+$&9_t?`?afM' + 'bGBOB^+Z%W)=)(f(SgH?>$ZIt4@Z4VTuBNf8ej(EkCS^%z&M!>r>fY' + '6NnC>n&jJCLIsagsuK(Nwt$yGJfwk(Y8J&Ypr}eg#z{xUqFZuiiRm?({dwa0z+lB{9U89~tb?2+j1NCY)lgbhbw6Z{P@#@k$ZQg5' + '#@t|oW&*|~sh^hi3v$zoYqz2N^v>s}?fRtb-qqFflQbBW=(7o8Ab9bh{>(z5pFZA4o)@#f*ey`M8J(Xe!OE*Xn!=q98+X@Yy7FNv' + 'VH(tp4{>OXSlxRwkuNA&`VbP04D)u@^v6Q=W|9DYYu4JMGM4cDhsKTNX7Aic' + 'lc=UKTj$&N`y*)4bm-2?atYor;eC+>1_K`(0g%l`-%Mn6tO?Wahd`bvhgxjGpi38)sOk3N{t4!7!l;vx5))QN8vQs+UwsvwJU`)k' + 'V4`u8V3c^8noozad(lU)pPwcJYB-tVVu?mFdNXF)z%fn|Ha#BA$7jdZCyA6q{}V5{T>Q+RsSqS3T-kvAWyJrwW&I395o)@>A?eY)3Rx?QSH#08ZK($Pqnt~tk)%N' + '^#44DcO#X?gf8IghLX%;HOzIc8b`umPK$|txiX35z>f>ZSQTES9MGItQ}}82kg}6X2;2G?UKu4&hZ>`oApTeDwy&F|f@$_tIpC55' + '&_@wT45Ld>W_OIa!+yf+l;gRmYEI#~lMt#*p0=sBZ_(oRcRxdS9WXqb@|_)`+4>A$WkTiGv-6`DPmW$aDUObclV>kN3npCl=+D8$#cYD=@raY2QO0%A' + 'v+=S-t?R0uHt0JJGxw!fiS>E#yL<~KlIWj2Cr?@VU$$Uoa6rO1Q}{Ct9p>aU=^f5m_A)RZd@sPa(Zx?d' + '>XV_ey0UhSzmxENi-~z2L^cPmNG2r{H%NTzL-vVI@Y+G~b~^7;L+}!J3+Mt;v}?X&h#t_^(Thagn6Ndu++4i3EM+1<$CY4NhtpNb' + 'ghl3w%)%_RC;6XTPXeA)qY3Jih^ta*tA*WdR-AX6?-M*5azsZMmMpW6K&|9T(Xr844U31=' + '4Z$ndH6a`X+#b_clXW|+uYHQ~LxMNvhURHnmD6|SRh>_89f^(>4qosn>tMy!;i38+)BF)n-q' + '6^8C*bOBot>@&Igw#HcnaQcrVmhu7JjN<^}k^zhx>5+f2`0ta+sE;kr^6hO9XuP(IWSmwLUK@nwxlVVvYKt}PM2MoOoByEL_^t@SaZllMqpBzO8-$d7K*BkMV@TyimOF!ztsQI56' + 'Y~N*v3~RnY^^Q|;C-|A>YqV$MyJ0!AcoB}blo4`HIErI' + '`orwQ7~bD;eA~|0IP74O|Hf?UH?aJGnsDU~ZziMp4^Z+qq60=TSxBE26}ggzUj-Q07$^>eyt#$)SYuY0tivw5}f6NSR&N!ncpgsL#xJ>3uY!71$*(LEh^X?(H9Oa)Z~B' + 'Vw!|KRp*6Q8icW17e+^*jqis0NAmEZyt}N`b_mC?@MqH2%*u?L&VU1+o?J4MNdi@rZmW6u+2A;kvE#f7?Ctah#142up7g@8l%ri9' + '+vEB>r6>8N$n+NQK`OmVoe+`UaVVy#=lEERl3P=`h+r!$vCbEk$T_H|G4xP7=jjl!>wtNx>f8II@#x!k135wFB0A;@)8&$_H!N?A' + 'si^fpgZUGc(G&`3yd@fO^CqlqT`Q$u);wlFzWhmm+ZUZ+GsOC~o^D80GHOsWAQz6>+4$n$5R?3+Dyac=6e-@HpF|?q2=jv+xn^Q!' + 'dkRFc!59zXWR=jPISQGVH878uNvBx>R{)~$!FQSA$4~)Bf}s$<3oHj>>n-anI?KbM7e=gQbxQQebTA6u|83uXzU7&}u+*mXKgpoS' + 'fyHQS%77KM47?n=vk2(LGZI6DE#BvoEA9_gmSu`kKrB(M(odUs>Ki96mMvE&O?am36pP(+LI}&?6?pO^VA5' + '6}GtHNZ%Jc&Zh+@ye~MVH%JC`x#Y;W(5)Q)pGIZA{|#aTpD%Oq=<8CkJ{fLM+t_arvfW5gwpkW{TMCjebh-s{WU&u&@C5qcBz)2z' + '(KA6M;v%lvEuq452F_9c)g=_|?{hx(Z4Y>*&;*>$dH7sJubw#yK&)AgV%FSZ#DxR{F<|iF27UTTrGgpa>pG(I2vKjsY0(S;)1%d5k?CDLW~g$9z@gPLxdUPu~V(i?cft1AhW{5eEEXg+b4^g4(mL' + '%?ll`(^zteC%=TybQrXl7N{Byq7~vaPQ`2^Hz6lMF&*%ZA{@w=W+roC&QQ_#3EZA$0vuxGLmI7e3uCFhK6eR}#Vw((6(05+o?u2=' + ';Q(oIt?~_Q0Wf1}(Sq1n{&8EC>s{9kGppP#5?m&ag4%A$x)Zo>aMHmwON^@}15z;T&mVJmyrEX~@2{M(sV~-nvZH`T9W%~?@rj^v' + 'oGJ+aURU^U^_~_B3)tP}Qe;(wvf-IAT6;5r=gCF7Q#ohScB*z=cE|_SaTOVlk)XYJsAqis-;mTK8I@i' + '^x%?w3nR2L#wVNs1R6-?DQB*|CUSus#&z4rTpXz00@(W)IGRUkq;idMlnlDgt-Jii>yzTu@mX>Hm($}jKZS?c2wF&cr;Eic2ihD0' + 'D9wMV+TihId8kN+!^&sI5tun9G5miq+@k;H*U^tn+3gcgp|xlk=UG=29R1kQQH#xwvSIv-hX)E-c35Yx;&nBMP27I>#wAwT6_eUQ' + 'Uo5t{T64m#h}V#tY+x9f)%(HGAQ`#$Xo&Xw-A04u%>>i{NMzZbPryR8g6Eb>AZacnBNIM?l^u4aE)#iodumRa)LcR&b{SVOo(~M))' + '#THoViQ{A@6h|2;%(hCjWc>g3~GjD;H%F~O$`eLgM)=X)a-*4{y4qHE0^#$N}xmr' + '9aw4rIt9)yHX&r}mJxeAtM)GGyA=^aoy)dl*RYef6)`E_wz4(>+@bLSgzVt#F<#Ksx^644Vr-fhSHV&P$;U@(*s&@{iw7=0MsTP2' + 'W|jZ^;iGTuBGY!hSuk!f{!49MsT|lvH9uWqhzwzEVjB+XO^+cvi~1JzXMQXO8hMM^bScQ^mZ2b8b}ESD)nPol=4w%HsUd89)FSJK' + 'qYW6vos~0I!!R1%639SanY?6s*1rl(_0jUq%xEywi_~lh-CxB`M4|0GL^X1MZRcGeHTj6oqsCC~y$M#ZdY1>gJZN2OJcq1km9-Bq' + ';AKT|PT58axwsuiCc?^iF9%q!LAzj^^D}<99s@S-XZ;)~N?LL2R|p?cG$XaVfBqR~$3m71oHh0tB69fp=gFRtM#&(Exu%e0U9W!_' + 'IuYSdrg=(L%m8XpO_xax5?Gg`3>F%cW`dm>ZX*dP+J!q5h`<6Vm`ln`sytH+O;$A`JJKWfT96C4>I7BsGZhFnT=UlhwQ_7(*#qON' + 'Gs8grB<38>YM8BOi3$_Op}nT7M)}+!iy&H#Kf$;LPUZ3QD#|9dC=|S*I4=ehQV}^r!z`6Xd87|A4;0Ljkq$5knAe88VE~A}cAFj<' + 'VOXO**b1e-Gx3FFhgkY_pbUBT+8S}b#(snWF;ziz1jQ(?g>3;(-BYV3`hLYT&~#$bkkd*-IXx>bC|b' + '@U2$}=7)g2D!KxaXJ#hW$Yo*;+xSHIG%i*;PC^)*62EinSGX_>`?-D$7Za;Aw^wYqtg{UB@X{O@sa$Hg^=mMmp*CTVb1s9j$8s^4EBz5BEVN>;2a6pf?#-nLXAUz&d^jr!' + '@iIQQLb1o=c1Zaat0bXz$_K5Q#P^}kN(+^%r*z0EoBCrskELZX)KT8Zjc>8>ELVIIo+|P1)fj5O3bwl!xG=RuE_s5|?PM@ig}zW#' + '7JtJ`lLuZpVeW3*w#9~R)Q)WjTefj~wsD&_*Iq4l>_OxQfoT1+Go&5kL$dAm@m`nz-n@=ZlfK@!LjT7uAeH?wad|AS%{?bmo={7(' + '5b9qQ>b$JxXPFJ!mmDn3xM$(^K}4Ij4PsKVr{fwyD?rD92G8*9eN*3{' + 'qoP(=yP*sW*lEX62?n!Wg#iuUWVtHFT`u8G%ZZ#&6OUi#-44bk9#r*wfy(Ees-MqL0r?gYmO?jN*t2-ZhxNAUwyU{cM<;9*NZw0^' + 'X&|MNO2`n=ttIo;Hyy|fZC$bj_>erXjEDr}^zX3MbUu947^' + 'U1Z%jDd|KlKhx1jrvxwSTDLNj~`|S50SOeAG9w{5{^O3N7=M(*A$G+B?NCK>ac?@aj}UN' + '^wNfKf0rgF&moFqRl`9qm1|Fnp)ZJ`p183l%&@Y;{e&HF9ny#%xX!Cj4yf>05h>;nO-EVfhiFqx$3Zb3CyWExQ&r4hAJ0LG^e)olz^!-qj6v-NiLC>yg_dhwp5$+Lm(F3OoFwVWe2k8cLWJfN?qo9NDte0x^RhyE' + 'jeF(`hfd4@0jk*a&0K{k2VwvWW-Z%Mndhw=AiF6J4P=aIl9J&`BIREEu_Y?0AHvugZdA^1%DYY=5tIKeKsJWW@Pma{I_I#}W=Xeb' + '*-%7cIVxCXLj%T;tBD_E)waDf4IK`>aHGhbp+nBe*-`QM<%=iJFoF5A7e5uxUYx!@FJ2s-9H0660Oc2hXyqL7c9bQf^B>1bANssfRSjryT6d_56U$Yua7usrBSYHT3p(nC+RTFh' + ';$$!xPXNd`GVp}yTvYI=wuN0~{5Lfp1Bh!>u}nD7|D|h0{J7A-zK(D{QFP4tEVrPy#`pg(A%As>Mtdh' + 'eU?n9Cok8Jew0g|y<9(rBiiCDsgn*^3`~4B9L}AiW7{dij9tdLl8rTUG;wT{z09b#@v6#528B-Hd7Ig+4g^Y6T`oMVnC' + 'y*wU(dq|^Gp0j66)m1LWQx4_}-J~yLHeT{Fg0Mak2tORt9;nfx717~ahK(@+1vn?i&h}Q=hYv=^(HP5b7O||vk;T(Cdlx%#hi9R^<%{Op*eFkk_-!YF0fYx0seHd{Dy%xog_+D9MV-u!C)DUGfG)JFmhKPO|?' + '2tCE{`(-k3`;Mp`4bf2s=Q@*$O%JITeCfV=*&@!#~2*o_GPD@umV)Cn5QVd3j?S*+$&Mb@R8c~24?F#?B<%_@A%sN`qG' + '{=w=ZF?Y0j{^ZHIh>9J^2Eqy>vqfd|NXCQ-NfmM8#u)D?c4om9Rpb*IlrhK*}WUaN*!ECW^7w^3u&lEJn+OqV#YU)2W(hU!I?z0dS41uZ&DrejY_lTQL9W=?V394XZ6MwDHrD4e*`F_BfkxP%9atfM' + 'VdVO8OI+|_lDc<~$=)*2gFa{@6MXN<{r`ujbt&=xC>8zzsPUJR+<%a~I5rRGa36B90KX94$`o?xF!FZuM6MM67}(GgZ+(cOBYS$H' + 'Rtc0H*wzz&wTQxFJ9`pbJ)+{4%{{>tMI4p8zbASrAqE3m{F9Los;kM+aG1dy<#&Z%z-D?A4}3AomC8~^JxnI7IBxmf{wzV-ZP;ZH' + '=jdN=YvZXPWf!#T8ezAMsDygkfpomSs;gMPN#rYQ0zOViIIPR*bs;!uPW3ZE_o4-=3JTFZbghPQ*2o(ZhxIAs{p!jhiy9=OX=n5>' + '$Qg|=MaC(O5eWU;NqGcn$Sbmj~DdRj=YrwYFjQpLqc4V7_Gze++e4~;f{XRM%b_(N-j>6*i3#Y(gK' + 'PIG@TTt|zPW-TEr+4YR_8Sfss0zUe8>?oWuVUze5Zvr8gCI%vf#3z0Q%G%onirX(jH54APwE8hrQtTG>Y?u2K5)=%~j0@Ya1SUjz' + 'RB|++pPVohX%gLo7lq-J;3Lh5`x_fJ|N-kW*3e$0gb6)&<9{>Q;rCj)M' + '2A5u7FLxZ!s=EA#ACNq`#H%>TxZ?%um|`=VLixTf%8usA)N!WLM95`D134A&b!-$*G78~U=%D7o^$_R0Vf6C!gkv;Q7(FV1Py07@b9If1KUfCq@(Qik' + 'femQN<<-27*bFekG&Nc$&3;?Okcd_lBxPCZXbzg@y<}@Q=k=1TRl5KbOiqpu8z_LY' + 'U)Y6V{qu6EHq%}PE_jJAPx;*m#ZTZkigNI&z)j*Sy{++tQ-hF^p`5`&%^+WmVuqZ57vGNLC!c*&9pZ&ypg(mmKEjfz9+7(oS+qeX' + 'um|Vrg&aD?7bS6n9E|dB9i+qR!<+*JN8UacYw?7$5HwS=isj5d6pTW49Lry|e&}WkX0!Y}kWQp-;YS0=A0ID+2@Lp`%&6n9yPb2`' + '7^uE?oo2aX${S_(xYLr>2)#%49%~|YN|duDmg2&Xu*Ve43wAgd&CbaUFji=n92O#&rHG0-F!Avs4fN>#v#~%AQrXN1p~3cejR(e<' + 'jDyq@7B7;qVH%4x=Ek&-+E9xlVBnPf0w~T}i*4F1?|vB{fm1tlV4qQ*xpo%4dFveSZ!e;Gc~`G7<05dO%2=2)FjBWLxgr?||IH3*' + '>B>BOcB3})k$He~r{%YAhtGES`u*tnPPu(P`}pRt(>W$7;lMWWH@{8gfp^)IU6`(~TZ#*-Je76zO1WZg1HxGgxiU}qa}Y)B)Ax;c' + '7Q5_S*x?wP&bb2v1t@TGH4-sd&R?$hkYF)mzH*Z>7<9ST)0f*n!&Ph=l*bJ^g~;_2?MDa6E`H^x4{CJc3R&MScoDjG__EU%+hxX3' + '%Jw|#PIhbz^5bR{3#l(90u*y!R3sjoz6^xz3B`f*F)V^{JiN##xpgweau_i@>;OUk' + '6ncM<+;f@3l-$aQ+w?tFZ$doD7LGw2q+EaR{{c`-0|XQR000O8001EX;!^B!JXin#8Ca?TAPgR~?k-YhemGm0%we8zHoEhm{79UT}VK?)XM20*=X' + 'bAS7-N58v4QnHlYySq6v76EiucUMB7xSu#@)?XeZvZ>>)|Pn3enEu11{9uEU@3=I$0k#X@iW%Wm70?x%s?4U#iCv??X2z2OzceMR!U4?7Wsxlx!@McBsP>DW53_?`4qm@G_~FeVRR2&di!HHwgfM?V{9h5ds20XqTF7t^h@(PQ*dHV(+<<;?3`L{Pe+uvSR^TmAGymu;oc>nstPj9oo' + 'AHIKk_~tl-_8$&j{QdCl%k0(9$A>TB^)~fpTfK+b`rr84ruyxz?3aT#uU}@O*fA7|vBfm90P07ClWZLwA7n3ndi(PAhu1&7ef{=t' + '+3UCOe*TcXJvcff5JVu`(>mWy)z3^XnrU8N$S>!4odKgS@Pm9OpR>z+vMju}(;~;B$=24^uo%G>F)48oWfw&?D<;`lKDa1m!&v<7' + 'MRij}|A!EnM4i7F+y?fPSHL==vjTP@*m0J)i2#e|HCqF%6u1q}@+oXw3>~;VZZ@!~76Vv}ye$cSCmJOB1#)yy>oT}saBR%qM%%LL' + 'bXf!K1Kq;{gpKz%2gdJdGzQ?miQ<2%eit0eXuaRJ=Z=UNyVOQRZpYp)VV0(%Yw8=}OSIAdnyBk*hy6mw2RX_ft;?Cj78|6+7nC3%' + '7zE=SHh)!T)o_W%kPlGyi+P*r!O@_5L_bqOfs%TOXwTr(Vh}CT4sJ}q87k5k4Fap{RBRq<;~zc84f2(t__' + '0u$#PiT0{QZG*tfmSEj5o3Q1SO_s$dZ>JHg0T9^pCXH18;UqGHXK%1Qnr0~qJB%LxgH5rh;-ssZ&?E+|rG!z!)(hKmteQ#*WCF(z' + '-Mkd*;=MH5PkI(t2ugIao|NhK5Zu0!;KXMs!BIzH=-td4ppvNNe|`z1WZfc=>K?rcA1Vw6>&R8Q69zC1*eO%Nx5`9iOaE9083QHN{Sd^x4O!!p`=9tSF+_aqP1&rY5v)}Gd2WY(w&NBYoZlBl&_)>APvg3M2FS0YMu{K' + '{{$2uS;%ralUxL4g6N1D47^$tM7-T@H|hEvt&nVXySqrGI0y8Wc1lZnF*=GvqshvWVJynD{-#0PC' + 'kY>eI2INu43^LhT)2nEQKy9IFqVsV70W^??4i7&3{QeNzlJI2HMqRIMc;^fU=0gnw' + 'CIyOR?+=f&5C433s0ZoKWQTyEK' + 'j9G5C2)K{6yLEY6#Cz({$DjV(XUR!DX?#3jH%d7D4d^J&r;^*En%A*v3M!#8yw4ve^Vt}|TtbyTcAS4ECZv8HoM;FvXd3e%' + 'I?-OJ`bp0~HIz9;rt0)g{OaP_8bn$?LvKmSWIUeqQd#a);?l>8DQjs=2FmpVFGEe3NDc$HO7yMA5I}pL!XTY8edmP-_RA>6;1&5|' + 'F}V?XGnBcgK5*ptE?fdOr9vKbwhTE^6zZ*>J=zqYFhaqFA%r)-uv}QQmO`t>DbMu8av4@lqb!BZYc`fE)q{_sn9vnYK+r7~7U{<(' + 'D28LwzY9$)hv+YT8k!4N{_HMiHEeJN4Xs;TFQBC?hehi_Ou7SDvV}PJ$@)keyJWEh+9D(1_H6)dQr{DzkdQOo(22' + ')4A1xBJ=Z%Kh&&p(KP~$Ok7b~>`M{D!FXI1V`NYMv`S=pIxctLdSCpq>pt90rl!fy;pU_ymk$W4fC7' + 'bY-&38O)pTOu)9#d(X@=JsFAcgZXk9m}kipGx1E$=jS~n`&' + 'J<^W?l<|T$CL7EA(kMjYpi>6lrCVB^h8kSCKC5E5928m1t5=OYwPzERO<1nK&6nIjorB482uG7*GU~H}(_|FT0&ZhHiCx^zY!+V?' + '3+4@j`Mes|G5^ubU437}*Z)S~usB+-(P8Yepw34GvYh3pRK_E1k>mLl`x9KpvIn6B{LEOucF(-EJGnClpJ@0t2RB<<&Ahqq8!&Ay' + '=rZdJ?NXLakwM3+TrZs1T1u;?;ROSTCi4L*+Y$XB-xk+2)^cXxK8i1k0raV?i=kquxO(8Mj|Ujsbb$w>E~Uo%1+J7=Y%x=gm-8j`' + 'zn+VWBW|~jSj$%}XLM1<+_5>&8!~7_J!E6{;S7}td3Dpy(Dk5$+<6LeHa0{sX}Hj5)XbRl>=^L^Qc_E^p' + 'eEf*VG2%GcLP|?`F#Gt?rXs{2NS+xijZ3_T=q~c1J*Buv=|JePVp_bNz0;I5v%8779yM1w>N*vB!-s5AUKBQXY4}g!UI6JAVv5m_CXb8;P>aUKbfg=^i>>0am`!dv^&%gjjcSRCLV|WkW*cy3!Rtz(h!+~nQF)C#Tl6%gT77=tP5Fc-R-grXoOEeq' + '36f7>Imi*e!M~DtZ6?<3c5xHLmJCKIP%|k;jaqQ}0t7L|rpW4Bc+ogm6CL=c+X07za&J)C?~I%kRKu}By%Q*j7>+egV@X~**=rxrEEKm)eMvZap70NZvrr(O*Sl)}a%M=&j-rj)Sj+jvm(G' + 'B4-6Qajr=|8!z*5(P4>jQO(g*0lq%?S&^@}-_NfOjU*GH;xzVT!8%b1&>EgJ1OO`fZ2%Dx6G%5K^9TP_iAH7heuQNhc@dN8kO+1S00Z*77zZ_1=8swt{lcXm>1$BN(HsnBPnRv|-%' + '=3Rz+5-c@ZP9|m{6p)I4H;N{K@$io)y34A}Rl$A{xP8inCjpzA7ugu$5o@SMTfn-6jc)sFHcjFG!_HsM$ZkV`1l)Ut~jtXS)!7yaI84enm%CRoHoNSE3UNdIn{*L#Rs5' + 'd#5F7Ly9uqu%yj{8CZ(@xCzTi^w;t~1T9-rgQbnK#k}@EIE%yFcI?_Dlp%V;p9kl77ajgqu(e_MBXt0+J_$ry58Y$0(4d4iM#;*A' + ';U$U#AuVE8a|ut%ZPmdEljnqZ8J-qq>|H{c0Y)r1Y$>G^BRwM-W=!h^;@s0@#msq8R_FT3L73uB+eMRe1z>3r|0`3>4s;*1{vM5EI+dO_)rtA~X0qSEG*l)r@h4YLWh{AWFAP5}SDgA_$cseAr`p;&k?j(~Bn|_%{9u' + '+T6^urqQH46t6jMwvemnWk%ZKz)lQHJPQc`ZST4J!B0Z1?Kc$M)6dFJj1W0jqALd9-6!jN|GjY}R8Ta9StItKwS5i2RHUE=?STt@' + '*-P{S7y_(~y4KmGXErpkn@!4vbHp)9Jz2#V>@9WIe#E7-QQg(uW0izG*{NAFUDpzFY{NN(YEDqtA)Nv6$LxIG#CL?a@63_pGi^9E' + 'Xxp8)@Ta}xkH@_|h=4N4S#$r(TRy2rNP(m82J;o_p`1C{OMC9PoqNojWFKua7vd_ND8N$!^QHHTJ5N`rYO7P5Zhl&VwlHvFJvr-CD%R81LVT&$TFha{N>Oh~=SI#^s(fAv+0nCKb&99#I*w{^x@>3O(%;eJ|@Kos%r4sjfI#r`rF}~pHuOatkfgYA>8oxG8!`8iWcRIdq~IzTua_k?R!o8Hv@9e' + 'R9_F=UJ)8Jt}_RwITD8=p&nu__FoHdx@HTM)~kr?m6s3KQte>$HNH-)?`^9;b~eSG$leN*G>W}_(XW%RFSN`WacuQ*#~rOjc~rXFrxkxy*e=4mvNKyPp<$rz>IQrl7Xyc`Ze(ykj+l7=+Te12MGth!3W{7!5O7>(ob_~=DM' + 'y^XjzRypIUn*g3Lh=A-ZO#F1JO`UxEjzHIcbQTt?qOIknOdX}sQFxd?$uIy%0gzZ12i}o~vTA-+`(jcBV<^bZh0LKqV#IMWpHee6mj6dyemdVfD#eBs*ZkZY3FxA+~2F#f_VAO>C5kuMCPMWIY5z*mdm`t@E8~sWgVKX${~4_pu0e9@}xAt(N1_53Klv>;y5d!SMQ$fleHNKmqP4~#8z$w^{XQ|r3~q!EBfKp-t(>j' + '>j-U{Ef!>K`wPi)$r6)HH=$<}m>zBa^}PX%N-RV$%G#byY?%Gb$>@lkS7`vWdJu9xXu9SFrszgSCR=x!S(aDWhC0mY(PPY-h-MzM' + 'X_(t^;&utir7^G6toH3yt}7FjXWB3&w3wx-3W}+_rmy}xfm0C<@9R36<_(6|BZLy>CggT(PP=q~rNCJRFf`1E)9Y+psHhz|NX8>7' + '-muL$8#ChC>-xh-`#ftjfAvsft|N1A2|ES^3i86hwH8`0z5Uak$5YnxFXHM)VXmHVERF48C16*~!u>8Lzmrt_<6W4%&B0vU>fbvy' + 'N`OQ?Fv7@D)p6_`jrZ1CpdQ$&4)-jis2s2=jqB' + 'I?F|TQ}p31QL8ZuPddI3=cx_hCFh_8jo-om!@54uUwTB7+`Y5$2B?)&lkd!4Os98j`T&JAH|VUI&{ETt;yhJU)0lq8Q)7dbEB@J9J(%fp_{f9' + 'w|3L{B=Zf4_!7c5^;zU-y(Xj27G@{`>rgad=dNrquo;02MF5|q*NS=79kRlIznn2k;Am<@5^bxQk!G)C<*~xnJt*{{;8Bin$qK~O' + 'QT*dS-=)#f@nISrynuh+{lT-Pcy~At$4kRh)KKb#XGm!AtiRV>0F7do8+7<)knZkj`r1V02S7jr^;}(wM2&+dC' + 'ibSl8Q{CoB#rKezBVNE3!2CZTz4C&OqkU!Jc;j2pn!BAm~V#8GovGRqW;$B3q^`o+?8-s+~' + '76L8<)YKc07}G3$!V)TH7`g=`(9x8U^H$4@u{)OmN-iz6#(7fp?g*{x8z><6!9~nHJxdu63EOBH^3ifu!j73?P3$;zPJ!T$F@GdE' + 'J{9aL6GffvZI*ErfM5V-r$FNjz(g;pPj@+IS~BY>&zF$P3p(p*s5D99)>|9m$Fb~G3c$V;wCA+CtFV^3!_pqB`Ly&rR&B%)eHe!O' + 'IS79k$3!(8=u4(6sdp#hoxQj;;q-a2op?Z;7R7YY+_3+HD4EzLRVUqvS2MNw%n0NgxZozldt*`a#Vrh94oQts' + '(m6+Eb!O(P=;Vi=j!(JzUUYK!({ZF&xo8&;$U+-KdK|!;CS#~jrqQ4zO-_NTKMZLSt7&oqNy1BcS0B41+D9;`0-K?F' + '7CvWmeC3%m6=7<6x^Te3Na)t;IFW6fI_FsjVY-}4z}^{&F_TIRJyFBxN+sP|G;g&Zv?bKO57t#jl+m9tzKnrhbl3Iu4iv}pF>om{O5;i2sbV3uBrnJmgIESPLS*IIIO_C6L}' + 'ap&vo-C;p%cqh6;NbP~8VDaBjmlO*1D}g=WYB>joclnI@zF|#olw`G#@bHA^ZR)' + '6CI+5f1x7it4MMavbjQ)rJj8qX4^g`nyNn$SJD3d7jtoG=y5E6Kk=%-dQ76HPu=%uE8x7cPYS%H8MCh1jpYeOG-{YY?0Hc=!f`x|' + 'Qw_TU;G`YA?C%-DJT^ZRn0+>7@HFZfOU{zck?O1^Yo8&+%uCt;z#>LHu(?itMF&{v%JDt+Tpn=8MFVRhif40OK%9e2U@&DiqVe1&' + '0|>AzjT47`0EX7z4FE8ntXKjrTneO-XasL*U6fQjpfaj!sG~52P)W&90=kN8Jzop!=uYP0n&`b2AQF9Nd(uypRsDEZ#J5mGt)PIE2|+(ZzbfyEn5>ixDbtCdtIx72m&A#I2rv<$0{;m' + '82SV_r=fR-9ks{SL*<)EySko%8rHjHW(_xV%SJ#;cz>;nm~VPlcQO0sV19mQ=EMWGCf{I4KursN=PWG0cV>f`3Bs^R28O*{e4{{hEFF' + 'DLZ`q_W0+co=OgPg08hEbwlw>Dw+%<(+7YV&jHYOFWNpUFNW#arwepmFFy^pjno@(pq@`I@NU+V-kSQkN+J3JE9LU@dOiE##qGTL%ir`(a94A6?11;bwK*#O=K7!q^;oKWlIpnOJ2CR=C>jJw5kZ4Dv)NOcAAr&LbnQVrv' + '4mi2!S=n^R{vbLV?L9ZZ!4VQ~Madwc?mA=BtvGi_7!F)=6sJ2PG(@mwh;uj%q5X(@Fd6|y-N-C0!k&SeY7KvV>czZ`{ge;}MGIq;' + 'lb-gC@QAMG*eSQcXoC>-Y#1hA7GpyWkPXX#zm%Egpms3>40Rgq7PNPyU1=WX!*-*TcB!B>K;' + '&1%SQAX(kdOwg@k0=jv}J+fPA@+qV0u{E{qO~k{Y%()1xj&fR`WF$qhb5wSQcmrL_@F?mOAcG|v9Tr-Zy;GHQgZGHOAqh~vl0c>X' + 'Q$xc`T~U`+QqtvpLCLtgI?}SngrjYw{CN?G1ssqIPV2g;te&_!ww9jzN;ZP!2I*N(Cny?oL^J5#sY^9h_r2UMt<45agf{ETitenj' + '5aOAVdLepR5s83HtE;OP3m!^4I<5>mf#|ZVMZ(^&wR2m38ePwi>rc_u^!L7QSLa{RuY`qHXU|j^S{ZPHxo;LAAqobbMq~XTC88js' + 'PePe!%>1>nm$`OY)`Qc44p?;GcNkMVx6dO8^W%W08AaC6c=HR8?4<_UcceP8XS7!%*~6`bFa0V8a{DHty@Cef`{PvwsY#5P<#ir}' + '$?n=(PT#RkThHA_lrY$BA4ISYig~Sdq-2{qcMn@;*M+e$(upKXje5NV45xA0{fDN>mXHWy2c999(z!CFQ)b3N2r=yzL*iu+w@V^!' + 'bTmruMjhbe>W;iBFUQk)IgE`~0+qy%9b@@BMu)~G*Rr`QFv>%dm|MK>cUEx;L+oocIF-_YM(<{_!`cp~UMJd2*S#%gvp&Tu1R=#d' + '%4>&ikmO@)$;7%j<2pzv^2DLWo^5+Ecr@s3NZgAZ6XqKA3Tt$N`W1{wd{yBFSfRdbUTc>%C1hcP!Z=b{s?V!ZSzN}K&sMvPN-t2(' + 'aFs@xlu6VXjFe$_(o~4kyCIkXp(?;ErD>gtd%)+bm;Oq1lymKCo-zC6)9I{peo0ynOQ(?7r!OZ5He+agCTuzm3U;knwdL8A99|!P#z;pEjzG$z|d4ZzJx4Uly>GhwTeEP4p_DyVo7h_!YSwYs{GrVpQBf=-ZJ0Ghr{W#!z3uv2Y#xyLLZ{9w++S`93' + 'Dln`dgyCf&mtgp)P' + 'WN9V?wN{8z$L1F%AC%?)U=`MvacUqsnJ)e*njp&5@rao>Z&{-$$P{mWiIo>Bka=QhD(=Wr?NXYZngU&b!%_lhG9hToF})&5tTUvx' + '9I^V+Q7w{{{(@Ymgx$50bzr&3+f5mR&h|~o>ufTUv6gi+^rE$2(so)k0c=G1yJ;(G%Os(#xaShYT&o{MRlxU%an$#co;V2Ui@$yW' + '0|kBKciQIW&@2$=TWg7gb)x&dun(C8`-2A-D$@zP{nB' + '(E9BDy0?#nqZ#x5jzE1vrjD~3q~=0jR77*M4QfFE05d!4&<>r$RBDgdv0(F=E8boa*aCP2)Ww*$B^p>$y7t)F8`=^rI}mMVn+@`r' + 'ks%qyzhI0KoyWaZhc7^gbnpTQC;A9<#5HWX?hd8H0u$RAom`FU5mRB!KrnQ2cJ)lQl_6ae!p$AvG>' + 'VEMzFgJTxYdQpBkN1Ga**0&>+@UZvI2YKV_;M=g3$@J9MhCzy(M~d3CDeT%>J>AQ)xT3s30w8*XJ1RjQk}fHp+ltc+J~Ni-P-ygAwXw(%t!Nazkl^#W5@-6{A64RdfjB*at&4>~X?g%=e*0J_LyFIv+pcB*XYf{}5j%cu85$qpKpKR+rWlY1#0>6Zs6T' + 'R3T0d;be#=FI8t)plSmvv!9Tt!=>9~2xc{s' + 'BL}Z!91WUG4tlMxo&MbSg>8R-XE(CGXwr=Q6g+7@f2w}pZm05YU{qn1h$iFB>$nSh)qvNZOinZE(C{t*mGm' + ';y4v)_5-ZPNpj}d>a4Vo(@btXQ(I#t7dq|-mkjakp0`9C1wd=nG&21)0^Lr&1hg_$SOePE;+hRfu|hy~xwClPHls;$FTilv+Ze71' + ')7js7e!Pfjl%+--)y&K3&^e8t-(biZ5HFpBUA^*jHgh5e?|<{yCpJz($hXA09f8>gI)0S5sX>r=9#v2' + '>1f0Vag4VxJf&x(KqZrvLiq#9yARaMyP23gB!m1adVvaK89h%Xjc(=~JXFUxCE>7<{&w<6fze4>xoSX+(fsl3YA6n`k-T>zca@|Y' + '{e<3(5=70Ri?9uLrzg3nYlNIB$0{1B7O1x@C2dBU0()&>7ztxW5_C$o^|sLeanayqpIMNCU2Hpqvz-bQx@V|>f}h;*p`T@)4VJ^4' + '+EKm42V+Qoa>c(O?q7B0tY7a1jkL*zLdHmqM?x>gN7bq*&>nKu2(1LUG2M`Tr4(H^a2XJ-' + 'jNDB^PK83YM*T`e>tbyi&O@7E9UUJ;BF*h1nN|I?1i|}VHOEM^N0`Pwut8Fp&Vma?oUN1sfaNplp%Y7!LaFDBJa)MiLH|CWgAIn-' + 'z@ZJc6T-G8)!317VY6QGi{P>ZpqOej_8DFg_st>4uMd)V3y4|Fu' + 'VQexV4i$f1&bR~Qu-Xw@0~wUc7-w-@=vEgOZ!-%<haXsc(y>iJwpB*O&{U8HL}1?PP(w' + '2$$|U`v^?`8QCthx>Qg=krCdL`HcYB+=VFF2nO!ts(%$Qy2=2F%ojTQi4%aj3wy(K5yQ!{zt`POHo1H3xwmg*eP=oAZJ(9XL3;LS' + '%25smC_dq^lkR9jY0pnbzA(gCIEQ`dJz&DG_obzRGThiflvTfIxyUD$d)rv9~U$kUYPFzCz~qz~6^^g*db<4Ii;+oT{ms_4P)O|psK>V|D3t&Oa{' + 'oOazyg;)n-8J;YECDmLpJh&{XeB6@P84GrBn*afX#JTyE43!ceym}TLFBc0=>>V9U#vlYY=hJ9!H(6&SFC_tMtA|0J18M7AOKF79' + 'B7KJnJ$==VwB}{lBGwun;6-vVO1@M)b' + 'j>WzWyBr=Y9F<1k)g88U^D4?uQJ|arhZ)aYu;8iaebV*Lh@#fN0=0A|fIv03=SLv`M#W$nf(`p>4xO)tO(u;YxEA;)69NW~$g!hT' + 'v}r9RbM2+>Qqz}H2$Kz1ZGT%@Yq4z6Owz1v@y%rxD}0ku;J{2luP`pUV~ifFnsM5)`2PlGz>$LR%n?)c$>Li>QfnHW)>Y<-AzOJC' + '+4iYp;seEai9TsOTq?2gGS)ajD{LA>ykcqs_)pA$WGzmjw8G7{bYph%;5)S(%AqRkcKvPr(>ZSI=ff0s_;cFb;ScWafXXzwWDn_Z' + '3P`z60^mE~$l^N5V>riQ0s~Upp^9LMy7Gu3z&*P68*L-O{E@-}U6t(7Z6UJ3oa7&1yL_0%STkt)ohLhI_<(W*ez=3z+e~`DVYGdj' + 'Phzj=e0#TzTOG&w>`E=Va9tlrsl+kfCnw`7e;3{XIi70I' + '9%TUy^934dsA-L2HkF=5FXfUl0^dGXMr5xWp`|4YiIkDj`%E~p=fnKX+s?m=YM$I-1r0HX!0=&~kx*_)Ke^yS;`Zn({;)qn`)J&<' + 'aAf8>Beh^S?I#6`VPI~`(mD%Bl~~YS1p)I>X!E=rH5*i3y@YZtCDtofQ!dp!uezK|jaC&1sj-Pxj6%)EEc~xTo6-9qoLa-7F(j#l' + '+IDc(Y0uiw8M`sJm*4^wLwI6gP!@O;&+uhc7S$o=a|K8_ON@1SC24_^g)&|+D!|u=5}=8LzdbX^uT#*Fo#fIHj>7F?BApo*c#S=buJ-o%' + '2Abt};hnhgrBA7y4V~*8#}(1}in50FDU`TO9^R~=FZa0sGFt;M)*~73`>x?kmgI%0_oC8tt;8D}&)i+#?N*bmdu_+z~+)P6q@h>ctaPNAWh6GRTDeBNLhPU8f9u31Mn~vEK7xSv|f&a^+c%(M*32Ow8?$l5>SnOV7Njv$vNn|9l' + '`_7p}e~_mQlbHIp++^H5)A9Y`8SAeHd_F#q+7-&D2pF3+_2cl^YK|f0VeguZ;&wvUq#V9x^EB*n2mQB!aCJFu5L1laP^;BV2C_%?' + 'Y#19J(y-?l;%C|JD~*Q>1QorXzdK}NChbONSm0d&>f8a=Qve|oCt6y|>#(Zw+`cNJ-nBHR{6BGrQIz?GqK&g$K$B9(Ruv;mIO8S(9c4AJt|D8y`9Vo}B?F+PG8e-l$BTsw)7appqh?Z~0DvJ?vo=TNFR~)#j2%_QXXC87' + '3;)S9pjbax1`Did&z|TB>MR^|5V-}DtwB-jTth^PQ&R&K4`G|;M@#E8)SY~RgIV!?bG>breCl&Gm(F$`;_ts(fmR#df7cd3i2itN&s$g+`*p6wem' + 'DZ)rv$j2Vlz7Bborx%^Sz@LllR6;' + 'lK>d)_(D$je8FmBfYW^dl*FRLd(4sw$cvy${NiJb%GBb{NMy+naUonanp4R{R&7sI3^o_tl#00TPd8!rtZW_6<<^E&$V^M$xk6}Nu(x!yC3bW&#E+_GFC' + '&%b#j3mul^jMWk>Dz7E1dOJIv-R`#zOx<-95cQLj;{}lxUTV*@cI~b=e>%O{+WKm`?f_ujDor(+fZ$N&Q=3ki$GBJXU@D*Z6a33jv4S$&v$WL+1*jrXS=aU{>)IYw_{L}=jV{tiHnW^zFe)?OH-4v&ygPqRQuX`7' + 'Fn&NWJERr(oGOHAm=`UDFU-PQb{J{t?ey7kbpmltypn-7Vnbfqd!9PJM2;b+F?>}7jb1LSr%U6>OdczJDWc)L;22l=@Uo~H4Ce6)' + '_}jVSYTfAX?9qj;2PkGxG_tuF*z4WR2kL2bgv5>!rt@}q7(Q%)' + '@oKHe=d2;@XshWxD|z1qeZOe^eo5aiTfbWdMbo^&WEaq8ihxa1?R`qInaKMR;(;!ZSIQ!ZZEM`k)C|}qni57hS(A7@bgE(G4%puf' + 'a@5y98^_EHF5%8zK&V%ZdZ`XVLakh@ZAp|Qk|C)y~TTYC5M1f' + 'Y9)}eACy5+`K7y^!LR!1W?erp5*b+z>_j)FfTZvn5{-F!yN0xSJv@nXb>U-90Yf{ETdm_hIVpMR8L{' + '7~RgR+T9a^?tT2FZ~-{?VwUi=Wu2m3AuP2sRtF9;x&-=F3XYhuc}@L5ZdJ%ZGHs~!{^(ekY2o1DjK#*|SHQ!wd^7@-21TIJ=zc&n' + '3#56Oa6DaGjA9tiNtEN?AxR8s-~d_Sk;NuFFWxW@+$SR21{|LRDVfmR41E%mo-iM71oCqF)wbi$^FD{foe&#Z3`|Ubf6;RScCHs81SBRCJY;tBa+~az9NrU+tramMq*VodWtb16|6h-3P1};' + 'o~&{0Zdis05IoUS`tAA1+TZH#eYX#hCCAuAgtNbNsR{2d`ud2VU*&?I819%3;Po4g)I;O-m%e2{ztJ0gskEEfb-}v(y2rNXU<+x-ezQWoK_{SA?M6ESjoXaCS6AJ#0;$#|L*Tm$%EmXo++ZPd8+Pdc6}sVC>$0<~Njw>hydyRAYa{Pu_PsQ3eeT6{_)_`~8F&Mi-?{{~)fazL@1ttBZ#_A;Nn#oGtwrTtL%Elv' + '{q-xt1)=WS`oSgOqzQKKh5PGPvR&W0knOKpLMub1wd@n04XH+}@j@?KM4r@{=FD_%Za+q-ctbYrm@df%1vxNRJ1(yeYy*SFII6@8' + ';5W&3t3(nZoCOEyrx`InOUK^Nj-0n1lvp&>J*(K3U*Zu|7gBqNOjCrJiDJU;yg6sJW1X>OHGHwWomjpt9_wZ>(6f*b#h+N8$lc`&' + '5!*w9!u#_DO2@rG37S<&xr*S5`$k|g#`k7^fe}sU4}Kz=#NzOUKSud@Toq&LfhUz22Rdi>2wX{;_pwa0dYL!Xa)35|n6zYAimQ~(r7~l>ha2UVN6;G4d$~^IsPIIiYuwAS>kX1_WVDIcpRKF&${`)lO!$VVJR59A9+3X' + 'eNv?@Hh@l)k^`?jtqUyIfV$}5!%s)qtHXm2KfgcBUcNp`S1OG(sIaRdesa=hhX}w=v1Bh=vxCpWJqmQmWF7s`!E|dw;aIMhzT#k8' + '8_UejvT@~QCnD@!MC29gqrb7}wQ|Mu?<_LvV*S-3W6P@QZz}hRNKPVGlHP23a-E0(FM`-2O>C(w@$~7w$XtM1r{yXk52_5Qg=$&n' + 'CJQO6ONR!GEmpYl^WZ$M#zpv>&`XCuGF!-}1%YVmp+rysh8EG=d}DAjvTAQ#u{tZ)6}ipI' + 'PwUFemeWj&fOS3vv0iT(cRwYtC@ms`XM=ojUSPhdLC_Vb^%ScA@?oXzfB}Mqh@cUnf9Ge4spK9A96>zk>ecq~qtn-qA0zr(!|Es&' + 'opQ!Glp7H*6`C2)Do&-C6F6u=b1>k7L_#8iI*IZ@HLq(cv_P?_*T?c$Wh<=%N0DL152kQ7VUH2!Jd+~7EaD)~N~2vTm6UQoxi18Ytq0-?' + 'nML^oaqo^5pJ6TF@MHsBv?>=fWf@2V>}PIFG-fVpfz0MJyxoiyEE!A8z$l+mOhUqZPlWkr~+o;^)pR0?z' + 'qos=O%he#{tW#C_u)2yw3{{a2>pE&meX=*8CM$O=WdAB9h`tRVK+QPi3_Xk!5_cyA-R7M$yya-gZiGC3Sz#X%z(c6W${j' + 'RRUtBS=?s|9=}_Gh7h=Qym9>+q{vSLB#SDupv8>C)NfZGIK)Q^^REO04u0pSm)i8;^vKs0FN?>FiXUhxXv0C)hVyAOv&wC^)B~&F1uFj}@%b?M_H2`x~0>MpMiL+rdrDb(W>PL=;{c12R&i+;TR)tK?;P' + '%dSpogNr)k&tb&>Y`bk?D|g+;FOGq>Nt{sCU))#Z?~NRF!-Y3-)pcT%qW)h6v@_PNhY$bKTsklKVOvnsN+(UloN_Kbes*iktU-u2>gMeY5Ke67b{P1d5vS%zz|9?qa`5K$%S_}#Ja&v7bM)fXEB' + '@n~CCpDybrIx8Z&{2N8TIdFbYqcJq_n<)OL>i6wr<*3o{lYbdJy{t1_XW3cNVpSmN<`b=K31p;K#3A~9s|~hi85}FBQbyO@bV#Bjwv-miTYsB)=`nA$_tH1sN1hG~*Z7YQyF}n%S$zJ(@JEdq=<`RRngbT{Z{P0z2jlh*M?(KJRy)=rhAD|(IE2=S)<($qyggm*-427{NEh|3G3|=y>r`4>zC#$h' + 'n4uFK`5)GnR++4ztUWc(4YoYX$ah!=(=al1sk7W?8J1rJx^Vhvi)L%~bL4gd+9$?ouZvK+u9Nf9O' + '8BYSWM6e3TEG;<0CQ~*Si&#C3;oVbA>k56~H5Y;d>u=9y+qcmB+s%*VbXjekE8~`fEK;*cFy3-?Oa+`V_&lU-JbGRCR&}Kx-Pblj' + 'j5&}Huw}clshF6R-8Hdqi+=gHz;;i4KaA0)c$SiIWMnV!H~|Nu%bkGdB7O?+|73+_jM|X0D=Sw$WqULN1o-98f+KezSW$aj{M*Gg' + '#es1FYjl3q7;m#PbSStGF|+b_3aPNjGksPmbeo' + '#;dX1`o3&4B^70ZBET4PY>0uc#E#)&uENd$2yL|&7CjO_k>zSTA~TGSHFT*6h-(Vm0*|4S3BGRU;f}=o--w0JF=#OX+-+4kM@aoPC6jJw@#R7U3!@NNA034h`ti{HcJw%DueFlD~vVwO1-}brf{Rk3f@@x#7gZ5E0y-Px$doy0By;Dabbywv`_Te?ET{%I!a^|D' + 'C6`y(E;XRb(2mVJXo!eDpl)82wNwMHOOJ#_>MWnVCV`JGiyP7!eHvuc;qc53>wb%<%' + 'bwBv_KNtER$_{~J?=&+N4$4?Xjw@IW)95V}K5Qi2KsUtS|LXUok7G2uaX>GWPI7NLQ}e(8Q+r!3Vt0DembIHA#gv1FB~Y6PC{YR8' + 'vtqbXkB$S{y8vSg=-0?BWR3E(Z$E}raNcC~!?$rO6q#~}8ZQ(c^&x|u`3dB&AKxBTNhvQ3?)2D_liAkFlWxso5m{q=Md&uXsX(O@qGw*J`}@aVRo%x7' + 'U6hJVzjCv2XSd`)d+WZYxB*%1y%u=muj0Xx)o%H9@GyyoL1>K+llJHn7m8=^rz+Dwt!K&GOrw9Du%ZPL>SIihXDvcPsMGHLzMq`7' + '`mxk25TmiV0d4}ekY;`Oy#hUgc5(A^^T-=y?WQQg)fw!#QD=jmGaRq?7Le85ufJ$^>j$6R4P+lHb9+&$b`Q6^r?MzjKn@|LP8H`@}&;8s$>t#Ku0' + 'Za8==X4Md`$CSNw+05yr!*}2rwZ1)A4)bm2K0KTfDjZ|i+HQKmgCtqdf!N7GcW*s$bK~Grfn`RMi11AUw0(Fyj*}H5G*?plx2Z)m' + 'idY7B=7Mmv$6=@h;a-6v)cvP<;Y1*xL`4v7qeTUhKagQ5U?;J_)*?;`ZrKAT46{woedC1YMA(x9=I' + '4~5I>bNqCYsjMJUp}5ZyQa>2AJ`|W^5D3g6B7&$X2uJO3mUTFIhrO~NFouVd)}$SC{}bFJKye~SP=JDD_@I6;@X&u{3i_Y8FD%&2' + 'RgimA)g_p*`msUMLIQ@loE6YFBb!vEiALMrQnodnOzLKs*b=Vr(eO*}g_E_&<7O;Iyw0OW;Rt~TK)9FC-`evGi!`owq!y-e~T%g-{SJ~WTEkiA4' + 'ZS~rk+gKON&>!`po14HJy0*ylq975)s&ZeT5Ozz7f~S_ZGG6BL!twRD52`Z0r;LR9!%fT4hSz9c{7oJ0GJm`ceQaOE{q|3*&sF}ecrSwjhp79`bBmx$Q?=hy3AhTn@4gX-C~Xf{%qZWFFxC8AH8Ev*' + '$L!)n&^tmUXN1XOKx!#OPZ^4KilBw#b>^8!<0VdMnkA;u=d8Xc7jzLve;48}6wTa_UEXo;4!I2jvF-YDle%XXM7{s@^-sTu*zEeP' + 'sRja@UO(_IZ4Y7F7ki!qH=FdulzA&DyiD#*sH>vEDc1d9wEYc`({QL+5%8Vbio%;C4ckI^?23G7gX}^6R;H6Pgf5d>Gbij{n~d!+' + '00&!+Y>h@seM?^!Fj@+2#t$%gJ_?>o6d;o9*v7UDY?-Bt%Kz50V(}WBLxyXFFDusdFm&9`fm|JgawtRM6oF`l{(FpH*k1%bY_N{@3;ZPqT*Ba>j@N25{KtQO|dCB_3_^)Q5?%=n*=}^d(Il5l5)%;znD)Z' + '#h{TXCrx4+RfbyeiARToOvfZLB+v#TRcZ;;rjE%30U{auThpIQ?e9+`i5vZEW|*&D#E~' + 'WpVhN8za{rSa!7~eyZ#Db-NcBfUfoXV_t+EOP%PCMLvA&`!K7q(6!jF6==qNB' + '5L3$5gB6Wz6{yqcx(Q~j>!HDDtSho0Zpmc2@OfJUjvmHs&eURw+eU}Ub7bvlH>80Tsu=zp&@SP<3w`siUgQCTKCit%fl`~?p+K?L' + 'YZONhjAd7GMJjqS!STc3!TiA^YpRY^$+wOE20=;!+`{hg(B^' + 'cvvN|%RSfYh`)+DU;Li{^kXquNZNW3unIK>-UOwb7xq@bm{M2`IUNe+SWk#e{1lM%g6_Jo0slR(s=SUC^Q)rjO!6z?I3W9C1)EoM' + '-bM}}P#F)VG7z(!sZWWWrkH?wsyZRy@vwxF(9gLTTqip&1EBa7p*)1FSQIgQ{LcYpWQ#YMfB|7NX+2MxagmOVg971>}+9L<$*!Ns__=T+BSp_}h?I=hnpf%p1{&s=r;)Unm!>=UWFy){c(%ajjo7Df4Mb`L0t;RE%3f`2{%' + 'XQfNqBV%JCAM>R(+uYwEYbJXN5T~@Q^DPSH2ui' + 'rMH>8Tj^^$o$kwsB2l19Gh;pd3dKaL8-ssy1v}KiWIV6nkU5R^{=FA*UoqJp^)`~MKsqW;a%Gq4+OMU3MGOgCx~K>' + '+^XwcL2$%Xx!hqD7R3U8lH;gp5v-Tiw8&9{i*ZB4>Pc&Tc}cNlJ7#tWgdKs;K!3Ykyj`sCJPWXz1xOC>^Ii&vbS=516($(H8~```#SVQME>tIWzRk&SaOpJh3QgH}F)9U?Z?>2)^Nh6>S@' + '{%Nk=b^-DX_Ued$vc&`a1XMJ0g3Rh4?n512S?dl%*iw>UAn~ijUtGUBi)$8MTvvK)+F!A>iq3b&>RQURb(OSN_g2<)i9^Dg`1g*L' + 'ud{(%m`nX~-0*7z*x?4W0r#A9y$=eXh-YPbHv0rbTz&!~zC!ghg2MS6BmoH7=-EE$tK>(0^0KJ<+v5U?ZX@B_CAt{7a5>Dib~PX{4vWTZ7+%H8=&#TvT@2%Ks&e?iixP?&Qi){wFi}`v$aiHv&d6saHszHQ1d>MlFM!d$' + 'QVBPWE-@{`XVw-Nj9jNMwSQIJF+yr$Qti$@rg`QGf9PYY)R8>!x%ICyUA8;~prM_hAUf{T-A|icR;!WG{q2_SVxIJ0#?0$Ki=EYD' + 'nop6oKoD`e`oj>^t~29kif2vF_G|xJ%eTeQ&#f^N(+avZ1vfS2LNiX!oXZRTB)2&7Gs=p*n%rc?b=hRqv>vYL$%ZChmOqn3brc)I' + '^mnk=s^T9%`cv}pBg!<*d4bb)2156IWOzogrXe3R%X~7qiC`V;t^3#WVg?`s)5b9C0*>qmSSYBJHsNepPFSW`*E1kW%7Ah;UrvV6' + 'WR7YmkU-8s5b5w$ZIhQ16jQLBL2lfe*K-h9P(e4r0EGB?2`dBePs5U3nG~}zh&FgaM<^5Y9Y#GQ92c;TK@}j?)R-;~y9T?*66hEh' + 'QPV-*HsGznoEF1N2uX>zJK_a?Y7e3D(~hD-$Zm7JR>hPv3ZSnt^e){MQdtXG>cXl(-7LxL$#`1Ew!6<=m=2h' + '3R&K0Py9Sl@BS>+gaMGrX&W3*CANodr&1->4$!~?NWr$}C~Kt>iSU)Wm{ppPP4s03' + '7)77XvA*xXcWxg7_x8H{@#Bv}TWKrCzy#pMky9;L%20}=Y5Nr^Da6mhToJ0nLdaCPKf!*2J$6EsfGQ6R?m?YZS' + 'iOkfThS!Erqe#d*0sV^`L>(4EUN-I3lW}A%GnqtBkz;K!w0cqXq&H%+QrlduD' + 'KzYp8<%+e$HXntpLru~~j5L(5V1*^}hNi>j@v6n%aAOPOds`Xb$sXD`m4z)SH?ZPgYstUXn*V_;`i+xhU;GWVW1J}a){|dYoiF?9' + 'R}g>lq}exq2av>FYwKC)@VS8v@fTUK2K{;hzR01oVBh07>AGMv?H1z!CZe|rvUfp_a$*`po(Alo1mBaT4g1JbKZc{zd*7V1MIKNM' + 'bb$lKAwx=MBwt1Ei_4+{u~8chD*bX4v&Q*;VTp=ael{tF!n?KC##z-q9#owNgAz&aBq1r9qJe&A?DOj9nxMDWu1AUv&||a^%Bt~I' + '^wZ13r^hc3X-5pn3L#M;C@Cl*WDfI^NoTNF_N`+KmTaHp=Hf>S6*ZpF5n@NlVAPY2u85`yfkxc{zErvf+Ic&;^P)x3d=Xutf&oMM' + '7lS2bf6&1LD4Y|(92YK;#{m(-Rh!bo`}UA{dt96G0fd)?Mj4gZ9oX?E1XqDe$lpv' + '1z?z3?&YNEwW>H#4P_he3q%l#`DC@-b3-t1)AnV#2@6aWAK2mk;8Appz6ou&UG' + '007CN0024w003rTb98WQZF4VeZ)9a`b1!9cZDwz5WHK*pZ)9a`X>Mml)c;YsTgXAk+|ZJ6OtoK+RJ~xZoG&mXQX(OoGTS^h(@CUG#cHFMsqryzRSD*?EOg>ygfg8*Hpz}' + 'KfbuO=2cl2q}?w_S6(nn' + 'FKbeqU^yRyB=7m;2skmnN21`Qyf14G&8BP9(lL_LY*u|kp?DRtx*|oPjF5nOD+<&GEEW>A!bt%Cpza&!$fE3^{n&nW4zsuEWC3mG' + '!=_(vdiE|W+Gd^6(JG;L*vcXU#^?zzUzRZ9zkewM=)MF4BRy<1r8zYSqmgC`V*3K#=;6et_$Fe@2Y0O+x$qn;+~gwgcLdp3V2}B' + 'n-a#WW^B)S>KddOa8paxS>87*sM@rJUdEc}8w$Wm?qI1PrP(cMYf{nmKlMPw;!|P@8Rvs$z-xXNgBM7k{mfD1o6oDDVo!!K%~D__l!aC2R8s8z^W`tkftDm*6+Xn{vr;}j^4(>`r;&}n??yw3EdYE+f}ov' + 'i@YsKHy1LNzAwlUwv4ha`z#APQY~Y?d(92O7X|y@2B#nw7T@Ss7ANl?810Na?YO4%%i7wa=We=`a9{_;g@WiF>`oHGPvT+5VVO' + 'g;{S|D*|YBD-G+cLb|(jy-dWj7;;GfsNSeD0o_#fOJA95YF!mYpH=ntRfQr0w~#Jdi(LgO9lmto`%L$1hVv|kbxlBegc1_}F#+{R' + '+NKK~sMU!_#d-re8byr)Vj#+xs&&o;u#+1F8h~L^MrA0uQ*@*^wne^Y@LzMVN7~?j=|17uFg#mp$p1zo6P!4jmen-+7B>J6!}CbQ' + 'ekNbc(Q5OP=V?_zI6U%3|BU)ZxqvCWX^LS(1(k#3FdccnxC!sYjvEI!#D!1>vo2q)E8LKQDoPFm8we_w?ixV~+QnSsn3jObSqJwM' + '^F_jGL3_0YYxj)V!cayJ)vro_kS{PmhdrGay>g&J=pksf|' + '+alEFHXeR)xhH2vmOR?8?$H1$?DxaIOtnkUQ*9WgUk(Ctm3Ox`6s-?podCtp)g?NnNhL-)a&EV`ZQP&PuX_7ljFm;+wUt$CyS6IGV%Jud95P3;j@B2_Jv-7S2KR7UmpF(|vBZhAvjXlVJvixDhpTdq-g' + 'N-M7V6F<13k%YzoM#-k`e%=rQvh6V14acCXjbWSZicRQ9`UdFI>8$2H&Eo+6qwEvRFEs>vPJPaag4fqjsHzCFKqxw0u(SSnqTcaQU1D`BowRC(`v)M!S!G+!r-RFh))(d`02sIb' + 'AXMfv9y+g&2X;CKi+8X#7x*eP_BYX3tUU{_2D-b_Y0-dO$Tnagu2$5;Mm7sjqi7RThpuNBtZ5Kx1SxBDe-;Jbvf)+C)TG;hhFh*u' + 'C^lz=uLb(&#i$({>SP$}DdSU%f0R{xnJlX&@52c9d0r$|w@iEa1+G4TDvB(eSqmaIXLC<&)-@2@F**^@W>Cn;+5AdH%GW|!r!%tuwZa~_)+%zJ' + 'iTdFPy-Q8vLIe6VsUxVuy$I0F>{3{$Tcdy-3P;zye+rvm>z*lbp?Tp^8JOX*6;;_~Re6n`X&4WKMp%uSpFFaJCpBNe6wk|AtB%wr6OX(w}CL2K%Vv6#q' + 's6}MhH#NUssJn0BwR;zo2&_HuCGrAn3&7tuq0(k8RL!9--x2qfEfJN(&t*ZkO~eGF#42hK3xeD=Z2CD^o7W^fNM6H`OB6B15UVfv' + '-ldSh-stuO)za|676W)x%?mYI8wa<9tnvAx-6-ga19X!lhp%vP)7t8W@b3|7L9aoT#ib8@eMw&=-we)?6`j*7D$x)Ez+MEQItX7u' + 'BNW4-Hg9OpULmdTlCD|yC^ytu7XL1g=wNM;OFl*ii8@B8?_sXyc`=Alf-d>V?ysHb4oU9<*vhqM-!}#vS3Dnu`y%iC@Fig3Zn>JUy2R>ZEz5f' + 'sw2UHvIHqMwh9&0FM^rQehe(*^zNeKYVSomyS(LMMVSnt7wceDN7kUIg5?-xu;GKn41>#Mt^=C!--=)c61H-KiDS;|RAYsGqN*=PuTnv^b4<^|bICs`t6i@M&Y_N$V0-5E;cKpzITJ0GxhRNIbfrDcPCLn@i)rBT$j^wrofu@pE2PL' + 'M*PvUj?JQ{$~@pR!vGBn6KRWOCx)n$qP!~$8mo!wA%Q8SO5@+D4(y9Cz#h@n@|lMuX1Rz-1WJ72iXMs>9m){K#)yqk(aTkcHjcg~' + 'c?*u{4-HMVrf`*f-{Let?3hIAL?&SoDN%sGnS?thWYf><|!uJOkErYaf!+;dK{b' + 'rESOZj^hV6BACI&tyb^n31mveik&JUD8%q' + 'p@Ln(s0|*M^XXPG6pD_;!^Aqzh_v7Un~=FRw2}nn#~!a~u^9)Wk1v!O6O$!|MePu6vNKO6R>N$(cmmNwEn`}nwR9HSVnLY$dj)A-' + 'QCY;>AEiP0xTjXNm$CrmT@lsHPHg0Hl_H=*g>Qz##4po{O)opK>7Uh!O>cCftj#`@^*^x-v^nwgj1H70C)sPjcs+FHnIvm1m~WoZ' + '1!YkfO0GlpI5gaGo8i~1t0MC430f?RpOv=A@221?5%N5I_2uhWXpG(nXjEIld>`%#xFe~@kx&zYS7x*G-iqh=WVsehyTt;KbC)s#' + 'JOFCgvIm6CUn(_uCzvu{sj68!f%WN2^`CYX+aZH)q)hc9F+S1(t(dD{ua087biS}{W%dmJS`PDPOi;|~MPEXtyywf$4kz`+p5h2M' + 'H}?CRyvx8GCn!@KVW_+$qu$x4YpG7NoOw>M!(e{k*~*$8+t|2_u-#R*B>8Y62_}uPL|7kVtxx6|pWY+A^fGpwkh;9AQUPJ%NQ|&Z' + 'L<;FLAry1siHC&!gpI(rX4ti%2)jmz>$J1^Ws=tq%xO4_gTqKUWe0h)1+VJkRwsx1ooOi^@yc^B#(vfrSl?_X_26>Me4)fJB-&`<' + 'X_EbZ7CmuUu42A&&?7vuL~U8RvSre}M`YSvkKJzJZ9w`w>E(8OI_uZo(@Zfjk<1Vi`hvV~thl`B_0Z6L6FK%=fsV~9)Slai?hEo8' + 'ygPsOFmlaaI^Ea>y^K+xnzyRy{mxtRK;v}*2fCYJ(CPi+O)dQIb9o2-Qw6&(28myykN#6T{huTw_ZUV-FM$5*^zxr@y^+<7o^bte' + '%RNS2#o&?2gkSvyX#Fm*)jp4Ar_??B#v8By?5l1qsW93duY7I0MI0K(Z8BQE1->cnX)Hj>N61|o`!KL0A`gP0(AYfOdX?#D8lvsB' + 'F`Df#I2FIR&HU+5-MYzbIzJzk>v6P_=ufrEq+1>%t@!=6p>%lZ>j}N7KOZz$-NEF5&6ewLFvHC$vZVJ~hI=I_H=x=-esNWT-6gMB9S}j)!`J`}nGx1aRW2Sxhv5I=H33P~i&Yy3' + 'Q@zF%-sGFUnMTn#=8k56U?S7Z31P;Q!=~GToJ>bZjEyQZJ42C9H#G`dQcO*Nyld)oijAYvMlI&T>ewTeer5vH~GH$6P0)_4G@KE>1LVu`=fVt#7j0X9SpAhMwUtF?NpJ0unl}47u#M^BX_${<0EP' + 'RVTQ=FwGUVP}JxouGmH0r`ctci0EzOF0;%b!tb%VlWw>-%HcwS{}L>lD!m!&{ypum<8a5wb1*j&JPb^tkGGKqg40d|IGp(t5>n?^' + '73f`~-gTOvVtGCAAHE<=kG7A?c)?t8Sa{X|cm4xvLC4&_xTT+BxX^FHJYncrsb!YJb4fB4Je' + '3TL~PvEO^_g5Iemhb-H@o&g2xi$DE7x8T40=xpz=G_4S>9qo3i?&KRF<^hd#8;bT~)$_4Xv1P@kWjTXrQa4nD' + '(RV{*0yf0&9+yML%mIcBY7IY}uv?o46kB0@qQa@I4wA#6w7P?T+-mPID>BuXZz^bgY9LW#gh~_T99|%v>m)(fnHsJ-RH>Nb(nuA3' + 'zH9EL$snhbszu)UoKC0jp;wfk;B(pE&_sb9h|q!DfNAZRE>9*aboy8PiQMd))%q4=44hsl2}##_hB`yxcg4h-!;+dWVwdk<$tvfY' + '>fd8BeZd&klzyc22aCLJYP?m!r@zst)CbHV2{W{spTn066ma?c#ZLk|{Jr!7Ygz{N-nA}niKL!9if!o0oi)YZl(!?6X@N_)4ThZ>QVTxQC6e@Zw1vJM!e&Jyh1CjokCxtLH_2mZlSchPictfWrAX' + 'U6%JS`5pPWBFNLl18_jOaP6ax9Td-6Sp^~9^hptgDQy#PnN0aB(V}*T6G+UsTu>aqND1`-i@MXfK>UkUy@%R_p7Q7%;7p||{`phB' + 'S`ca$Sx?|d>~itYv#nMBa7E-~u>Dx^fI0@44>Isr&F(n!TCv$-$88@5nefPm;h0}*UKHc<)~CR9W>9_#^*$(?VCGBj>eCCEC$}uR' + 'C#1)sr%M}5i&2pKr)19(huu3wQwW=N(5p}lWMc>V45m!ZxHrvIX|^O;cIyk7o_R)Qz(v?H$cc%j_3e%bR*}gMmOrVpoZLqDvb(w?u%=21M0IrcA$W+olbt^0-=@!0j{Xk3k?C>U>{|XIQ*I-orF~)7Ktmo0*9Ag&Memz-rL6CPUq(i' + '!B#w-je8AmS>j&N;CoN#x%Ly2?Ew*fK_U*ApPN9iB}+VncNdU~U=WA95SAnedv_VlT0pfogz{jgDU`E~YcO`pTR5KkaF-;DGF}X$' + '%eSQ@q@k+gAg*{jAFMn2ZlrO~fcR=yTbkExVF-n7@PZ#R%pkB0n8p?~&AL;4$4)3uKFN_96b{u+*iE)HK=Ky!PjX-OE)N@%3K%6c' + 'f&vug#iqhzgu(ahF+JC9L+`-h0HJQVp?+$VSvZSzfv1(IdwZ(a4JJeDph3cY!#m2auRHG9c(o!^_KfE$c7' + 'R)cL#^t5SVFZ8>Ya-J!|6yJ@Qyr}mOu;A9G8-16Qy`j#m^;Js=#@H?9)BB(' + '#YL0cGMS$gCC>LnX44}*v{_)Z6?)f@H8a9RLv@9poU4W^0{$8F' + '@HkfTF$f}3*D@%(Xn#|=FD+Gc73{2vjEs)jJqO?D5bg+m|0d$T6fo0Qp(f}(-aQRuB(;w^;xg}OY`&UZ%KH$5>|2n6i(0^HPr2xGY46cJ{U5YPuipG`KD$*o!dFDN;pt#6BUE3dzi8' + ')e)!XR}B4?_+X{{ufwfnD}$++1UEYA)JU~oZeZp@Nry)_80XSd=U6wa!G@`uH9b8Qtd{Dz;eBC+_>Wxz89tD}u9SGL#fdv~;^v4G' + 'Y-TEe-p?zf+C7qR-1nGn@kDZ8Uh4H^@^W9XbnT_`$?4P-&c;-E_PMYPAD`88r7TpQ1;a<~nc@#)-<my)+VD#cerTzTY`LZE>HKJ4hOj#~o$e?-cbAq=jMEcAKlFfI{pyEhA|50MJKWHQwjRZF=y#Pub6ke3' + 'XUZU?!=sJdW>stsFE+9l@=BwrTH5EeJjabECVhvnP|C2QR^VW>4c76ixpRPdT1lFw3al?6' + '9L(b2rQ4qL8$jDg+MOPcvS+}WBuCp#WG6a*Ke>HTd)Ju5IJJoKA>YY3_b)6VS+vhqu*{b%7vzjjfu91O-7Za7enl9EV2i@jp>$2>QfGIk@t8860yv&~!40L(IKbW%9yoot' + 'FmC2?PIovu-6G>GPvnjyufY3dBPe<' + '+1j_R49kiMlvRgbDJF+SGWPz^moX@eZl1&&2J|2~eC@0|3=nXMR2QLHyo2Cw4)u7-RI<}AhUaAX' + '(A+*q^8J@?9E(ls$cOK^@)^?B@w1eDY;qMxpkCPy(8>Di}ba+XO*KU+6Hts5ng@{eCm?EyUEXKXe?6e&)a3sgwGPp5ecHf|G3r-`;QGhkKF_5!IKnxF2)Z?c~~elB4X)Il6Z6s<}?>Q-5fn^GG`#' + 'zEte2pobBq_A5u&q8(0?I^H?j8$x?h<=HVVGO{Nf9$hm><3T6`U{=V~h-w-v9c&GIH0V27yXuNGJV<5-h9V?GRK3Bq{OhCggO{ev' + '`7{;ZCmlY2Ila^l%QOs656_h?24b>6@D_BNl|iq7x~!=SY8rXl_I_s?t+RN5-C&aa;S+8#!=KU021eUqqLs5iZ2B!GtE%{5^~C9?' + '!H?6b^tj#lo-#@An7f&O7z*gJ<0l;-1lLFB!20Z`?RVOBBzC9DXcCtJ=`i;g-9{7!^naLjCkkU1NzVHfYz?^Fkfu>SHI)T4i;Y*t' + 'cP1PQYp&C5RPYZ7RP-0B->6X5jw=juFba3R#IFGAT8e=o+^&3hKFC3JnN%?+yy^`5EN`kWL_boS1yjeDihu1^w)279)6Xfl>I335' + 'B=v{qKY6EpmdZCW3$F92)b-MXBXWdhCM=hFXfdaGBl6RfjyT>tpx&uVHS%EayB^Rxa!9XjmF)w*eW8K_LNFMh``O(xFk' + 'HLf;OCxS5YX1#DA86ON>%bhtCtXHBB5-o+bzDeaOM*JW_*LkaQ7HyGV@}$qPFUCG{=k?I~m52t|C18epOL_{{ssTiE+>yeadv3T#' + 'W%HL|h$|zzWS=SMNm=v$cI0Q4MI_kU|N)0j=;8lRHrSA;I6-VVbE|EwwVW+eKKw2br^jdXc^ot?nxq1`=KXFK#x3rrMMxHsf-=V1=vH&b2ALCTh' + 'Ii{9UY56mp^^Id31H`2h*)6G?p|URLj_u_Wl7pL|7$x??lrIcey(3r^qgR8S$(fA1O=<9Q`~9Reczv+by(iETo^Uzox4JU)tFBnd' + 'FZmVn!apkBQ!7J(K{XeZau4sM}m`KE@+ONYnUu-L@xrNg5$4--z3+z>shN`#^t$+nc$CBES*' + 'dF5#Ohg^C)bG4y8QUywE?`LeZj+ve$bCR9`B9P@kAuXBcLJ7aT=z$Ey_;%?h6?cfU#|Ji+laPvs=+doh+RN_$=_1>WiT{j-NVzal' + '8o_R|se0TI7v(Z!zo&MU(=?Vjy0SVtPfQ^fWOI8{rul(FM$<4vzn&(6Ye$+US%!HJG4_&ug=wm%RpxSh>iukn3BZgx@peeZW51yZdf@ftu>MB|2^}}tCz2T{^|bR`|p2x`ReCi{@~_^yPo{{^vT=%' + 'cR&C5+x' + 'M;$Bk#BB8`H}+X}9lbWz?mNdCe{~IZF~@GTmcI9>u|g|rm6|&1j#WzMQrWxC$)!C<>T3@c`1G=eoKnrJmbyw>Ww)m;nr2OT^`6!&' + 'D^;&?o}Na|DeqicFD&s{*kRY5YT21rv60%J=DQYVDQni%5}Wf38Pn{^ts`v=x5{@I_rot+cb}u>ag0~FIyliBqwKiGld}}=&FnJgdSg(0QhOeoKZ{Mo?Ug0=#vzzAkI^vcUcIco=E^v#&+ZD(' + 'SRvi<`&wED1$bf3hCNckovU+%R%hvh=PT~=XRNn(TiNGd#Pd0`>Y6jw-gqE(kaHQm?ZxdA^Sgsl*Y1ns)rN6h;9C}`cWLt&FJo=Y' + 'Q+-(hHbqCL$)D|tX{R;dJ!}Xbh^vv~?p*@u1SP@3f%ql(m={z7' + '#)VcClG0QWTB?yANNU~y-Fbnk%mm!CeungA(8O>4NYi{s@}W3R&9ytIP))nk7pvk=Qc2VR~xR6*Q|3Sy_@Hbc5D' + 'Sc1KbNvz<$h>R*4z#qhsC%AmTHBrEGYvYzC@Mb{3ATu@$ZD5(>n$cXvQ|~E{vm~pR{$c=e(EI-x-m1jfQ2$Z(Z;}G;Q2Xu#OI$Kr*b$+kYzZEA;1L)Vu7%>dvC@i4=x-`>~PrB^MgYR5GMY09h-v<&tAJe' + 'BVyqxUMnJ6aVL-i$THwu;8{LQ*Ji?A6V$f^mAl)-Vqhs@iM7jcg^??}z|pzOUI!4LKLUct#X%ap54Md14UcLXfo6;|0^CfnbPH~r' + '#u9hlz<~p7P%jOFqnQu9$sz2e6Ou{lc^Z(1hrk|Dw}9xl0-|9i;?N+hbM!+qq@iT~fqd}m3Q%>Tv<|((8*9%uh!CccnQ~t#aGeDR' + 'cO~qz2S`4!%qA29eX&T(50J=rq=^zl%Z3TFlYAgwz?z5VG00pM-mC=z0)U;3J$N1nQxB;HoSTc4qc~OIMkgk#QF!*{;wSJ8#Yde~OIac`%QWLR2kmGJ}G6z8UHg' + '8*Fs(sJbj0SX+4z(_oUjr;K_>V|}%bz!g(2l(y%AdS' + 'g52oScz}>v?mr3r0wo=+>5mtm+%h-%4!l5@yrzNlfC0uClRZL_HSDmEJ?8NVZM4zGW1MD$1A$P0IOzfi9%yj|Tp*W_Zb^8TEFtm+' + '?8Cd()f4Ox8=#vC@9S{$p>5C!e$+Ghl20%eVLsIoczBIRJ%TYNI9{Hd>~u+5I1adT=8ZY$IG{JoRhZ#K' + 'yrK%d#+#88?II0*8=HluI4S}LBOrZ(hz6k^S+}?X?+Pk7YIDd?Q>Z2J11}^)2N4))g|vq1K{HG>;SK#0c7#2dK~2$(ha`z%UJSz9&^=;viAzLMmRV{T^nxoa' + 'VVZ<2z`+hqscZ`Qjd=(_Fd8le-8~|xz&CYVg)SIVSmBKwHPC|w!lw!0im4CK$a0*ftKVVQb(20&zg8$)(%q#-{w7)nTOe}5)Q&iB' + 'sOSzrIa@nBxL|_nt8lvDD)R^cK!gy?vIlsXGKaO>G^K%u^}i7T`9i7Wc&r%JhV>XeCz7!|Iw{VCqAH|b?qeU4Z7gv?^=L>HvT%+v' + 'PAI7RtEFOr#_K7AGTCn|NT`t4_~Bscn+HsJM*OZUl8NB#03rah`~pYaTBAoMCGA_FNbd-hb|We*El3`+BI%YJ3}`U1Jy9q6W|$LI' + '2o(;ZUM5VrUHt)R8_)!wQhn@m`XzOc4Sg=1ILArkIkh#%f=eHZNirM}2_SQb<2c8_x44D6!*WKoM5PIu7B_>+Ub^P^_;1%ts@UW5' + 'bwysM@A9AW}>98%B(k2Z_br5c5Tib;_<}(Sif;7Ve1^NTZM=M9@u~C(fTAd85L#X5rpB4+xq>6GbAj@=}ZOOLn9l&tl_B{pgr!ctFQNi9t20' + 'IQS#l1Z3Y+BohKi`Hi&ae`Sp;F4z%2Q(l2llu=^o@`qiKp~#*H5V3l?4GEDe(ke_2#@Phe{M#qbZ|EP^7-|zXf?Mlh' + 'S%}YMG34u(PibnF=^h(8E9;j2M`&KC0Nxavp$Vao0;>~zgvB~}o-l%N2)ttLx%Z88Pj!DL)SXmM}?hOH4t?oGFxP<`oZZN=K{NUY?$OJ>c268<`1tn6lPsOG(aU0k=3v|Q{QjHBy`zT?+6-x}&0l**O7WsUI_)zZd5WFwSrGwQicM-B1=Rg_f' + 'pY)eH*zN+P>2{to)p*L{)gl9B=r?}-@YG&y!xQtg=T)`ka3b$(}!8q*8gsq^BW+1VFvLp|I4#<4jK!BQuEip1g' + '^w$K(C(pmWHWevaNi?>Ux+PpbhqpDSy5#*M!j<|AtFb5%i70-=2H;BCj!aRFw|+zsr7yg$a52Rnm9k|4sycXq?7R96ZJ2jtb;N4nsVlXqb8#>-B2w<5V}Ua;c9&q#n>Zk7i$q?D4o*%oM`wjC7cN!=ZH-txmCPv?IM8&U;#wFM@k!dQ&;g!=AhCoU&hG!`$ettICa*_B5Sc(kKA?g(paizH?8%qGmbfMee33&gN+q&4yCB>w' + 'A#b_`Sa4a5u?I9lGSV$*R!1%-<$60sCB#TxQdEM_e!TJQqI#)?43le8N~TCu|EVM%*RC14RdQD(oYZ9Ymqqt$<-f48J26' + 'LR6M3HoS|{G1?|7Aox4mr@Yj)7eIiXVHM!o8vN88%knr1Cn9E_+C=K8R`W`PaOkI}nDT{GV+njNQc3hkXH(1*RwO9' + '|Mr}M&`~my>DXvKZ9AJNoJmfoBokZcc6{EhQ_5B&!viW>+zChZ6yty_&oqz4%|xzq%p{5(~Y-A+V^t' + 'Z5vips{``s{E(eY#(0;dQKwgy!Hu>>viTbv9r%%^kgj0+6s-YwJ>J~Hva5BQf3kg;?Ra4$?2$kE4vxz9f?J8g==6SO6ciGb04R@$' + 'sP;>;ZQdw{f=)=eoCm{Z#qB9H(KQr>(~cq*o8_^tjZE5?b|eh&{^|BF>n$8QZ3RMJPa`R1`;4SY$p3UJ4o9G(sy)#dE!3mXh%ol=' + 'E^J|+@dw$Y8pF68E05Y9If8U;Iar$ASa{XgwnbS|3H_O4O$zO4=hEVu*Jy<=pIw;NHX$k?ShO5xL|fj*0h?{@G*)y`Y7eyDL4j71z&' + 'Gn+Nx$;3bk&4uXAw%O)m=CK`vhgjeG8LIH;wmUQ^^oe~=x1I7dBV~|K$eoVwGqESj{;bRK9Gj=XRsI' + '^OBcYC%RQtvk?MPbI6*g`eU$UCL`On7y=z^pUTii28f{YEjA7VL#$^lrHECWL_tS2)2zA^2*z>qT3-(}GK{X`-KTYAzb0uPJAnw3' + 'INFHv-Uvas0siYGiKMZlQQQRZPtV27Q0*hZHxaZFBsfP_DVfhG?9%nYE=`4??s#bLMqS30Wbs}~h)(;$*{a=Cly3@j7G&?G8_Y3C' + '#BX-9tK4delKu+t+H!-<#vHKz;p#aM?PgNUSy;l-oyQ5CpafoC' + 'm59|b(h(TYU$P(0AM!|DkBxHM$WZ~I#7rGIWY&yM9{K`_d(I_p@dF@(+pflhdb-5{@HC!J*)!~|K!>LU4}xw|ti8^)?^pTEF?' + 'Gdh#UXwF2+kyQ9VLrAqHN;<23Xos9kqJd{ZO?>t77q^3pZPiR@0Jj>vWUsxNw3;)Nea3BFnp3RQsC938Px0wMvZ;x-KesdUWbF!kn)w9Zg>k^F4CQa%6`+Q1TbjmZ$&=E2zTzV=ZZtbq)>fVlqeKaSmz4)4*lB~SmUPAQSC}4' + 'xoYXbC{M`RQGI789U;Bdt0jhXsH(QxmG4M-BBg{7ZP6OwrjD{oY82y+l`*$m}YY(0K9fM6we+Y#0IN)A@$gl072~u}%;0#lwe2M6$xe' + 'IVzUW@Fojq+0y9N3-OUg3r@pHT>7L)FONNd?&>S5T0hPSQGz0f5!kfNk{`=ATV_sFYqU|)*{cIyP-CZkugl0bK79b`E#VC6?ugHCzPR~VN`b!gpPh`rWwp#+qv(Rojm+8wsaAd_jdLlAjC7{I_bod%vTkbmk5*vBvq`y0rVf+L^HJXwy&_uoUDw' + 'Z{&KjeIBH#P$;npLUF~1I}bC6#F?r^1FBCpC^yqEf*W>7#$xkJnRuZ|&f%yZ&IoiZ3!XxKi)Vdd*&D35o{%8o_jm;TB$YhQ&@=u{B|F7N7knqT>WDvL83KNaZf@aeVjH' + 'CD1L;I54|(=crfhQ>92BF=RFw^Iy_&N2MAYUw`_aS9O@4UjDsNd^eXq{eM?J`#)D$@b4dfaxEe3?>WOy7K-zHWf_hB6k@_`7F)&n' + '=+ER=J<-Q|e0_8%dko7{AEzy@u2h#*g}9^bQ|n5Eq+Gp_Zsew4Wyq2U89N~nZB3nS*MT4XR?R(Bo5ygI6Ju^IkInY@5Y6g37r*ub' + 'Z0EZU=Sn3$VjAm`(_#AfS^`ds+0kvcec7qY9UG)p?B|d8' + '+~l!aQnzz_K;y8I3>@W(lFv5euniPCQ9Yj7Jd_v%h422vr|*9F*Zr%PelPs;-TPmEdG+q@;X~iMxA#BYzrBC`!~OUFegEdw{rk6n' + '-2I-P{P`dM15ir?1QY-O00;m803iTDSq$&;2mk=#9RL6+0001HVRLkFY;AKdZEs{{Y;!MVb8TjCY-BPoaB^>SWod3-b#!TLb1ras' + 'tyo)c+c*|}_pjjUN!oEVZBr};8Dz0(#yc%`+!Tph%)l@dMxtyr5~&rbv~H09zK2&)7pG0P4iH<^x$)zQhupr)mws0gZYdW*Ltp3UCS8b}I`7$pc-1!YG#Ei%N(e_KLR5~CHciw0Akv!>FL$>$o=' + 'j3a{69cl22UtA8' + 'e)k+4VfHV5h#ASzgpM#mNB=}wk%~1J&D{GXT5$Mv4h|{i$p6}GHYaMA))BxUHScmJSdKt-^^b(T(Q#tW4Q0jeG(aUn>>LvLFXC|FToLL`rW@V>8Mgwyw8)q_Wt' + '!U~tN){)F&<>R$BwT&=Cn;3P}aY{IGk`LX39d>O4zUT%Et+VP;L4AUL-hu{TL12nA?GPk4(5B!l5uz04n)4h}KsT4+b3;I!SA!Ptv2$U@@^}~s@' + 'RBZ#kPkdu4{5h2Ea@eCsKcVC25&HROuqr}-m;bFI11Wr`umv94?sM5^~cK~s8wZ*3vh' + '*2>Pn5=%uLMIlF)q{yb(&;^*9Fp`31gMrd0R7zl_1&9BRbT)Ga3^;=z4_WR=ID1gS*OL$&LpH4;e' + '+LyoSvq64`?)_9{Z?(f{x)8bYqxAG+R`JEShAuH1*Pi>)iu?o~UcpHZy=i)>7YMu3HBClJQo)h=l|@wo+V~8X$0(`84!i5gLnl=$Ffx=N&yiG)@wnzY1j1(BJ;y=(G`>p9P0R^bX8(gP%Mpu{w-!LU|O899M;y75K>b)$?YJ?9?pg' + 'X>cKGrH_Wj$p{%yt%E;+4@lqor>|6ixS@Y9y|&=jhW8' + '612Z0I}q5A63C%FgSk@-?q^^>V70UkUZWkscYBjUEG{#DxN(3Enxqf1Su-t?yT+-Y?4lb2jP!_9wbbuTb*P!W|r(M)OB?cUw' + 'Nc%Lj6E?2YUEX!RnadVmHmUL+CjLSi#>3DEnWJOKKx)`M)$h%)8#!i^0S5vczt0P0>>n>XPMr?Fk@1&~&m|2d;jrm1qes>A8Uv7R' + 'kv-9Lr5;1vQ<;2|3iI1$c(M1TFiM^(q++Ey7AGTb%Q74Z0UsXS' + 'dVdvfD|q7gJwjLFoC(ImyxRAuCha0OjCvwWW*=cOT6lh-T8-rQBkc*0t2EvNM&(R}i~2Qq*9{2X(}2d!1>M7dVvx!V>0ouhVxgp%dagWO@BWNbs_{U1R}cmU*hA_=cMIU51J(^YtSH*tfZh*0E=Q@LofoNK7' + '9B`$y<4$O0lVq?Nu4>)Hh)PR|Rqz*lQ;lK292)Ml89~b7tCM0g`UgwH72n~2s=4Ep2rM6oJT4QUPush_R!h*EKg2kgLfIv_u9di^eil#^#Zz#FnyhNO-RQc?o3og;*0{Fw{MH_~TEX)$);(;B1>)KR@GI1W;ju{~dD' + '=x{M1f-b$$U)y_W~p(VdUd8MFjDkuH2MkH`cyJ=>__P}+`E!gmpQ=5f{&7dvr@sK7Np%y*7E@QB!na>Ua>' + 'j_kw=1Z}Z)v)&Gg!`STEU{6zcF>X#y%!^|YYc|+3|F8t1+^~B=knwKU-trX7)9c}puy7y5SioJ^+|#BLM_7f!a_BFM8zIT;_NZVO' + '0^l>6o){YBO?=Iaq6*ZJ|jVV#`>oUhw^n79-);f_o8KJ25bzqnzujA6en7dQQ-$!&$AkeTa)' + 'i}ZWSdB|%' + 'kWJlrVJ~4($)&DnuPQ3b^6Ac2bP|))9g78lyR#%tSYQocCDDrV+piz<#tarq$%#JLTwtcBr@N=8r@N=IX`5}5=bL@MZ$+Lb<#yM!' + 'eNxnQ(-(c&)ZNL6e70)pUcB$C@>)IF7X7XIyWVfFMcb*L{oSstZ<3-*E>1Q$#=7W>RaJDI==31nx?J@O^JQ!NkbTpxZk-=l' + 'T_e!C_Mc{(eZ4}2iwYY&Jvqr=T|9pN0fBF99EBHV3IXgKy`J=}3g#MdAhwqwtQ{J4PB=E0l&y!8n6g{;6y*T|nJ?wh1>&}z1#;3n~K+lRB{p9Qq^yIqe' + 'SGRdr{vyVChq' + 'JMqlkiSp*Q&(~sA+zsD$LaZ6rza`I`THq_DT+l+s;w^v^1#%puBhcWcE!HKFIA2xe!T0&Lc%RozYq8@D' + 'XhaRyVgrkN*8oa5jXa-sqS~a%=_8sS%Pq5}+GKA=Dfj&&3Hes^x6N7u!=;?Rc?U%7=Bo-8?3Z8iuk++0dtBcUXm4HMKbUd3' + '&g)_etACk%pk)$;G`cgFUW*9W>+lXe&dkR=_?}C&1vjlAP3t>Q+F%HOc70' + 'Obtcrr(%aB;e|?)hD+73iSS-^PB;67*D<_>sNUhXJ{hDLpot)Vd(CW{KsB|WndYO??eZ%s;?|0#t_#{d(*yyc' + ';16C)yES_$iYkpQhYL`yYBhlDO2JRI`>qFDDk(q^!R%yEnYSKr-8F10XJxm+eOSzCr)Ct=bkZUW49l=^JfpL~M86SjRI14wRa~GS' + '9=E_YRj=VsU)r>ps>Mt{@tSJmRJGI(LMsGqKWBm9gNcISPWkf*2Ee_yJZP+3=4>S0!4Af42*)LB6L<8vi?BgQcZp+cH9@R3q%>ss' + '7~BbULPjA@*pP`4oB)n5iaIJ?0aICVHSo%pyr>DS1v1*g1LYdns=NDiOdkf-;6!nBX%rPlG}q+%SE7JD@2!C3ut9mN+T>e0g&nYi' + '2{J%wYqUOLI}>^UI6?sb-U?f}X>mq1l6;$-O##o171&33RtbOv)iV{K;!#qpR{QO~Vv~B>Fh+Y#9nI=70vx#sgsNhbd5RVPaBRbq+`U^bUGljA>FB+9SigwOi`P-)p3SQp!Ovjw|VK2Dk9aL6wR$QP)wzW^{_goajD8Sri*DRmDuOx@I{wYVu_3c$AJrVr!vnlO}JRLh`YJaK9Hy{xr$8CqMxQEj{W' + 'm~owchei%g@~apYkM1-1uaY=g-$quavnP_}GIC;=xPT*_r))lPH=u1{orf%PUkSuPg|tFYGmKy}FtGya!b0ub^mFNi-elR8bq^Pfk91VKaugHAEE2B`v1hL7waF(8Vv7wU1vWL5IOd2' + 'Hha-Fy(nwgGEY~{b_ZMIHHdlDygMap3QT}q59c&C`(M8%8|d|GT%qVcUY9MvSFKK)eZSlHfMC^^fD+ge`wq^3*LUn}F6&ybv)_yg' + 'FiS!*?_u_J0i)+Q>3NoAAi%4l5=&!!r7$$kx-C|HmL$)5j-hmPo~$`OM;z?|wYTM(2Y>w<=K@^|XxJz3ntipFdn4in^WHW!3zk9x~qHL^#Y5t|D@uCz{^om5xK1M|KR#JiQUM00X@NFdT&ZK|OGzNZcb7blLAjJ5Mu>tdU_hjsO57n&rEqg++=ZyS=Adaylx1m_Vl3Xn_KVhSPXH8V8Xs@R;MZBbF1NV69v<5InU#fH=S{>-8Q<5zzv}pB-3r>*Q(C^)G(>iDJ{(d{f@+TaM#r?&Jvpm_5zJ@*n0KQD80rx_2ZqyW3(XF3+xX' + 'E6^g*W)H3`gkQgg1;(lkRTBGv;0V)EAdz)W+pI*_o!(TZwWm)1C^Wul2WxA?Rm0bV56Wzj83%ooWT-AhZxhP2l29;Ok{`<(-rVc85m0y2O>9%nTLKJ&RdflMi+u3s{;2QGUJOY=mdl%<{hHNISYZH6pXc2wO8fT3@VgVK*RkF;Cq-Ac-B0TlY)MIWR9j' + '5xxB`ikznJ{ZZpQ0$5nMknZ&6%wSDm&fcH~u{~R-Tf5+;+5xX(qoCA=$B^6e^q~-x8i=wWn)Y4MszCMob9al4PwBeoM6$HUnZGv-' + 'T#KqN^lN5;w}j^JNlXK45UklREyeCnjYj^8hcF=UX-0oqVN-Ibvve35#GTe*V85w4B$wnB1GOl&z&@bSSo^L?*+(&gqGkGgH_<4%{iLEYIEZnQ+Bst+JXz$MAgU4LaWb6DuZtre*YfU' + 'IBtXk!@_ca8bYbjY|~Bk0yAZ4W1h*h4?}WWsp+JdCF~Qh>-X(k{hv8C<36(m6CWOIfZ@Ap+aXca;{e3m*duEvy-GVj^w>waJ5;ZQ' + '5td0ONl6VGdyBSPhU6dH-=g+yYLY57FpHFYGd$+2E?}O=a~|(~qW^P^#uz0{xz?z{kA+4dF$+T3%s$8lziB^ygJ%#rBqtx@2!A~2' + '6R{<}FZP|6W+|Zzv*hVD4Syp-lW_G6DmtV^K7!T{zo2OY1Ima`H1hU$|((+DKc5HJ1Y*L(a' + '3XSY#yQzwsa1=Dcd3VhZkUVkCDW#U7sUdT?iOHTEzMiaT?3RITR01Ps47p&^Xd8P#%2JJ3Nk-|KEk2wpbxBX-iR7V8}lu7;%AkDOt9h4tB^;Cm)s7HS|w(h1}xM;b1&Q0@+^71' + 'P0F18!kQddf-LngKsABw9H;sk-HChdckSSg%22' + '(z#?HgBs%HOpiSCc}-DxYH0+ftownGz_YoFR%W^aK%n>ouFSYV&2*p@d@-q4G(Q*;@Ikk@9t>1@`Mt2kHz`1Va' + 'nHCmges3vSQMoy9q0ya@;}(n>#&DLN95s~WPTyDzI%Y(>zm?#G!c00An1!Ujys}Rlup{>o!ppaEg)rQ1zy#Do#kPc#ZLumT89R8V' + '$VFbsA=?Z%%G|(69mdM{DUcA_Z$Y_40XE#EAXN$vf10ppm~+ioZ7_1x$*-}GJ|' + 'y2CL5%Q;RINJ0p$=$h&+rdr-%xS^@hwa&~vCFauD$+IVxYg!A;H-TfytQDI*ndp6!bj229&qaG(g6Y!U;dUlH*J~#6zQV_#l-pAH' + ';mg3*gzA|0vN_Cskx0*^*5T4Qsr<3lbI+T@yA4>aCE1KOaGr?SEtK1KKGBjm)+RY5!xMCn4Ee`RAK@1`+0&km(@T$;in' + 'A!AM>v+yNFlJCPuR@ENm$}YFU@iFqr+c&*+tz)wXTP5&ZT__BMiHjAcB=O}u^K{zvLW^APVOXj=2|Fa%gTte>a8$C=Fs~q;2Ful=' + '@sytvU+vo$i`;I5rkd0T+NbxPz7A%ELFhZ1>Rc-W2?`&XU}ZVKCt#xE^~2s)q+$>!SEr>!2iBvJ^U$dB;f$v{v$4;~_nqhP7ZN+q' + '{iR}g5yHn0c6+ehubl+;+o8upvXb5IOKKBOK8viGKtEqJFiT)ZC&359WDNW2t}>(no`2`UrLNuE%jHl(!P#oJ_uNV*PQ5MaJt}P&' + 'WUfZD4h$S$K!VSQ8YO|jQ1ZtfdpTgeP$7TPY6Wc2J*$ihr>TEGsA_F&p' + ')`p-Cr{vvJ#+A;30W+HqNShKiXFMJ8fKnu#+$*`=D7E8mD8%=$b9E@60WYhh9+K1}q^8<*bLT3ATqv5YoWC#BI}0;vuN(|c8`ski' + 'KzT?JkL3(I6YqOG_e$k#qU08;UW_K*aleanlSZYSPC!Claxno*Mr1Jq5)~`+a3~85)T4G+7rXAZkrbeimPhf(q0%D0g7N93R*;mF' + 'VX*2yjHD)8ISaG%WWc9M2{JB3oxtG1o0@LpUO;rUpvrKUGd`Zz9DYQHh9cUKf~58pY(3|Wud@j&uvnayUe0F;w-AXAC)2Ggx5+0)xoMiVCWE!Yu$E38zh%-D>N' + '{XAqaE{pm`@O2F-7=0@Q-J10Z2dPYueb-xj)5U=}HyS%9*8=1WPyo}tgTJK9;J*f' + ';VVfXref|=IKOKKU{-H-4OV*-?^o5H>MC`OznGO=$Q)xM7w{?qnMJ!RHln)Al9#kJ<;sF7G<4w!)9LW%DTT8(EWR@CGP+Yw3ws6q~bCfD;?x1yb-y+AnvC<(~Im+x}x7iA0Hd{Lklb@%F@qfE~zhN$-tl=)5h!74^M`&$72so->PLV' + 'L%>;}?yXaUa4{mkMjm%3<68q_;K!DsqMQ;Qsr6?hB>MRmIfZtEJ(e%75%h8`+`q(FVP~' + '3t~{GeBoQP>aC0A2tQFW+lim|0%SP-Cb{g-m6S&&VFhF?j=P!pzc?_RlpK77u5E{G#!!Jd=h*' + 'iA|9lC@&}L!$;4?p(91Usyc2Mf#NqCBvDzguA6we@PE^Y^QEgog{HJ6~kHfG8{?%3)' + 'Vy;O#0{35+$=}1eG)&%soRPo62AN@5j5Pfmg0^p4*bJ}+2VarttOPWwl*RMyvgAdvdIK9IFfDq_amAB6Ci2!hA8=rY=~MYm7fce6!IKGKZX7(8gxoB7<}G|WMkl1Qm^D1}}!S5zXV' + 'OT~R9;~S%wB9cLn_2i0H$c&W`VG*V6iCjLdUm1J' + '_JRuPv2RXiW-`3+j%`eE%I(atT;69kz~co(KE(#Vw}m$D3~=l>ZH;})**fM^1kt09@hVU?FOa9(qRSO$ZCnq@8rY7@2Pqy2rJ}F<' + 'Km6WILuRKelc#u3VjdbUTkW^qek&m@lKI&pd63!>o_gK9%P9nebYzg1&P%Q16Z&YW3Oo' + '*HXTezB>_gOe2r$RhpmcH?}q$JwdBVO8OaeeG7``Q}pkdbJOSstLEeY(^qlcFusCwx*nt9!qx;6ZY@cAq*W2UlOdrZjzHQqR`MBF' + 'hQV2R+7R`PRXhDxRE?4=4pNtaIb9vC^(2s~OYc|Gj%P#d_#mhqA4IgHoQ9{)z^Yh__c`w$SalicmL8!c!(dP19hLOjn1_)Vh~^{>' + 'ARC)GRK+!5fgKGPm2-4rB^xVcFs>vlAl?w_9LLhYb)2g5jhI_7-1G<%%k_KXsjx9r-1PTgT#DonRTiJGjI7}<s^4W=YJJg0FLk#^mlM->I!m;@f@(X#DDKM1){a*omE@>{8=ZZ>KE*p&CC{`^b' + 'xGVd!TaFm>A4EMsaZ^^+{O<+b{^1_ziM08DG*AIVxOBF(iEEyTYi' + '@%5@qdxjy!M4;!~?fPgL>Wj{odBE{@a8PN|56WAQOi8ZQzt4GYe^a;)JcQVgogRT)JuOt1kt+-X=3SEbFQzp=i#ES<)U#E$V1U1z' + 'S8NKyu+^ccR#5eaz@bhbHJ#!(Xtf_E6giO9#PDdJssrC5l8rn0{=vlc5x$RbA9;||I6@@iJy0r!#Ju#Fa&;0f(Bej*B^4Id#PF+e' + 'qen!Wh^ChwOSve;L6$0c+Pj<3M^bYZZ$#JhI*FjPerve;10dTs-Wc;T&1<4Ccj' + '*h#+sgt9)LP;79@mF#%VZ)q2R8m)3S^Rp~xgA8u-F^KVuOfrr^E#yJR$XGQ%vwA69l8<{4&6)$K5rql^4;rm+h9O0H2-^R~DPz=N&=FC){`RcdsWJWe)c+x4ed@kiBf)cO7t)`f9`;C_aq4TfF#GjiX+' + 'lk0t1QMi-p)0k~SZo^%Ir_lf`{+I&n(4DL-zu~z{29?{bSeI}%hez_#fWDfL9VGfaly{_R;zV~j@BNb9^MPfRN*f_wH_k2L2b=oR' + 'EjhBZQbtU+^&xO7iP!Si2g`?#r|#+B>9LoJWcGFFTjj&Vb#s%+BD=QYa!q3S2fz-Sq*MUFPB5N(MG_{IRq%&R?^(FvPzeTku?^Gg' + '=);w&4uiZ|)ytyLqxX;P++^$H5(tiD@SF{F_tO*RI1oGvd~{0q2zA|FrUdhaQ`y!*?ZleM?7~5' + '_yijwepHjgJ#5%VNqywg9P-W=?cI4;VGx4?C#P}hhWE4pmj)jQiNg=ygE(R&u0}a%Z{3`5968R#jx^qlJnMWN70>LTlWJg*SNqcF' + 'u^t`wgg%MVA#{QdsMoTbO6_4POje3G_7OqKsiB1zQl&N0P6&H_0#gEZm{+;' + '4bJn!-CEX$E0X8MbD5c*#S^x|bK~k@ZvLoS$xV`TxK9my>6U}seCUFq3_f(>;y|T*o?>=H*X3BiuG!ti=_Emdw4=Wc7_m@~*nxL?' + 'c9q8Kd`JvU?~%4lc=iG;k>SVG2(iURrHjf>bK{H;OU+Z%hQQ$$c!zC*>}=#$N>!bk`jm%D(4@p=b4m;9gSEK6O3pqWEi;6E^0ivP' + 'X?9(#-q6P?qJ)^cW2VW!sNQ&Lco3(A4tvEQBN*q~j&WG>K7F0-`?A6qOu1uqiQkgR$7wsFmd9(*4DgG5>i#gPqhX$$5NaDoprEQGREkOLH1f=@_@&Z*0feEzOvP{KL_4f%=CrnksF|#2pC4j>nr0C=SrZmROzm8cEs6`VnzNGlZzc5e4u*Nu8tjvy>4JxA;' + 'K%R@dq|YOs$IVddOY@Fd8Bdvs+M$uvY!@%l$0A)>kWWzKquFPwxdM@%9H=zG`et@66^~aO!WL^R5n=uGT574m;uGrcP#f;9TJbNt' + '!o>drP)h>@6aWAK2mk;8ApoIN5Hpl9001@W000yK003rTb98WQZF4VjWoKz~baHtvaCzl@{a52gvgq&p6^iaV2Yc6eGLzh#``9RV' + '31c#xFu(zm%`V~U2wMj2*pgS0XNG0@-(OXKSAPf_NH+JpoRh(lx~r?ZtE=m)tK&Ex-!1cLluagi(?o3*wYPb6kTvb;(fcM^RMUJO' + 'W#u$l*44+n%*sg~EwZwh+8RUQI=?BJw!VvsCMwD{FWaIjv-uoel+%2f!#`ykRkLVZ)stHWY+Pq$Gpp)FUN=K{U(Jj1CYogB' + '7PMaH@Fj=J%`%^~QL|butGWdoC%5_J!?J=lnn5%#CVAQ9@CyM#9jJ%MHPxzyu0)@T_O@EJTSZy4MK&+~i7jNJL-h)fT;x$zN7L$4' + 'Ij^!Q`&`~dvtph%!>u@ux3*?=wTRMmwrW>(o~BW;Kun-mS+yDVvf0`a@9JFsYOBS3UR?9alWIPPPGHetc0G|04%@uW+NvHz@3Z9+' + 'n&zdaS({DfSp(gYCDkilVzw$LEex0XKCf5lB!iLR-}zf;K2msz0pZ`5S$k`IJ%z_XbPByYt(xMqe*6neUG=HK7rbH%RH7OcNT1f#' + '=Q|`HRRjl7l-Voq2GLQ09i0zgYMYjqTGco@!(~=CxhxCs*z?v_`fl`ddNzs@Kzul<7E73oULF6><(E7EH`|$A{qg_A{jK!y?dW)X' + 'cyKs6^Onta{t3me#NTx1>JO~eyNmDBqr?5t@%bn{-g`gt)V=&4UT1jqN38Le!{fIne>qPNkAK=bI((a+?v3B2`|tM7_V&l4vva5o' + 'UHT`IVcXmO_PgEqU;q7wZKK}V=zMhc(@0drHff!$2>!c|_bz^p2T{C#a*ThDFOKlf>Dh7ozzX~;x}U+v_xHG*K0m|}EDyG{KEuBe' + 'tfZT~_j(Y${<3eBKRO++Qy!2#8=an#Oi5HsWWagVEXOcz={a-^P1~$NmXZi+4v(-5-1Zy{jMk' + 'HnhFBd#7U$EY^Jh*aCBEenA>5+Phzdf`j}rR1KxsG+P2`LLqD=RkVVLbn+LN|Gn`T' + 'DfZ;pH~o2blh-)q&2*XJ-`PC>oMx@yr2I|R-0D~98{f;XVm6<$&9~#fo?7&F4|S@n{fbq;X1`y56F(T0PR~aBhv$H(U2&SvvemrB' + 'irK2ISo=!}2!OEX|01xyKY0C3I{JCEe=+s|Y>gZIkjASNn?lxsaLwhb8GsFTQE(iW$P;(o48O#u$zp(*uSq%FJK0zO>CTJH!5&9H&*aanZi`tqVPkm>UA?XVq(fTh#fKP2g-+OkiVAS7r7wE9R`sl^mx%*hAZKy=vMYf%BPAZ*kGUMpfoh_%r#yYJCqR' + 'Jo=nZR)`Df+eTD+we=SF$z%9n0`V=nD@`k-pT}o=Hi@|NN@#U-K2FCD`3r{fB6Cf6$xml{?>+6^KY8xW&ipWfeStGt+$L~ywVQbY' + 'DVQysT>!U$4S(Bqb-W(E8}36sFxyBMFO`$q`YH=pcZ{4d@E' + 'LW7kuD{kTeefu88TJOt)s(zm}AM)wZ`_OlgRIpBs@jU^OI+D|Nc5>wK<4<{UbK5q2$&?^)w<#z!Fn0}Ng;RB~cX*bL&fg0l?@rFg03)8x' + 'hqLRfSgx*tG`Z#gYc2*^<>X|X!v1vhwtGe!AdN+aw9#0DInQr^q8Uu{wchSOwk^z6jKu>DjNj7nviIne=M9{bFI$v${h;%VOrdvyk~xzqC#3+Gj>iYvK}4my+1tq>pK0(Kw$+gE_m1@1Fb}a6o;1#' + '1f!3%D3`0&e4pcPPKQJasEiJa66FowXCc3ODT53I)7#89D6OTr-0KLIbv|Je(oh_iz_=Cw8pzRKdg3Iva;6ss%MCJZ{L(5nXf*A^eimuWRwE%*mIQm9|B4ttjnl0E&Pb^_dwfkUO7;iQ}{9Pol2nV(Y-7^_b?' + 'Szai1q>F6nr$J^kAQPWWK3rF2&Zu*4t_PHNk2YW4jIrF2X@HhtyKD2CswN+9l%U55yJpgc38_JjyR1AfT^_+cINX4}U6p9>n12+z' + 'EGeL*1iK;QgWH(;7>3u`D0FphaaCIT26o+?G%4k=lv{2HF=u5}0!hqMPVvoc_U1po@e_bs+ea`USy@{{li?gq3!E)24scTe5r8%(' + '7##SfXf4|Cjk>*axcx*@vzB=Si?vQ&s%%jWNI!rc@W@UBa{`PdPp|Xa>|;^YM%TBt*m{c2gspQ_*Hzv7DVwiy_OrjsipFt#mK9At' + 'jXvGxB{QXRgQGN=avQ1S0}(UK03Vm;QtSo52c9!z2FNW?yQ3717bw#LfzBC|Vl1Zj5nJDl0G&aEs|t1#_SBDdzN0ohja&{pSs4Kr' + 'h)Up-Xck4gYF#zW&JtiF^NeIG%vgvh&OoM)Ci4pV#nD1&IF+Aa@<~G+r#>t2iJK5b5yPNp7Fv}kv!$XeDpjZgX60Q^O$)vo*0`T9' + 'dvP?3`_UH(d#OqQimXrr3PKBFBFJ%z>@#~xUiUA*yc({Su!;5hX#eUm&Y2Ji4*cD;H@VG_cjYj~-$q}(!E)*q0Uodz5`Z(iHtAG<' + '%PhwR28-ZGqhYIUwsfgt7HM2zMMrFe&EDWkyRIBMK9TlJ!Z7xoV#5No-D_ZWt2XbE&te82FEFivw9F6jFTcdtH3w(Fz}IET8XQJqoLO~DZ^BLV$rD|j)7R#B%_hPL-ATQzb%GtwibkBPIMg1VA' + 'uI9^~;R4L$&&rw5<19NF*QcQ86D+RG?d@Xzy6Jk)v?MnK@7lHi7I1xe|NI<{%jq?d?Lp' + ';?g^RW-tECP*~*cZ8ha3fCn=7KyNZ{29aiajM3Ss;(st&yVks-V*?~-V8&CSFaV!npd{N!DBB?Q72bnLRi@A+l-&T7{PQGVwvpa3' + 'qO6H>{6R7G&3TM{6b(1g0(P@#1%bq^^lg!`3+SpQUgf<$Sm5m+HR' + 'utC3$ccZVr9H`BDH~Qx5hv5xK1qwes9Kf+`P(aPcKt_)b$1J`mwNr4W$HpYDd-Yo{2hlLJ*W(q~R$}7;QzN`k!`W&+Uu5m%R+ZlX' + '$-@%JtC$*?JV-oo#oXi20&ajRe@pQcOBX1fO-;h*M}bNlicAEGViAF^16y8ur&f?4*Itv)XKdjBuDf<~z+LeBP@}xdUvS_L@kU%k' + '6Vo}9MHMeODbe+}STRD3-mPwK&~bQ>P0*PvWyGi{vt@HzwVIjW$F!Jg<{@QA{f0^4yHUFW0qauphCwtO4zGmBLB(}hn8{IC3T;in' + 'VZw;#51T+ATY7cIu(=tUk1vn|' + '3WCZK8KKQ7FAc4c&dR1sPZ;v_`cixMN0`*CYz?uJ89wF6D7Ty#_)W%yJ1qG6m1A)ZY8&)dvLK8=(!RDxnz)o?Y?_fH&I+m^U>e6Y?}{' + '#0Hg_7Gi_DEBmlSSi0m5tckhU-!W;hOHn9LWsH*W&y@*s4MX!TY=YGsSi?a!Z@9kQVQ==74mbn?ViYA%wxZZLMgL-gG!()=_LMN=' + '|2pN+5fCcpwfytK2`pkxe8ed3gA|Yh<y!4=T`s8_h&`Cfmi>{i_#2BuyCYxviauH~=5QF!ZYD^hB@7^GhRrK' + '61xN#r0s^=B{WOXgl(}FA6ctv(Xv7n0BHsmy&B9U)ioZY3cAay}n?ed&pdU*8XRE`^0{~))rVa=`_!msS*zLdPN}4_Qw_gY(0tKAVLtPvek?1x02gg!$ggCoA1fK{)kS<' + 'vO)AEt?1>Yd*Zi~QTC?PY;K%|y4)I%%XVG?iK!--(SgOQ9S&@{lJHB`57B_=7zt*Q!Xk5' + '&XBm6>o>*YD`~351jJKs_h%s3J!FV#4&rRMU_l6b3' + '77j8J?K-q%CYwWwzFk6{E3zb8*rHR--sEAfORv(0rF$m4z6oodmnJA!v?VchDu!gjlrWav8P68~A`Hv|yqST7&' + 'ez&afY4V}rlj5gS_KnRK|5aGEuqE|{Hz~dtlqrmFsq`+2HOc7OFUos15TJ_#jJA@xsxW}yfU}4)<#}PwI}w6`jJt*%29VRvO-f=)' + 'UQ8ZYfyf8MXmZ()@SaOq=4zm)LASXZ%|S$Bd=MJCydoh?CJSJ=>^i|S^Xxju!=hTwh2p&)7exn(MKo=H5cO0DrTQXY^!vLmQL@+^' + '3^OQA71wbFI~8dUhpsU3{^@gPg#Ris3)7`fwZ%#*u%k=Mbx4#q&vnOduUi;jrE7qVkR|5(Li6tvx$1^2SW+3MWb5ey@O#8FCK>G' + 'K=8E+pnAccD`CMCUa5j7c$`_n$B%IxX^~@1d0k9tw1wqvO6Rck;!uy+h#8jUkNyf+N0q<|oaQ)pjP+Sdb6h2YG' + 'iXBa9#L0Jg@ZEFneS?uFK29Di_~JuvowV_@g5jhBg|HX@N8FFTi(Wgi!|QWT(wvWCX#woBVyM}tl*i&VIUWIKNjzqCc7w%LNGV41' + 'GLtI9R>hP!;qnriCybjl8jPk^(a%`Q34TVEQGdZ$^*1zFUVgg#@(M#Bar~RuDblP!p!&=T3>Q=PyAGX5<-3CmUFu1Vh(rW7Nd^P1H>J#w>i%>BjK;JQ$sT;O4d)4RlLr#r^qdc0u@|j5WQ6%O)f&r' + 'F+&Mtme1`xlU&sG7&MqD0%WieMYeOPCRdllM*(kE9P}-bKbeenoSN~PG_~`nUQ|8`5b@_w(gw%cGLL3fAXHJ' + 'GYI{Eoh8V>y1a+uraXbp(I3CMVV?yeYKQmSOt3`!OBpz?^Gj`gc$u{hBRtP{FxYhs<%Y{FMsUJ98jf(I1meYlS}ygsin%p^i?yku' + '6uC;u!e__=cdKhF+(;|yqJ;-J' + ')!foGu2|ZD6F#h7pENjS?w)UFX?{E&Fhi' + 'QSo_7or^t_49iM;vk8$H4GS-_Pp~ygnMauns(CqjzW0DfHvtl0Y(X+ixS(z>n&{r`zinONK`K0Vl4ra1b4jw$Sw!19m3jV%E-rye' + '52r;!gLd>H)3!R}=pG$*Yv^IN@6exs+np0PiM-kg;j7B|oyQFmGlzjuI@x9&+79|+q{mW?!a(e3rnDhP!0!Fimj)-Wjk>%>*QC}B' + ';=nW&gdy`x_VC%ObF0$ai_avX__l4AR6SjUfR+p56>S(LCsWtUmge5&fHQ+5Mc%Nb6!W0jFKH$?Bo>kmLIQ%P9b73`<' + '&VqUjfAbQ03xm|Dw#W!-mE5F9j@5Y76upo|2V@1-hp)f%ACT3BHZ_-+)TcvLzy5MZ=SG-R3y@sYfgrlAzyVI!zXMOi2cLk%ej(I1%B4wk' + '?Iu2?K;A_S#Vr{2l54my$KVV1;77b_6V`J_f0W?vS*|%' + '!bVtSVR`DvOS)?B-o6h352Ql)1>*Kr||' + 'Fz=wp(0isdB*$7AVwDt&CD-u_`=nha>apdCQwX9Z&cJ;v&t{_#CKISXJkfPv;q8~Wwq=XkOtaY*jfM=ky9{wB#TH7H4@!n+O9lO2' + '-tHJQB^~e3|4XcN^|*+Ps>oW5EHn&5CYPBavyr&YWM(SAV8Ehb;woyZY;A0dm+AMOr=WJBkI4s#u}*F?vNr(+C&g=~x3ECPR~<' + '>RzrNP>r|$-c;qblA4pmq!xB2VcSUbzx@zL1k-Lwo$a2F+xSfn49-G-V$m+kmbnv{o0$sPQ3s+I|mfgHI2OB&ech~#>?^z$sz9Cs6@(sr4mAK' + 'slm&$=jwCE' + '$w84^)jJeO1-NU7Q?bLMX;#Q{1FUpe8J0dG~WYs|!HzTG$MI3LP+*a-8nigsd03PZcA7Yy44zS=}0`(2Ei' + 'IanKpYIxThb>mq?-wd(JH%a2$(avJ7IzZ7KQ>Ih>n`1qBBd7D5~Yr=' + '8z@|;4gv4yWHXckhRltUHQ7N;ohF}Dr8?T2l9{7tyV=$$1y8}aWU)YT5G3iY' + 'tC=|*%@BYRP*7jn_I;5TD9v9J^QYpU;;56)T(&nJpB;YBE@}6DK0X;kU2jEoTxi&S_FCe;KYzRP)z1DrTQzy8%=?qKAf4^Av)Jc&' + '&OBtdV{SH3EQ|7ncT+^|7=1TW%GhMqcZf;@TaP}hySGI3X!M$ApObgb+-tZGu6C*>5`}xJ2Ut4kG*M*-TWdI{y^iTebW!*6ip0Bt' + 'xhL!VaCkT%dGZ$&d?K-)2Y$Yx)?4Df8u&9=_%unGTCFENu2P#EY;BY!(^5XoteCrNd0(IHNRd4mep^hxy9&e{`Z1(VQFBd{-E|+p2MzX5buO8n|bdlHeg_;-97~!9^u;Us+W+qKWuB' + '*x3I;@5$~`%DaU^SJ}1Zhr@PBsH<%U!{KB@52hpZ??yw`#jpgzs' + '*YHrz;>jTQ2>a^3dEVk!?gup(-yZ;o(a)oOCas=Zk#s&5XI!|@HA&{h_MH=D+yLR3tkbj6{^2>Iw(*2n_gCBn=Pqy|h5yXWWBW=R' + 'fEIiA65jwz&KO3^&QHPfk#Z-4?`92?)%z~o@G||-twhiFw)r|`rg%UKKwVFN{*yN;uH065t{G!b2{iK15l7Y5nOwByMP37IX=;Ba' + 'eAI+J&%(1ky?0$MOsjRGIJ7@;7YYnAy-ecfsyaSN9poPMe~wi5?2JuBFZAbCJ>3MrC597Y>QIzpnA<' + '37DFz=kcrkNUYD66>ax;b!*ROH~tO$yRL2ASOdBUA9^N-Tx%Hg?uK*MUj8!Tid5<4(#ns=M|#^=Y%tD13bB>YUgI)doKUY+cgjNtDkju#1Oa&%r;MlN%_z7CJEky1HXP' + '*t-~?r29vBn(y3Wchime4m><=f7^qW_gTa8-Xn{>Y1AllGv-d?K$tBi{a?-(xYr@#Mk$dL7(aX)z1bAy)4TQ-I0hdsA4k?LxhSit' + '5GT)i{+a9hY(>09LeSR7IA9v$HG}!4Tr(0L3Q@1(7jlK!_(;ZVHJ)s%=j~1xmp976lhe{_*UD~b#id$>Llot!ZdavbMp^Q+hE!6b' + 'w$6o#(L%~E%V;AHx!ab~8vRB|&8lZ+JHxYDb|4Cw9$MEkJ9ykROO*K4NBHVw)yl6wsXJ%b<596F#2rZnok$<2(ZZ!=;b9-xjwXK>r3W<@fjTiyJ$U=6fD^IB' + 'K?7T}W~U44GbzeHeLN;WX3V*_@WI?JS>WO%soX8{$t_T0v)Gvxb<@fLYW}30&i8iDA3D#SXZbhk6^ruRFE' + '%@RRRvGS^D@3!UjwRRBtXnDd7r$b^s*wNH5`U(5j_KrLDi^TITR7qGX66cLIa?-1is9(1CyShOVx}dUk^Mjk{)(&`BesOpuO=$tfXE7C9@!TB3hiXqontgggzr5+}csHsOJ9@@0SE3km8}m6NqJcLKIU' + 'lS{g^#=sJ1f=psdbs;ifKJ~j$OKTP_2kDQn%wYK5hc3swIuCn9Lq9zPagUjZxPRCEdl' + 'elmip&{~gv{!s~v@bC7##MuSN&+ldCs9<~sPpa~gXC@@XCmorPhEJYxgdJy)@SI~43iT(RnpnH7-{;9w)8MJ_+a%HF|96K%;xjVi' + 'FP&)2g)a4CiIsU&z~@d_Gj-IzCutO2F8^z?Mm;fK6jW<)FG$tJyCHSkEEbp?8<=?^Z}IHo1vYd~n40)Z=lG$`7bM&OIGSCei>T1>Y|s@Z8^pvZAgq?i#EZ#?c^)&%M`z;25A*oN4xHir)G(ae2z8sWGmT-g?V{Ilri_cA$SaIJeGutQ' + 'kse!7mr1lNkjs!@pCQIbh%hnorzJS)TxWPZO%`%YK~fY#q1jyQjwwhMeCZ?bffEcJ8T10#^DL&9SkIQ$sV86meDpko`-)bv*GOW&' + '*gzNLw`{+l4#+m5x<}hk' + 'hU?IvbP961_QoRIBL@kjgk~N{AwgRiOZewrW})$J+JHF1GRbvTmW6`dZrY)|=YsXxUUnNz(g9RIJ$m0zwUnL4(e2pNX0^yRFSEQo' + '#khXOGd<<+?DUQlbhk1b%3d|7&WDAMshkqNT`+MWj4<~1bn;#2dx|0HKd7MehblPt6' + '5?L&oNU;K5)#BAAeo*E1MX?uAB|ObPNvHT>bUZra7Xjl{>1TWU<8{;t#W05U`5#Qd3E~C!f-!lQPTv=#z>{R%b6MTIyTW9OI#nfU' + 'JK0tZhechyx_SnqDb6}sL09x+ESVEE-dPX?!QM+grdiU%hJjZWV7a?)Bj9b6U$!OaZKCkY)7B~X4k=rV(KCOU?vCMHtG?`R9LwTw' + 'G91!^b|8NpVHfgMa_vMR5O%o5^J7~KJBEJ-w0IC&;oTt~-v2`!u78AJntjY6oGs+<{khvUa;l3iQx$z?*gC9^ZvPfXXR?-Ro8V3`r~#B-8+PgRf)xeA<%|SOoq' + '9mYsx_{4g)%*$R$E(r+kfef%N40THgfg*_K)FqkGmIw+47@l3Mf@E&9Td??Wg*;s^Aq9;d51KdqYL' + '^C?DcC=H`O#1KBpz)D{Rvuui2w=!g!Azl&UOD49+>zlj@r5eMiP8^11kx%mB^)esw;}5=~&~s!VygkOMLv1!3To9BRzaRu7{IM!$' + '#Z9mv)6gFI>h}G?ksYK)$0t5+`af*F|-^y4J4@' + 'UaD%R%sk}+t5Jnx+7`(T!KoO+3*lVZRfr}{Lin3ds`aHyOxIjyZ*uz#YIekdVLQM6M=kY7f7-Q^5L<3`|=JcF(Q$X' + 'Bm(ruRayLF1#0A^u28#T6Ty#`GkLqw_ja$?Et*oM^CatGH}k1{Yi4>BstIsbuQLjo4pcSuT>26cj`WKT6?jW5Rl{bZxzWXRQZS8V' + 'hPkZHWg?9-m!{ZEj1otQrC6bDKG+x2Wz&y2s18_&-4I3i5X~M&_)Ev|kaBJUGBI3As1D!SL-kg?E70{gOhaI#&HHrBCRdxuGgv&5)LP8S$w$*4Lgz|bP}(r{Ya' + 'NSI2$7(M>N=<$=J_h2Ur87fYZy7zYD5&clqdE}tEzmzakt~+h(cG06E%Z3HXc@<LyGhPSpwNQ%#a4^?ytp1BTm#Y&c6$KOi&yPz=fC2<)S}oah~7n6;McdT3>&6_0yI8(e`I{jcE2o|FM}' + 'HI*4$*OIS5z1Izi86(F8jXZYrD5r)+KOjU2y>D1CH6RfOtO4*BZbgroR^d8u$6Hqy%5Xz0z=Y`-IsjP;$!o3e=)?TFRX5|WY&~)D' + 'p*ud{l_&(MQr~c&hVhz?X+j?d#_i>bY!l=6V9VsooGmk_i5f?KD+c_G{uQCgQry-JlZ1alOgfI^BLK!0gPa?sT`1;)l6kdiQQ8FN' + '!yLh^0P@`-fY_4jTAnncs<48CD_ZC^+;*qkUU7f;caTTe5jjCVLuB$qI$^W!-IUa_he@4^cYQ)E-fE}vL|)&jxV81?dA&*}cw+~;lUy_DK4i%0bx#wMDiOvQaLoGTmWAaR<' + 'r@Ovm_%!_PY&Dm7cvIp&v;xUVo8>G?qSt!+U9b@Xc?Evdj?X=x$>iNokzwS4#%)i#BRsacz{!PZua' + '$pg|6951JW?^%5aBOXJFLHHmbZKp6E^v8`R!wi)' + 'I1s(-R}6Hrfz*fOX28WRg0v0#fuQhZ5fmydjclSc>5`))UhI$G;g@VFwi{n4kTY-IyqO_gmgU{cPRUx*6*PuyERC%W$EAgtam&p%RWm^44w!w?Zk70kjbW&q5Q3B1hR-(@KtQb_7zxv!o5o+fLW>' + 'pUI?3CO<*whUwNQpOCRoTK2{#X*yTrV6YPPFL>Ud_7*+|!WRO3k>qAYS(Zi7TC;_!YCGyIRu#yQ7lU&mAy=1dlCzSlY?F4jLW?!p' + 'Xw?W3wGz&;6v?dLiWaordDYV4@}Gyh>h{y!?dSV%AFtqxB(y>mg=~jM^8)ynh_HTjpf(e$IwReCPcpVBim!sUoqAw#q+>H!WZcJc' + 'Bi9>rR9->&^y?NI*=@@o?D$vDb;6KRPF(NzG^E>|T7#^EZ)uA;O1guP_8$Oh}MT@%TLR%m!r1p}m' + 'l&OEsLRCVt$_To-uC^f?u6s&n4zKg;^9r8aVCK1^_U3xdA&r9n;G5CNc7E}Bj!M^H3#0y;q;)<@b%-^Eckg@UAN|gcUW>nXJGJMe' + 'hjWxWJa(^Rx6r+P@8+rL=Ov1H%jbgjtp9ILN0jp-^%RAPIq#y3k%K@qvGH_5Rp14<+AJ}JS*l-U#Zef6%S>i5pFLUXP' + '=A`mO1-v{}Y=!(~*)K~Fxu*stj}>OuK8eJ^^_OO6!5^I49q!j4%m@{+F?aycz4z*%=V{rWmCs<1HN86qUo+^&WQ=KBjOs`XIxz!G}6riXE0HkGoW}+#R!X7BLNu' + '-CG{09W$8sDP0!H&%s8xKT1A50KV?3V-AZ`%#O*N7wwqKg#wJI3@qQqgzWx+v6rZtc=3>3AU?+#@EZ(2$' + 'bqZ}v=yK6cP1Qv@7ix9_i(jp(Vk7>GvVg~O^aT2Me0Y8OozauVPyGK-O928D0~7!N00;m803iTN8H6L70ssI&2LJ#R0000000000' + '0001_fdBvi0A^uxbZ~5Kb1z?CX>MtBUtcb8c~DCM0u%!j000080000X0KZv*F8Tog0Lumd03HAU00000000000HlG<0ssJJVRLkF' + 'Y;AKdVRUq5ZggpHZZBV7X>MtBUtcb8c~DCM0u%!j000080000X0Q^Z>5V#xw0P}GG02=@R00000000000HlEf1pokMVRLkFY;AKd' + 'VRUq5ZggpHZZBVBZ*pZWaCuNm0Rj{Q6aWAK2mk;8ApmxOTCPwD008zK001HY0000000000005+c^dSHMW?^%5aBOXJFJW|aWo~q7' + 'Z*DJNYh`k7Wo%z;Z)0mNaCuNm0Rj{Q6aWAK2mk;8ApmHGuBJ~5007M;001BW0000000000005+cgew35W?^%5aBOXJFJW|aWo~q7' + 'Z*DJXZggdGW?^Gxb1rasP)h*<6ay3h000O8001EXtezlg_W=L^-~|8x9{>OV0000000000q=6AP003rTb98WQZF4VWZDM6)WNB_^' + 'b1z?CX>MtBUtcb8c~DCM0u%!j000080000X0930Fff5S<074`H03HAU00000000000HlFMIRF4=VRLkFY;AKdWo=?*WMpY>XLB!b' + 'Z*OdAZf7oVc~DCM0u%!j000080000X0EVlQlBqZV0Qv&~0384T00000000000HlGFL;wJ0VRLkFY;AKdWo=?*WMpY>XLB!db#88D' + 'axQRrP)h*<6ay3h000O8001EXikkA&Q8xepRssP49smFU0000000000q=9#R003rTb98WQZF4VWZDM6)WNB_^b1!prZ*pO0WiD`e' + 'P)h*<6ay3h000O8001EXgp*hXBnJQh5*Yvh8~^|S0000000000q=5vr003rTb98WQZF4VWZDM6)WNB_^b1!sxaAk8YaCuNm0Rj{Q' + '6aWAK2mk;8Aplm%FKrx7002cN0RR*L0000000000005+cV!QwVW?^%5aBOXJFKusRWo&aUbZ>2JP)h*<6ay3h000O8001EXZ(|tX' + '@Bjb+PXPb`8vpMtBUtcb8c~DCM0u%!j000080000X0000000IC200000' + '04M+e00000000000HlH03jqLTVRLkFY;AKdZEs{{Y;!MVb8TO6Y;|*RY;|)lUtei%X>?y-E^v8JO928D0~7!N00;m803iTBIQ!xi' + 'D*yly(^b0000000000q=Cyh0RU!Ub98WQZF4VeZ)9a`b1!9cZDwz5WHK*dbaZ8IbZKvH' + 'E^v8JO928D0~7!N00;m803iV1J_T;+3;+NOD*ym800000000000001_fnq!X0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0CqZf0p`' + 'b#h^JX>V>{Wpiz2Z){{TE^v8JO928D0~7!N00;m803iSl2kLX81pokI4*&oq00000000000001_ftyJI0A^uxbZ~5Kb1!XgWMyn~' + 'FJ*IWW^Zg{GB0IqVr67xX>MmOaCuNm0Rj{Q6aWAK2mk;8ApnxQ5Rg?7006O1001oj0000000000005+cfKLGcW?^%5aBOXJFKusR' + 'Wo&aVWpiz2Z){{TFJ*IWW^Zg{GGAe4W@&C^Gh{Asc~DCM0u%!j000080000X01F8g&bk8t0Eh|z04x9i00000000000HlE+VF3VU' + 'VRLkFY;AKdZEs{{Y;!MVb8TjCY-BPoWpiz2Z){{TUtw%%XKrP3E^v8JO928D0~7!N00;m803iT2_hkvd0RRA)0{{Rq0000000000' + '0001_fh1)C0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMi2bZ>26X>Md?cx7@faCuNm0Rj{Q6aWAK2mk;8Apql2SzMbC' + '008+u001ul0000000000005+cGiLz+W?^%5aBOXJFKusRWo&aVWpiz2Z){{TFJ*IWW^Zg{GGAkFZf0+CZDn$EE^v8JO928D0~7!N' + '00;m803iUXVp=vl2><}B7XSb*00000000000001_fg5-M0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMi4Z*FsRVQzGD' + 'E^v8JO928D0~7!N00;m803iU**0(b61ONbG3jhE!00000000000001_fun%|0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5' + 'WHMi4Z*FsRVQzGDUuAP`GcIs>P)h*<6ay3h000O8001EXm!`g+&jtVh%NhUxG5`Po0000000000q=Dmx0RU!Ub98WQZF4VeZ)9a`' + 'b1!9cZDwz5WHK*hb8TjCY-BQDX>M?JbYEh1X>4R=axQRrP)h*<6ay3h000O8001EXEqB(fwFm$J;U541EC2ui0000000000q=5pC' + '0RU!Ub98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDZDn+FX=8IPaCuNm0Rj{Q6aWAK2mk;8ApojWN}kvd006Qy001cf00000' + '00000005+c0ha*)W?^%5aBOXJFKusRWo&aVWpiz2Z){{TFJ*IWW^Zg{GGA?Jb7L-Wc~DCM0u%!j000080000X0Ml=3vRMxR0KPx~' + '05Sjo00000000000HlE+r~v?GVRLkFY;AKdZEs{{Y;!MVb8TjCY-BPoWpiz2Z){{TUu|t;X=Yz=VRCb6Zf7oVc~DCM0u%!j00008' + '0000X0KX$>g@Ggh0G*!z05$*s00000000000HlG*w*dfVVRLkFY;AKdZEs{{Y;!MVb8TjCY-BPoWpiz2Z){{TUu|z}Wn*=0VRBz%' + 'Z*6dFWq2-dc~DCM0u%!j000080000X0Lgz-4?5a$j?0adl;GV`XzLaCuNm0Rj{Q6aWAK2mk;8Apj#(;Y!#8008m{0024w0000000000005+c' + '`}6?-W?^%5aBOXJFKusRWo&aVWpiz2Z){{TFJ*IWW^Zg{GGA_Qa&2L3X?kT}V{dPAWNB_;bY*icaCuNm0Rj{Q6aWAK2mk;8AplIc' + 'e3Q}#001i;001ih0000000000005+cEBOHcW?^%5aBOXJFKusRWo&aVWpiz2Z){{TFJ*IWW^Zg{GGA|XbZ~WaE^v8JO928D0~7!N' + '00;m803iTZ%h9TT2><}mBme*}00000000000001_fkpuW0A^uxbZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0IwZDwz5WHMiHVQF$@WM6G_' + 'VJ>iaP)h*<6ay3h000O8001EXq)$S_>jVG*+YSH#EC2ui0000000000q=6C(0sv-Vb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjC' + 'Y-BQDaA9(DX>MmOaCuNm0Rj{Q6aWAK2mk;8ApqDlyQ@_N006}g002Ay0000000000005+cM-T!4W?^%5aBOXJFKusRWo&aVWpiz2' + 'Z){{TFJ*IWW^Zg{GGB0VWn^h%bY)~;VQgtM?JbS`jtP)h*<6ay3h000O8001EX<2f~oDhvPs' + '7cBq)E&u=k0000000000q=Dc$0sv-Vb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDaB^>BWpi_HaxQRrP)h*<6ay3h000O8' + '001EXU@?-xNi+Ze$>RV3G5`Po0000000000q=8mO0sv-Vb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjCY-BQDaB^>SWod3-V`yP%' + 'ZZ2?nP)h*<6ay3h000O8001EXko;`@TQL9tW5NIcGynhq0000000000q=D~x0sv-Vb98WQZF4VeZ)9a`b1!9cZDwz5WHK*hb8TjC' + 'Y-BQDaB^>SWod3-V{dJ6Y-M;ZaCuNm0Rj{Q6aWAK2mk;8Apq?IC^sYt004C%001@s0000000000005+coU8%>W?^%5aBOXJFKusR' + 'Wo&aVWpiz2Z){{TFJ*IWW^Zg{GGB0VZ**m8ZeMeBa&=>Lb#i4caCuNm0Rj{Q6aWAK2mk;8Apjf($pGUX005VO001@s0000000000' + '005+c6t)5YW?^%5aBOXJFKusRWo&aVWpiz2Z){{TFJ*IWW^Zg{GGB6Kb7^FCWnW`&ZgX^DZgg`laCuNm0Rj{Q6aWAK2mk;8AppYJ' + 'j{^+_003AI0021v0000000000005+cOVt7ZW?^%5aBOXJFKusRWo&aVWpiz2Z){{TFJ*IWW^Zg{GGB9Ladl;GbZKF1Uu0o)VPkAz' + 'b8{|mc~DCM0u%!j000080000X0Ea@yq>lms06+!+04o3h00000000000HlGh+5!M(VRLkFY;AKdZEs{{Y;!MVb8TjCY-BPoWpiz2' + 'Z){{TUvqhLbY*QWaCuNm0Rj{Q6aWAK2mk;8Apo)!4;}ba`-Pb1rasP)h*<6ay3h000O8001EX1Sl$;=tTekWGn#yEdT%j0000000000q=9SU' + '0sv-Vb98WQZF4VeZ)9a`b1!9cZDwz5WHK*pZ)9a`X>MmSWod3-a%E;^a%FB~WnX7yZ*66Ca(OOlb8l`?O928D0~7!N00;m803iTDSq$&;2mk=#9RL6+00000000000001_fyuc80A^ux' + 'bZ~5Kb1!XgWMyn~FJ*IWW^Zg{GB0p)Z**m8ZeMkDX>4;YaCuNm0Rj{Q6aWAK2mk;8ApmtT29CD^007Sh001ih0000000000005+c' + '0>c9UW?^%5aBOXJFKusRWo&aVWpiz2Z){{TFLGsYa&KgHV`*Y(Y-x0PE^v8JO928D0~7!N00;m803iS!k2(|X9{>Owj{pD`00000' + '000000001_fda+@0A^uxbZ~5Kb1!XgWMyn~FLZQtE^v8JO928D0~7!N00;m803iUOR1hHq)~00000000000001_fh6Vw' + '0A^uxbZ~5Kb1!mbXK8bEa(OOrc~DCM0u%!j000080000X021t%a8LsP0PG0>022TJ00000000000HlHH7X$!iVRLkFY;AKda&>NW' + 'X>DaLaCuNm1qJ{B001`tHvsDr003(n1ONa4' +) diff --git a/modeling_fastplms.py b/modeling_fastplms.py new file mode 100644 index 0000000000000000000000000000000000000000..2cfdcd116e8149f7299ed901ddcffc438883bc0e --- /dev/null +++ b/modeling_fastplms.py @@ -0,0 +1,231 @@ +"""Generated bridge to the unchanged FastPLMs package sources.""" + +import base64 +import hashlib +import importlib +import importlib.util +import sys +import tempfile +from importlib.metadata import PackageNotFoundError, distribution +from io import BytesIO +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +from .fastplms_bundle import RUNTIME_DATA, RUNTIME_HASH + +if RUNTIME_HASH != "278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232": + raise RuntimeError("FastPLMs runtime identity differs from the bridge.") + +_RUNTIME_TEMPORARIES = [] + +def _archive_runtime_hashes(payload): + result = {} + with ZipFile(BytesIO(payload)) as archive: + for member in archive.infolist(): + name = member.filename + parts = Path(name).parts + if ( + member.is_dir() + or "\\" in name + or not parts + or parts[0] != "fastplms" + or len(parts) < 2 + or any(part in {"", ".", ".."} for part in parts) + or Path(name).suffix in {".pyc", ".pyo"} + or member.flag_bits & 0x1 + or member.compress_type != ZIP_DEFLATED + or member.external_attr >> 16 != 0o100644 + ): + raise RuntimeError("Embedded FastPLMs archive has an unsafe path.") + relative = Path(*parts[1:]).as_posix() + if relative in result: + raise RuntimeError("Embedded FastPLMs archive repeats a path.") + result[relative] = hashlib.sha256(archive.read(member)).hexdigest() + return result + +def _ensure_runtime(): + payload = base64.b85decode("".join(RUNTIME_DATA)) + if hashlib.sha256(payload).hexdigest() != RUNTIME_HASH: + raise RuntimeError("Embedded FastPLMs runtime hash mismatch.") + expected = _archive_runtime_hashes(payload) + temporary = tempfile.TemporaryDirectory(prefix="fastplms-artifact-runtime-") + try: + runtime_root = Path(temporary.name) + with ZipFile(BytesIO(payload)) as archive: + for member in archive.infolist(): + target = runtime_root.joinpath(*Path(member.filename).parts) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("xb") as handle: + handle.write(archive.read(member)) + package_root = runtime_root / "fastplms" + if _runtime_file_hashes(package_root) != expected: + raise RuntimeError( + "Private FastPLMs runtime differs from the embedded archive." + ) + except BaseException: + temporary.cleanup() + raise + _RUNTIME_TEMPORARIES.append(temporary) + return package_root + +def _runtime_file_hashes(package_root): + result = {} + for path in sorted(package_root.rglob("*")): + relative = path.relative_to(package_root) + if path.is_symlink(): + raise RuntimeError("Private FastPLMs runtime contains a symlink.") + if path.is_dir(): + continue + if path.suffix in {".pyc", ".pyo"}: + raise RuntimeError("Private FastPLMs runtime contains bytecode.") + if not path.is_file(): + raise RuntimeError("Private FastPLMs runtime contains a non-file entry.") + result[relative.as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() + return result + +def _installed_runtime_digest(installed_root, relative): + candidate = installed_root / relative + if candidate.is_file(): + return hashlib.sha256(candidate.read_bytes()).hexdigest() + if relative != "kernels.lock": + return None + try: + installed_distribution = distribution("fastplms") + except PackageNotFoundError: + return None + for entry in installed_distribution.files or (): + normalized = str(entry).replace("\\", "/") + if normalized.endswith(".dist-info/kernels.lock"): + lock_path = Path(installed_distribution.locate_file(entry)) + if lock_path.is_file(): + return hashlib.sha256(lock_path.read_bytes()).hexdigest() + return None + +def _extend_loaded_package_paths(package_root): + for name, module in list(sys.modules.items()): + if name != "fastplms" and not name.startswith("fastplms."): + continue + paths = getattr(module, "__path__", None) + if paths is None: + continue + relative = name.split(".")[1:] + candidate = package_root.joinpath(*relative) + candidate_text = str(candidate) + if candidate.is_dir() and candidate_text not in paths: + paths.append(candidate_text) + +def _merge_runtime(installed, package_root): + incoming = _runtime_file_hashes(package_root) + known = dict(getattr(installed, "__fastplms_artifact_runtime_files__", {})) + installed_root_text = getattr( + installed, "__fastplms_artifact_installed_root__", None + ) + if not known: + installed_file = getattr(installed, "__file__", None) + if installed_file is None: + raise RuntimeError( + "The loaded fastplms package has no source path and cannot be verified " + "against the embedded artifact runtime." + ) + installed_root = Path(installed_file).resolve().parent + for relative, digest in incoming.items(): + if _installed_runtime_digest(installed_root, relative) != digest: + raise RuntimeError( + "The installed FastPLMs runtime differs from this artifact at " + f"{relative!r}. Install the artifact's matching FastPLMs release " + "or use a separate Python process." + ) + installed_root_text = str(installed_root) + installed.__fastplms_artifact_installed_root__ = installed_root_text + conflicts = sorted( + relative + for relative, digest in incoming.items() + if relative in known and known[relative] != digest + ) + if conflicts: + raise RuntimeError( + "FastPLMs artifacts contain incompatible runtime sources at " + + ", ".join(repr(path) for path in conflicts[:5]) + + ". Load incompatible releases in separate Python processes." + ) + if installed_root_text is not None: + installed_root = Path(installed_root_text) + for relative, digest in incoming.items(): + if relative in known: + continue + if _installed_runtime_digest(installed_root, relative) != digest: + raise RuntimeError( + "The installed FastPLMs runtime differs from this artifact at " + f"{relative!r}. Install the artifact's matching FastPLMs release " + "or use a separate Python process." + ) + known.update(incoming) + installed.__fastplms_artifact_runtime_files__ = known + roots = list(getattr(installed, "__fastplms_artifact_runtime_roots__", ())) + if str(package_root) not in roots: + roots.append(str(package_root)) + installed.__fastplms_artifact_runtime_roots__ = tuple(roots) + temporaries = list( + getattr(installed, "__fastplms_artifact_runtime_temporaries__", ()) + ) + for temporary in _RUNTIME_TEMPORARIES: + if temporary not in temporaries: + temporaries.append(temporary) + installed.__fastplms_artifact_runtime_temporaries__ = tuple(temporaries) + hashes = set(getattr(installed, "__fastplms_artifact_runtime_hashes__", ())) + hashes.add(RUNTIME_HASH) + installed.__fastplms_artifact_runtime_hashes__ = frozenset(hashes) + _extend_loaded_package_paths(package_root) + return installed + +def _import_without_bytecode(module_name): + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + return importlib.import_module(module_name) + finally: + sys.dont_write_bytecode = previous + +def _install_runtime(): + installed = sys.modules.get("fastplms") + hashes = getattr(installed, "__fastplms_artifact_runtime_hashes__", ()) + if RUNTIME_HASH in hashes: + return installed + package_root = _ensure_runtime() + if installed is not None: + return _merge_runtime(installed, package_root) + spec = importlib.util.spec_from_file_location( + "fastplms", + package_root / "__init__.py", + submodule_search_locations=[str(package_root)], + ) + if spec is None or spec.loader is None: + raise ImportError("Unable to load the embedded FastPLMs runtime.") + package = importlib.util.module_from_spec(spec) + package.__fastplms_artifact_runtime_hash__ = RUNTIME_HASH + package.__fastplms_artifact_runtime_hashes__ = frozenset({RUNTIME_HASH}) + package.__fastplms_artifact_runtime_files__ = _runtime_file_hashes(package_root) + package.__fastplms_artifact_runtime_roots__ = (str(package_root),) + package.__fastplms_artifact_runtime_temporaries__ = tuple( + _RUNTIME_TEMPORARIES + ) + sys.modules["fastplms"] = package + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + try: + spec.loader.exec_module(package) + except BaseException: + sys.modules.pop("fastplms", None) + raise + finally: + sys.dont_write_bytecode = previous + return package + +_install_runtime() +_module_225 = _import_without_bytecode("fastplms.models.esmfold2.configuration_esmfold2") +ESMFold2Config = _module_225.ESMFold2Config +ESMFold2Config.__module__ = __name__ +_module_228 = _import_without_bytecode("fastplms.models.esmfold2.modeling_esmfold2_experimental") +ESMFold2ExperimentalModel = _module_228.ESMFold2ExperimentalModel +ESMFold2ExperimentalModel.__module__ = __name__ diff --git a/runtime-attestation.json b/runtime-attestation.json new file mode 100644 index 0000000000000000000000000000000000000000..8db75fe17a57268354a152ed206dcdbadb164a9d --- /dev/null +++ b/runtime-attestation.json @@ -0,0 +1,84 @@ +{ + "files": { + "LICENSES/FastPLMs-Apache-2.0.txt": "sha256:2d2b50c7b1414bff1189a1db1f0cfb92e3e064b50f4c2b1019827b683e1b629a", + "LICENSES/biohub-esm/LICENSE.md": "sha256:b63df9ca1dd96b3b21eec226b51b236d0bd152ac20eafc43aad46bf832b48d8a", + "LICENSES/biohub-esm/THIRD_PARTY_NOTICE.md": "sha256:5bff8515ba4e0f53abdc43714c180b79c5b606160497d98de741a369cb9b6a23", + "LICENSES/biohub-transformers/LICENSE": "sha256:77fd4710def9ec3c0f6225800e0235f15a425abd4a8b03559127fcd782612049", + "LICENSES/protein-ttt/LICENSE": "sha256:bb01e7d5554f9e2e117172e56551452f68a7818df7bc8e71cd7a776a1d4ba3df", + "LICENSES/protein-ttt/PROVENANCE.md": "sha256:dc641c37353c2efd50ccbdb316ca4aae495ec02c1563e0e15bac92f75fc482e5", + "README.md": "sha256:63874229209985c9188702cc5ea68866effe709c3a44911c21f290456ba248da", + "THIRD_PARTY_NOTICES.md": "sha256:25704b3c76404696cae52e7fca13088d329f70f412687340351259e86cd62baa", + "config.json": "sha256:2285e75322e27fffbce442a46012cbd897c8953bc494b2b52337484bf95957d3", + "fastplms/__init__.py": "sha256:4fb3196022ca8ec699d59d09bdbc5f0184195552b773698ab9b061fe3cd7df12", + "fastplms/attention/__init__.py": "sha256:f60b9fecfb4bcb37a4e7c26dc2f752b9035f9cbad627b4a84213f3a92ec88f7d", + "fastplms/attention/_core.py": "sha256:8f7ec5b65bd8b6c6fa4951d50d1c0e499abf03ae00914794b51fc410201e3e33", + "fastplms/attention/_kernel_lock.py": "sha256:85d8521a2af5f94fad3948af3814db0c866c414ee4d43df797b9bd6f980e947b", + "fastplms/attention/interfaces.py": "sha256:1c6f06a8e411e0f9bf6d230522205c93ae46ea58864006fbb892aa05e5ca5749", + "fastplms/embeddings/__init__.py": "sha256:47ff8cdf682d44037dd9edab133e2e60675d60786bd5cf0bffd1998f31985555", + "fastplms/embeddings/pooling.py": "sha256:a140266ed6b1cc344c8507edc5c6c4f2dce464c3db70ba4b16c7ac2ba2fad96e", + "fastplms/embeddings/runner.py": "sha256:23ee4727a918d6d331f7a0f89b823d149f1a791f0c5586e3496d7b6eb2ce97e0", + "fastplms/embeddings/storage.py": "sha256:3fbe2bab75092e5a4cadf4d27e4752181d597469a65a55db085ceef808ed418e", + "fastplms/embeddings/types.py": "sha256:119718a20989d1ae5a60fabc0f5e98bdc172c5163b04db3d4554ac3956b30e52", + "fastplms/models.toml": "sha256:05a8399f084a5babb5f0916cee7e564c4030767f3ff0230c4f46e539209847d1", + "fastplms/models/__init__.py": "sha256:5e48c2cb3877aa6f42f3b5411d53b16bba2e32827bbde634f47f174c5cb36f86", + "fastplms/models/esm_plusplus/__init__.py": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "fastplms/models/esm_plusplus/modeling_esm_plusplus.py": "sha256:7fc8047100a2d0ca230bc687126c4ccd3f46c0e51cefd39413e72e3842e14012", + "fastplms/models/esmfold2/__init__.py": "sha256:78e39abe4223670d359591d3c276f3fb9276ce276e18fe7939be04a87dcff69d", + "fastplms/models/esmfold2/attention.py": "sha256:e9645c79e198c7dc2d277240cd8010010179f6aacb7e8bb511b8607bcfa793ae", + "fastplms/models/esmfold2/configuration_esmfold2.py": "sha256:f7485ca5cd3ffb67dfe1f12811ad039a98bc8cfb7e0194f4883ed1395f27e433", + "fastplms/models/esmfold2/embedding.py": "sha256:c6a62cf1b9ccdc09b449100df46475198c9d493ff6ad4cf250c11ee568c3a8e4", + "fastplms/models/esmfold2/esmfold2_affine3d.py": "sha256:f64c3326684266c033bb674bfce02cfff3fb4825c49173a11d12519e965816c7", + "fastplms/models/esmfold2/esmfold2_aligner.py": "sha256:a21b34fa91915d3d5bcceaacbd0676c6965a97058f46ceacb784ea75dbd94b27", + "fastplms/models/esmfold2/esmfold2_atom_indexer.py": "sha256:0b363b1c0fef1f4a37e82189d6d5912f669f544ad5d6b28c54c9b2fb3d3b884a", + "fastplms/models/esmfold2/esmfold2_conformers.py": "sha256:0742d37e5776af7cf477aabc95d80ab519b8a7997b4b580e3beea1c5c654b222", + "fastplms/models/esmfold2/esmfold2_constants.py": "sha256:65f978329690b46c4a3cc9f4815fe439b1e355be03ef2a9134b83ab889958751", + "fastplms/models/esmfold2/esmfold2_constants_esm3.py": "sha256:6f9c8bbf0f80b6cd9ede136d142b51ff2b84f1b339cb6b6668c2669e8c2584b5", + "fastplms/models/esmfold2/esmfold2_input_builder.py": "sha256:90ca458a80095e959e5574ea93ac6598f6ab4512e54e91e67a1c9c93c8476372", + "fastplms/models/esmfold2/esmfold2_metrics.py": "sha256:09ddac476d7359d35cf008b749051943c63fa62c2ac1dce5e2276e32d47a0da4", + "fastplms/models/esmfold2/esmfold2_misc.py": "sha256:4439bb4fc427713841c556a1be5932f93bfc2b4d63c471983250386253470352", + "fastplms/models/esmfold2/esmfold2_mmcif_parsing.py": "sha256:29e968626ab375864037005651b41b26531924dcffb544d14a8b87c1aa8e680a", + "fastplms/models/esmfold2/esmfold2_molecular_complex.py": "sha256:8e2768ce766e3e655197a337d922a1d6fa7d61ea4b4c9d72032a6434c345f605", + "fastplms/models/esmfold2/esmfold2_msa.py": "sha256:24dd1309ab181cebb81c0f5fb21182863b75cc8d23b62aec02d3c48897a2c838", + "fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py": "sha256:c679b63c6d0efadad32bc9cbe1157d6da87a363c1191347447ac735e6e8352a1", + "fastplms/models/esmfold2/esmfold2_normalize_coordinates.py": "sha256:a0d7180fee6c7bf8032c24b7e6c45a769115d2d6039ce8d8c3396283cb79e31d", + "fastplms/models/esmfold2/esmfold2_output.py": "sha256:1b82de5166e3229b5ff57e243721a24265bf8607293ebf0700647015a09af338", + "fastplms/models/esmfold2/esmfold2_paired_msa.py": "sha256:046d5fd2c98c5d88e0644d3cf7623cd31a355724c11fa4968907c49ff173a87f", + "fastplms/models/esmfold2/esmfold2_parsing.py": "sha256:6ca4a2d85d6158cadcde79bf8d1820c54eeb2160e00b99643c29e270d0a3f870", + "fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py": "sha256:b76577c9e17bdef5417fea35c6ac134cff2ba949a1c9cb389323134541e52d04", + "fastplms/models/esmfold2/esmfold2_prepare_input.py": "sha256:f2b5f63a451555d41944e5abc8b85a978edfb456ce493821b313f8343d3eed19", + "fastplms/models/esmfold2/esmfold2_processor.py": "sha256:c3be4ef4737f86c7f7969749ad1ad5e483fb6e2a7a2cb7eeef52afad28d3c40e", + "fastplms/models/esmfold2/esmfold2_protein_chain.py": "sha256:e87d7cbfab56d0cd420d8ce8b15a23872cdfe6c1036f2467555e57691af172af", + "fastplms/models/esmfold2/esmfold2_protein_complex.py": "sha256:ee78539192414000b78da7285175f0316d79f549c179678638d009054b4985f7", + "fastplms/models/esmfold2/esmfold2_protein_structure.py": "sha256:d77254171dd7dc7e269693091e51aa04a7cd8dc4a442d425fab7d922c1b1308d", + "fastplms/models/esmfold2/esmfold2_residue_constants.py": "sha256:f54e2856761f1d44e188ddd4682bd93b5eac6c3acac060ec8669efd5e19d49bd", + "fastplms/models/esmfold2/esmfold2_sequential_dataclass.py": "sha256:d1bf752de753f99673287f7b3365be84dfb68517256b1c3ecf0ff1fe2a0c4bc1", + "fastplms/models/esmfold2/esmfold2_system.py": "sha256:4a0b339bc7446a9ca255f6005ecb8d385c69b7a3e57170c5a48e372bb2340d13", + "fastplms/models/esmfold2/esmfold2_types.py": "sha256:8963440d40e3143b984679a716a5ac8327fa09692765836dea179c41e2bc9f5d", + "fastplms/models/esmfold2/esmfold2_utils_types.py": "sha256:b846d61a383fc55091231ec8bc2b829045a4edb52dcd3907c30b81b6057fd4eb", + "fastplms/models/esmfold2/modeling_esmfold2.py": "sha256:6b6e4629ad04c561ea8c5aa674eae59f7d4284e97c4d027a5a5ef50db258202c", + "fastplms/models/esmfold2/modeling_esmfold2_common.py": "sha256:d9545f880ba6e92b7ed59a7894a4172fb7470bd4b2077fe8ed570ba7cf8bc614", + "fastplms/models/esmfold2/modeling_esmfold2_experimental.py": "sha256:22e24d76869045f545ef192e748d5ba995078c8758becae62a284f68da82e718", + "fastplms/models/esmfold2/protein_reference_geometry.json": "sha256:c726d81b928df0005b2d6877e2c0d5ef3ebfb11744e04ca8972388057dc565c0", + "fastplms/models/esmfold2/protein_utils.py": "sha256:a51cdad1769620389ca58d6782bc787ed70cadb1f310689cf3dcd1d2d2fa92be", + "fastplms/models/esmfold2/reproducibility.py": "sha256:2027064e3eea918f535d9b60734a3bc9288478f5b957847266d328dcfca88443", + "fastplms/models/ttt.py": "sha256:a0df4e98b02120d423e3c7ca9b866a8d0e3748b9076042a0102a838a11aed046", + "fastplms/registry.py": "sha256:afca271911b651a882345b74a58366494a1d784e4a48c8683a87f5508f4ba16e", + "fastplms/runtime.py": "sha256:110018646d6f248cedab140a030c3065e1b062b61f6aff659c231e538614bc01", + "fastplms_bundle.py": "sha256:82ddf2db65cdeb055905461bd86e65abd6352ba5a16566c73bcb42b407c938fb", + "modeling_fastplms.py": "sha256:ba568e30aae5c616d31d597a46990ef09c9bc377a443307a116e90e66ed471fb" + }, + "model_id": "esmfold2_experimental_cutoff2025", + "redistributable": true, + "release_tool_revision": "1b9ce023f1e06571cf3e6324be0610ffa53e0a4a", + "release_tool_sha256": "1459b5d7d13d9b07bd97b3eee764f2ce73623e15e32d07ddf6825c2a9509afb9", + "runtime_bundle_sha256": "278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232", + "runtime_revision": "1b9ce023f1e06571cf3e6324be0610ffa53e0a4a", + "schema_version": 2, + "scope": "runtime-only", + "source_tree_sha256": "15e781c5f1cd2ba8486e22076df15ffab37d3c00a689bf25280d803f2d60ee74", + "weights": { + "repo_id": "Synthyra/ESMFold2-Experimental-Cutoff2025", + "revision": "632ff4a9e68f1de78ee956a613267bdcdb5b354d" + }, + "weights_license_status": "resolved" +}