roxxxie0512 commited on
Commit
a4cdcc4
·
verified ·
1 Parent(s): 3f6150d

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +17 -0
  2. CITATION.cff +30 -0
  3. LICENSE +202 -0
  4. NOTICE +16 -0
  5. README.md +224 -1
  6. checkpoints/PepReDi_base.pt +3 -0
  7. checkpoints/SMILESReDi_base.ckpt +3 -0
  8. checkpoints/UAAReDi_base.pt +3 -0
  9. configs/paper.json +20 -0
  10. configs/targets.fasta +6 -0
  11. docs/ARTIFACTS.md +32 -0
  12. docs/artifacts.json +38 -0
  13. environment.yml +28 -0
  14. requirements.txt +18 -0
  15. scripts/run_experiment.py +177 -0
  16. scripts/verify_release.py +52 -0
  17. smiles/__init__.py +1 -0
  18. smiles/conjugation/AoaDau.py +732 -0
  19. smiles/conjugation/CptFactory.py +312 -0
  20. smiles/conjugation/DSEBSN38.py +377 -0
  21. smiles/conjugation/DSECCpt.py +522 -0
  22. smiles/conjugation/DSEPSN38.py +373 -0
  23. smiles/conjugation/DSPACpt.py +517 -0
  24. smiles/conjugation/DSPASN38.py +515 -0
  25. smiles/conjugation/DauFactory.py +298 -0
  26. smiles/conjugation/GlutarylDau.py +362 -0
  27. smiles/conjugation/MalHexCpt.py +560 -0
  28. smiles/conjugation/SN38Factory.py +308 -0
  29. smiles/conjugation/SSCpt.py +891 -0
  30. smiles/conjugation/ValAlaDau.py +348 -0
  31. smiles/conjugation/ValCitDau.py +358 -0
  32. smiles/conjugation/VcMMAE.py +457 -0
  33. smiles/conjugation/__init__.py +1 -0
  34. smiles/conjugation/pdc_builder.py +223 -0
  35. smiles/evaluation/__init__.py +1 -0
  36. smiles/evaluation/metrics.py +570 -0
  37. smiles/evaluation/pdc_analyzer.py +431 -0
  38. smiles/evaluation/peptide_analyzer.py +1259 -0
  39. smiles/evaluation/run_metrics.py +282 -0
  40. smiles/evaluation/run_metrics_uaa.py +590 -0
  41. smiles/generation/__init__.py +1 -0
  42. smiles/generation/cupofmoca_guided.py +996 -0
  43. smiles/generation/cupofmoca_guided_aa.py +1126 -0
  44. smiles/generation/cupofmoca_guided_uaa.py +1196 -0
  45. smiles/generation/cupofmoca_staged.py +584 -0
  46. smiles/generation/cupofmoca_staged_aa.py +724 -0
  47. smiles/generation/cupofmoca_staged_uaa.py +797 -0
  48. smiles/generation/guided.py +651 -0
  49. smiles/generation/guided_aa.py +872 -0
  50. smiles/generation/guided_uaa.py +981 -0
.gitignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .DS_Store
7
+ .idea/
8
+ .vscode/
9
+ .env
10
+ generation_logs/
11
+ results/
12
+ outputs/
13
+ *.log
14
+
15
+ # Hugging Face and PyTorch downloads should remain outside this repository.
16
+ .cache/
17
+
CITATION.cff ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite the CupOFMoCA paper."
3
+ title: "CupOFMoCA: Coupled Objective-Guided Discrete Flows for Molecular Conjugate Assembly"
4
+ type: software
5
+ authors:
6
+ - family-names: Zhang
7
+ given-names: Ruoxi Rosie
8
+ - family-names: Li
9
+ given-names: Ziang
10
+ - family-names: Gu
11
+ given-names: Jiatao
12
+ - family-names: Chatterjee
13
+ given-names: Pranam
14
+ license: Apache-2.0
15
+ repository-code: "https://huggingface.co/ChatterjeeLab/CupOFMoCA"
16
+ preferred-citation:
17
+ type: conference-paper
18
+ title: "CupOFMoCA: Coupled Objective-Guided Discrete Flows for Molecular Conjugate Assembly"
19
+ authors:
20
+ - family-names: Zhang
21
+ given-names: Ruoxi Rosie
22
+ - family-names: Li
23
+ given-names: Ziang
24
+ - family-names: Gu
25
+ given-names: Jiatao
26
+ - family-names: Chatterjee
27
+ given-names: Pranam
28
+ collection-title: "Advances in Neural Information Processing Systems"
29
+ year: 2026
30
+
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
NOTICE ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CupOFMoCA
2
+ Copyright 2026 CupOFMoCA authors
3
+
4
+ The code in this repository is licensed under Apache License 2.0. Model
5
+ weights and third-party model downloads can be subject to their source model
6
+ licenses; users are responsible for reviewing those terms.
7
+
8
+ third_party/SMILESToPeptide contains the exact peptide-to-SMILES conversion
9
+ helpers used in the submitted PDC experiments. The implementation is adapted
10
+ from p2smi by Aaron Feller, distributed under the MIT License. Its license is
11
+ included at third_party/SMILESToPeptide/LICENSE-p2smi.
12
+
13
+ Runtime model dependencies include:
14
+ - facebook/esm2_t33_650M_UR50D
15
+ - aaronfeller/PeptideCLM-23M-all
16
+
README.md CHANGED
@@ -1,3 +1,226 @@
1
  ---
2
- license: cc-by-nc-nd-4.0
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ license: apache-2.0
3
+ library_name: pytorch
4
+ tags:
5
+ - chemistry
6
+ - drug-discovery
7
+ - molecular-generation
8
+ - peptide
9
+ - discrete-flow
10
+ - peptide-drug-conjugate
11
  ---
12
+
13
+ # CupOFMoCA
14
+
15
+ Code and PDC model artifacts for **CupOFMoCA: Coupled Objective-Guided Discrete Flows for
16
+ Molecular Conjugate Assembly** (NeurIPS 2026).
17
+
18
+ CupOFMoCA performs coupled, objective-guided discrete-flow generation while enforcing chemical
19
+ assembly constraints. This release supports the paper's peptide-drug conjugate (PDC) experiments
20
+ across SMILES, natural amino-acid (AA), and unnatural amino-acid (UAA) representations.
21
+
22
+ ## Model overview
23
+
24
+ CupOFMoCA is a research pipeline for generating peptide-drug conjugate candidates. It couples a
25
+ pretrained discrete-flow generator with property guidance and conjugation-validity checks during
26
+ sampling. This repository contains three generator checkpoints and the property scorers used by the
27
+ released PDC pipeline:
28
+
29
+ | Checkpoint | Representation | Role |
30
+ |---|---|---|
31
+ | `SMILESReDi_base.ckpt` | peptide SMILES | pretrained SMILES discrete-flow generator |
32
+ | `PepReDi_base.pt` | canonical amino acids | pretrained AA discrete-flow generator |
33
+ | `UAAReDi_base.pt` | extended amino-acid vocabulary | pretrained UAA discrete-flow generator |
34
+
35
+ The checkpoints are loaded by the repository's custom PyTorch code; this is not a Transformers
36
+ pipeline and does not provide a hosted-inference widget.
37
+
38
+ ### Intended uses
39
+
40
+ - Reproduce and inspect the paper's PDC generation experiments.
41
+ - Generate computational PDC candidates for research and method development.
42
+ - Extend or compare constrained, multi-objective molecular-generation methods.
43
+
44
+ The release is not intended for clinical decisions, direct therapeutic use, or as a substitute for
45
+ chemical synthesis, experimental validation, toxicology, or safety assessment.
46
+
47
+ ### Training data
48
+
49
+ - **AA-PepReDi:** peptides from PepNN and BioLip2, together with 6--49-residue sequences from
50
+ PPIRef, using an 80:10:10 train/validation/test split.
51
+ - **UAA-PepReDi:** 150,000 synthetic sequences constructed with `p2smi` across combinations of
52
+ non-canonical and D-amino-acid composition, using an 80:10:10 split.
53
+ - **SMILESReDi:** the released pretrained SMILESReDi weights are used without additional
54
+ CupOFMoCA fine-tuning.
55
+
56
+ Architecture, optimization, data construction, and scorer details are reported in Appendix E of
57
+ the paper. Training datasets are not redistributed in this repository.
58
+
59
+ ### Evaluation
60
+
61
+ The paper evaluates generated PDCs for assembly validity and predicted functional properties across
62
+ ERBB2, GNRHR, and NRP1, using SMILES, AA, and UAA representations. The reported results and
63
+ experimental protocol are in the paper; the submitted evaluation routines are included under
64
+ `smiles/evaluation/`.
65
+
66
+ ### Limitations
67
+
68
+ - Outputs and objective values are computational predictions, not experimentally validated
69
+ compounds or guarantees of efficacy, synthesizability, toxicity, or safety.
70
+ - The released configurations cover three receptor targets and fixed target-specific conjugation
71
+ chemistries; performance outside this scope has not been established.
72
+ - The release contains the paper's PDC stack only. Its PROTAC experiments require separate code and
73
+ artifacts.
74
+ - Results can vary with hardware, CUDA/PyTorch versions, and revisions of models downloaded at
75
+ runtime.
76
+
77
+ ## Repository layout
78
+
79
+ ```text
80
+ checkpoints/ generator weights
81
+ configs/ paper settings and target sequences
82
+ docs/ model-artifact details and checksums
83
+ scripts/run_experiment.py unified experiment runner
84
+ scripts/verify_release.py release integrity checker
85
+ smiles/
86
+ generation/ generation entry points
87
+ evaluation/ metrics and analyzers
88
+ models/ ReDi model and data code
89
+ conjugation/ PDC assembly builders
90
+ scoring/ property scorers
91
+ smiles_tokenizer/ tokenizer code and vocabulary
92
+ third_party/ peptide-to-SMILES conversion helper
93
+ ```
94
+
95
+ The release currently covers the PDC implementation. The PROTAC experiments described in the paper
96
+ use a separate code and artifact stack that is not included here.
97
+
98
+ ## 1. Environment
99
+
100
+ A CUDA GPU is strongly recommended. The tested environment used Python 3.9, PyTorch 2.5.1, and
101
+ CUDA 12.1.
102
+
103
+ ```bash
104
+ conda env create -f environment.yml
105
+ conda activate cupofmoca
106
+ ```
107
+
108
+ Alternatively, install a PyTorch build appropriate for your CUDA driver and then install the pinned
109
+ packages:
110
+
111
+ ```bash
112
+ pip install -r requirements.txt
113
+ ```
114
+
115
+ On first use, Transformers downloads `facebook/esm2_t33_650M_UR50D` and
116
+ `aaronfeller/PeptideCLM-23M-all` from Hugging Face. Set `HF_HOME` to a persistent cache if needed.
117
+
118
+ Verify the bundled artifacts and source paths before running an experiment:
119
+
120
+ ```bash
121
+ python scripts/verify_release.py
122
+ ```
123
+
124
+ ## 2. Run PDC generation
125
+
126
+ Run commands from the repository root. Preview a configured command without loading models:
127
+
128
+ ```bash
129
+ python scripts/run_experiment.py \
130
+ --representation uaa \
131
+ --variant cupofmoca \
132
+ --method guided \
133
+ --target NRP1 \
134
+ --dry-run
135
+ ```
136
+
137
+ Run the experiment:
138
+
139
+ ```bash
140
+ python scripts/run_experiment.py \
141
+ --representation uaa \
142
+ --variant cupofmoca \
143
+ --method guided \
144
+ --target NRP1
145
+ ```
146
+
147
+ Run a small smoke test:
148
+
149
+ ```bash
150
+ python scripts/run_experiment.py \
151
+ --representation aa --variant baseline --method staged --target ERBB2 \
152
+ --num-candidates 1
153
+ ```
154
+
155
+ Outputs are written under `outputs/` in separate run directories. Existing outputs are protected
156
+ unless `--force` is supplied. Arguments after `--` are passed to the underlying generator:
157
+
158
+ ```bash
159
+ python scripts/run_experiment.py \
160
+ --representation smiles --variant cupofmoca --method guided --target GNRHR \
161
+ -- --top_k 100
162
+ ```
163
+
164
+ ### Configurations
165
+
166
+ | Target | Protein | Conjugation builder | Default SMILES length |
167
+ |---|---|---|---:|
168
+ | ERBB2 | receptor tyrosine-protein kinase erbB-2 | `SSCpt` | 80 |
169
+ | GNRHR | gonadotropin-releasing hormone receptor | `AoaDau` | 80 |
170
+ | NRP1 | neuropilin-1 | `DSPASN38` | 100 |
171
+
172
+ AA and UAA runs use length 10. Shared settings include 128 optimization steps, top-p 1.0,
173
+ objective weights `[1, 1]`, and acceptance sampling with beta 50 for CupOFMoCA. The machine-readable
174
+ configuration is in `configs/paper.json`; target sequences are in `configs/targets.fasta`.
175
+
176
+ ### Direct entry points
177
+
178
+ The full argument surfaces remain available under `smiles/generation/`:
179
+
180
+ ```text
181
+ guided.py cupofmoca_guided.py
182
+ guided_aa.py cupofmoca_guided_aa.py
183
+ guided_uaa.py cupofmoca_guided_uaa.py
184
+ staged.py cupofmoca_staged.py
185
+ staged_aa.py cupofmoca_staged_aa.py
186
+ staged_uaa.py cupofmoca_staged_uaa.py
187
+ ```
188
+
189
+ Run one directly as a module, for example:
190
+
191
+ ```bash
192
+ python -m smiles.generation.cupofmoca_guided_uaa --help
193
+ ```
194
+
195
+ `smiles/evaluation/run_metrics.py` and `smiles/evaluation/run_metrics_uaa.py` contain the submitted evaluation routines.
196
+ Use `python <file> --help` to inspect their arguments.
197
+
198
+ ## 3. Included checkpoints
199
+
200
+ The release includes the SMILES, AA, and UAA generator weights together with the binding,
201
+ hemolysis, non-fouling, and solubility scorers needed by the PDC pipeline. Artifact roles, sizes, and
202
+ SHA-256 values are listed in [docs/ARTIFACTS.md](docs/ARTIFACTS.md).
203
+
204
+ The AA-input affinity predictor is not included because the PDC generation configuration uses the
205
+ bundled SMILES-input scorer (`--affinity_type smiles`).
206
+
207
+ Large `*.pt` and `*.ckpt` files are configured for Git LFS. The repository is approximately 1.9 GB
208
+ before environment and model-cache downloads.
209
+
210
+ The checkpoint formats are loaded through PyTorch. As with any pickle-backed PyTorch artifact,
211
+ only load weights obtained from a source you trust.
212
+
213
+ ## 4. Notes
214
+
215
+ - The paper reports NVIDIA RTX A6000 48 GB GPUs. Lower-memory devices may require smaller batches.
216
+ - Exact bitwise output can vary across CUDA, PyTorch, GPU, and downloaded model revisions.
217
+ - Generated structures are computational hypotheses that require experimental validation. Property
218
+ models are predictors, not clinical or safety guarantees.
219
+ - The peptide-to-SMILES helper used by the experiments is vendored under `third_party/` with its
220
+ upstream license notice.
221
+
222
+ ## 5. Citation and license
223
+
224
+ Citation metadata is provided in `CITATION.cff`. The repository code is released under Apache-2.0;
225
+ see `LICENSE` and `NOTICE`. Third-party components and downloaded model artifacts retain their
226
+ respective terms.
checkpoints/PepReDi_base.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3b6437edee514bd7adb9aacea776f3dd97c59ebc7b4928b390eafdd87eaeb8c9
3
+ size 344474053
checkpoints/SMILESReDi_base.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8b2a6049e7ce6c2ef2ab0da0987ee14a5c7e3df737c5c32202631f5c230a81df
3
+ size 1038182356
checkpoints/UAAReDi_base.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fa9c25692f308e87befee15cecdd4a24d9e2270e039b12d073023390632b2a8d
3
+ size 344824232
configs/paper.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "default_seeds": [42, 123, 456],
3
+ "num_candidates_per_seed": 100,
4
+ "num_samples_per_batch": 1,
5
+ "optimization_steps": 128,
6
+ "top_p": 1.0,
7
+ "top_k": 200,
8
+ "cascade_k": 8,
9
+ "beta_min": 50.0,
10
+ "beta_max": 50.0,
11
+ "weights": [1.0, 1.0],
12
+ "require_valid_x0": true,
13
+ "max_x0_retries": 10,
14
+ "targets": {
15
+ "ERBB2": {"builder": "SSCpt", "smiles_length": 80},
16
+ "GNRHR": {"builder": "AoaDau", "smiles_length": 80},
17
+ "NRP1": {"builder": "DSPASN38", "smiles_length": 100}
18
+ },
19
+ "representation_lengths": {"aa": 10, "uaa": 10}
20
+ }
configs/targets.fasta ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ >ERBB2
2
+ MELAALCRWGLLLALLPPGAASTQVCTGTDMKLRLPASPETHLDMLRHLYQGCQVVQGNLELTYLPTNASLSFLQDIQEVQGYVLIAHNQVRQVPLQRLRIVRGTQLFEDNYALAVLDNGDPLNNTTPVTGASPGGLRELQLRSLTEILKGGVLIQRNPQLCYQDTILWKDIFHKNNQLALTLIDTNRSRACHPCSPMCKGSRCWGESSEDCQSLTRTVCAGGCARCKGPLPTDCCHEQCAAGCTGPKHSDCLACLHFNHSGICELHCPALVTYNTDTFESMPNPEGRYTFGASCVTACPYNYLSTDVGSCTLVCPLHNQEVTAEDGTQRCEKCSKPCARVCYGLGMEHLREVRAVTSANIQEFAGCKKIFGSLAFLPESFDGDPASNTAPLQPEQLQVFETLEEITGYLYISAWPDSLPDLSVFQNLQVIRGRILHNGAYSLTLQGLGISWLGLRSLRELGSGLALIHHNTHLCFVHTVPWDQLFRNPHQALLHTANRPEDECVGEGLACHQLCARGHCWGPGPTQCVNCSQFLRGQECVEECRVLQGLPREYVNARHCLPCHPECQPQNGSVTCFGPEADQCVACAHYKDPPFCVARCPSGVKPDLSYMPIWKFPDEEGACQPCPINCTHSCVDLDDKGCPAEQRASPLTSIISAVVGILLVVVLGVVFGILIKRRQQKIRKYTMRRLLQETELVEPLTPSGAMPNQAQMRILKETELRKVKVLGSGAFGTVYKGIWIPDGENVKIPVAIKVLRENTSPKANKEILDEAYVMAGVGSPYVSRLLGICLTSTVQLVTQLMPYGCLLDHVRENRGRLGSQDLLNWCMQIAKGMSYLEDVRLVHRDLAARNVLVKSPNHVKITDFGLARLLDIDETEYHADGGKVPIKWMALESILRRRFTHQSDVWSYGVTVWELMTFGAKPYDGIPAREIPDLLEKGERLPQPPICTIDVYMIMVKCWMIDSECRPRFRELVSEFSRMARDPQRFVVIQNEDLGPASPLDSTFYRSLLEDDDMGDLVDAEEYLVPQQGFFCPDPAPGAGGMVHHRHRSSSTRSGGGDLTLGLEPSEEEAPRSPLAPSEGAGSDVFDGDLGMGAAKGLQSLPTHDPSPLQRYSEDPTVPLPSETDGYVAPLTCSPQPEYVNQPDVRPQPPSPREGPLPAARPAGATLERPKTLSPGKNGVVKDVFAFGGAVENPEYLTPQGGAAPQPHPPPAFSPAFDNLYYWDQDPPERGAPPSTFKGTPTAENPEYLGLDVPV
3
+ >GNRHR
4
+ MANSASPEQNQNHCSAINNSIPLMQGNLPTLTLSGKIRVTVTFFLFLLSATFNASFLLKLQKWTQKKEKGKKLSRMKLLLKHLTLANLLETLIVMPLDGMWNITVQWYAGELLCKVLSYLKLFSMYAPAFMMVVISLDRSLAITRPLALKSNSKVGQSMVGLAWILSSVFAGPQLYIFRMIHLADSSGQTKVFSQCVTHCSFSQWWHQAFYNFFTFSCLFIIPLFIMLICNAKIIFTLTRVLHQDPHELQLNQSKNNIPRARLKTLKMTVAFATSFTVCWTPYYVLGIWYWFDPEMLNRLSDPVNHFFFLFAFLNPCFDPLIYGYFSL
5
+ >NRP1
6
+ MERGLPLLCAVLALVLAPAGAFRNDKCGDTIKIESPGYLTSPGYPHSYHPSEKCEWLIQAPDPYQRIMINFNPHFDLEDRDCKYDYVEVFDGENENGHFRGKFCGKIAPPPVVSSGPFLFIKFVSDYETHGAGFSIRYEIFKRGPECSQNYTTPSGVIKSPGFPEKYPNSLECTYIVFVPKMSEIILEFESFDLEPDSNPPGGMFCRYDRLEIWDGFPDVGPHIGRYCGQKTPGRIRSSSGILSMVFYTDSAIAKEGFSANYSVLQSSVSEDFKCMEALGMESGEIHSDQITASSQYSTNWSAERSRLNYPENGWTPGEDSYREWIQVDLGLLRFVTAVGTQGAISKETKKKYYVKTYKIDVSSNGEDWITIKEGNKPVLFQGNTNPTDVVVAVFPKPLITRFVRIKPATWETGISMRFEVYGCKITDYPCSGMLGMVSGLISDSQITSSNQGDRNWMPENIRLVTSRSGWALPPAPHSYINEWLQIDLGEEKIVRGIIIQGGKHRENKVFMRKFKIGYSNNGSDWKMIMDDSKRKAKSFEGNNNYDTPELRTFPALSTRFIRIYPERATHGGLGLRMELLGCEVEAPTAGPTTPNGNLVDECDDDQANCHSGTGDDFQLTGGTTVLATEKPTVIDSTIQSEFPTYGFNCEFGWGSHKTFCHWEHDNHVQLKWSVLTSKTGPIQDHTGDGNFIYSQADENQKGKVARLVSPVVYSQNSAHCMTFWYHMSGSHVGTLRVKLRYQKPEEYDQLVWMAIGHQGDHWKEGRVLLHKSLKLYQVIFEGEIGKGNLGGIAVDDISINNHISQEDCAKPADLDKKNPEIKIDETGSTPGYEGEGEGDKNISRKPGNVLKTLDPILITIIAMSALGVLLGAVCGVVLYCACWHNGMSERNLSALENYNFELVDGVKLKKDKLNTQSTYSEA
docs/ARTIFACTS.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model artifacts
2
+
3
+ The release includes every local checkpoint needed by the submitted PDC generation code. Run
4
+ `python scripts/verify_release.py` to verify byte counts, SHA-256 hashes, and private-path hygiene.
5
+ The machine-readable manifest is in `docs/artifacts.json`.
6
+
7
+ | Artifact | Purpose | Size | SHA-256 |
8
+ |---|---|---:|---|
9
+ | `checkpoints/SMILESReDi_base.ckpt` | SMILES generator | 1,038,182,356 B | `8b2a6049e7ce6c2ef2ab0da0987ee14a5c7e3df737c5c32202631f5c230a81df` |
10
+ | `checkpoints/PepReDi_base.pt` | natural-AA generator | 344,474,053 B | `3b6437edee514bd7adb9aacea776f3dd97c59ebc7b4928b390eafdd87eaeb8c9` |
11
+ | `checkpoints/UAAReDi_base.pt` | UAA generator | 344,824,232 B | `fa9c25692f308e87befee15cecdd4a24d9e2270e039b12d073023390632b2a8d` |
12
+ | `smiles/scoring/checkpoints/binding-affinity.pt` | SMILES binding scorer | 132,489,414 B | `8daf2ce5ec84045e94bbe41b0a57669287ac937a2a51e97da1af36ae8dc5e16f` |
13
+ | `smiles/scoring/checkpoints/hemolysis-xgboost.json` | hemolysis scorer | 1,932,561 B | `69552199fa7722369211aa0e33adc9850816fb816f555ce9e207c73e0543b0ee` |
14
+ | `smiles/scoring/checkpoints/nonfouling-xgboost.json` | non-fouling scorer | 645,478 B | `08364879c255e236cf0a337505e99e36824f1a72090f5829d3f7870d4ca11644` |
15
+ | `smiles/scoring/checkpoints/solubility-xgboost.json` | solubility scorer | 196,887 B | `3c6a534db85550c947d864aa5cf0b1a2862333bc50eb4b6d5053074cc7d66f02` |
16
+
17
+ ESM-2 (`facebook/esm2_t33_650M_UR50D`) and PeptideCLM
18
+ (`aaronfeller/PeptideCLM-23M-all`) are downloaded from Hugging Face on first use and therefore are
19
+ not duplicated here.
20
+
21
+ The optional AA-input binding predictor was not used for the reported PDC runs, which used
22
+ `--affinity_type smiles`, and is intentionally excluded (it would add about 2.7 GB). The public
23
+ wrapper passes the reported SMILES-input setting explicitly.
24
+
25
+ When publishing with `hf upload`, the Hugging Face Hub client handles large-file storage. If this
26
+ release is mirrored to a Git LFS remote:
27
+
28
+ ```bash
29
+ git lfs install
30
+ git lfs track "*.pt" "*.ckpt"
31
+ git add .gitattributes checkpoints smiles/scoring/checkpoints
32
+ ```
docs/artifacts.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "path": "checkpoints/SMILESReDi_base.ckpt",
4
+ "bytes": 1038182356,
5
+ "sha256": "8b2a6049e7ce6c2ef2ab0da0987ee14a5c7e3df737c5c32202631f5c230a81df"
6
+ },
7
+ {
8
+ "path": "checkpoints/PepReDi_base.pt",
9
+ "bytes": 344474053,
10
+ "sha256": "3b6437edee514bd7adb9aacea776f3dd97c59ebc7b4928b390eafdd87eaeb8c9"
11
+ },
12
+ {
13
+ "path": "checkpoints/UAAReDi_base.pt",
14
+ "bytes": 344824232,
15
+ "sha256": "fa9c25692f308e87befee15cecdd4a24d9e2270e039b12d073023390632b2a8d"
16
+ },
17
+ {
18
+ "path": "smiles/scoring/checkpoints/binding-affinity.pt",
19
+ "bytes": 132489414,
20
+ "sha256": "8daf2ce5ec84045e94bbe41b0a57669287ac937a2a51e97da1af36ae8dc5e16f"
21
+ },
22
+ {
23
+ "path": "smiles/scoring/checkpoints/hemolysis-xgboost.json",
24
+ "bytes": 1932561,
25
+ "sha256": "69552199fa7722369211aa0e33adc9850816fb816f555ce9e207c73e0543b0ee"
26
+ },
27
+ {
28
+ "path": "smiles/scoring/checkpoints/nonfouling-xgboost.json",
29
+ "bytes": 645478,
30
+ "sha256": "08364879c255e236cf0a337505e99e36824f1a72090f5829d3f7870d4ca11644"
31
+ },
32
+ {
33
+ "path": "smiles/scoring/checkpoints/solubility-xgboost.json",
34
+ "bytes": 196887,
35
+ "sha256": "3c6a534db85550c947d864aa5cf0b1a2862333bc50eb4b6d5053074cc7d66f02"
36
+ }
37
+ ]
38
+
environment.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: cupofmoca
2
+ channels:
3
+ - pytorch
4
+ - nvidia
5
+ - conda-forge
6
+ dependencies:
7
+ - python=3.9
8
+ - pip
9
+ - pytorch=2.5.1
10
+ - pytorch-cuda=12.1
11
+ - numpy=1.26.4
12
+ - scipy=1.13.1
13
+ - pandas=2.3.2
14
+ - scikit-learn=1.6.1
15
+ - xgboost=2.1.4
16
+ - matplotlib=3.9.4
17
+ - seaborn=0.13.2
18
+ - pillow=11.3.0
19
+ - rdkit=2025.03.6
20
+ - pip:
21
+ - lightning==2.5.5
22
+ - transformers==4.41.2
23
+ - datasets==4.0.0
24
+ - fair-esm==2.0.0
25
+ - SmilesPE==0.0.3
26
+ - easydict==1.13
27
+ - tqdm==4.67.1
28
+
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch==2.5.1
2
+ lightning==2.5.5
3
+ transformers==4.41.2
4
+ datasets==4.0.0
5
+ fair-esm==2.0.0
6
+ rdkit==2025.3.6
7
+ SmilesPE==0.0.3
8
+ numpy==1.26.4
9
+ scipy==1.13.1
10
+ pandas==2.3.2
11
+ scikit-learn==1.6.1
12
+ xgboost==2.1.4
13
+ matplotlib==3.9.4
14
+ seaborn==0.13.2
15
+ pillow==11.3.0
16
+ easydict==1.13
17
+ tqdm==4.67.1
18
+
scripts/run_experiment.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run reproducible CupOFMoCA PDC experiments.
3
+
4
+ This wrapper keeps the submitted generation programs intact while providing one
5
+ portable interface, bundled checkpoint defaults, named targets, and collision-
6
+ safe outputs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ ROOT = Path(__file__).resolve().parents[1]
20
+ CONFIG = json.loads((ROOT / "configs" / "paper.json").read_text())
21
+
22
+ CHECKPOINTS = {
23
+ "smiles": ROOT / "checkpoints" / "SMILESReDi_base.ckpt",
24
+ "aa": ROOT / "checkpoints" / "PepReDi_base.pt",
25
+ "uaa": ROOT / "checkpoints" / "UAAReDi_base.pt",
26
+ }
27
+
28
+
29
+ def read_fasta(path: Path) -> dict[str, str]:
30
+ records: dict[str, str] = {}
31
+ name = None
32
+ chunks: list[str] = []
33
+ for raw in path.read_text().splitlines():
34
+ line = raw.strip()
35
+ if not line:
36
+ continue
37
+ if line.startswith(">"):
38
+ if name is not None:
39
+ records[name] = "".join(chunks)
40
+ name, chunks = line[1:].split()[0], []
41
+ elif name is None:
42
+ raise ValueError(f"Sequence before FASTA header in {path}")
43
+ else:
44
+ chunks.append(line)
45
+ if name is not None:
46
+ records[name] = "".join(chunks)
47
+ return records
48
+
49
+
50
+ def parse_args() -> argparse.Namespace:
51
+ parser = argparse.ArgumentParser(description=__doc__)
52
+ parser.add_argument("--representation", choices=("smiles", "aa", "uaa"), required=True)
53
+ parser.add_argument("--variant", choices=("baseline", "cupofmoca"), default="cupofmoca")
54
+ parser.add_argument("--method", choices=("guided", "staged"), default="guided")
55
+ parser.add_argument("--target", choices=tuple(CONFIG["targets"]), required=True)
56
+ parser.add_argument(
57
+ "--seeds", type=int, nargs="+", default=CONFIG["default_seeds"],
58
+ help="Random seeds for generation.",
59
+ )
60
+ parser.add_argument("--num-candidates", type=int, default=CONFIG["num_candidates_per_seed"])
61
+ parser.add_argument("--gen-len", type=int, default=None)
62
+ parser.add_argument("--checkpoint", type=Path, default=None)
63
+ parser.add_argument("--output-root", type=Path, default=ROOT / "outputs")
64
+ parser.add_argument("--python", default=sys.executable)
65
+ parser.add_argument("--dry-run", action="store_true")
66
+ parser.add_argument("--force", action="store_true", help="Allow an existing output to be replaced.")
67
+ parser.add_argument(
68
+ "extra_args", nargs=argparse.REMAINDER,
69
+ help="Arguments after '--' are appended to the underlying generator command.",
70
+ )
71
+ args = parser.parse_args()
72
+ if args.num_candidates < 1:
73
+ parser.error("--num-candidates must be positive")
74
+ return args
75
+
76
+
77
+ def build_command(args: argparse.Namespace, seed: int, target_seq: str, output: Path) -> list[str]:
78
+ suffix = "" if args.representation == "smiles" else f"_{args.representation}"
79
+ prefix = "cupofmoca_" if args.variant == "cupofmoca" else ""
80
+ program = f"smiles.generation.{prefix}{args.method}{suffix}"
81
+ checkpoint = (args.checkpoint or CHECKPOINTS[args.representation]).resolve()
82
+ target_cfg = CONFIG["targets"][args.target]
83
+ gen_len = args.gen_len or (
84
+ target_cfg["smiles_length"]
85
+ if args.representation == "smiles"
86
+ else CONFIG["representation_lengths"][args.representation]
87
+ )
88
+
89
+ cmd = [
90
+ args.python, "-u", "-m", program,
91
+ "--checkpoint", str(checkpoint),
92
+ "--target", target_seq,
93
+ "--num_samples", str(CONFIG["num_samples_per_batch"]),
94
+ "--num_batches", str(args.num_candidates),
95
+ "--gen_len", str(gen_len),
96
+ "--optimization_steps", str(CONFIG["optimization_steps"]),
97
+ "--weights", *map(str, CONFIG["weights"]),
98
+ "--top_p", str(CONFIG["top_p"]),
99
+ "--seed", str(seed),
100
+ "--output_file", str(output),
101
+ ]
102
+
103
+ if args.representation in {"aa", "uaa"}:
104
+ cmd += ["--tokenizer_type", "amino"]
105
+ if args.representation == "aa":
106
+ # This is the scorer used by the PDC paper runs. The optional AA-input
107
+ # scorer is not needed and its multi-GB checkpoint is intentionally omitted.
108
+ cmd += ["--affinity_type", "smiles"]
109
+
110
+ if args.method == "guided":
111
+ cmd += [
112
+ "--builder_type", target_cfg["builder"],
113
+ "--require_valid_x0",
114
+ "--max_x0_retries", str(CONFIG["max_x0_retries"]),
115
+ "--output_dir", str(output.parent / "logs"),
116
+ ]
117
+
118
+ if args.variant == "cupofmoca":
119
+ cmd += [
120
+ "--use_acceptance",
121
+ "--beta_min", str(CONFIG["beta_min"]),
122
+ "--beta_max", str(CONFIG["beta_max"]),
123
+ ]
124
+ if args.method == "guided":
125
+ cmd += ["--guidance_method", "exp_tilt"]
126
+ if args.representation in {"aa", "uaa"}:
127
+ cmd += ["--cascade_k", str(CONFIG["cascade_k"])]
128
+ else:
129
+ cmd += ["--output_dir", str(output.parent / "logs")]
130
+
131
+ extra = list(args.extra_args)
132
+ if extra and extra[0] == "--":
133
+ extra = extra[1:]
134
+ return cmd + extra
135
+
136
+
137
+ def main() -> int:
138
+ args = parse_args()
139
+ targets = read_fasta(ROOT / "configs" / "targets.fasta")
140
+ target_seq = targets[args.target]
141
+ failures = 0
142
+
143
+ for seed in args.seeds:
144
+ run_dir = (
145
+ args.output_root.resolve()
146
+ / args.target
147
+ / args.representation
148
+ / f"{args.variant}_{args.method}"
149
+ / f"seed_{seed}"
150
+ )
151
+ output = run_dir / "molecules.txt"
152
+ if output.exists() and not args.force and not args.dry_run:
153
+ print(f"Refusing to overwrite {output}; pass --force to replace it.", file=sys.stderr)
154
+ failures += 1
155
+ continue
156
+ if output.exists() and args.force:
157
+ output.unlink()
158
+ command = build_command(args, seed, target_seq, output)
159
+ display = " ".join(
160
+ f"<{args.target} sequence, {len(target_seq)} aa>" if item == target_seq else shlex.quote(item)
161
+ for item in command
162
+ )
163
+ print(display)
164
+ if args.dry_run:
165
+ continue
166
+ run_dir.mkdir(parents=True, exist_ok=True)
167
+ completed = subprocess.run(command, cwd=ROOT, check=False)
168
+ if completed.returncode:
169
+ failures += 1
170
+ print(f"Seed {seed} failed with exit code {completed.returncode}.", file=sys.stderr)
171
+
172
+ return 1 if failures else 0
173
+
174
+
175
+ if __name__ == "__main__":
176
+ raise SystemExit(main())
177
+
scripts/verify_release.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Verify bundled PDC artifacts and portability-sensitive source files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ MANIFEST = ROOT / "docs" / "artifacts.json"
14
+
15
+
16
+ def sha256(path: Path) -> str:
17
+ digest = hashlib.sha256()
18
+ with path.open("rb") as handle:
19
+ for block in iter(lambda: handle.read(8 * 1024 * 1024), b""):
20
+ digest.update(block)
21
+ return digest.hexdigest()
22
+
23
+
24
+ def main() -> int:
25
+ entries = json.loads(MANIFEST.read_text())
26
+ failures = 0
27
+ for entry in entries:
28
+ path = ROOT / entry["path"]
29
+ if not path.is_file():
30
+ print(f"MISSING {entry['path']}")
31
+ failures += 1
32
+ continue
33
+ size_ok = path.stat().st_size == entry["bytes"]
34
+ hash_ok = sha256(path) == entry["sha256"]
35
+ state = "OK" if size_ok and hash_ok else "FAILED"
36
+ print(f"{state:7} {entry['path']}")
37
+ failures += int(not (size_ok and hash_ok))
38
+
39
+ private_roots = tuple("/" + stem for stem in ("scratch/", "Users/", "home/"))
40
+ for path in list((ROOT / "smiles").rglob("*.py")) + list((ROOT / "scripts").rglob("*.py")):
41
+ text = path.read_text(errors="replace")
42
+ if any(root in text for root in private_roots):
43
+ print(f"PRIVATE {path.relative_to(ROOT)}")
44
+ failures += 1
45
+
46
+ print("Release verification passed." if not failures else f"Verification found {failures} problem(s).")
47
+ return 1 if failures else 0
48
+
49
+
50
+ if __name__ == "__main__":
51
+ raise SystemExit(main())
52
+
smiles/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """CupOFMoCA peptide-drug conjugate implementation."""
smiles/conjugation/AoaDau.py ADDED
@@ -0,0 +1,732 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AoaDaunorubicin PDC Builder
3
+
4
+ Conjugation chemistry: Lysine ε-amine + Aoa-Daunorubicin (amide bond formation)
5
+
6
+ Reference PDC: PDC_00208 (GnRH-III-[8Lys(Dau=Aoa)])
7
+ - Receptor: GNRHR
8
+ - Linker: Aminooxyacetic acid (Aoa)
9
+ - Payload: Daunorubicin
10
+
11
+ Architecture:
12
+ Peptide──Lys──NH──C(=O)──CH₂──O──N═C──Daunorubicin
13
+ │ │
14
+ amide bond Aoa linker oxime bond (pre-formed)
15
+ """
16
+
17
+ from rdkit import Chem
18
+ from rdkit.Chem import RWMol, AllChem
19
+
20
+ from .pdc_builder import PDC_Builder
21
+
22
+
23
+ # ============================================================================
24
+ # Aoa-Daunorubicin PDC Builder
25
+ # ============================================================================
26
+
27
+ class AoaDaunorubicin_PDC_Builder(PDC_Builder):
28
+ """
29
+ PDC builder for Aminooxyacetic acid (Aoa) - Daunorubicin conjugates
30
+
31
+ Conjugation chemistry (amide bond formation):
32
+ - Peptide site: Lysine ε-amine (side chain -NH2)
33
+ - Linker-payload site: Carboxylic acid of Aoa (already conjugated to Dau via oxime)
34
+ - Reaction: Amide bond formation (Lys-NH2 + HOOC-Aoa-Dau → Lys-NH-CO-Aoa-Dau)
35
+
36
+ Architecture:
37
+ Peptide──Lys──NH──C(=O)──CH₂──O──N═C(CH₃)──Daunorubicin
38
+ └── amide ──┘ └── oxime (pre-formed) ──┘
39
+
40
+ Reference: PDC_00208 (GnRH-III-[8Lys(Dau=Aoa)])
41
+ """
42
+
43
+ # Default Aoa-Daunorubicin SMILES with free carboxylic acid
44
+ # Structure: HOOC-CH2-O-N=C(CH3)-Daunorubicin
45
+ # The oxime bond is already formed between Aoa and Dau ketone
46
+ DEFAULT_AOA_DAU_SMILES = (
47
+ "OC(=O)CO/N=C(\\C)[C@@]1(O)Cc2c(O)c3c(c(O)c2[C@H](OC2C[C@@H](N)[C@@H](O)[C@@H](C)O2)C1)"
48
+ "C(=O)c1c(OC)cccc1C3=O"
49
+ )
50
+
51
+ def __init__(self, aoa_dau_smiles=None):
52
+ """
53
+ Initialize Aoa-Daunorubicin builder
54
+
55
+ Args:
56
+ aoa_dau_smiles: Optional custom Aoa-Dau SMILES. If None, uses default.
57
+ Must have free carboxylic acid for conjugation.
58
+ """
59
+ if aoa_dau_smiles is None:
60
+ aoa_dau_smiles = self.DEFAULT_AOA_DAU_SMILES
61
+
62
+ super().__init__(aoa_dau_smiles, component_name="Aoa-Daunorubicin (linker-payload)")
63
+
64
+ def _verify_reactive_group(self):
65
+ """Verify carboxylic acid group is present for amide bond formation"""
66
+ # Pattern: Carboxylic acid (COOH)
67
+ carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
68
+
69
+ if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
70
+ print(" Warning: No carboxylic acid group detected in Aoa-Dau")
71
+ else:
72
+ print(" Aoa-Daunorubicin loaded with carboxylic acid reactive group")
73
+
74
+ # Also verify oxime bond is present (Aoa-Dau linkage)
75
+ oxime_pattern = Chem.MolFromSmarts('[#6]-[#8]-[#7]=[#6]')
76
+ if self.linker_payload_mol.HasSubstructMatch(oxime_pattern):
77
+ print(" Oxime bond (Aoa-Dau linkage) detected")
78
+ else:
79
+ print(" Warning: No oxime bond detected - check Aoa-Dau structure")
80
+
81
+ def find_conjugation_sites(self, peptide_smiles):
82
+ """
83
+ Find all lysine ε-amine groups in peptide
84
+
85
+ Returns list of dicts with site information including atom index
86
+ """
87
+ return self.find_peptide_lysines(peptide_smiles)
88
+
89
+ def find_peptide_lysines(self, peptide_smiles):
90
+ """
91
+ Find all lysine residues with free ε-amine in peptide
92
+
93
+ Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
94
+
95
+ Returns:
96
+ List of dicts with 'atom_idx' (nitrogen index) and metadata
97
+ """
98
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
99
+ if peptide_mol is None:
100
+ raise ValueError("Invalid peptide SMILES")
101
+
102
+ lysines = []
103
+
104
+ # SMARTS pattern for Lysine ε-amine: 4 CH2 groups ending in NH2
105
+ # This specifically matches side chain, not N-terminus
106
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
107
+ matches = peptide_mol.GetSubstructMatches(lys_pattern)
108
+
109
+ for match in matches:
110
+ # match indices: 0=CH2, 1=CH2, 2=CH2, 3=CH2, 4=NH2
111
+ nitrogen_idx = match[4]
112
+
113
+ lysines.append({
114
+ 'atom_idx': nitrogen_idx,
115
+ 'side_chain_atoms': list(match),
116
+ 'type': 'Lys_epsilon_amine'
117
+ })
118
+
119
+ # Also count N-terminus for informational purposes
120
+ # N-terminus: NH2 attached to backbone carbon (CH for most AAs, CH2 for Gly)
121
+ n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
122
+ n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
123
+
124
+ if n_term_matches:
125
+ print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
126
+
127
+ return lysines
128
+
129
+ def count_all_nh2_sites(self, peptide_smiles):
130
+ """
131
+ Count all NH2 sites in peptide (both Lys ε-amines and N-terminus)
132
+
133
+ Useful for understanding what sites are available.
134
+
135
+ Returns:
136
+ Dict with counts: {'lys_epsilon': n, 'n_terminus': n, 'other': n, 'total': n}
137
+ """
138
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
139
+ if peptide_mol is None:
140
+ raise ValueError("Invalid peptide SMILES")
141
+
142
+ counts = {'lys_epsilon': 0, 'n_terminus': 0, 'other': 0, 'total': 0}
143
+
144
+ # Lys ε-amine: CH2-CH2-CH2-CH2-NH2 (4-carbon side chain)
145
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
146
+ counts['lys_epsilon'] = len(peptide_mol.GetSubstructMatches(lys_pattern))
147
+
148
+ # N-terminus: NH2 attached to backbone carbon (either CH for most AAs, or CH2 for Gly)
149
+ # Pattern: NH2-C-C(=O) where C is part of backbone
150
+ n_term_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
151
+ counts['n_terminus'] = len(peptide_mol.GetSubstructMatches(n_term_pattern))
152
+
153
+ # Total NH2 (any primary amine)
154
+ all_nh2_pattern = Chem.MolFromSmarts('[NH2]')
155
+ total_nh2 = len(peptide_mol.GetSubstructMatches(all_nh2_pattern))
156
+
157
+ counts['other'] = total_nh2 - counts['lys_epsilon'] - counts['n_terminus']
158
+ counts['total'] = total_nh2
159
+
160
+ return counts
161
+
162
+ def _find_carboxylic_acid_atoms(self, mol):
163
+ """
164
+ Find the carboxylic acid carbon (the reactive site for amide formation)
165
+
166
+ Returns: (carbon_idx, oxygen_idx) of the C(=O)OH group
167
+ """
168
+ # Pattern: C(=O)OH
169
+ cooh_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
170
+ matches = mol.GetSubstructMatches(cooh_pattern)
171
+
172
+ if not matches:
173
+ return None, None
174
+
175
+ # Return first match: (carbon_idx, hydroxyl_oxygen_idx)
176
+ match = matches[0]
177
+ return match[0], match[1]
178
+
179
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
180
+ """
181
+ Perform amide bond conjugation between NH2 groups and Aoa carboxylic acid
182
+
183
+ Args:
184
+ peptide_mol: RDKit Mol object
185
+ site_info: Not used (kept for compatibility)
186
+ exclude_n_terminus: If True, skip N-terminus conjugation
187
+
188
+ Returns: List of all possible conjugate SMILES
189
+ """
190
+ return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
191
+
192
+ def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
193
+ """
194
+ Create ALL possible conjugates by forming amide bond at NH2 sites
195
+
196
+ Chemistry:
197
+ - Any-NH2 + HOOC-Aoa-Dau → Any-NH-C(=O)-Aoa-Dau + H2O
198
+
199
+ Args:
200
+ peptide_mol: RDKit Mol object of peptide
201
+ lysine_info: Not used (kept for interface compatibility)
202
+ exclude_n_terminus: If True, only conjugate at Lys ε-amines (4-carbon chain),
203
+ not at N-terminus α-amine. Default: True
204
+
205
+ Returns:
206
+ List of all possible conjugate SMILES
207
+ """
208
+ if exclude_n_terminus:
209
+ # SMARTS that matches only Lys ε-amine: requires 4 CH2 chain before NH2
210
+ # This includes the connection to the peptide backbone (*) to preserve the full peptide
211
+ # Pattern: *-CH2-CH2-CH2-CH2-NH2 (where * is the connection to the peptide)
212
+ amide_rxn = AllChem.ReactionFromSmarts(
213
+ '[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
214
+ )
215
+ else:
216
+ # Match ANY primary amine (includes N-terminus)
217
+ # Include wildcard connection to preserve the rest of the molecule
218
+ amide_rxn = AllChem.ReactionFromSmarts(
219
+ '[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
220
+ )
221
+
222
+ # Run the reaction - returns all possible products
223
+ products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
224
+
225
+ if not products:
226
+ raise ValueError("Amide bond formation reaction failed - no products generated")
227
+
228
+ # Collect ALL valid conjugates
229
+ conjugates = []
230
+ seen_smiles = set() # Deduplicate
231
+
232
+ for product_tuple in products:
233
+ try:
234
+ conjugate_mol = product_tuple[0]
235
+ Chem.SanitizeMol(conjugate_mol)
236
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
237
+
238
+ # Deduplicate (same SMILES = same molecule)
239
+ if conjugate_smiles not in seen_smiles:
240
+ seen_smiles.add(conjugate_smiles)
241
+ conjugates.append(conjugate_smiles)
242
+ except Exception as e:
243
+ # Skip invalid products
244
+ continue
245
+
246
+ if not conjugates:
247
+ raise ValueError("All products failed sanitization")
248
+
249
+ return conjugates
250
+
251
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
252
+ """
253
+ Conjugate Aoa-Daunorubicin to peptide via amide bond at NH2 groups
254
+
255
+ Args:
256
+ peptide_smiles: SMILES string of peptide (must contain NH2)
257
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines,
258
+ not at N-terminus. Set to False to include N-terminus.
259
+ return_all: If True, return all possible conjugates. If False, return first only.
260
+
261
+ Returns:
262
+ If return_all=True: List of all possible conjugate SMILES
263
+ If return_all=False: Single conjugate SMILES (first one)
264
+ """
265
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
266
+ if peptide_mol is None:
267
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
268
+
269
+ sites = self.find_conjugation_sites(peptide_smiles)
270
+ print(f"Found {len(sites)} Lys ε-amine site(s)")
271
+
272
+ # Get all conjugates
273
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
274
+
275
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
276
+ print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}]")
277
+
278
+ if return_all:
279
+ return conjugates
280
+ else:
281
+ return conjugates[0] if conjugates else None
282
+
283
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
284
+ """
285
+ Batch conjugate multiple peptides
286
+
287
+ Args:
288
+ peptide_smiles_list: List of peptide SMILES strings
289
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
290
+ return_all: If True, return all possible conjugates per peptide
291
+
292
+ Returns:
293
+ Dict with:
294
+ 'conjugates': List of lists (if return_all=True) or list of SMILES
295
+ 'successful': List of successful peptide indices
296
+ 'failed': List of (index, error) tuples
297
+ 'total_conjugates': Total number of conjugates generated
298
+ """
299
+ results = {
300
+ 'conjugates': [],
301
+ 'successful': [],
302
+ 'failed': [],
303
+ 'total_conjugates': 0
304
+ }
305
+
306
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
307
+ try:
308
+ conjugates = self.conjugate_to_peptide(
309
+ peptide_smiles,
310
+ exclude_n_terminus=exclude_n_terminus,
311
+ return_all=return_all
312
+ )
313
+ if return_all:
314
+ results['conjugates'].append(conjugates)
315
+ results['total_conjugates'] += len(conjugates)
316
+ else:
317
+ results['conjugates'].append(conjugates)
318
+ results['total_conjugates'] += 1
319
+ results['successful'].append(i)
320
+ except Exception as e:
321
+ print(f" Peptide {i} failed: {e}")
322
+ results['conjugates'].append(None)
323
+ results['failed'].append((i, str(e)))
324
+
325
+ print(f"\n=== Summary ===")
326
+ print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
327
+ print(f"Total conjugates generated: {results['total_conjugates']}")
328
+
329
+ return results
330
+
331
+ def flatten_batch_results(self, results):
332
+ """
333
+ Flatten batch conjugation results into a single list of all conjugates
334
+
335
+ Args:
336
+ results: Dict returned by batch_conjugate()
337
+
338
+ Returns:
339
+ List of all conjugate SMILES (flattened)
340
+ """
341
+ all_conjugates = []
342
+ for conjugate_list in results['conjugates']:
343
+ if conjugate_list is not None:
344
+ if isinstance(conjugate_list, list):
345
+ all_conjugates.extend(conjugate_list)
346
+ else:
347
+ all_conjugates.append(conjugate_list)
348
+ return all_conjugates
349
+
350
+ # =========================================================================
351
+ # Attachment Site Compatibility Checks (Lys-amine + Aoa-COOH specific)
352
+ # =========================================================================
353
+
354
+ def has_aoa_amide_conjugation(self, mol):
355
+ """
356
+ Check if molecule has the Aoa-amide conjugation pattern
357
+
358
+ Pattern: N-C(=O)-CH2-O-N=C (amide-CH2-O-oxime linkage)
359
+ This is the signature of successful Lys-Aoa conjugation.
360
+
361
+ Returns:
362
+ bool: True if Aoa conjugation pattern is present
363
+ """
364
+ # Specific Aoa-conjugation pattern
365
+ # Looking for: N-C(=O)-C-O-N=C (amide-CH2-O-oxime linkage)
366
+ aoa_conjugation_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH2][OX2][NX2]=[#6]')
367
+ if aoa_conjugation_pattern:
368
+ return mol.HasSubstructMatch(aoa_conjugation_pattern)
369
+ return False
370
+
371
+ def has_oxime_bond(self, mol):
372
+ """
373
+ Check if molecule has oxime bond (Aoa-Dau linkage)
374
+
375
+ Pattern: C-O-N=C
376
+ This confirms the Aoa-Dau linker-payload is intact.
377
+ """
378
+ oxime_pattern = Chem.MolFromSmarts('[#6]-[#8]-[#7]=[#6]')
379
+ if oxime_pattern:
380
+ return mol.HasSubstructMatch(oxime_pattern)
381
+ return False
382
+
383
+ def has_unreacted_aoa_carboxylic_acid(self, mol):
384
+ """
385
+ Check if molecule has unreacted Aoa carboxylic acid
386
+
387
+ The specific Aoa pattern: HOOC-CH2-O-N=C
388
+ If this is present, the Aoa hasn't been conjugated to the peptide.
389
+
390
+ Note: We specifically check for the Aoa-COOH pattern, not just any COOH,
391
+ because peptides naturally have carboxylic acids (C-terminus, Asp, Glu).
392
+
393
+ Returns:
394
+ bool: True if unreacted Aoa-COOH is present (bad)
395
+ """
396
+ # Specific pattern: Free COOH attached to CH2-O-N=C (Aoa pattern)
397
+ unreacted_aoa_pattern = Chem.MolFromSmarts('[CX3](=[OX1])[OX2H1][CH2][OX2][NX2]=[#6]')
398
+ if unreacted_aoa_pattern:
399
+ return mol.HasSubstructMatch(unreacted_aoa_pattern)
400
+ return False
401
+
402
+ def find_amide_conjugation_sites(self, mol):
403
+ """
404
+ Find amide bonds that are part of the Aoa conjugation
405
+
406
+ Specifically looks for: N-C(=O)-CH2-O-N=C pattern
407
+
408
+ Returns:
409
+ List of (nitrogen_idx, carbonyl_carbon_idx) tuples
410
+ """
411
+ aoa_conjugation_pattern = Chem.MolFromSmarts('[NX3:1][CX3:2](=[OX1])[CH2][OX2][NX2]=[#6]')
412
+ conjugation_sites = []
413
+
414
+ if aoa_conjugation_pattern:
415
+ matches = mol.GetSubstructMatches(aoa_conjugation_pattern)
416
+ for match in matches:
417
+ nitrogen_idx = match[0]
418
+ carbonyl_c_idx = match[1]
419
+ conjugation_sites.append((nitrogen_idx, carbonyl_c_idx))
420
+
421
+ return conjugation_sites
422
+
423
+ def check_valency_at_conjugation_site(self, mol, nitrogen_idx):
424
+ """
425
+ Check valency at amide nitrogen conjugation site
426
+
427
+ For Lys-Aoa conjugation, the nitrogen should be:
428
+ - Amide nitrogen (bonded to carbonyl carbon)
429
+ - Secondary amine (2 bonds to heavy atoms)
430
+ - No charge
431
+
432
+ Args:
433
+ mol: RDKit Mol object
434
+ nitrogen_idx: Index of the nitrogen atom to check
435
+
436
+ Returns:
437
+ bool: True if valency is valid
438
+ """
439
+ atom = mol.GetAtomWithIdx(nitrogen_idx)
440
+
441
+ if atom.GetSymbol() != 'N':
442
+ return True # Not nitrogen, skip
443
+
444
+ degree = atom.GetDegree() # Bonds to heavy atoms
445
+ total_hs = atom.GetTotalNumHs()
446
+ formal_charge = atom.GetFormalCharge()
447
+
448
+ # Get neighbor information
449
+ neighbors = [mol.GetAtomWithIdx(n.GetIdx()) for n in atom.GetNeighbors()]
450
+
451
+ # Count carbonyl carbons (C=O) among neighbors
452
+ carbonyl_neighbors = 0
453
+ for neighbor in neighbors:
454
+ if neighbor.GetSymbol() == 'C':
455
+ # Check if this carbon has a double bond to oxygen
456
+ for n2 in neighbor.GetNeighbors():
457
+ if n2.GetSymbol() == 'O':
458
+ bond = mol.GetBondBetweenAtoms(neighbor.GetIdx(), n2.GetIdx())
459
+ if bond and bond.GetBondType() == Chem.BondType.DOUBLE:
460
+ carbonyl_neighbors += 1
461
+ break
462
+
463
+ # ========== Valid Amide Nitrogen States ==========
464
+
465
+ # 1. Secondary amide: N bonded to 2 heavy atoms (one is carbonyl C), 1 H
466
+ # R-NH-C(=O)-R'
467
+ if degree == 2 and total_hs == 1 and formal_charge == 0 and carbonyl_neighbors >= 1:
468
+ return True
469
+
470
+ # 2. Tertiary amide: N bonded to 3 heavy atoms (one is carbonyl C), 0 H
471
+ # R-N(R')-C(=O)-R''
472
+ if degree == 3 and total_hs == 0 and formal_charge == 0 and carbonyl_neighbors >= 1:
473
+ return True
474
+
475
+ # 3. Aromatic N in peptide backbone is okay
476
+ if atom.GetIsAromatic():
477
+ return True
478
+
479
+ # ========== Invalid States ==========
480
+
481
+ # Quaternary nitrogen with positive charge (not typical for amide)
482
+ if degree >= 4 and formal_charge > 0:
483
+ return False
484
+
485
+ # N with no carbonyl neighbor but expected to be amide
486
+ if carbonyl_neighbors == 0:
487
+ # This might be a free amine - need context to determine if that's okay
488
+ # For Aoa conjugation, we expect amide formation
489
+ pass # Allow for now, other checks will catch issues
490
+
491
+ return True
492
+
493
+ def check_attachment_site_compatibility(self, mol):
494
+ """
495
+ Check if Lys-Aoa amide attachment site chemistry is valid
496
+
497
+ For Aoa-Daunorubicin conjugation, validates:
498
+ 1. Has Aoa-amide conjugation pattern (N-C(=O)-CH2-O-N=C)
499
+ 2. Has oxime bond intact (Aoa-Dau linkage)
500
+ 3. No unreacted Aoa-COOH (conjugation must have happened)
501
+ 4. Valid valency at conjugation nitrogen sites
502
+
503
+ Args:
504
+ mol: RDKit Mol object of the conjugate
505
+
506
+ Returns:
507
+ bool: True if attachment site is valid, False otherwise
508
+ """
509
+ # ===== CRITICAL CHECK: Must have Aoa-amide conjugation pattern =====
510
+ if not self.has_aoa_amide_conjugation(mol):
511
+ return False
512
+
513
+ # ===== Check oxime bond is intact =====
514
+ if not self.has_oxime_bond(mol):
515
+ return False
516
+
517
+ # ===== Check no unreacted Aoa-COOH =====
518
+ # If the original Aoa carboxylic acid pattern is still present,
519
+ # conjugation didn't happen properly
520
+ if self.has_unreacted_aoa_carboxylic_acid(mol):
521
+ return False
522
+
523
+ # ===== Check valency at conjugation sites =====
524
+ conjugation_sites = self.find_amide_conjugation_sites(mol)
525
+
526
+ for nitrogen_idx, carbonyl_c_idx in conjugation_sites:
527
+ if not self.check_valency_at_conjugation_site(mol, nitrogen_idx):
528
+ return False
529
+
530
+ return True
531
+
532
+ def _basic_validation(self, mol):
533
+ """
534
+ Basic validation checks for Aoa-Daunorubicin conjugates
535
+
536
+ Includes attachment site compatibility check with valency validation.
537
+
538
+ Checks:
539
+ 1. Attachment site compatibility (Aoa-amide pattern, oxime, valency)
540
+ 2. Daunorubicin core present
541
+ 3. Aminosugar present (daunosamine)
542
+
543
+ Note: We do NOT check for absence of COOH because peptides naturally
544
+ have carboxylic acids (C-terminus, Asp, Glu residues).
545
+ """
546
+ checks = {}
547
+
548
+ # Check 1: Has amide bond (general check)
549
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
550
+ checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
551
+
552
+ # Check 2: Oxime bond present (Aoa-Dau linkage: C-O-N=C)
553
+ checks['has_oxime_bond'] = self.has_oxime_bond(mol)
554
+
555
+ # Check 3: Specific Aoa-conjugation pattern
556
+ checks['has_aoa_conjugation'] = self.has_aoa_amide_conjugation(mol)
557
+
558
+ # Check 4: No unreacted Aoa-COOH
559
+ checks['no_unreacted_aoa'] = not self.has_unreacted_aoa_carboxylic_acid(mol)
560
+
561
+ # Check 5: Full attachment site compatibility (includes valency)
562
+ checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
563
+
564
+ # Check 6: Daunorubicin core present (anthraquinone with specific substitution)
565
+ anthraquinone = Chem.MolFromSmarts('[#6]1[#6][#6][#6]2[#6](=[#8])[#6]3[#6][#6][#6][#6][#6]3[#6](=[#8])[#6]2[#6]1')
566
+ checks['has_daunorubicin_core'] = mol.HasSubstructMatch(anthraquinone)
567
+
568
+ # Check 7: Has aminosugar (daunosamine - 6-membered ring with N)
569
+ aminosugar = Chem.MolFromSmarts('[OX2]1[#6][#6][#6]([NX3])[#6][#6]1')
570
+ checks['has_aminosugar'] = mol.HasSubstructMatch(aminosugar)
571
+
572
+ # Find and report conjugation sites for diagnostics
573
+ conjugation_sites = self.find_amide_conjugation_sites(mol)
574
+ checks['conjugation_sites'] = conjugation_sites
575
+
576
+ # Overall pass/fail - attachment_site_valid is the key check
577
+ checks['_passed'] = checks['attachment_site_valid']
578
+
579
+ return checks
580
+
581
+ def validate_conjugate(self, conjugate_smiles):
582
+ """
583
+ Validate that the conjugate is chemically reasonable
584
+
585
+ Returns:
586
+ (passed: bool, checks: dict)
587
+ """
588
+ mol = Chem.MolFromSmiles(conjugate_smiles)
589
+
590
+ if mol is None:
591
+ return False, {'error': 'Could not parse SMILES'}
592
+
593
+ checks = self._basic_validation(mol)
594
+ checks['parseable'] = True
595
+
596
+ passed = checks.get('_passed', True)
597
+ if '_passed' in checks:
598
+ del checks['_passed']
599
+
600
+ return passed, checks
601
+
602
+
603
+ # ============================================================================
604
+ # Main - Test Examples
605
+ # ============================================================================
606
+
607
+ if __name__ == "__main__":
608
+
609
+ print("=" * 70)
610
+ print("Aoa-Daunorubicin PDC Builder - Test")
611
+ print("=" * 70 + "\n")
612
+
613
+ # Initialize builder
614
+ builder = AoaDaunorubicin_PDC_Builder()
615
+
616
+ # Reference peptide: GnRH-III (PEP00168)
617
+ gnrh_iii_smiles = (
618
+ "[H]NCCCC[C@H](NC(=O)[C@H](Cc1cn([H])c2ccccc12)NC(=O)[C@H](CC(=O)O)"
619
+ "NC(=O)[C@H](Cc1cn([H])cn1)NC(=O)[C@H](CO)NC(=O)[C@H](Cc1cn([H])c2ccccc12)"
620
+ "NC(=O)[C@H](Cc1cn([H])cn1)NC(=O)[C@@H]1CCC(=O)N1)C(=O)N1CCC[C@H]1C(=O)NCC(N)=O"
621
+ )
622
+
623
+ # Test peptides
624
+ test_peptides = [
625
+ # Simple test: Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine = 2 NH2 sites)
626
+ "NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
627
+ # Lys-Arg (has N-terminus NH2 + Lys ε-amine = 2 NH2 sites)
628
+ "N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
629
+ # GnRH-III
630
+ gnrh_iii_smiles,
631
+ ]
632
+
633
+ print("\n" + "=" * 70)
634
+ print("Testing NH2 Site Detection")
635
+ print("=" * 70 + "\n")
636
+
637
+ for i, pep in enumerate(test_peptides):
638
+ name = ["Gly-Lys-Gly", "Lys-Arg", "GnRH-III"][i]
639
+ counts = builder.count_all_nh2_sites(pep)
640
+ print(f"{name}:")
641
+ print(f" Lys ε-amines: {counts['lys_epsilon']}")
642
+ print(f" N-terminus: {counts['n_terminus']}")
643
+ print(f" Other NH2: {counts['other']}")
644
+ print(f" Total NH2: {counts['total']}")
645
+ print()
646
+
647
+ print("\n" + "=" * 70)
648
+ print("Testing: exclude_n_terminus=True (DEFAULT)")
649
+ print("=" * 70 + "\n")
650
+
651
+ peptide = test_peptides[0] # Gly-Lys-Gly
652
+ print(f"Input peptide (Gly-Lys-Gly): {peptide}")
653
+
654
+ try:
655
+ conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
656
+ print(f"\n SUCCESS!")
657
+ print(f"Number of conjugates: {len(conjugates)} (should be 1 - Lys only)")
658
+
659
+ for i, conj in enumerate(conjugates):
660
+ valid, checks = builder.validate_conjugate(conj)
661
+ print(f" Conjugate {i+1}: {'PASSED' if valid else 'FAILED'}")
662
+ print(conj)
663
+
664
+ except Exception as e:
665
+ print(f"\n FAILED: {e}")
666
+ import traceback
667
+ traceback.print_exc()
668
+
669
+ print("\n" + "=" * 70)
670
+ print("Testing: exclude_n_terminus=False (include N-terminus)")
671
+ print("=" * 70 + "\n")
672
+
673
+ print(f"Input peptide (Gly-Lys-Gly): {peptide}")
674
+
675
+ try:
676
+ conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=False)
677
+ print(f"\n SUCCESS!")
678
+ print(f"Number of conjugates: {len(conjugates)} (should be 2 - Lys + N-terminus)")
679
+
680
+ for i, conj in enumerate(conjugates):
681
+ valid, checks = builder.validate_conjugate(conj)
682
+ print(f" Conjugate {i+1}: {'PASSED' if valid else 'FAILED'}")
683
+ print(conj)
684
+
685
+ except Exception as e:
686
+ print(f"\n FAILED: {e}")
687
+ import traceback
688
+ traceback.print_exc()
689
+
690
+ print("\n" + "=" * 70)
691
+ print("Testing GnRH-III (has only 1 Lys at position 8)")
692
+ print("=" * 70 + "\n")
693
+
694
+ print(f"Input peptide (GnRH-III): {gnrh_iii_smiles[:60]}...")
695
+ print("\nNote: GnRH-III sequence is Glp-His-Trp-Ser-His-Asp-Trp-Lys-Pro-Gly")
696
+ print(" Position 4 is Serine (not Lysine), so only 1 Lys at position 8")
697
+
698
+ # First check the NH2 sites
699
+ counts = builder.count_all_nh2_sites(gnrh_iii_smiles)
700
+ print(f"\nNH2 site breakdown:")
701
+ print(f" Lys ε-amines: {counts['lys_epsilon']} (expected: 1 for Lys8)")
702
+ print(f" N-terminus: {counts['n_terminus']} (expected: 0 - Glp is cyclic)")
703
+ print(f" Other: {counts['other']}")
704
+
705
+ try:
706
+ conjugates = builder.conjugate_to_peptide(gnrh_iii_smiles, exclude_n_terminus=True)
707
+ print(f"\n SUCCESS!")
708
+ print(f"Number of conjugates: {len(conjugates)}")
709
+
710
+ for i, conj in enumerate(conjugates):
711
+ valid, checks = builder.validate_conjugate(conj)
712
+ status = "PASSED" if valid else "FAILED"
713
+ print(f" Conjugate {i+1}: {status} - {len(conj)} chars")
714
+ print(conj)
715
+
716
+ except Exception as e:
717
+ print(f"\n FAILED: {e}")
718
+ import traceback
719
+ traceback.print_exc()
720
+
721
+ print("\n" + "=" * 70)
722
+ print("Batch Conjugation Test (exclude_n_terminus=True)")
723
+ print("=" * 70 + "\n")
724
+
725
+ results = builder.batch_conjugate(test_peptides, exclude_n_terminus=True)
726
+
727
+ print(f"\nResults breakdown:")
728
+ for i in results['successful']:
729
+ conjugates = results['conjugates'][i]
730
+ num_valid = sum(1 for c in conjugates if builder.validate_conjugate(c)[0])
731
+ name = ["Gly-Lys-Gly", "Lys-Arg", "GnRH-III"][i]
732
+ print(f" {name}: {len(conjugates)} conjugate(s), {num_valid} valid")
smiles/conjugation/CptFactory.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Camptothecin PDC Factory - Drug-level builder with linker sampling
3
+
4
+ Instead of specifying a particular linker-drug combo (e.g. SSCpt, DSPACpt),
5
+ the user specifies only the drug (Camptothecin) and a linker is sampled
6
+ according to ERBB2+Cpt entry frequencies from PDCdb.
7
+
8
+ Linker frequencies (ERBB2 + Camptothecin):
9
+ ===========================================
10
+ Disulfide bond (SSCpt) - 7 entries
11
+ Maleimide-hexanoate (MalHexCpt) - 5 entries
12
+ 3-Disulfanylpropanoic acid (DSPACpt) - 5 entries
13
+ 2-(Disulfanyl)ethyl carbonate (DSECCpt) - 5 entries
14
+ Total: 22 entries
15
+
16
+ Sampling probabilities:
17
+ SSCpt: 7/22 ≈ 0.318
18
+ MalHexCpt: 5/22 ≈ 0.227
19
+ DSPACpt: 5/22 ≈ 0.227
20
+ DSECCpt: 5/22 ≈ 0.227
21
+
22
+ Usage:
23
+ ======
24
+ # Drug-only mode - linker is sampled per peptide
25
+ factory = CamptothecinPDC_Factory()
26
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
27
+
28
+ # Fixed linker mode - use a specific linker
29
+ factory = CamptothecinPDC_Factory(linker='SSCpt')
30
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
31
+
32
+ # All linkers mode - generate conjugates with every linker
33
+ factory = CamptothecinPDC_Factory(linker='all')
34
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
35
+ """
36
+
37
+ import numpy as np
38
+ import contextlib
39
+ import io
40
+
41
+ from .SSCpt import DisulfideCamptothecin_PDC_Builder
42
+ from .DSPACpt import DisulfanylpropanoicAcidCpt_PDC_Builder
43
+ from .MalHexCpt import MaleimideHexanoateCpt_PDC_Builder
44
+ from .DSECCpt import DisulfanylethylCarbonateCpt_PDC_Builder
45
+
46
+
47
+ # ============================================================================
48
+ # Linker registry: name → (builder_class, PDCdb frequency, description)
49
+ # ============================================================================
50
+
51
+ ERBB2_CPT_LINKER_REGISTRY = {
52
+ 'SSCpt': {
53
+ 'class': DisulfideCamptothecin_PDC_Builder,
54
+ 'frequency': 7,
55
+ 'linker_id': 'LIN00005',
56
+ 'description': 'Disulfide bond (ester + carbonate variants)',
57
+ 'pdc_refs': ['PDC_00294', 'PDC_00295'],
58
+ },
59
+ 'MalHexCpt': {
60
+ 'class': MaleimideHexanoateCpt_PDC_Builder,
61
+ 'frequency': 5,
62
+ 'linker_id': 'LIN00031',
63
+ 'description': '4-yl 6-(2,5-dioxo-2,5-dihydro-1H-pyrrol-1-yl)hexanoate',
64
+ 'pdc_refs': ['PDC_00336'],
65
+ },
66
+ 'DSPACpt': {
67
+ 'class': DisulfanylpropanoicAcidCpt_PDC_Builder,
68
+ 'frequency': 5,
69
+ 'linker_id': 'LIN00012',
70
+ 'description': '3-Disulfanylpropanoic acid',
71
+ 'pdc_refs': ['PDC_00335'],
72
+ },
73
+ 'DSECCpt': {
74
+ 'class': DisulfanylethylCarbonateCpt_PDC_Builder,
75
+ 'frequency': 5,
76
+ 'linker_id': 'LIN00039',
77
+ 'description': '2-(Disulfanyl)ethyl hydrogen carbonate',
78
+ 'pdc_refs': ['PDC_00337'],
79
+ },
80
+ }
81
+
82
+
83
+ def _compute_linker_probabilities(registry=None):
84
+ """Compute sampling probabilities from frequency counts."""
85
+ if registry is None:
86
+ registry = ERBB2_CPT_LINKER_REGISTRY
87
+ names = list(registry.keys())
88
+ freqs = np.array([registry[n]['frequency'] for n in names], dtype=np.float64)
89
+ probs = freqs / freqs.sum()
90
+ return names, probs
91
+
92
+
93
+ class CamptothecinPDC_Factory:
94
+ """
95
+ Drug-level PDC factory for Camptothecin conjugates.
96
+
97
+ Wraps all four ERBB2+Cpt linker builders and provides:
98
+ - 'sample' mode (default): randomly pick a linker per call according to
99
+ PDCdb entry frequencies
100
+ - 'all' mode: generate conjugates with every linker
101
+ - fixed linker mode: always use a specific linker (e.g. 'SSCpt')
102
+
103
+ The interface mirrors the individual builder's `conjugate_to_peptide` so it
104
+ can be used as a drop-in replacement in the Conjugation classifier.
105
+ """
106
+
107
+ def __init__(self, linker='sample', rng_seed=None):
108
+ """
109
+ Args:
110
+ linker: One of:
111
+ - 'sample' (default): sample linker per peptide according to
112
+ ERBB2+Cpt frequency weights
113
+ - 'all': use every linker and pool conjugates
114
+ - A specific linker name ('SSCpt', 'DSPACpt', 'MalHexCpt', 'DSECCpt')
115
+ rng_seed: Optional seed for reproducible linker sampling
116
+ """
117
+ self.linker_mode = linker
118
+ self.registry = ERBB2_CPT_LINKER_REGISTRY
119
+ self.rng = np.random.default_rng(rng_seed)
120
+
121
+ # Pre-compute probabilities
122
+ self._linker_names, self._linker_probs = _compute_linker_probabilities(self.registry)
123
+
124
+ # Validate fixed linker
125
+ if linker not in ('sample', 'all') and linker not in self.registry:
126
+ raise ValueError(
127
+ f"Unknown linker '{linker}'. "
128
+ f"Choose from: 'sample', 'all', {list(self.registry.keys())}"
129
+ )
130
+
131
+ # Pre-instantiate builders (suppress verbose init output)
132
+ self._builders = {}
133
+ for name, entry in self.registry.items():
134
+ with contextlib.redirect_stdout(io.StringIO()):
135
+ self._builders[name] = entry['class']()
136
+
137
+ # Track which linker was last sampled (useful for logging)
138
+ self.last_sampled_linker = None
139
+
140
+ # ------------------------------------------------------------------
141
+ # Linker selection
142
+ # ------------------------------------------------------------------
143
+
144
+ def sample_linker(self):
145
+ """Sample a linker name according to frequency weights."""
146
+ idx = self.rng.choice(len(self._linker_names), p=self._linker_probs)
147
+ return self._linker_names[idx]
148
+
149
+ def get_builder(self, linker_name):
150
+ """Return the pre-instantiated builder for a given linker name."""
151
+ return self._builders[linker_name]
152
+
153
+ @property
154
+ def linker_probabilities(self):
155
+ """Return dict of linker name → sampling probability."""
156
+ return dict(zip(self._linker_names, self._linker_probs.tolist()))
157
+
158
+ # ------------------------------------------------------------------
159
+ # Conjugation interface (mirrors individual builders)
160
+ # ------------------------------------------------------------------
161
+
162
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
163
+ """
164
+ Conjugate Camptothecin to peptide, selecting linker by mode.
165
+
166
+ Args:
167
+ peptide_smiles: SMILES string of peptide (must contain Lys -NH2)
168
+ exclude_n_terminus: If True, only conjugate at Lys ε-amines
169
+ return_all: If True, return all positional conjugates
170
+
171
+ Returns:
172
+ List of conjugate SMILES.
173
+ In 'sample' mode: conjugates from the sampled linker.
174
+ In 'all' mode: conjugates from every linker (pooled).
175
+ In fixed mode: conjugates from the specified linker.
176
+ """
177
+ if self.linker_mode == 'all':
178
+ self.last_sampled_linker = 'all'
179
+ return self._conjugate_all_linkers(peptide_smiles, exclude_n_terminus, return_all)
180
+ elif self.linker_mode == 'sample':
181
+ chosen = self.sample_linker()
182
+ self.last_sampled_linker = chosen
183
+ return self._conjugate_with_linker(chosen, peptide_smiles, exclude_n_terminus, return_all)
184
+ else:
185
+ self.last_sampled_linker = self.linker_mode
186
+ return self._conjugate_with_linker(self.linker_mode, peptide_smiles, exclude_n_terminus, return_all)
187
+
188
+ def _conjugate_with_linker(self, linker_name, peptide_smiles, exclude_n_terminus, return_all):
189
+ """Run conjugation with a single, specific linker."""
190
+ builder = self._builders[linker_name]
191
+ with contextlib.redirect_stdout(io.StringIO()):
192
+ conjugates = builder.conjugate_to_peptide(
193
+ peptide_smiles,
194
+ exclude_n_terminus=exclude_n_terminus,
195
+ return_all=return_all,
196
+ )
197
+ # Normalize to list
198
+ if not isinstance(conjugates, list):
199
+ conjugates = [conjugates] if conjugates else []
200
+ return conjugates
201
+
202
+ def _conjugate_all_linkers(self, peptide_smiles, exclude_n_terminus, return_all):
203
+ """Generate conjugates with every linker and pool them."""
204
+ all_conjugates = []
205
+ seen = set()
206
+ for name in self._linker_names:
207
+ try:
208
+ conjs = self._conjugate_with_linker(name, peptide_smiles, exclude_n_terminus, return_all)
209
+ for c in conjs:
210
+ if c not in seen:
211
+ seen.add(c)
212
+ all_conjugates.append(c)
213
+ except Exception:
214
+ continue
215
+ if not all_conjugates:
216
+ raise ValueError("No conjugates generated with any linker")
217
+ return all_conjugates
218
+
219
+ # ------------------------------------------------------------------
220
+ # Validation (delegates to the appropriate builder)
221
+ # ------------------------------------------------------------------
222
+
223
+ def validate_conjugate(self, conjugate_smiles, linker_name=None):
224
+ """
225
+ Validate a conjugate SMILES.
226
+
227
+ If linker_name is given, validate with that builder.
228
+ Otherwise, try all builders and return the best result.
229
+ """
230
+ if linker_name and linker_name in self._builders:
231
+ return self._builders[linker_name].validate_conjugate(conjugate_smiles)
232
+
233
+ # Try each builder; return first passing result
234
+ for name, builder in self._builders.items():
235
+ passed, checks = builder.validate_conjugate(conjugate_smiles)
236
+ if passed:
237
+ checks['matched_linker'] = name
238
+ return True, checks
239
+
240
+ # None passed - return last failure
241
+ return False, checks
242
+
243
+ def check_attachment_site_compatibility(self, conjugate_smiles, linker_name=None):
244
+ """Delegate to appropriate builder's attachment check."""
245
+ if linker_name and linker_name in self._builders:
246
+ return self._builders[linker_name].check_attachment_site_compatibility(conjugate_smiles)
247
+
248
+ for name, builder in self._builders.items():
249
+ passed, details = builder.check_attachment_site_compatibility(conjugate_smiles)
250
+ if passed:
251
+ details['matched_linker'] = name
252
+ return True, details
253
+ return False, details
254
+
255
+ # ------------------------------------------------------------------
256
+ # Batch interface
257
+ # ------------------------------------------------------------------
258
+
259
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
260
+ """
261
+ Batch conjugate multiple peptides.
262
+
263
+ In 'sample' mode each peptide gets a freshly sampled linker.
264
+
265
+ Returns:
266
+ Dict with 'conjugates', 'successful', 'failed', 'total_conjugates',
267
+ and 'linkers_used' (list of linker names, one per peptide).
268
+ """
269
+ results = {
270
+ 'conjugates': [],
271
+ 'successful': [],
272
+ 'failed': [],
273
+ 'total_conjugates': 0,
274
+ 'linkers_used': [],
275
+ }
276
+
277
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
278
+ try:
279
+ conjugates = self.conjugate_to_peptide(
280
+ peptide_smiles,
281
+ exclude_n_terminus=exclude_n_terminus,
282
+ return_all=return_all,
283
+ )
284
+ if return_all:
285
+ results['conjugates'].append(conjugates)
286
+ results['total_conjugates'] += len(conjugates)
287
+ else:
288
+ results['conjugates'].append(conjugates)
289
+ results['total_conjugates'] += 1
290
+ results['successful'].append(i)
291
+ # Track which linker was used (only meaningful in sample/fixed mode)
292
+ results['linkers_used'].append(
293
+ self.linker_mode if self.linker_mode not in ('sample', 'all') else None
294
+ )
295
+ except Exception as e:
296
+ results['conjugates'].append(None)
297
+ results['failed'].append((i, str(e)))
298
+ results['linkers_used'].append(None)
299
+
300
+ return results
301
+
302
+ # ------------------------------------------------------------------
303
+ # Informational
304
+ # ------------------------------------------------------------------
305
+
306
+ def __repr__(self):
307
+ probs = self.linker_probabilities
308
+ lines = [f"CamptothecinPDC_Factory(mode='{self.linker_mode}')"]
309
+ for name, prob in probs.items():
310
+ desc = self.registry[name]['description']
311
+ lines.append(f" {name:12s} p={prob:.3f} ({desc})")
312
+ return '\n'.join(lines)
smiles/conjugation/DSEBSN38.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 4-(2-Disulfanylethoxy)-4-oxobutanoic Acid - SN-38 PDC Builder (DSEBSN38)
3
+
4
+ Conjugation chemistry: Cysteine thiol + Linker thiol → Disulfide bond (S-S)
5
+
6
+ Reference PDCs:
7
+ - PDC_02100 (SN38(10)-4-CRGDK)
8
+ - PDC_02103 (SN38(10)-4-(Ac)CGVQIRGDK)
9
+ - PDC_02105 (SN38(10)-4-(Ac)CRGDK)
10
+ - PDC_02108 (SN38(10)-4-CDRGDK)
11
+ - PDC_02109 (SN38(10)-4-(Ac)CDRGDK)
12
+ - PDC_02111 (SN38(20)-4-CRGDK)
13
+
14
+ Receptor: NRP1 (Neuropilin-1)
15
+ Linker: LIN00168 - 4-(2-Disulfanylethoxy)-4-oxobutanoic acid
16
+ Payload: 7-Ethyl-10-hydroxycamptothecin (SN-38)
17
+ Architecture: Monomer, Cys thiol conjugation (disulfide bond)
18
+
19
+ Chemistry Note:
20
+ ===============
21
+ LIN00168 provides a longer disulfide-ester linkage via succinic acid scaffold:
22
+ HOOC─CH₂─CH₂─C(═O)─O─CH₂─CH₂─S─SH
23
+
24
+ butanoic acid ethoxy-disulfanyl
25
+
26
+ Linker-to-Drug Connection (Ester at drug OH, thiol for peptide Cys):
27
+ SN-38(10-O)─C(═O)─CH₂─CH₂─C(═O)─O─CH₂─CH₂─SH
28
+
29
+ Full PDC Architecture:
30
+ Peptide──Cys──S─S──CH₂─CH₂─O─C(═O)─CH₂─CH₂─C(═O)─O──SN-38
31
+ └── disulfide ─┘ └── linker ──┘ └ ester ┘
32
+
33
+ Usage:
34
+ ======
35
+ builder = DisulfanylethOxyButanoicAcidSN38_PDC_Builder()
36
+ conjugates = builder.conjugate_to_peptide(peptide_smiles)
37
+ """
38
+
39
+ from rdkit import Chem
40
+ from rdkit.Chem import AllChem, Descriptors
41
+
42
+ from .pdc_builder import PDC_Builder
43
+
44
+
45
+ # ============================================================================
46
+ # 4-(2-Disulfanylethoxy)-4-oxobutanoic Acid - SN-38 PDC Builder
47
+ # ============================================================================
48
+
49
+ class DisulfanylethOxyButanoicAcidSN38_PDC_Builder(PDC_Builder):
50
+ """
51
+ PDC builder for 4-(2-Disulfanylethoxy)-4-oxobutanoic Acid + SN-38 conjugates
52
+
53
+ Conjugation chemistry (disulfide bond formation):
54
+ - Peptide site: Cysteine thiol (side chain -SH)
55
+ - Linker-payload site: Terminal thiol (-SH) on linker
56
+ - Reaction: Peptide-Cys-SH + HS-Linker-Drug → Peptide-Cys-S-S-Linker-Drug
57
+
58
+ Linker: LIN00168 - 4-(2-Disulfanylethoxy)-4-oxobutanoic acid
59
+ IUPAC: 4-(2-(disulfanyl)ethoxy)-4-oxobutanoic acid
60
+ Connectivity: HOOC-CH2-CH2-C(=O)-O-CH2-CH2-S-SH
61
+ Succinic acid monoester with 2-(disulfanyl)ethanol
62
+
63
+ Drug: 7-Ethyl-10-hydroxycamptothecin (SN-38)
64
+ Connection: Ester at C-10 hydroxyl (default) or C-20 hydroxyl
65
+
66
+ Reference: PDC_02100, PDC_02111 (NRP1)
67
+ """
68
+
69
+ # ========================================================================
70
+ # Linker-Drug SMILES Variants
71
+ # ========================================================================
72
+
73
+ # SN-38 with DSEB ester at C-10 (phenolic OH), free thiol
74
+ # Drug(10-O)-C(=O)-CH2-CH2-C(=O)-O-CH2-CH2-SH
75
+ POS10_LINKER_SN38_SMILES = (
76
+ "CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(OC(=O)CCC(=O)OCCS)ccc3nc2-1"
77
+ )
78
+
79
+ # SN-38 with DSEB ester at C-20 (tertiary OH), free thiol
80
+ POS20_LINKER_SN38_SMILES = (
81
+ "CCC1(OC(=O)CCC(=O)OCCS)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(O)ccc3nc2-1"
82
+ )
83
+
84
+ DEFAULT_LINKER_SN38_SMILES = POS10_LINKER_SN38_SMILES
85
+
86
+ DRUG_POSITIONS = {
87
+ '10': POS10_LINKER_SN38_SMILES,
88
+ '20': POS20_LINKER_SN38_SMILES,
89
+ }
90
+
91
+ def __init__(self, linker_drug_smiles=None, drug_position='all'):
92
+ """
93
+ Initialize DSEB-SN38 builder
94
+
95
+ Args:
96
+ linker_drug_smiles: Optional custom Linker-Drug SMILES.
97
+ drug_position: 'all' (default), '10', or '20'.
98
+ """
99
+ self.drug_position = drug_position
100
+
101
+ if linker_drug_smiles is not None:
102
+ self._variants = [linker_drug_smiles]
103
+ self._variant_names = ['custom']
104
+ super().__init__(
105
+ linker_drug_smiles,
106
+ component_name="Custom-DSEB-SN38 (NRP1)"
107
+ )
108
+ elif drug_position == 'all':
109
+ self._variants = [self.POS10_LINKER_SN38_SMILES, self.POS20_LINKER_SN38_SMILES]
110
+ self._variant_names = ['pos10', 'pos20']
111
+ super().__init__(
112
+ self.POS10_LINKER_SN38_SMILES,
113
+ component_name="DSEB-SN38 (LIN00168, all positions)"
114
+ )
115
+ else:
116
+ if drug_position not in self.DRUG_POSITIONS:
117
+ raise ValueError(f"Unknown drug_position '{drug_position}'. "
118
+ f"Choose from: 'all', '10', '20'")
119
+ smiles = self.DRUG_POSITIONS[drug_position]
120
+ self._variants = [smiles]
121
+ self._variant_names = [f'pos{drug_position}']
122
+ super().__init__(
123
+ smiles,
124
+ component_name=f"DSEB-SN38 (LIN00168, C-{drug_position})"
125
+ )
126
+
127
+ def _verify_reactive_group(self):
128
+ """Verify thiol group is present for disulfide bond formation"""
129
+ for atom in self.linker_payload_mol.GetAtoms():
130
+ if atom.GetAtomicNum() == 16:
131
+ if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
132
+ print(" DSEB-SN38 loaded with free thiol reactive group")
133
+ break
134
+ else:
135
+ print(" Warning: No free thiol group detected in DSEB-SN38")
136
+
137
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
138
+ if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
139
+ print(" Ester bond (linker-to-drug) detected")
140
+ else:
141
+ print(" Warning: No ester bond detected")
142
+
143
+ # ------------------------------------------------------------------
144
+ # Conjugation site finding (Cysteine thiol)
145
+ # ------------------------------------------------------------------
146
+
147
+ def find_conjugation_sites(self, peptide_smiles):
148
+ """Find all free cysteine thiol groups in peptide"""
149
+ return self.find_peptide_cysteines(peptide_smiles)
150
+
151
+ def find_peptide_cysteines(self, peptide_smiles):
152
+ """Find all free cysteine residues (thiol groups) in peptide"""
153
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
154
+ if peptide_mol is None:
155
+ raise ValueError("Invalid peptide SMILES")
156
+
157
+ cysteines = []
158
+ for atom in peptide_mol.GetAtoms():
159
+ if atom.GetAtomicNum() == 16:
160
+ total_hs = atom.GetTotalNumHs()
161
+ degree = atom.GetDegree()
162
+ if total_hs >= 1 and degree == 1:
163
+ cysteines.append({
164
+ 'atom_idx': atom.GetIdx(),
165
+ 'num_hydrogens': total_hs,
166
+ 'neighbors': [peptide_mol.GetAtomWithIdx(n.GetIdx()).GetSymbol()
167
+ for n in atom.GetNeighbors()],
168
+ 'type': 'Cys_thiol'
169
+ })
170
+ return cysteines
171
+
172
+ # ------------------------------------------------------------------
173
+ # Conjugation reaction (disulfide bond formation)
174
+ # ------------------------------------------------------------------
175
+
176
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
177
+ """Form disulfide bond between peptide Cys thiol and linker-drug thiol."""
178
+ all_conjugates = []
179
+ seen = set()
180
+
181
+ for variant_smiles in self._variants:
182
+ variant_mol = Chem.MolFromSmiles(variant_smiles)
183
+ if variant_mol is None:
184
+ continue
185
+
186
+ disulfide_rxn = AllChem.ReactionFromSmarts(
187
+ '[C:1][SH:2].[SH:3][C:4]>>[C:1][S:2][S:3][C:4]'
188
+ )
189
+
190
+ products = disulfide_rxn.RunReactants((peptide_mol, variant_mol))
191
+ if not products:
192
+ continue
193
+
194
+ for product_tuple in products:
195
+ try:
196
+ conjugate_mol = product_tuple[0]
197
+ Chem.SanitizeMol(conjugate_mol)
198
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
199
+ if conjugate_smiles not in seen:
200
+ seen.add(conjugate_smiles)
201
+ all_conjugates.append(conjugate_smiles)
202
+ except Exception:
203
+ continue
204
+
205
+ if not all_conjugates:
206
+ raise ValueError("Disulfide bond formation failed - no products generated")
207
+
208
+ return all_conjugates
209
+
210
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
211
+ """
212
+ Conjugate DSEB-SN38 to peptide via disulfide bond at Cys thiol.
213
+
214
+ Args:
215
+ peptide_smiles: SMILES string of peptide (must contain Cys)
216
+ exclude_n_terminus: Not used for Cys conjugation (kept for interface)
217
+ return_all: If True, return all possible conjugates
218
+
219
+ Returns:
220
+ List of all possible conjugate SMILES
221
+ """
222
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
223
+ if peptide_mol is None:
224
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
225
+
226
+ sites = self.find_conjugation_sites(peptide_smiles)
227
+ print(f"Found {len(sites)} Cys thiol site(s)")
228
+
229
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
230
+
231
+ pos_label = f"C-{self.drug_position}" if self.drug_position != 'all' else "C-10 + C-20"
232
+ print(f"Generated {len(conjugates)} unique conjugate(s) [Cys disulfide] [DSEB {pos_label}]")
233
+
234
+ if return_all:
235
+ return conjugates
236
+ else:
237
+ return conjugates[0] if conjugates else None
238
+
239
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
240
+ """Batch conjugate multiple peptides."""
241
+ results = {
242
+ 'conjugates': [], 'successful': [], 'failed': [], 'total_conjugates': 0
243
+ }
244
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
245
+ try:
246
+ conjugates = self.conjugate_to_peptide(
247
+ peptide_smiles, exclude_n_terminus=exclude_n_terminus, return_all=return_all
248
+ )
249
+ if return_all:
250
+ results['conjugates'].append(conjugates)
251
+ results['total_conjugates'] += len(conjugates)
252
+ else:
253
+ results['conjugates'].append(conjugates)
254
+ results['total_conjugates'] += 1
255
+ results['successful'].append(i)
256
+ except Exception as e:
257
+ print(f" Peptide {i} failed: {e}")
258
+ results['conjugates'].append(None)
259
+ results['failed'].append((i, str(e)))
260
+
261
+ print(f"\n=== Summary ===")
262
+ print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, "
263
+ f"Failed: {len(results['failed'])}")
264
+ print(f"Total conjugates generated: {results['total_conjugates']}")
265
+ return results
266
+
267
+ def flatten_batch_results(self, results):
268
+ """Flatten batch conjugation results into a single list"""
269
+ all_conjugates = []
270
+ for conjugate_list in results['conjugates']:
271
+ if conjugate_list is not None:
272
+ if isinstance(conjugate_list, list):
273
+ all_conjugates.extend(conjugate_list)
274
+ else:
275
+ all_conjugates.append(conjugate_list)
276
+ return all_conjugates
277
+
278
+ # ========================================================================
279
+ # Validation
280
+ # ========================================================================
281
+
282
+ def has_disulfide_bond(self, mol):
283
+ """Check if molecule has disulfide bond (S-S)"""
284
+ disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
285
+ return mol.HasSubstructMatch(disulfide_pattern)
286
+
287
+ def has_free_thiol(self, mol):
288
+ """Check if molecule has unconjugated (free) thiol groups"""
289
+ for atom in mol.GetAtoms():
290
+ if atom.GetAtomicNum() == 16:
291
+ if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
292
+ return True
293
+ return False
294
+
295
+ def has_ester_bond_to_drug(self, mol):
296
+ """Check for ester bond connecting linker to drug"""
297
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
298
+ return mol.HasSubstructMatch(ester_pattern)
299
+
300
+ def has_sn38_core(self, mol):
301
+ """Check for SN-38 core structure"""
302
+ cpt_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
303
+ return mol.HasSubstructMatch(cpt_core)
304
+
305
+ def check_attachment_site_compatibility(self, conjugate_smiles):
306
+ """Check attachment site compatibility for Cys-SS-DSEB-SN38 conjugation."""
307
+ mol = Chem.MolFromSmiles(conjugate_smiles)
308
+ if mol is None:
309
+ return False, {'parseable': False, 'error': 'Could not parse SMILES'}
310
+
311
+ details = {
312
+ 'parseable': True,
313
+ 'validation_method': 'cys_disulfide_ester',
314
+ 'linker': 'LIN00168 (4-(2-Disulfanylethoxy)-4-oxobutanoic acid)',
315
+ 'drug': 'SN-38 (7-Ethyl-10-hydroxycamptothecin)',
316
+ }
317
+ details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
318
+ details['no_free_thiol'] = not self.has_free_thiol(mol)
319
+ details['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
320
+
321
+ passed = all([details['has_disulfide_bond'], details['no_free_thiol']])
322
+ return passed, details
323
+
324
+ def _basic_validation(self, mol):
325
+ """Basic validation checks for DSEB-SN38 conjugates."""
326
+ checks = {}
327
+ checks['has_disulfide_bond'] = mol.HasSubstructMatch(Chem.MolFromSmarts('[#16]-[#16]'))
328
+ checks['no_free_thiol'] = not self.has_free_thiol(mol)
329
+ checks['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
330
+ checks['has_sn38_core'] = self.has_sn38_core(mol)
331
+ checks['_passed'] = all([checks['has_disulfide_bond'], checks['no_free_thiol']])
332
+ return checks
333
+
334
+ def validate_conjugate(self, conjugate_smiles):
335
+ """Validate that the conjugate is chemically reasonable."""
336
+ mol = Chem.MolFromSmiles(conjugate_smiles)
337
+ if mol is None:
338
+ return False, {'error': 'Could not parse SMILES'}
339
+ checks = self._basic_validation(mol)
340
+ checks['parseable'] = True
341
+ passed = checks.get('_passed', True)
342
+ if '_passed' in checks:
343
+ del checks['_passed']
344
+ return passed, checks
345
+
346
+
347
+ # ============================================================================
348
+ # Main - Test Examples
349
+ # ============================================================================
350
+
351
+ if __name__ == "__main__":
352
+
353
+ print("=" * 70)
354
+ print("DSEB-SN38 PDC Builder - Test")
355
+ print("Reference: NRP1 + LIN00168 + SN-38")
356
+ print("=" * 70 + "\n")
357
+
358
+ test_peptides = [
359
+ "NCC(=O)N[C@@H](CS)C(=O)NCC(=O)O", # Gly-Cys-Gly
360
+ ]
361
+
362
+ builder = DisulfanylethOxyButanoicAcidSN38_PDC_Builder()
363
+
364
+ for i, peptide in enumerate(test_peptides):
365
+ print(f"\n--- Test peptide {i+1} ---")
366
+ print(f"Input: {peptide}")
367
+
368
+ try:
369
+ conjugates = builder.conjugate_to_peptide(peptide)
370
+ print(f" Generated {len(conjugates)} conjugate(s)")
371
+ for j, conj in enumerate(conjugates):
372
+ valid, checks = builder.validate_conjugate(conj)
373
+ print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'}")
374
+ except Exception as e:
375
+ print(f" FAILED: {e}")
376
+ import traceback
377
+ traceback.print_exc()
smiles/conjugation/DSECCpt.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Disulfanylethyl Carbonate - Camptothecin PDC Builder (DSECCpt)
3
+
4
+ Conjugation chemistry: Lysine ε-amine + Cys-SS-Linker-Camptothecin (amide bond formation)
5
+
6
+ Reference PDC:
7
+ - PDC_00337 (SMAC-P1 CPT conjugate 7)
8
+
9
+ Receptor: ERBB2 (Receptor tyrosine-protein kinase erbB-2)
10
+ Linker: LIN00039 - 2-(Disulfanyl)ethyl hydrogen carbonate (disulfide + carbonate)
11
+ Payload: DRG00009 - Camptothecin
12
+ Architecture: Monomer, Lys side chain conjugation
13
+
14
+ Chemistry Note:
15
+ ===============
16
+ LIN00039 (2-(Disulfanyl)ethyl hydrogen carbonate) provides a disulfide-carbonate linkage:
17
+ HS─S─CH₂─CH₂─O─C(═O)─OH
18
+
19
+ Linker-to-Drug Connection (Carbonate):
20
+ Cys─S─S─CH₂─CH₂─O─C(═O)─O─Camptothecin
21
+ │
22
+ carbonate bond (C-20 OH of CPT)
23
+
24
+ Full PDC Architecture:
25
+ Peptide──Lys──NH──C(═O)──Cys──S─S──CH₂─CH₂─O──C(═O)─O──Camptothecin
26
+ └── amide ──┘ └── disulfide ──┘ └── carbonate ──┘
27
+
28
+ Note: This linker chemistry is equivalent to the SSCpt carbonate variant (Z8/PDC_00295).
29
+
30
+ Usage:
31
+ ======
32
+ builder = DisulfanylethylCarbonateCpt_PDC_Builder()
33
+ conjugates = builder.conjugate_to_peptide(peptide_smiles)
34
+ """
35
+
36
+ from rdkit import Chem
37
+ from rdkit.Chem import AllChem, Descriptors
38
+
39
+ from .pdc_builder import PDC_Builder
40
+
41
+
42
+ # ============================================================================
43
+ # Disulfanylethyl Carbonate - Camptothecin PDC Builder
44
+ # ============================================================================
45
+
46
+ class DisulfanylethylCarbonateCpt_PDC_Builder(PDC_Builder):
47
+ """
48
+ PDC builder for 2-(Disulfanyl)ethyl Carbonate + Camptothecin conjugates
49
+
50
+ Conjugation chemistry (amide bond formation):
51
+ - Peptide site: Lysine ε-amine (side chain -NH2)
52
+ - Linker-payload site: Carboxylic acid of Cys (Cys-SS-Linker-Drug)
53
+ - Reaction: Lys-NH2 + HOOC-Cys-SS-Linker-Drug → Lys-NH-CO-Cys-SS-Linker-Drug
54
+
55
+ Linker: LIN00039 - 2-(Disulfanyl)ethyl hydrogen carbonate
56
+ SMILES: C(CSS)OC(=O)O
57
+ IUPAC: 2-(disulfanyl)ethyl hydrogen carbonate
58
+ Connectivity: HS-S-CH2-CH2-O-C(=O)-OH
59
+ MW: 154.21, MF: C3H6O3S2
60
+
61
+ Drug: DRG00009 - Camptothecin
62
+ Canonical SMILES: CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1
63
+ Connection: Carbonate at C-20 hydroxyl
64
+
65
+ Reference: PDC_00337 (SMAC-P1 CPT conjugate 7, ERBB2)
66
+ """
67
+
68
+ # ========================================================================
69
+ # Cys-SS-Linker-Drug SMILES
70
+ # ========================================================================
71
+ #
72
+ # Pre-formed Cys-DisulfanylethylCarbonate-Camptothecin assembly:
73
+ # HOOC─Cys(NH₂)─CH₂─S─S─CH₂─CH₂─O─C(═O)─O─CPT(C-20)
74
+ #
75
+ # Cys provides COOH for amide bond with Lys ε-amine
76
+ # Disulfide bond (S-S) is cleavable inside cells
77
+ # Carbonate bond (O-C(=O)-O) connects linker to CPT C-20 hydroxyl
78
+ #
79
+ # ========================================================================
80
+
81
+ CYS_SS_LINKER_CPT_SMILES = (
82
+ "N[C@H](CSSCCOC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C(=O)O"
83
+ )
84
+
85
+ def __init__(self, cys_linker_drug_smiles=None):
86
+ """
87
+ Initialize Disulfanylethyl Carbonate-Camptothecin builder
88
+
89
+ Args:
90
+ cys_linker_drug_smiles: Optional custom Cys-SS-Linker-Drug SMILES.
91
+ If None, uses default LIN00039+CPT assembly.
92
+ Must have free carboxylic acid for conjugation.
93
+ """
94
+ if cys_linker_drug_smiles is None:
95
+ cys_linker_drug_smiles = self.CYS_SS_LINKER_CPT_SMILES
96
+
97
+ super().__init__(
98
+ cys_linker_drug_smiles,
99
+ component_name="Cys-SS-DisulfanylethylCarbonate-Camptothecin (LIN00039+DRG00009)"
100
+ )
101
+
102
+ def _verify_reactive_group(self):
103
+ """Verify carboxylic acid group is present for amide bond formation"""
104
+ # Pattern: Carboxylic acid (COOH)
105
+ carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
106
+
107
+ if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
108
+ print(" Warning: No carboxylic acid group detected in Cys-SS-Linker-Drug")
109
+ else:
110
+ print(" Cys-SS-DSEC-Camptothecin loaded with carboxylic acid reactive group")
111
+
112
+ # Verify disulfide bond is present
113
+ disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
114
+ if self.linker_payload_mol.HasSubstructMatch(disulfide_pattern):
115
+ print(" Disulfide bond (S-S) detected")
116
+ else:
117
+ print(" Warning: No disulfide bond detected - check structure")
118
+
119
+ # Verify Cys α-amine is present
120
+ cys_amine_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2]S)C(=O)')
121
+ if self.linker_payload_mol.HasSubstructMatch(cys_amine_pattern):
122
+ print(" Cys α-amine detected (will be free in conjugate)")
123
+ else:
124
+ print(" Warning: Cys α-amine pattern not detected")
125
+
126
+ # Verify carbonate bond to drug (O-C(=O)-O pattern)
127
+ carbonate_pattern = Chem.MolFromSmarts('[OX2][CX3](=[OX1])[OX2]')
128
+ if self.linker_payload_mol.HasSubstructMatch(carbonate_pattern):
129
+ print(" Carbonate bond (linker-to-drug) detected")
130
+ else:
131
+ print(" Warning: No carbonate bond detected - check linker-drug connection")
132
+
133
+ def find_conjugation_sites(self, peptide_smiles):
134
+ """
135
+ Find all lysine ε-amine groups in peptide
136
+
137
+ Returns list of dicts with site information including atom index
138
+ """
139
+ return self.find_peptide_lysines(peptide_smiles)
140
+
141
+ def find_peptide_lysines(self, peptide_smiles):
142
+ """
143
+ Find all lysine residues with free ε-amine in peptide
144
+
145
+ Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
146
+
147
+ Returns:
148
+ List of dicts with 'atom_idx' (nitrogen index) and metadata
149
+ """
150
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
151
+ if peptide_mol is None:
152
+ raise ValueError("Invalid peptide SMILES")
153
+
154
+ lysines = []
155
+
156
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
157
+ matches = peptide_mol.GetSubstructMatches(lys_pattern)
158
+
159
+ for match in matches:
160
+ nitrogen_idx = match[4]
161
+ lysines.append({
162
+ 'atom_idx': nitrogen_idx,
163
+ 'side_chain_atoms': list(match),
164
+ 'type': 'Lys_epsilon_amine'
165
+ })
166
+
167
+ n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
168
+ n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
169
+
170
+ if n_term_matches:
171
+ print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
172
+
173
+ return lysines
174
+
175
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
176
+ """
177
+ Perform amide bond conjugation between NH2 groups and Cys carboxylic acid
178
+
179
+ Args:
180
+ peptide_mol: RDKit Mol object
181
+ site_info: Not used (kept for compatibility)
182
+ exclude_n_terminus: If True, skip N-terminus conjugation
183
+
184
+ Returns: List of all possible conjugate SMILES
185
+ """
186
+ if exclude_n_terminus:
187
+ amide_rxn = AllChem.ReactionFromSmarts(
188
+ '[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
189
+ )
190
+ else:
191
+ amide_rxn = AllChem.ReactionFromSmarts(
192
+ '[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
193
+ )
194
+
195
+ products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
196
+
197
+ if not products:
198
+ raise ValueError("Amide bond formation reaction failed - no products generated")
199
+
200
+ conjugates = []
201
+ seen_smiles = set()
202
+
203
+ for product_tuple in products:
204
+ try:
205
+ conjugate_mol = product_tuple[0]
206
+ Chem.SanitizeMol(conjugate_mol)
207
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
208
+
209
+ if conjugate_smiles not in seen_smiles:
210
+ seen_smiles.add(conjugate_smiles)
211
+ conjugates.append(conjugate_smiles)
212
+ except Exception:
213
+ continue
214
+
215
+ if not conjugates:
216
+ raise ValueError("All products failed sanitization")
217
+
218
+ return conjugates
219
+
220
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
221
+ """
222
+ Conjugate Cys-SS-DSEC-Camptothecin to peptide via amide bond at NH2 groups
223
+
224
+ Args:
225
+ peptide_smiles: SMILES string of peptide (must contain Lys with NH2)
226
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
227
+ return_all: If True, return all possible conjugates
228
+
229
+ Returns:
230
+ List of all possible conjugate SMILES
231
+ """
232
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
233
+ if peptide_mol is None:
234
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
235
+
236
+ sites = self.find_conjugation_sites(peptide_smiles)
237
+ print(f"Found {len(sites)} Lys ε-amine site(s)")
238
+
239
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
240
+
241
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
242
+ print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}] [DSEC carbonate]")
243
+
244
+ if return_all:
245
+ return conjugates
246
+ else:
247
+ return conjugates[0] if conjugates else None
248
+
249
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
250
+ """
251
+ Batch conjugate multiple peptides
252
+
253
+ Args:
254
+ peptide_smiles_list: List of peptide SMILES strings
255
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
256
+ return_all: If True, return all possible conjugates per peptide
257
+
258
+ Returns:
259
+ Dict with 'conjugates', 'successful', 'failed', 'total_conjugates'
260
+ """
261
+ results = {
262
+ 'conjugates': [],
263
+ 'successful': [],
264
+ 'failed': [],
265
+ 'total_conjugates': 0
266
+ }
267
+
268
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
269
+ try:
270
+ conjugates = self.conjugate_to_peptide(
271
+ peptide_smiles,
272
+ exclude_n_terminus=exclude_n_terminus,
273
+ return_all=return_all
274
+ )
275
+ if return_all:
276
+ results['conjugates'].append(conjugates)
277
+ results['total_conjugates'] += len(conjugates)
278
+ else:
279
+ results['conjugates'].append(conjugates)
280
+ results['total_conjugates'] += 1
281
+ results['successful'].append(i)
282
+ except Exception as e:
283
+ print(f" Peptide {i} failed: {e}")
284
+ results['conjugates'].append(None)
285
+ results['failed'].append((i, str(e)))
286
+
287
+ print(f"\n=== Summary ===")
288
+ print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
289
+ print(f"Total conjugates generated: {results['total_conjugates']}")
290
+
291
+ return results
292
+
293
+ def flatten_batch_results(self, results):
294
+ """Flatten batch conjugation results into a single list"""
295
+ all_conjugates = []
296
+ for conjugate_list in results['conjugates']:
297
+ if conjugate_list is not None:
298
+ if isinstance(conjugate_list, list):
299
+ all_conjugates.extend(conjugate_list)
300
+ else:
301
+ all_conjugates.append(conjugate_list)
302
+ return all_conjugates
303
+
304
+ # ========================================================================
305
+ # Attachment Site Compatibility Checks
306
+ # ========================================================================
307
+
308
+ def has_lys_cys_amide_conjugation(self, mol):
309
+ """Check if molecule has Lys-Cys amide conjugation bond"""
310
+ lys_cys_amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
311
+ return mol.HasSubstructMatch(lys_cys_amide_pattern)
312
+
313
+ def has_disulfide_bond(self, mol):
314
+ """Check if molecule has disulfide bond (S-S)"""
315
+ disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
316
+ return mol.HasSubstructMatch(disulfide_pattern)
317
+
318
+ def has_carbonate_bond(self, mol):
319
+ """
320
+ Check if molecule has carbonate bond (O-C(=O)-O)
321
+
322
+ This specifically checks for the carbonate linkage between linker and drug.
323
+ """
324
+ carbonate_pattern = Chem.MolFromSmarts('[OX2][CX3](=[OX1])[OX2]')
325
+ return mol.HasSubstructMatch(carbonate_pattern)
326
+
327
+ def has_unreacted_lys_epsilon_amine(self, mol):
328
+ """Check for unreacted Lys ε-amine (free NH2 on 4-carbon side chain)"""
329
+ lys_amine_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
330
+ return mol.HasSubstructMatch(lys_amine_pattern)
331
+
332
+ def has_unreacted_cys_carboxylic_acid(self, mol):
333
+ """Check for unreacted Cys carboxylic acid from Cys-SS-Linker-Drug"""
334
+ cys_cooh_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2][SX2])[CX3](=[OX1])[OX2H1]')
335
+ return mol.HasSubstructMatch(cys_cooh_pattern)
336
+
337
+ def find_amide_conjugation_sites(self, mol):
338
+ """Find all Lys-Cys amide conjugation sites in the molecule"""
339
+ lys_cys_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
340
+ matches = mol.GetSubstructMatches(lys_cys_pattern)
341
+
342
+ sites = []
343
+ for match in matches:
344
+ nitrogen_idx = match[0]
345
+ carbonyl_c_idx = match[1]
346
+ cys_alpha_idx = match[3]
347
+ sites.append((nitrogen_idx, carbonyl_c_idx, cys_alpha_idx))
348
+
349
+ return sites
350
+
351
+ def check_valency_at_conjugation_site(self, mol, site_indices):
352
+ """Check that valency is correct at Lys-Cys conjugation site"""
353
+ nitrogen_idx, carbonyl_c_idx, cys_alpha_idx = site_indices
354
+
355
+ try:
356
+ n_atom = mol.GetAtomWithIdx(nitrogen_idx)
357
+ if n_atom.GetTotalDegree() != 3:
358
+ return False
359
+
360
+ c_atom = mol.GetAtomWithIdx(carbonyl_c_idx)
361
+ if c_atom.GetTotalDegree() != 3:
362
+ return False
363
+
364
+ cys_c_atom = mol.GetAtomWithIdx(cys_alpha_idx)
365
+ if cys_c_atom.GetTotalDegree() != 4:
366
+ return False
367
+
368
+ return True
369
+ except Exception:
370
+ return False
371
+
372
+ def check_attachment_site_compatibility(self, conjugate_smiles):
373
+ """
374
+ Check attachment site compatibility for Lys-Cys-SS-DSEC-CPT conjugation
375
+
376
+ Validates:
377
+ 1. Lys-Cys amide conjugation is present
378
+ 2. Disulfide bond (S-S) is intact
379
+ 3. Carbonate bond is present (linker-to-drug)
380
+ 4. Valency at conjugation sites is correct
381
+ 5. No unreacted Cys-COOH (unconjugated linker-drug)
382
+
383
+ Returns:
384
+ Tuple of (passed: bool, details: dict)
385
+ """
386
+ mol = Chem.MolFromSmiles(conjugate_smiles)
387
+
388
+ if mol is None:
389
+ return False, {'parseable': False, 'error': 'Could not parse SMILES'}
390
+
391
+ details = {
392
+ 'parseable': True,
393
+ 'validation_method': 'lys_cys_amide_disulfide_carbonate',
394
+ 'linker': 'LIN00039 (2-(Disulfanyl)ethyl hydrogen carbonate)',
395
+ 'drug': 'DRG00009 (Camptothecin)',
396
+ 'pdc_ref': 'PDC_00337'
397
+ }
398
+
399
+ details['has_lys_cys_amide'] = self.has_lys_cys_amide_conjugation(mol)
400
+ details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
401
+ details['has_carbonate_bond'] = self.has_carbonate_bond(mol)
402
+
403
+ conjugation_sites = self.find_amide_conjugation_sites(mol)
404
+ details['conjugation_sites'] = len(conjugation_sites)
405
+
406
+ valency_ok = True
407
+ if conjugation_sites:
408
+ for site in conjugation_sites:
409
+ if not self.check_valency_at_conjugation_site(mol, site):
410
+ valency_ok = False
411
+ break
412
+ details['valency_correct'] = valency_ok
413
+
414
+ details['no_unreacted_cys_cooh'] = not self.has_unreacted_cys_carboxylic_acid(mol)
415
+ details['has_unreacted_lys_amine'] = self.has_unreacted_lys_epsilon_amine(mol)
416
+
417
+ passed = all([
418
+ details['has_lys_cys_amide'],
419
+ details['has_disulfide_bond'],
420
+ details['has_carbonate_bond'],
421
+ details['valency_correct'],
422
+ details['no_unreacted_cys_cooh']
423
+ ])
424
+
425
+ return passed, details
426
+
427
+ def _basic_validation(self, mol):
428
+ """
429
+ Basic validation checks for DSEC-Camptothecin conjugates
430
+
431
+ Checks:
432
+ 1. Amide bond present (Lys-Cys conjugation)
433
+ 2. Disulfide bond present (S-S linker)
434
+ 3. Specific Lys-Cys-SS conjugation pattern
435
+ 4. Carbonate bond present (linker-to-drug)
436
+ 5. Camptothecin core present
437
+ """
438
+ checks = {}
439
+
440
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
441
+ checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
442
+
443
+ disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
444
+ checks['has_disulfide_bond'] = mol.HasSubstructMatch(disulfide_pattern)
445
+
446
+ lys_cys_ss_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
447
+ checks['has_lys_cys_ss_conjugation'] = mol.HasSubstructMatch(lys_cys_ss_pattern)
448
+
449
+ carbonate_pattern = Chem.MolFromSmarts('[OX2][CX3](=[OX1])[OX2]')
450
+ checks['has_carbonate_bond'] = mol.HasSubstructMatch(carbonate_pattern)
451
+
452
+ camptothecin_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
453
+ checks['has_camptothecin_core'] = mol.HasSubstructMatch(camptothecin_core)
454
+
455
+ checks['_passed'] = all([
456
+ checks['has_amide_bond'],
457
+ checks['has_disulfide_bond'],
458
+ checks['has_lys_cys_ss_conjugation'],
459
+ checks['has_carbonate_bond']
460
+ ])
461
+
462
+ return checks
463
+
464
+ def validate_conjugate(self, conjugate_smiles):
465
+ """
466
+ Validate that the conjugate is chemically reasonable
467
+
468
+ Returns:
469
+ (passed: bool, checks: dict)
470
+ """
471
+ mol = Chem.MolFromSmiles(conjugate_smiles)
472
+
473
+ if mol is None:
474
+ return False, {'error': 'Could not parse SMILES'}
475
+
476
+ checks = self._basic_validation(mol)
477
+ checks['parseable'] = True
478
+
479
+ passed = checks.get('_passed', True)
480
+ if '_passed' in checks:
481
+ del checks['_passed']
482
+
483
+ return passed, checks
484
+
485
+
486
+ # ============================================================================
487
+ # Main - Test Examples
488
+ # ============================================================================
489
+
490
+ if __name__ == "__main__":
491
+
492
+ print("=" * 70)
493
+ print("Disulfanylethyl Carbonate-Camptothecin PDC Builder - Test")
494
+ print("Reference: PDC_00337 (SMAC-P1 CPT conjugate 7, LIN00039)")
495
+ print("=" * 70 + "\n")
496
+
497
+ # Test peptides
498
+ test_peptides = [
499
+ # Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine)
500
+ "NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
501
+ # Lys-Arg
502
+ "N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
503
+ ]
504
+
505
+ builder = DisulfanylethylCarbonateCpt_PDC_Builder()
506
+
507
+ for i, peptide in enumerate(test_peptides):
508
+ print(f"\n--- Test peptide {i+1} ---")
509
+ print(f"Input: {peptide}")
510
+
511
+ try:
512
+ conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
513
+ print(f" Generated {len(conjugates)} conjugate(s)")
514
+
515
+ for j, conj in enumerate(conjugates):
516
+ valid, checks = builder.validate_conjugate(conj)
517
+ print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'} ({len(conj)} chars)")
518
+
519
+ except Exception as e:
520
+ print(f" FAILED: {e}")
521
+ import traceback
522
+ traceback.print_exc()
smiles/conjugation/DSEPSN38.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 5-(2-Disulfanylethoxy)-5-oxopentanoic Acid - SN-38 PDC Builder (DSEPSN38)
3
+
4
+ Conjugation chemistry: Cysteine thiol + Linker thiol → Disulfide bond (S-S)
5
+
6
+ Reference PDCs:
7
+ - PDC_02101 (SN38(10)-5-CRGDK)
8
+ - PDC_02106 (SN38(10)-5-(Ac)CRGDK)
9
+
10
+ Receptor: NRP1 (Neuropilin-1)
11
+ Linker: LIN00169 - 5-(2-Disulfanylethoxy)-5-oxopentanoic acid
12
+ Payload: 7-Ethyl-10-hydroxycamptothecin (SN-38)
13
+ Architecture: Monomer, Cys thiol conjugation (disulfide bond)
14
+
15
+ Chemistry Note:
16
+ ===============
17
+ LIN00169 provides a glutaric acid-based disulfide-ester linkage:
18
+ HOOC─CH₂─CH₂─CH₂─C(═O)─O─CH₂─CH₂─S─SH
19
+
20
+ pentanoic acid ethoxy-disulfanyl
21
+
22
+ Linker-to-Drug Connection:
23
+ SN-38(10-O)─C(═O)─CH₂─CH₂─CH₂─C(═O)─O─CH₂─CH₂─SH
24
+
25
+ Full PDC Architecture:
26
+ Peptide──Cys──S─S──CH₂─CH₂─O─C(═O)─CH₂─CH₂─CH₂─C(═O)─O──SN-38
27
+ └── disulfide ─┘ └── linker ──────┘ └ ester ┘
28
+
29
+ Usage:
30
+ ======
31
+ builder = DisulfanylethOxyPentanoicAcidSN38_PDC_Builder()
32
+ conjugates = builder.conjugate_to_peptide(peptide_smiles)
33
+ """
34
+
35
+ from rdkit import Chem
36
+ from rdkit.Chem import AllChem, Descriptors
37
+
38
+ from .pdc_builder import PDC_Builder
39
+
40
+
41
+ # ============================================================================
42
+ # 5-(2-Disulfanylethoxy)-5-oxopentanoic Acid - SN-38 PDC Builder
43
+ # ============================================================================
44
+
45
+ class DisulfanylethOxyPentanoicAcidSN38_PDC_Builder(PDC_Builder):
46
+ """
47
+ PDC builder for 5-(2-Disulfanylethoxy)-5-oxopentanoic Acid + SN-38 conjugates
48
+
49
+ Conjugation chemistry (disulfide bond formation):
50
+ - Peptide site: Cysteine thiol (side chain -SH)
51
+ - Linker-payload site: Terminal thiol (-SH) on linker
52
+ - Reaction: Peptide-Cys-SH + HS-Linker-Drug → Peptide-Cys-S-S-Linker-Drug
53
+
54
+ Linker: LIN00169 - 5-(2-Disulfanylethoxy)-5-oxopentanoic acid
55
+ IUPAC: 5-(2-(disulfanyl)ethoxy)-5-oxopentanoic acid
56
+ Connectivity: HOOC-CH2-CH2-CH2-C(=O)-O-CH2-CH2-S-SH
57
+ Glutaric acid monoester with 2-(disulfanyl)ethanol
58
+
59
+ Drug: 7-Ethyl-10-hydroxycamptothecin (SN-38)
60
+ Connection: Ester at C-10 hydroxyl (default) or C-20 hydroxyl
61
+
62
+ Reference: PDC_02101, PDC_02106 (NRP1)
63
+ """
64
+
65
+ # ========================================================================
66
+ # Linker-Drug SMILES Variants
67
+ # ========================================================================
68
+
69
+ # SN-38 with DSEP ester at C-10 (phenolic OH), free thiol
70
+ # Drug(10-O)-C(=O)-CH2-CH2-CH2-C(=O)-O-CH2-CH2-SH
71
+ POS10_LINKER_SN38_SMILES = (
72
+ "CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(OC(=O)CCCC(=O)OCCS)ccc3nc2-1"
73
+ )
74
+
75
+ # SN-38 with DSEP ester at C-20 (tertiary OH), free thiol
76
+ POS20_LINKER_SN38_SMILES = (
77
+ "CCC1(OC(=O)CCCC(=O)OCCS)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(O)ccc3nc2-1"
78
+ )
79
+
80
+ DEFAULT_LINKER_SN38_SMILES = POS10_LINKER_SN38_SMILES
81
+
82
+ DRUG_POSITIONS = {
83
+ '10': POS10_LINKER_SN38_SMILES,
84
+ '20': POS20_LINKER_SN38_SMILES,
85
+ }
86
+
87
+ def __init__(self, linker_drug_smiles=None, drug_position='all'):
88
+ """
89
+ Initialize DSEP-SN38 builder
90
+
91
+ Args:
92
+ linker_drug_smiles: Optional custom Linker-Drug SMILES.
93
+ drug_position: 'all' (default), '10', or '20'.
94
+ """
95
+ self.drug_position = drug_position
96
+
97
+ if linker_drug_smiles is not None:
98
+ self._variants = [linker_drug_smiles]
99
+ self._variant_names = ['custom']
100
+ super().__init__(
101
+ linker_drug_smiles,
102
+ component_name="Custom-DSEP-SN38 (NRP1)"
103
+ )
104
+ elif drug_position == 'all':
105
+ self._variants = [self.POS10_LINKER_SN38_SMILES, self.POS20_LINKER_SN38_SMILES]
106
+ self._variant_names = ['pos10', 'pos20']
107
+ super().__init__(
108
+ self.POS10_LINKER_SN38_SMILES,
109
+ component_name="DSEP-SN38 (LIN00169, all positions)"
110
+ )
111
+ else:
112
+ if drug_position not in self.DRUG_POSITIONS:
113
+ raise ValueError(f"Unknown drug_position '{drug_position}'. "
114
+ f"Choose from: 'all', '10', '20'")
115
+ smiles = self.DRUG_POSITIONS[drug_position]
116
+ self._variants = [smiles]
117
+ self._variant_names = [f'pos{drug_position}']
118
+ super().__init__(
119
+ smiles,
120
+ component_name=f"DSEP-SN38 (LIN00169, C-{drug_position})"
121
+ )
122
+
123
+ def _verify_reactive_group(self):
124
+ """Verify thiol group is present for disulfide bond formation"""
125
+ for atom in self.linker_payload_mol.GetAtoms():
126
+ if atom.GetAtomicNum() == 16:
127
+ if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
128
+ print(" DSEP-SN38 loaded with free thiol reactive group")
129
+ break
130
+ else:
131
+ print(" Warning: No free thiol group detected in DSEP-SN38")
132
+
133
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
134
+ if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
135
+ print(" Ester bond (linker-to-drug) detected")
136
+ else:
137
+ print(" Warning: No ester bond detected")
138
+
139
+ # ------------------------------------------------------------------
140
+ # Conjugation site finding (Cysteine thiol)
141
+ # ------------------------------------------------------------------
142
+
143
+ def find_conjugation_sites(self, peptide_smiles):
144
+ """Find all free cysteine thiol groups in peptide"""
145
+ return self.find_peptide_cysteines(peptide_smiles)
146
+
147
+ def find_peptide_cysteines(self, peptide_smiles):
148
+ """Find all free cysteine residues (thiol groups) in peptide"""
149
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
150
+ if peptide_mol is None:
151
+ raise ValueError("Invalid peptide SMILES")
152
+
153
+ cysteines = []
154
+ for atom in peptide_mol.GetAtoms():
155
+ if atom.GetAtomicNum() == 16:
156
+ total_hs = atom.GetTotalNumHs()
157
+ degree = atom.GetDegree()
158
+ if total_hs >= 1 and degree == 1:
159
+ cysteines.append({
160
+ 'atom_idx': atom.GetIdx(),
161
+ 'num_hydrogens': total_hs,
162
+ 'neighbors': [peptide_mol.GetAtomWithIdx(n.GetIdx()).GetSymbol()
163
+ for n in atom.GetNeighbors()],
164
+ 'type': 'Cys_thiol'
165
+ })
166
+ return cysteines
167
+
168
+ # ------------------------------------------------------------------
169
+ # Conjugation reaction (disulfide bond formation)
170
+ # ------------------------------------------------------------------
171
+
172
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
173
+ """Form disulfide bond between peptide Cys thiol and linker-drug thiol."""
174
+ all_conjugates = []
175
+ seen = set()
176
+
177
+ for variant_smiles in self._variants:
178
+ variant_mol = Chem.MolFromSmiles(variant_smiles)
179
+ if variant_mol is None:
180
+ continue
181
+
182
+ disulfide_rxn = AllChem.ReactionFromSmarts(
183
+ '[C:1][SH:2].[SH:3][C:4]>>[C:1][S:2][S:3][C:4]'
184
+ )
185
+
186
+ products = disulfide_rxn.RunReactants((peptide_mol, variant_mol))
187
+ if not products:
188
+ continue
189
+
190
+ for product_tuple in products:
191
+ try:
192
+ conjugate_mol = product_tuple[0]
193
+ Chem.SanitizeMol(conjugate_mol)
194
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
195
+ if conjugate_smiles not in seen:
196
+ seen.add(conjugate_smiles)
197
+ all_conjugates.append(conjugate_smiles)
198
+ except Exception:
199
+ continue
200
+
201
+ if not all_conjugates:
202
+ raise ValueError("Disulfide bond formation failed - no products generated")
203
+
204
+ return all_conjugates
205
+
206
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
207
+ """
208
+ Conjugate DSEP-SN38 to peptide via disulfide bond at Cys thiol.
209
+
210
+ Args:
211
+ peptide_smiles: SMILES string of peptide (must contain Cys)
212
+ exclude_n_terminus: Not used for Cys conjugation (kept for interface)
213
+ return_all: If True, return all possible conjugates
214
+
215
+ Returns:
216
+ List of all possible conjugate SMILES
217
+ """
218
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
219
+ if peptide_mol is None:
220
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
221
+
222
+ sites = self.find_conjugation_sites(peptide_smiles)
223
+ print(f"Found {len(sites)} Cys thiol site(s)")
224
+
225
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
226
+
227
+ pos_label = f"C-{self.drug_position}" if self.drug_position != 'all' else "C-10 + C-20"
228
+ print(f"Generated {len(conjugates)} unique conjugate(s) [Cys disulfide] [DSEP {pos_label}]")
229
+
230
+ if return_all:
231
+ return conjugates
232
+ else:
233
+ return conjugates[0] if conjugates else None
234
+
235
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
236
+ """Batch conjugate multiple peptides."""
237
+ results = {
238
+ 'conjugates': [], 'successful': [], 'failed': [], 'total_conjugates': 0
239
+ }
240
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
241
+ try:
242
+ conjugates = self.conjugate_to_peptide(
243
+ peptide_smiles, exclude_n_terminus=exclude_n_terminus, return_all=return_all
244
+ )
245
+ if return_all:
246
+ results['conjugates'].append(conjugates)
247
+ results['total_conjugates'] += len(conjugates)
248
+ else:
249
+ results['conjugates'].append(conjugates)
250
+ results['total_conjugates'] += 1
251
+ results['successful'].append(i)
252
+ except Exception as e:
253
+ print(f" Peptide {i} failed: {e}")
254
+ results['conjugates'].append(None)
255
+ results['failed'].append((i, str(e)))
256
+
257
+ print(f"\n=== Summary ===")
258
+ print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, "
259
+ f"Failed: {len(results['failed'])}")
260
+ print(f"Total conjugates generated: {results['total_conjugates']}")
261
+ return results
262
+
263
+ def flatten_batch_results(self, results):
264
+ """Flatten batch conjugation results into a single list"""
265
+ all_conjugates = []
266
+ for conjugate_list in results['conjugates']:
267
+ if conjugate_list is not None:
268
+ if isinstance(conjugate_list, list):
269
+ all_conjugates.extend(conjugate_list)
270
+ else:
271
+ all_conjugates.append(conjugate_list)
272
+ return all_conjugates
273
+
274
+ # ========================================================================
275
+ # Validation
276
+ # ========================================================================
277
+
278
+ def has_disulfide_bond(self, mol):
279
+ """Check if molecule has disulfide bond (S-S)"""
280
+ disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
281
+ return mol.HasSubstructMatch(disulfide_pattern)
282
+
283
+ def has_free_thiol(self, mol):
284
+ """Check if molecule has unconjugated (free) thiol groups"""
285
+ for atom in mol.GetAtoms():
286
+ if atom.GetAtomicNum() == 16:
287
+ if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
288
+ return True
289
+ return False
290
+
291
+ def has_ester_bond_to_drug(self, mol):
292
+ """Check for ester bond connecting linker to drug"""
293
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
294
+ return mol.HasSubstructMatch(ester_pattern)
295
+
296
+ def has_sn38_core(self, mol):
297
+ """Check for SN-38 core structure"""
298
+ cpt_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
299
+ return mol.HasSubstructMatch(cpt_core)
300
+
301
+ def check_attachment_site_compatibility(self, conjugate_smiles):
302
+ """Check attachment site compatibility for Cys-SS-DSEP-SN38 conjugation."""
303
+ mol = Chem.MolFromSmiles(conjugate_smiles)
304
+ if mol is None:
305
+ return False, {'parseable': False, 'error': 'Could not parse SMILES'}
306
+
307
+ details = {
308
+ 'parseable': True,
309
+ 'validation_method': 'cys_disulfide_ester',
310
+ 'linker': 'LIN00169 (5-(2-Disulfanylethoxy)-5-oxopentanoic acid)',
311
+ 'drug': 'SN-38 (7-Ethyl-10-hydroxycamptothecin)',
312
+ }
313
+ details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
314
+ details['no_free_thiol'] = not self.has_free_thiol(mol)
315
+ details['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
316
+
317
+ passed = all([details['has_disulfide_bond'], details['no_free_thiol']])
318
+ return passed, details
319
+
320
+ def _basic_validation(self, mol):
321
+ """Basic validation checks for DSEP-SN38 conjugates."""
322
+ checks = {}
323
+ checks['has_disulfide_bond'] = mol.HasSubstructMatch(Chem.MolFromSmarts('[#16]-[#16]'))
324
+ checks['no_free_thiol'] = not self.has_free_thiol(mol)
325
+ checks['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
326
+ checks['has_sn38_core'] = self.has_sn38_core(mol)
327
+ checks['_passed'] = all([checks['has_disulfide_bond'], checks['no_free_thiol']])
328
+ return checks
329
+
330
+ def validate_conjugate(self, conjugate_smiles):
331
+ """Validate that the conjugate is chemically reasonable."""
332
+ mol = Chem.MolFromSmiles(conjugate_smiles)
333
+ if mol is None:
334
+ return False, {'error': 'Could not parse SMILES'}
335
+ checks = self._basic_validation(mol)
336
+ checks['parseable'] = True
337
+ passed = checks.get('_passed', True)
338
+ if '_passed' in checks:
339
+ del checks['_passed']
340
+ return passed, checks
341
+
342
+
343
+ # ============================================================================
344
+ # Main - Test Examples
345
+ # ============================================================================
346
+
347
+ if __name__ == "__main__":
348
+
349
+ print("=" * 70)
350
+ print("DSEP-SN38 PDC Builder - Test")
351
+ print("Reference: NRP1 + LIN00169 + SN-38")
352
+ print("=" * 70 + "\n")
353
+
354
+ test_peptides = [
355
+ "NCC(=O)N[C@@H](CS)C(=O)NCC(=O)O", # Gly-Cys-Gly
356
+ ]
357
+
358
+ builder = DisulfanylethOxyPentanoicAcidSN38_PDC_Builder()
359
+
360
+ for i, peptide in enumerate(test_peptides):
361
+ print(f"\n--- Test peptide {i+1} ---")
362
+ print(f"Input: {peptide}")
363
+
364
+ try:
365
+ conjugates = builder.conjugate_to_peptide(peptide)
366
+ print(f" Generated {len(conjugates)} conjugate(s)")
367
+ for j, conj in enumerate(conjugates):
368
+ valid, checks = builder.validate_conjugate(conj)
369
+ print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'}")
370
+ except Exception as e:
371
+ print(f" FAILED: {e}")
372
+ import traceback
373
+ traceback.print_exc()
smiles/conjugation/DSPACpt.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Disulfanylpropanoic Acid - Camptothecin PDC Builder (DSPACpt)
3
+
4
+ Conjugation chemistry: Lysine ε-amine + Cys-SS-Linker-Camptothecin (amide bond formation)
5
+
6
+ Reference PDC:
7
+ - PDC_00335 (SMAC-P1 CPT conjugate 5)
8
+
9
+ Receptor: ERBB2 (Receptor tyrosine-protein kinase erbB-2)
10
+ Linker: LIN00012 - 3-Disulfanylpropanoic Acid (disulfide + ester)
11
+ Payload: DRG00009 - Camptothecin
12
+ Architecture: Monomer, Lys side chain conjugation
13
+
14
+ Chemistry Note:
15
+ ===============
16
+ LIN00012 (3-Disulfanylpropanoic Acid) provides a disulfide-ester linkage:
17
+ HS─S─CH₂─CH₂─C(═O)─OH
18
+
19
+ Linker-to-Drug Connection (Ester):
20
+ Cys─S─S─CH₂─CH₂─C(═O)─O─Camptothecin
21
+ │
22
+ ester bond (C-20 OH of CPT)
23
+
24
+ Full PDC Architecture:
25
+ Peptide──Lys──NH──C(═O)──Cys──S─S──CH₂─CH₂─C(═O)─O──Camptothecin
26
+ └── amide ──┘ └── disulfide ──┘ └── ester ──┘
27
+
28
+ Note: This linker chemistry is equivalent to the SSCpt ester variant (Z7/PDC_00294).
29
+
30
+ Usage:
31
+ ======
32
+ builder = DisulfanylpropanoicAcidCpt_PDC_Builder()
33
+ conjugates = builder.conjugate_to_peptide(peptide_smiles)
34
+ """
35
+
36
+ from rdkit import Chem
37
+ from rdkit.Chem import AllChem, Descriptors
38
+
39
+ from .pdc_builder import PDC_Builder
40
+
41
+
42
+ # ============================================================================
43
+ # Disulfanylpropanoic Acid - Camptothecin PDC Builder
44
+ # ============================================================================
45
+
46
+ class DisulfanylpropanoicAcidCpt_PDC_Builder(PDC_Builder):
47
+ """
48
+ PDC builder for 3-Disulfanylpropanoic Acid + Camptothecin conjugates
49
+
50
+ Conjugation chemistry (amide bond formation):
51
+ - Peptide site: Lysine ε-amine (side chain -NH2)
52
+ - Linker-payload site: Carboxylic acid of Cys (Cys-SS-Linker-Drug)
53
+ - Reaction: Lys-NH2 + HOOC-Cys-SS-Linker-Drug → Lys-NH-CO-Cys-SS-Linker-Drug
54
+
55
+ Linker: LIN00012 - 3-Disulfanylpropanoic Acid
56
+ SMILES: C(CSS)C(=O)O
57
+ IUPAC: 3-(disulfanyl)propanoic acid
58
+ Connectivity: HS-S-CH2-CH2-C(=O)-OH
59
+ MW: 138.21, MF: C3H6O2S2
60
+
61
+ Drug: DRG00009 - Camptothecin
62
+ Canonical SMILES: CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1
63
+ Connection: Ester at C-20 hydroxyl
64
+
65
+ Reference: PDC_00335 (SMAC-P1 CPT conjugate 5, ERBB2)
66
+ """
67
+
68
+ # ========================================================================
69
+ # Cys-SS-Linker-Drug SMILES
70
+ # ========================================================================
71
+ #
72
+ # Pre-formed Cys-Disulfanylpropanoic acid-Camptothecin assembly:
73
+ # HOOC─Cys(NH₂)─CH₂─S─S─CH₂─CH₂─C(═O)─O─CPT(C-20)
74
+ #
75
+ # Cys provides COOH for amide bond with Lys ε-amine
76
+ # Disulfide bond (S-S) is cleavable inside cells
77
+ # Ester bond connects linker to CPT C-20 hydroxyl
78
+ #
79
+ # ========================================================================
80
+
81
+ CYS_SS_LINKER_CPT_SMILES = (
82
+ "N[C@H](CSSCCC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C(=O)O"
83
+ )
84
+
85
+ def __init__(self, cys_linker_drug_smiles=None):
86
+ """
87
+ Initialize Disulfanylpropanoic Acid-Camptothecin builder
88
+
89
+ Args:
90
+ cys_linker_drug_smiles: Optional custom Cys-SS-Linker-Drug SMILES.
91
+ If None, uses default LIN00012+CPT assembly.
92
+ Must have free carboxylic acid for conjugation.
93
+ """
94
+ if cys_linker_drug_smiles is None:
95
+ cys_linker_drug_smiles = self.CYS_SS_LINKER_CPT_SMILES
96
+
97
+ super().__init__(
98
+ cys_linker_drug_smiles,
99
+ component_name="Cys-SS-DisulfanylpropanoicAcid-Camptothecin (LIN00012+DRG00009)"
100
+ )
101
+
102
+ def _verify_reactive_group(self):
103
+ """Verify carboxylic acid group is present for amide bond formation"""
104
+ # Pattern: Carboxylic acid (COOH)
105
+ carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
106
+
107
+ if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
108
+ print(" Warning: No carboxylic acid group detected in Cys-SS-Linker-Drug")
109
+ else:
110
+ print(" Cys-SS-DSPA-Camptothecin loaded with carboxylic acid reactive group")
111
+
112
+ # Verify disulfide bond is present
113
+ disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
114
+ if self.linker_payload_mol.HasSubstructMatch(disulfide_pattern):
115
+ print(" Disulfide bond (S-S) detected")
116
+ else:
117
+ print(" Warning: No disulfide bond detected - check structure")
118
+
119
+ # Verify Cys α-amine is present
120
+ cys_amine_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2]S)C(=O)')
121
+ if self.linker_payload_mol.HasSubstructMatch(cys_amine_pattern):
122
+ print(" Cys α-amine detected (will be free in conjugate)")
123
+ else:
124
+ print(" Warning: Cys α-amine pattern not detected")
125
+
126
+ # Verify ester bond to drug
127
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
128
+ if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
129
+ print(" Ester bond (linker-to-drug) detected")
130
+ else:
131
+ print(" Warning: No ester bond detected - check linker-drug connection")
132
+
133
+ def find_conjugation_sites(self, peptide_smiles):
134
+ """
135
+ Find all lysine ε-amine groups in peptide
136
+
137
+ Returns list of dicts with site information including atom index
138
+ """
139
+ return self.find_peptide_lysines(peptide_smiles)
140
+
141
+ def find_peptide_lysines(self, peptide_smiles):
142
+ """
143
+ Find all lysine residues with free ε-amine in peptide
144
+
145
+ Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
146
+
147
+ Returns:
148
+ List of dicts with 'atom_idx' (nitrogen index) and metadata
149
+ """
150
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
151
+ if peptide_mol is None:
152
+ raise ValueError("Invalid peptide SMILES")
153
+
154
+ lysines = []
155
+
156
+ # SMARTS pattern for Lysine ε-amine: 4 CH2 groups ending in NH2
157
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
158
+ matches = peptide_mol.GetSubstructMatches(lys_pattern)
159
+
160
+ for match in matches:
161
+ nitrogen_idx = match[4]
162
+ lysines.append({
163
+ 'atom_idx': nitrogen_idx,
164
+ 'side_chain_atoms': list(match),
165
+ 'type': 'Lys_epsilon_amine'
166
+ })
167
+
168
+ # Count N-terminus for informational purposes
169
+ n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
170
+ n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
171
+
172
+ if n_term_matches:
173
+ print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
174
+
175
+ return lysines
176
+
177
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
178
+ """
179
+ Perform amide bond conjugation between NH2 groups and Cys carboxylic acid
180
+
181
+ Args:
182
+ peptide_mol: RDKit Mol object
183
+ site_info: Not used (kept for compatibility)
184
+ exclude_n_terminus: If True, skip N-terminus conjugation
185
+
186
+ Returns: List of all possible conjugate SMILES
187
+ """
188
+ if exclude_n_terminus:
189
+ amide_rxn = AllChem.ReactionFromSmarts(
190
+ '[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
191
+ )
192
+ else:
193
+ amide_rxn = AllChem.ReactionFromSmarts(
194
+ '[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
195
+ )
196
+
197
+ products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
198
+
199
+ if not products:
200
+ raise ValueError("Amide bond formation reaction failed - no products generated")
201
+
202
+ conjugates = []
203
+ seen_smiles = set()
204
+
205
+ for product_tuple in products:
206
+ try:
207
+ conjugate_mol = product_tuple[0]
208
+ Chem.SanitizeMol(conjugate_mol)
209
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
210
+
211
+ if conjugate_smiles not in seen_smiles:
212
+ seen_smiles.add(conjugate_smiles)
213
+ conjugates.append(conjugate_smiles)
214
+ except Exception:
215
+ continue
216
+
217
+ if not conjugates:
218
+ raise ValueError("All products failed sanitization")
219
+
220
+ return conjugates
221
+
222
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
223
+ """
224
+ Conjugate Cys-SS-DSPA-Camptothecin to peptide via amide bond at NH2 groups
225
+
226
+ Args:
227
+ peptide_smiles: SMILES string of peptide (must contain Lys with NH2)
228
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
229
+ return_all: If True, return all possible conjugates
230
+
231
+ Returns:
232
+ List of all possible conjugate SMILES
233
+ """
234
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
235
+ if peptide_mol is None:
236
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
237
+
238
+ sites = self.find_conjugation_sites(peptide_smiles)
239
+ print(f"Found {len(sites)} Lys ε-amine site(s)")
240
+
241
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
242
+
243
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
244
+ print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}] [DSPA ester]")
245
+
246
+ if return_all:
247
+ return conjugates
248
+ else:
249
+ return conjugates[0] if conjugates else None
250
+
251
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
252
+ """
253
+ Batch conjugate multiple peptides
254
+
255
+ Args:
256
+ peptide_smiles_list: List of peptide SMILES strings
257
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
258
+ return_all: If True, return all possible conjugates per peptide
259
+
260
+ Returns:
261
+ Dict with 'conjugates', 'successful', 'failed', 'total_conjugates'
262
+ """
263
+ results = {
264
+ 'conjugates': [],
265
+ 'successful': [],
266
+ 'failed': [],
267
+ 'total_conjugates': 0
268
+ }
269
+
270
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
271
+ try:
272
+ conjugates = self.conjugate_to_peptide(
273
+ peptide_smiles,
274
+ exclude_n_terminus=exclude_n_terminus,
275
+ return_all=return_all
276
+ )
277
+ if return_all:
278
+ results['conjugates'].append(conjugates)
279
+ results['total_conjugates'] += len(conjugates)
280
+ else:
281
+ results['conjugates'].append(conjugates)
282
+ results['total_conjugates'] += 1
283
+ results['successful'].append(i)
284
+ except Exception as e:
285
+ print(f" Peptide {i} failed: {e}")
286
+ results['conjugates'].append(None)
287
+ results['failed'].append((i, str(e)))
288
+
289
+ print(f"\n=== Summary ===")
290
+ print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
291
+ print(f"Total conjugates generated: {results['total_conjugates']}")
292
+
293
+ return results
294
+
295
+ def flatten_batch_results(self, results):
296
+ """Flatten batch conjugation results into a single list"""
297
+ all_conjugates = []
298
+ for conjugate_list in results['conjugates']:
299
+ if conjugate_list is not None:
300
+ if isinstance(conjugate_list, list):
301
+ all_conjugates.extend(conjugate_list)
302
+ else:
303
+ all_conjugates.append(conjugate_list)
304
+ return all_conjugates
305
+
306
+ # ========================================================================
307
+ # Attachment Site Compatibility Checks
308
+ # ========================================================================
309
+
310
+ def has_lys_cys_amide_conjugation(self, mol):
311
+ """Check if molecule has Lys-Cys amide conjugation bond"""
312
+ lys_cys_amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
313
+ return mol.HasSubstructMatch(lys_cys_amide_pattern)
314
+
315
+ def has_disulfide_bond(self, mol):
316
+ """Check if molecule has disulfide bond (S-S)"""
317
+ disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
318
+ return mol.HasSubstructMatch(disulfide_pattern)
319
+
320
+ def has_unreacted_lys_epsilon_amine(self, mol):
321
+ """Check for unreacted Lys ε-amine (free NH2 on 4-carbon side chain)"""
322
+ lys_amine_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
323
+ return mol.HasSubstructMatch(lys_amine_pattern)
324
+
325
+ def has_unreacted_cys_carboxylic_acid(self, mol):
326
+ """Check for unreacted Cys carboxylic acid from Cys-SS-Linker-Drug"""
327
+ cys_cooh_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2][SX2])[CX3](=[OX1])[OX2H1]')
328
+ return mol.HasSubstructMatch(cys_cooh_pattern)
329
+
330
+ def has_ester_bond_to_drug(self, mol):
331
+ """Check for ester bond connecting linker to drug"""
332
+ ester_pattern = Chem.MolFromSmarts('[SX2][SX2][CH2][CH2][CX3](=[OX1])[OX2][C]')
333
+ return mol.HasSubstructMatch(ester_pattern)
334
+
335
+ def find_amide_conjugation_sites(self, mol):
336
+ """Find all Lys-Cys amide conjugation sites in the molecule"""
337
+ lys_cys_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
338
+ matches = mol.GetSubstructMatches(lys_cys_pattern)
339
+
340
+ sites = []
341
+ for match in matches:
342
+ nitrogen_idx = match[0]
343
+ carbonyl_c_idx = match[1]
344
+ cys_alpha_idx = match[3]
345
+ sites.append((nitrogen_idx, carbonyl_c_idx, cys_alpha_idx))
346
+
347
+ return sites
348
+
349
+ def check_valency_at_conjugation_site(self, mol, site_indices):
350
+ """Check that valency is correct at Lys-Cys conjugation site"""
351
+ nitrogen_idx, carbonyl_c_idx, cys_alpha_idx = site_indices
352
+
353
+ try:
354
+ n_atom = mol.GetAtomWithIdx(nitrogen_idx)
355
+ if n_atom.GetTotalDegree() != 3:
356
+ return False
357
+
358
+ c_atom = mol.GetAtomWithIdx(carbonyl_c_idx)
359
+ if c_atom.GetTotalDegree() != 3:
360
+ return False
361
+
362
+ cys_c_atom = mol.GetAtomWithIdx(cys_alpha_idx)
363
+ if cys_c_atom.GetTotalDegree() != 4:
364
+ return False
365
+
366
+ return True
367
+ except Exception:
368
+ return False
369
+
370
+ def check_attachment_site_compatibility(self, conjugate_smiles):
371
+ """
372
+ Check attachment site compatibility for Lys-Cys-SS-DSPA-CPT conjugation
373
+
374
+ Validates:
375
+ 1. Lys-Cys amide conjugation is present
376
+ 2. Disulfide bond (S-S) is intact
377
+ 3. Valency at conjugation sites is correct
378
+ 4. No unreacted Cys-COOH (unconjugated linker-drug)
379
+ 5. Ester bond to drug is present
380
+
381
+ Returns:
382
+ Tuple of (passed: bool, details: dict)
383
+ """
384
+ mol = Chem.MolFromSmiles(conjugate_smiles)
385
+
386
+ if mol is None:
387
+ return False, {'parseable': False, 'error': 'Could not parse SMILES'}
388
+
389
+ details = {
390
+ 'parseable': True,
391
+ 'validation_method': 'lys_cys_amide_disulfide_ester',
392
+ 'linker': 'LIN00012 (3-Disulfanylpropanoic Acid)',
393
+ 'drug': 'DRG00009 (Camptothecin)',
394
+ 'pdc_ref': 'PDC_00335'
395
+ }
396
+
397
+ details['has_lys_cys_amide'] = self.has_lys_cys_amide_conjugation(mol)
398
+ details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
399
+
400
+ conjugation_sites = self.find_amide_conjugation_sites(mol)
401
+ details['conjugation_sites'] = len(conjugation_sites)
402
+
403
+ valency_ok = True
404
+ if conjugation_sites:
405
+ for site in conjugation_sites:
406
+ if not self.check_valency_at_conjugation_site(mol, site):
407
+ valency_ok = False
408
+ break
409
+ details['valency_correct'] = valency_ok
410
+
411
+ details['no_unreacted_cys_cooh'] = not self.has_unreacted_cys_carboxylic_acid(mol)
412
+ details['has_unreacted_lys_amine'] = self.has_unreacted_lys_epsilon_amine(mol)
413
+
414
+ passed = all([
415
+ details['has_lys_cys_amide'],
416
+ details['has_disulfide_bond'],
417
+ details['valency_correct'],
418
+ details['no_unreacted_cys_cooh']
419
+ ])
420
+
421
+ return passed, details
422
+
423
+ def _basic_validation(self, mol):
424
+ """
425
+ Basic validation checks for DSPA-Camptothecin conjugates
426
+
427
+ Checks:
428
+ 1. Amide bond present (Lys-Cys conjugation)
429
+ 2. Disulfide bond present (S-S linker)
430
+ 3. Specific Lys-Cys-SS conjugation pattern
431
+ 4. Camptothecin core present
432
+ 5. Ester bond to drug present
433
+ """
434
+ checks = {}
435
+
436
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
437
+ checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
438
+
439
+ disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
440
+ checks['has_disulfide_bond'] = mol.HasSubstructMatch(disulfide_pattern)
441
+
442
+ lys_cys_ss_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
443
+ checks['has_lys_cys_ss_conjugation'] = mol.HasSubstructMatch(lys_cys_ss_pattern)
444
+
445
+ camptothecin_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
446
+ checks['has_camptothecin_core'] = mol.HasSubstructMatch(camptothecin_core)
447
+
448
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
449
+ checks['has_ester_bond'] = mol.HasSubstructMatch(ester_pattern)
450
+
451
+ checks['_passed'] = all([
452
+ checks['has_amide_bond'],
453
+ checks['has_disulfide_bond'],
454
+ checks['has_lys_cys_ss_conjugation']
455
+ ])
456
+
457
+ return checks
458
+
459
+ def validate_conjugate(self, conjugate_smiles):
460
+ """
461
+ Validate that the conjugate is chemically reasonable
462
+
463
+ Returns:
464
+ (passed: bool, checks: dict)
465
+ """
466
+ mol = Chem.MolFromSmiles(conjugate_smiles)
467
+
468
+ if mol is None:
469
+ return False, {'error': 'Could not parse SMILES'}
470
+
471
+ checks = self._basic_validation(mol)
472
+ checks['parseable'] = True
473
+
474
+ passed = checks.get('_passed', True)
475
+ if '_passed' in checks:
476
+ del checks['_passed']
477
+
478
+ return passed, checks
479
+
480
+
481
+ # ============================================================================
482
+ # Main - Test Examples
483
+ # ============================================================================
484
+
485
+ if __name__ == "__main__":
486
+
487
+ print("=" * 70)
488
+ print("Disulfanylpropanoic Acid-Camptothecin PDC Builder - Test")
489
+ print("Reference: PDC_00335 (SMAC-P1 CPT conjugate 5, LIN00012)")
490
+ print("=" * 70 + "\n")
491
+
492
+ # Test peptides
493
+ test_peptides = [
494
+ # Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine)
495
+ "NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
496
+ # Lys-Arg
497
+ "N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
498
+ ]
499
+
500
+ builder = DisulfanylpropanoicAcidCpt_PDC_Builder()
501
+
502
+ for i, peptide in enumerate(test_peptides):
503
+ print(f"\n--- Test peptide {i+1} ---")
504
+ print(f"Input: {peptide}")
505
+
506
+ try:
507
+ conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
508
+ print(f" Generated {len(conjugates)} conjugate(s)")
509
+
510
+ for j, conj in enumerate(conjugates):
511
+ valid, checks = builder.validate_conjugate(conj)
512
+ print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'} ({len(conj)} chars)")
513
+
514
+ except Exception as e:
515
+ print(f" FAILED: {e}")
516
+ import traceback
517
+ traceback.print_exc()
smiles/conjugation/DSPASN38.py ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Disulfanylpropanoic Acid - SN-38 PDC Builder (DSPASN38)
3
+
4
+ Conjugation chemistry: Cysteine thiol + Linker thiol → Disulfide bond (S-S)
5
+
6
+ Reference PDCs:
7
+ - PDC_02099 (SN38(10)-CRGDK)
8
+ - PDC_02104 (SN38(10)-(Ac)CRGDK)
9
+ - PDC_02102 (SN38(10)-(Ac)CGVQIRGDK)
10
+ - PDC_02107 (SN38(10)-CRRGDK)
11
+ - PDC_02110 (SN38(10)-(Ac)CDRGDK)
12
+ - PDC_02112 (SN38(20)-CRGDK)
13
+ - PDC_02113 ((MOM)SN38(20)-CRGDK)
14
+ - PDC_02114 ((MOM)SN38(20)-(Ac)CRGDK)
15
+ - PDC_02115 ((MOM)SN38(20)-(Ac)CGVQIRGDK)
16
+
17
+ Receptor: NRP1 (Neuropilin-1)
18
+ Linker: LIN00149 - 3-Disulfanylpropanoic Acid (disulfide + ester)
19
+ Payload: DRG - 7-Ethyl-10-hydroxycamptothecin (SN-38)
20
+ Architecture: Monomer, Cys thiol conjugation (disulfide bond)
21
+
22
+ Chemistry Note:
23
+ ===============
24
+ LIN00149 (3-Disulfanylpropanoic Acid) connects via:
25
+ HS─CH₂─CH₂─C(═O)─OH
26
+
27
+ Linker-to-Drug Connection (Ester):
28
+ SN-38(10-O)─C(═O)─CH₂─CH₂─SH (position 10, default)
29
+ SN-38(20-O)─C(═O)─CH₂─CH₂─SH (position 20)
30
+
31
+ Full PDC Architecture:
32
+ Peptide──Cys──S─S──CH₂─CH₂─C(═O)─O──SN-38
33
+ └── disulfide ──┘ └── ester ──┘
34
+
35
+ Usage:
36
+ ======
37
+ builder = DisulfanylpropanoicAcidSN38_PDC_Builder()
38
+ conjugates = builder.conjugate_to_peptide(peptide_smiles)
39
+ """
40
+
41
+ from rdkit import Chem
42
+ from rdkit.Chem import AllChem, Descriptors
43
+
44
+ from .pdc_builder import PDC_Builder
45
+
46
+
47
+ # ============================================================================
48
+ # Disulfanylpropanoic Acid - SN-38 PDC Builder
49
+ # ============================================================================
50
+
51
+ class DisulfanylpropanoicAcidSN38_PDC_Builder(PDC_Builder):
52
+ """
53
+ PDC builder for 3-Disulfanylpropanoic Acid + SN-38 conjugates
54
+
55
+ Conjugation chemistry (disulfide bond formation):
56
+ - Peptide site: Cysteine thiol (side chain -SH)
57
+ - Linker-payload site: Terminal thiol (-SH) on DSPA linker
58
+ - Reaction: Peptide-Cys-SH + HS-Linker-Drug → Peptide-Cys-S-S-Linker-Drug
59
+
60
+ Linker: LIN00149 - 3-Disulfanylpropanoic Acid
61
+ SMILES: OC(=O)CCSS
62
+ IUPAC: 3-(disulfanyl)propanoic acid
63
+ Connectivity: HS-S-CH2-CH2-C(=O)-OH
64
+
65
+ Drug: 7-Ethyl-10-hydroxycamptothecin (SN-38)
66
+ Canonical SMILES: CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(O)ccc3nc2-1
67
+ Connection: Ester at C-10 hydroxyl (default) or C-20 hydroxyl
68
+
69
+ Supported drug conjugation positions:
70
+ - '10' (default): Ester at 10-OH (phenolic, most common in PDCdb)
71
+ - '20': Ester at 20-OH (tertiary aliphatic)
72
+ - 'all': Both positions, conjugates pooled
73
+
74
+ Reference: PDC_02099, PDC_02112 (NRP1)
75
+ """
76
+
77
+ # ========================================================================
78
+ # Linker-Drug SMILES Variants
79
+ # ========================================================================
80
+ #
81
+ # DSPA linker ester to SN-38 with free terminal thiol for Cys conjugation
82
+ #
83
+ # Position 10 (phenolic OH):
84
+ # HS─CH₂─CH₂─C(═O)─O─SN38(10)
85
+ #
86
+ # Position 20 (tertiary OH):
87
+ # HS─CH₂─CH₂─C(═O)─O─SN38(20)
88
+ #
89
+ # ========================================================================
90
+
91
+ # SN-38 with DSPA ester at C-10 (phenolic OH), free thiol
92
+ POS10_LINKER_SN38_SMILES = (
93
+ "CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(OC(=O)CCS)ccc3nc2-1"
94
+ )
95
+
96
+ # SN-38 with DSPA ester at C-20 (tertiary OH), free thiol
97
+ POS20_LINKER_SN38_SMILES = (
98
+ "CCC1(OC(=O)CCS)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(O)ccc3nc2-1"
99
+ )
100
+
101
+ # Default: position 10 (most common)
102
+ DEFAULT_LINKER_SN38_SMILES = POS10_LINKER_SN38_SMILES
103
+
104
+ # Available drug positions
105
+ DRUG_POSITIONS = {
106
+ '10': POS10_LINKER_SN38_SMILES,
107
+ '20': POS20_LINKER_SN38_SMILES,
108
+ }
109
+
110
+ def __init__(self, linker_drug_smiles=None, drug_position='all'):
111
+ """
112
+ Initialize DSPA-SN38 builder
113
+
114
+ Args:
115
+ linker_drug_smiles: Optional custom Linker-Drug SMILES.
116
+ If provided, overrides drug_position.
117
+ Must have free thiol for Cys disulfide conjugation.
118
+ drug_position: 'all' (default), '10', or '20'.
119
+ '10': ester at C-10 phenolic OH (most common)
120
+ '20': ester at C-20 tertiary OH
121
+ 'all': both positions, conjugates pooled
122
+ Ignored if linker_drug_smiles is provided.
123
+ """
124
+ self.drug_position = drug_position
125
+
126
+ if linker_drug_smiles is not None:
127
+ self._variants = [linker_drug_smiles]
128
+ self._variant_names = ['custom']
129
+ super().__init__(
130
+ linker_drug_smiles,
131
+ component_name="Custom-DSPA-SN38 (NRP1)"
132
+ )
133
+ elif drug_position == 'all':
134
+ self._variants = [self.POS10_LINKER_SN38_SMILES, self.POS20_LINKER_SN38_SMILES]
135
+ self._variant_names = ['pos10', 'pos20']
136
+ super().__init__(
137
+ self.POS10_LINKER_SN38_SMILES,
138
+ component_name="DSPA-SN38 (LIN00149, all positions)"
139
+ )
140
+ else:
141
+ if drug_position not in self.DRUG_POSITIONS:
142
+ raise ValueError(f"Unknown drug_position '{drug_position}'. "
143
+ f"Choose from: 'all', '10', '20'")
144
+ smiles = self.DRUG_POSITIONS[drug_position]
145
+ self._variants = [smiles]
146
+ self._variant_names = [f'pos{drug_position}']
147
+ super().__init__(
148
+ smiles,
149
+ component_name=f"DSPA-SN38 (LIN00149, C-{drug_position})"
150
+ )
151
+
152
+ def _verify_reactive_group(self):
153
+ """Verify thiol group is present for disulfide bond formation"""
154
+ # Check for free thiol (SH)
155
+ for atom in self.linker_payload_mol.GetAtoms():
156
+ if atom.GetAtomicNum() == 16: # Sulfur
157
+ if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
158
+ print(" DSPA-SN38 loaded with free thiol reactive group")
159
+ break
160
+ else:
161
+ print(" Warning: No free thiol group detected in DSPA-SN38")
162
+
163
+ # Verify ester bond to drug
164
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][c]')
165
+ if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
166
+ print(" Ester bond (linker-to-drug) detected")
167
+ else:
168
+ # Try broader ester pattern
169
+ ester_broad = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
170
+ if self.linker_payload_mol.HasSubstructMatch(ester_broad):
171
+ print(" Ester bond (linker-to-drug) detected")
172
+ else:
173
+ print(" Warning: No ester bond detected - check linker-drug connection")
174
+
175
+ # ------------------------------------------------------------------
176
+ # Conjugation site finding (Cysteine thiol)
177
+ # ------------------------------------------------------------------
178
+
179
+ def find_conjugation_sites(self, peptide_smiles):
180
+ """
181
+ Find all free cysteine thiol groups in peptide
182
+
183
+ Returns list of dicts with site information including atom index
184
+ """
185
+ return self.find_peptide_cysteines(peptide_smiles)
186
+
187
+ def find_peptide_cysteines(self, peptide_smiles):
188
+ """
189
+ Find all free cysteine residues (thiol groups) in peptide
190
+
191
+ Iterates over all atoms to find S with free H (thiol, not disulfide).
192
+
193
+ Returns:
194
+ List of dicts with 'atom_idx' (sulfur index) and metadata
195
+ """
196
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
197
+ if peptide_mol is None:
198
+ raise ValueError("Invalid peptide SMILES")
199
+
200
+ cysteines = []
201
+
202
+ for atom in peptide_mol.GetAtoms():
203
+ if atom.GetAtomicNum() == 16: # Sulfur
204
+ total_hs = atom.GetTotalNumHs()
205
+ degree = atom.GetDegree()
206
+
207
+ # Thiol: S with 1 H and bonded to 1 carbon (not disulfide S-S)
208
+ if total_hs >= 1 and degree == 1:
209
+ cysteines.append({
210
+ 'atom_idx': atom.GetIdx(),
211
+ 'num_hydrogens': total_hs,
212
+ 'neighbors': [peptide_mol.GetAtomWithIdx(n.GetIdx()).GetSymbol()
213
+ for n in atom.GetNeighbors()],
214
+ 'type': 'Cys_thiol'
215
+ })
216
+
217
+ return cysteines
218
+
219
+ # ------------------------------------------------------------------
220
+ # Conjugation reaction (disulfide bond formation)
221
+ # ------------------------------------------------------------------
222
+
223
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
224
+ """
225
+ Form disulfide bond between peptide Cys thiol and linker-drug thiol.
226
+
227
+ Args:
228
+ peptide_mol: RDKit Mol object
229
+ site_info: Not used (kept for compatibility)
230
+ exclude_n_terminus: Not used for Cys conjugation (kept for interface)
231
+
232
+ Returns: List of all possible conjugate SMILES
233
+ """
234
+ all_conjugates = []
235
+ seen = set()
236
+
237
+ for variant_smiles in self._variants:
238
+ variant_mol = Chem.MolFromSmiles(variant_smiles)
239
+ if variant_mol is None:
240
+ continue
241
+
242
+ # SMARTS: form disulfide between any two thiols
243
+ # Reactant 0 = peptide (Cys-CH2-SH), Reactant 1 = linker-drug (-CH2-SH)
244
+ disulfide_rxn = AllChem.ReactionFromSmarts(
245
+ '[C:1][SH:2].[SH:3][C:4]>>[C:1][S:2][S:3][C:4]'
246
+ )
247
+
248
+ products = disulfide_rxn.RunReactants((peptide_mol, variant_mol))
249
+
250
+ if not products:
251
+ continue
252
+
253
+ for product_tuple in products:
254
+ try:
255
+ conjugate_mol = product_tuple[0]
256
+ Chem.SanitizeMol(conjugate_mol)
257
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
258
+
259
+ if conjugate_smiles not in seen:
260
+ seen.add(conjugate_smiles)
261
+ all_conjugates.append(conjugate_smiles)
262
+ except Exception:
263
+ continue
264
+
265
+ if not all_conjugates:
266
+ raise ValueError("Disulfide bond formation failed - no products generated")
267
+
268
+ return all_conjugates
269
+
270
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
271
+ """
272
+ Conjugate DSPA-SN38 to peptide via disulfide bond at Cys thiol.
273
+
274
+ Args:
275
+ peptide_smiles: SMILES string of peptide (must contain Cys with free thiol)
276
+ exclude_n_terminus: Not used for Cys conjugation (kept for interface)
277
+ return_all: If True, return all possible conjugates
278
+
279
+ Returns:
280
+ List of all possible conjugate SMILES
281
+ """
282
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
283
+ if peptide_mol is None:
284
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
285
+
286
+ sites = self.find_conjugation_sites(peptide_smiles)
287
+ print(f"Found {len(sites)} Cys thiol site(s)")
288
+
289
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
290
+
291
+ pos_label = f"C-{self.drug_position}" if self.drug_position != 'all' else "C-10 + C-20"
292
+ print(f"Generated {len(conjugates)} unique conjugate(s) [Cys disulfide] [DSPA {pos_label}]")
293
+
294
+ if return_all:
295
+ return conjugates
296
+ else:
297
+ return conjugates[0] if conjugates else None
298
+
299
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
300
+ """
301
+ Batch conjugate multiple peptides.
302
+
303
+ Returns:
304
+ Dict with 'conjugates', 'successful', 'failed', 'total_conjugates'
305
+ """
306
+ results = {
307
+ 'conjugates': [],
308
+ 'successful': [],
309
+ 'failed': [],
310
+ 'total_conjugates': 0
311
+ }
312
+
313
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
314
+ try:
315
+ conjugates = self.conjugate_to_peptide(
316
+ peptide_smiles,
317
+ exclude_n_terminus=exclude_n_terminus,
318
+ return_all=return_all
319
+ )
320
+ if return_all:
321
+ results['conjugates'].append(conjugates)
322
+ results['total_conjugates'] += len(conjugates)
323
+ else:
324
+ results['conjugates'].append(conjugates)
325
+ results['total_conjugates'] += 1
326
+ results['successful'].append(i)
327
+ except Exception as e:
328
+ print(f" Peptide {i} failed: {e}")
329
+ results['conjugates'].append(None)
330
+ results['failed'].append((i, str(e)))
331
+
332
+ print(f"\n=== Summary ===")
333
+ print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, "
334
+ f"Failed: {len(results['failed'])}")
335
+ print(f"Total conjugates generated: {results['total_conjugates']}")
336
+
337
+ return results
338
+
339
+ def flatten_batch_results(self, results):
340
+ """Flatten batch conjugation results into a single list"""
341
+ all_conjugates = []
342
+ for conjugate_list in results['conjugates']:
343
+ if conjugate_list is not None:
344
+ if isinstance(conjugate_list, list):
345
+ all_conjugates.extend(conjugate_list)
346
+ else:
347
+ all_conjugates.append(conjugate_list)
348
+ return all_conjugates
349
+
350
+ # ========================================================================
351
+ # Attachment Site Compatibility Checks (Cys disulfide)
352
+ # ========================================================================
353
+
354
+ def has_disulfide_bond(self, mol):
355
+ """Check if molecule has disulfide bond (S-S)"""
356
+ disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
357
+ return mol.HasSubstructMatch(disulfide_pattern)
358
+
359
+ def has_free_thiol(self, mol):
360
+ """Check if molecule has unconjugated (free) thiol groups"""
361
+ for atom in mol.GetAtoms():
362
+ if atom.GetAtomicNum() == 16:
363
+ if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
364
+ return True
365
+ return False
366
+
367
+ def has_ester_bond_to_drug(self, mol):
368
+ """Check for ester bond connecting linker to drug"""
369
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
370
+ return mol.HasSubstructMatch(ester_pattern)
371
+
372
+ def has_sn38_core(self, mol):
373
+ """Check for SN-38 core structure (camptothecin with 7-ethyl)"""
374
+ # Camptothecin-like core: fused ring system with lactone
375
+ cpt_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
376
+ return mol.HasSubstructMatch(cpt_core)
377
+
378
+ def check_attachment_site_compatibility(self, conjugate_smiles):
379
+ """
380
+ Check attachment site compatibility for Cys-SS-DSPA-SN38 conjugation.
381
+
382
+ Validates:
383
+ 1. Disulfide bond (S-S) is present
384
+ 2. No free thiol remaining
385
+ 3. Ester bond to drug is present
386
+
387
+ Returns:
388
+ Tuple of (passed: bool, details: dict)
389
+ """
390
+ mol = Chem.MolFromSmiles(conjugate_smiles)
391
+
392
+ if mol is None:
393
+ return False, {'parseable': False, 'error': 'Could not parse SMILES'}
394
+
395
+ details = {
396
+ 'parseable': True,
397
+ 'validation_method': 'cys_disulfide_ester',
398
+ 'linker': 'LIN00149 (3-Disulfanylpropanoic Acid)',
399
+ 'drug': 'SN-38 (7-Ethyl-10-hydroxycamptothecin)',
400
+ }
401
+
402
+ details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
403
+ details['no_free_thiol'] = not self.has_free_thiol(mol)
404
+ details['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
405
+
406
+ passed = all([
407
+ details['has_disulfide_bond'],
408
+ details['no_free_thiol'],
409
+ ])
410
+
411
+ return passed, details
412
+
413
+ def _basic_validation(self, mol):
414
+ """
415
+ Basic validation checks for DSPA-SN38 conjugates.
416
+
417
+ Checks:
418
+ 1. Disulfide bond present (Cys-Linker conjugation)
419
+ 2. No free thiol remaining
420
+ 3. Ester bond to drug present
421
+ 4. SN-38 core present
422
+ """
423
+ checks = {}
424
+
425
+ disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
426
+ checks['has_disulfide_bond'] = mol.HasSubstructMatch(disulfide_pattern)
427
+
428
+ checks['no_free_thiol'] = not self.has_free_thiol(mol)
429
+
430
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
431
+ checks['has_ester_bond'] = mol.HasSubstructMatch(ester_pattern)
432
+
433
+ checks['has_sn38_core'] = self.has_sn38_core(mol)
434
+
435
+ checks['_passed'] = all([
436
+ checks['has_disulfide_bond'],
437
+ checks['no_free_thiol'],
438
+ ])
439
+
440
+ return checks
441
+
442
+ def validate_conjugate(self, conjugate_smiles):
443
+ """
444
+ Validate that the conjugate is chemically reasonable.
445
+
446
+ Returns:
447
+ (passed: bool, checks: dict)
448
+ """
449
+ mol = Chem.MolFromSmiles(conjugate_smiles)
450
+
451
+ if mol is None:
452
+ return False, {'error': 'Could not parse SMILES'}
453
+
454
+ checks = self._basic_validation(mol)
455
+ checks['parseable'] = True
456
+
457
+ passed = checks.get('_passed', True)
458
+ if '_passed' in checks:
459
+ del checks['_passed']
460
+
461
+ return passed, checks
462
+
463
+
464
+ # ============================================================================
465
+ # Main - Test Examples
466
+ # ============================================================================
467
+
468
+ if __name__ == "__main__":
469
+
470
+ print("=" * 70)
471
+ print("DSPA-SN38 PDC Builder - Test")
472
+ print("Reference: NRP1 + LIN00149 + SN-38")
473
+ print("=" * 70 + "\n")
474
+
475
+ # Test peptides with free cysteine (NRP1-targeting sequences)
476
+ test_peptides = [
477
+ # Cys-Arg-Gly-Asp-Lys (CRGDK) - reference NRP1 peptide
478
+ "N[C@@H](CS)C(=O)N[C@@H](CCCNC(=N)N)C(=O)NCC(=O)N[C@@H](CC(=O)O)C(=O)N[C@@H](CCCCN)C(=O)O",
479
+ # Simple Gly-Cys-Gly
480
+ "NCC(=O)N[C@@H](CS)C(=O)NCC(=O)O",
481
+ ]
482
+
483
+ print("Test 1: Default mode (all positions: C-10 + C-20)")
484
+ print("-" * 50)
485
+
486
+ builder = DisulfanylpropanoicAcidSN38_PDC_Builder()
487
+
488
+ for i, peptide in enumerate(test_peptides):
489
+ print(f"\n--- Test peptide {i+1} ---")
490
+ print(f"Input: {peptide[:60]}...")
491
+
492
+ try:
493
+ conjugates = builder.conjugate_to_peptide(peptide)
494
+ print(f" Generated {len(conjugates)} conjugate(s)")
495
+
496
+ for j, conj in enumerate(conjugates):
497
+ valid, checks = builder.validate_conjugate(conj)
498
+ print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'} "
499
+ f"({len(conj)} chars)")
500
+
501
+ except Exception as e:
502
+ print(f" FAILED: {e}")
503
+ import traceback
504
+ traceback.print_exc()
505
+
506
+ print("\n\nTest 2: Position-10 only")
507
+ print("-" * 50)
508
+
509
+ builder_10 = DisulfanylpropanoicAcidSN38_PDC_Builder(drug_position='10')
510
+
511
+ try:
512
+ conjugates = builder_10.conjugate_to_peptide(test_peptides[1])
513
+ print(f" Generated {len(conjugates)} conjugate(s) [C-10 only]")
514
+ except Exception as e:
515
+ print(f" FAILED: {e}")
smiles/conjugation/DauFactory.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Daunorubicin PDC Factory - Drug-level builder with linker sampling
3
+
4
+ Instead of specifying a particular linker-drug combo (e.g. AoaDau, ValAlaDau),
5
+ the user specifies only the drug (Daunorubicin) and a linker is sampled
6
+ according to GNRHR+Dau entry frequencies from PDCdb.
7
+
8
+ Linker frequencies (GNRHR + Daunorubicin):
9
+ ===========================================
10
+ Aminooxyacetic acid (Aoa) (AoaDau) - 21 entries
11
+ Val-Ala-PABC + glutaryl spacer (ValAlaDau) - 2 entries
12
+ Val-Cit-PABC + glutaryl spacer (ValCitDau) - 2 entries
13
+ Glutaric acid (GlutarylDau) - 2 entries
14
+ Total: 27 entries
15
+
16
+ Sampling probabilities:
17
+ AoaDau: 21/27 ≈ 0.778
18
+ ValAlaDau: 2/27 ≈ 0.074
19
+ ValCitDau: 2/27 ≈ 0.074
20
+ GlutarylDau: 2/27 ≈ 0.074
21
+
22
+ Usage:
23
+ ======
24
+ # Drug-only mode - linker is sampled per peptide
25
+ factory = DaunorubicinPDC_Factory()
26
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
27
+
28
+ # Fixed linker mode - use a specific linker
29
+ factory = DaunorubicinPDC_Factory(linker='AoaDau')
30
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
31
+
32
+ # All linkers mode - generate conjugates with every linker
33
+ factory = DaunorubicinPDC_Factory(linker='all')
34
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
35
+ """
36
+
37
+ import numpy as np
38
+ import contextlib
39
+ import io
40
+
41
+ from .AoaDau import AoaDaunorubicin_PDC_Builder
42
+ from .ValAlaDau import ValAlaPABCDaunorubicin_PDC_Builder
43
+ from .ValCitDau import ValCitPABCDaunorubicin_PDC_Builder
44
+ from .GlutarylDau import GlutarylDaunorubicin_PDC_Builder
45
+
46
+
47
+ # ============================================================================
48
+ # Linker registry: name → (builder_class, PDCdb frequency, description)
49
+ # ============================================================================
50
+
51
+ GNRHR_DAU_LINKER_REGISTRY = {
52
+ 'AoaDau': {
53
+ 'class': AoaDaunorubicin_PDC_Builder,
54
+ 'frequency': 21,
55
+ 'linker_id': 'LIN00019',
56
+ 'description': 'Aminooxyacetic acid (Aoa) - oxime bond to Dau ketone',
57
+ 'pdc_refs': ['PDC_00011', 'PDC_00208'],
58
+ },
59
+ 'ValAlaDau': {
60
+ 'class': ValAlaPABCDaunorubicin_PDC_Builder,
61
+ 'frequency': 2,
62
+ 'linker_id': 'LIN00028',
63
+ 'description': 'Val-Ala dipeptide + PABC self-immolative + glutaryl spacer',
64
+ 'pdc_refs': ['PDC_00185', 'PDC_00193'],
65
+ },
66
+ 'ValCitDau': {
67
+ 'class': ValCitPABCDaunorubicin_PDC_Builder,
68
+ 'frequency': 2,
69
+ 'linker_id': 'LIN00005',
70
+ 'description': 'Val-Cit dipeptide + PABC self-immolative + glutaryl spacer',
71
+ 'pdc_refs': ['PDC_00189', 'PDC_00194'],
72
+ },
73
+ 'GlutarylDau': {
74
+ 'class': GlutarylDaunorubicin_PDC_Builder,
75
+ 'frequency': 2,
76
+ 'linker_id': 'LIN00033',
77
+ 'description': 'Glutaric acid - simple dicarboxylic acid spacer',
78
+ 'pdc_refs': ['PDC_00190', 'PDC_00195'],
79
+ },
80
+ }
81
+
82
+
83
+ def _compute_linker_probabilities(registry=None):
84
+ """Compute sampling probabilities from frequency counts."""
85
+ if registry is None:
86
+ registry = GNRHR_DAU_LINKER_REGISTRY
87
+ names = list(registry.keys())
88
+ freqs = np.array([registry[n]['frequency'] for n in names], dtype=np.float64)
89
+ probs = freqs / freqs.sum()
90
+ return names, probs
91
+
92
+
93
+ class DaunorubicinPDC_Factory:
94
+ """
95
+ Drug-level PDC factory for Daunorubicin conjugates.
96
+
97
+ Wraps all four GNRHR+Dau linker builders and provides:
98
+ - 'sample' mode (default): randomly pick a linker per call according to
99
+ PDCdb entry frequencies
100
+ - 'all' mode: generate conjugates with every linker
101
+ - fixed linker mode: always use a specific linker (e.g. 'AoaDau')
102
+
103
+ The interface mirrors the individual builder's `conjugate_to_peptide` so it
104
+ can be used as a drop-in replacement in the Conjugation classifier.
105
+ """
106
+
107
+ def __init__(self, linker='sample', rng_seed=None):
108
+ """
109
+ Args:
110
+ linker: One of:
111
+ - 'sample' (default): sample linker per peptide according to
112
+ GNRHR+Dau frequency weights
113
+ - 'all': use every linker and pool conjugates
114
+ - A specific linker name ('AoaDau', 'ValAlaDau', 'ValCitDau',
115
+ 'GlutarylDau')
116
+ rng_seed: Optional seed for reproducible linker sampling
117
+ """
118
+ self.linker_mode = linker
119
+ self.registry = GNRHR_DAU_LINKER_REGISTRY
120
+ self.rng = np.random.default_rng(rng_seed)
121
+
122
+ # Pre-compute probabilities
123
+ self._linker_names, self._linker_probs = _compute_linker_probabilities(self.registry)
124
+
125
+ # Validate fixed linker
126
+ if linker not in ('sample', 'all') and linker not in self.registry:
127
+ raise ValueError(
128
+ f"Unknown linker '{linker}'. "
129
+ f"Choose from: 'sample', 'all', {list(self.registry.keys())}"
130
+ )
131
+
132
+ # Pre-instantiate builders (suppress verbose init output)
133
+ self._builders = {}
134
+ for name, entry in self.registry.items():
135
+ with contextlib.redirect_stdout(io.StringIO()):
136
+ self._builders[name] = entry['class']()
137
+
138
+ # Track which linker was last sampled (useful for logging)
139
+ self.last_sampled_linker = None
140
+
141
+ # ------------------------------------------------------------------
142
+ # Linker selection
143
+ # ------------------------------------------------------------------
144
+
145
+ def sample_linker(self):
146
+ """Sample a linker name according to frequency weights."""
147
+ idx = self.rng.choice(len(self._linker_names), p=self._linker_probs)
148
+ return self._linker_names[idx]
149
+
150
+ def get_builder(self, linker_name):
151
+ """Return the pre-instantiated builder for a given linker name."""
152
+ return self._builders[linker_name]
153
+
154
+ @property
155
+ def linker_probabilities(self):
156
+ """Return dict of linker name → sampling probability."""
157
+ return dict(zip(self._linker_names, self._linker_probs.tolist()))
158
+
159
+ # ------------------------------------------------------------------
160
+ # Conjugation interface (mirrors individual builders)
161
+ # ------------------------------------------------------------------
162
+
163
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
164
+ """
165
+ Conjugate Daunorubicin to peptide, selecting linker by mode.
166
+
167
+ Args:
168
+ peptide_smiles: SMILES string of peptide (must contain Lys -NH2)
169
+ exclude_n_terminus: If True, only conjugate at Lys ε-amines
170
+ return_all: If True, return all positional conjugates
171
+
172
+ Returns:
173
+ List of conjugate SMILES.
174
+ In 'sample' mode: conjugates from the sampled linker.
175
+ In 'all' mode: conjugates from every linker (pooled).
176
+ In fixed mode: conjugates from the specified linker.
177
+ """
178
+ if self.linker_mode == 'all':
179
+ self.last_sampled_linker = 'all'
180
+ return self._conjugate_all_linkers(peptide_smiles, exclude_n_terminus, return_all)
181
+ elif self.linker_mode == 'sample':
182
+ chosen = self.sample_linker()
183
+ self.last_sampled_linker = chosen
184
+ return self._conjugate_with_linker(chosen, peptide_smiles, exclude_n_terminus, return_all)
185
+ else:
186
+ self.last_sampled_linker = self.linker_mode
187
+ return self._conjugate_with_linker(self.linker_mode, peptide_smiles, exclude_n_terminus, return_all)
188
+
189
+ def _conjugate_with_linker(self, linker_name, peptide_smiles, exclude_n_terminus, return_all):
190
+ """Run conjugation with a single, specific linker."""
191
+ builder = self._builders[linker_name]
192
+ with contextlib.redirect_stdout(io.StringIO()):
193
+ conjugates = builder.conjugate_to_peptide(
194
+ peptide_smiles,
195
+ exclude_n_terminus=exclude_n_terminus,
196
+ return_all=return_all,
197
+ )
198
+ # Normalize to list
199
+ if not isinstance(conjugates, list):
200
+ conjugates = [conjugates] if conjugates else []
201
+ return conjugates
202
+
203
+ def _conjugate_all_linkers(self, peptide_smiles, exclude_n_terminus, return_all):
204
+ """Generate conjugates with every linker and pool them."""
205
+ all_conjugates = []
206
+ seen = set()
207
+ for name in self._linker_names:
208
+ try:
209
+ conjs = self._conjugate_with_linker(name, peptide_smiles, exclude_n_terminus, return_all)
210
+ for c in conjs:
211
+ if c not in seen:
212
+ seen.add(c)
213
+ all_conjugates.append(c)
214
+ except Exception:
215
+ continue
216
+ if not all_conjugates:
217
+ raise ValueError("No conjugates generated with any linker")
218
+ return all_conjugates
219
+
220
+ # ------------------------------------------------------------------
221
+ # Validation (delegates to the appropriate builder)
222
+ # ------------------------------------------------------------------
223
+
224
+ def validate_conjugate(self, conjugate_smiles, linker_name=None):
225
+ """
226
+ Validate a conjugate SMILES.
227
+
228
+ If linker_name is given, validate with that builder.
229
+ Otherwise, try all builders and return the best result.
230
+ """
231
+ if linker_name and linker_name in self._builders:
232
+ return self._builders[linker_name].validate_conjugate(conjugate_smiles)
233
+
234
+ # Try each builder; return first passing result
235
+ for name, builder in self._builders.items():
236
+ passed, checks = builder.validate_conjugate(conjugate_smiles)
237
+ if passed:
238
+ checks['matched_linker'] = name
239
+ return True, checks
240
+
241
+ # None passed - return last failure
242
+ return False, checks
243
+
244
+ def check_attachment_site_compatibility(self, conjugate_smiles, linker_name=None):
245
+ """Delegate to appropriate builder's attachment check."""
246
+ if linker_name and linker_name in self._builders:
247
+ return self._builders[linker_name].check_attachment_site_compatibility(conjugate_smiles)
248
+
249
+ for name, builder in self._builders.items():
250
+ passed, details = builder.check_attachment_site_compatibility(conjugate_smiles)
251
+ if passed:
252
+ details['matched_linker'] = name
253
+ return True, details
254
+ return False, details
255
+
256
+ # ------------------------------------------------------------------
257
+ # Batch interface
258
+ # ------------------------------------------------------------------
259
+
260
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
261
+ """
262
+ Batch conjugate multiple peptides.
263
+
264
+ In 'sample' mode each peptide gets a freshly sampled linker.
265
+
266
+ Returns:
267
+ Dict with 'conjugates', 'successful', 'failed', 'total_conjugates',
268
+ and 'linkers_used' (list of linker names, one per peptide).
269
+ """
270
+ results = {
271
+ 'conjugates': [],
272
+ 'successful': [],
273
+ 'failed': [],
274
+ 'total_conjugates': 0,
275
+ 'linkers_used': [],
276
+ }
277
+
278
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
279
+ try:
280
+ conjugates = self.conjugate_to_peptide(
281
+ peptide_smiles,
282
+ exclude_n_terminus=exclude_n_terminus,
283
+ return_all=return_all,
284
+ )
285
+ if return_all:
286
+ results['conjugates'].append(conjugates)
287
+ results['total_conjugates'] += len(conjugates)
288
+ else:
289
+ results['conjugates'].append(conjugates)
290
+ results['total_conjugates'] += 1
291
+ results['successful'].append(i)
292
+ results['linkers_used'].append(self.last_sampled_linker)
293
+ except Exception as e:
294
+ results['conjugates'].append(None)
295
+ results['failed'].append((i, str(e)))
296
+ results['linkers_used'].append(None)
297
+
298
+ return results
smiles/conjugation/GlutarylDau.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Glutaryl-Daunorubicin PDC Builder
3
+
4
+ Conjugation chemistry: Lysine ε-amine + Glutaryl-Daunorubicin (amide bond formation)
5
+
6
+ Reference PDCs:
7
+ - PDC_00190 (GnRH-III-[2ΔHis-3D-Tic,8Lys(glutaryl-Val-Cit-PABC-diamine-PTX)])
8
+ - PDC_00195 (GnRH-III-[2His-3Trp,8Lys(glutaryl-Val-Cit-PABC-diamine-PTX)])
9
+ - Receptor: GNRHR
10
+ - Linker: Glutaric acid (LIN00033)
11
+ - Payload: Daunorubicin (DRG00010)
12
+
13
+ Architecture:
14
+ Peptide──Lys──NH──C(=O)──(CH₂)₃──C(=O)──NH──Daunorubicin
15
+ │ │
16
+ amide bond glutaryl spacer amide bond (pre-formed)
17
+
18
+ The glutaric acid acts as a simple dicarboxylic acid spacer:
19
+ - One COOH forms amide with Dau 3'-amine (pre-formed in linker-payload)
20
+ - Other COOH forms amide with Lys ε-amine (conjugation reaction)
21
+ """
22
+
23
+ from rdkit import Chem
24
+ from rdkit.Chem import RWMol, AllChem
25
+
26
+ from .pdc_builder import PDC_Builder
27
+
28
+
29
+ class GlutarylDaunorubicin_PDC_Builder(PDC_Builder):
30
+ """
31
+ PDC builder for Glutaric acid (Glutaryl) - Daunorubicin conjugates
32
+
33
+ Conjugation chemistry (amide bond formation):
34
+ - Peptide site: Lysine ε-amine (side chain -NH2)
35
+ - Linker-payload site: Carboxylic acid of glutaryl spacer (already conjugated
36
+ to Dau 3'-amine via amide)
37
+ - Reaction: Amide bond formation (Lys-NH2 + HOOC-glutaryl-Dau → Lys-NH-CO-glutaryl-Dau)
38
+
39
+ Architecture:
40
+ Peptide──Lys──NH──C(=O)──(CH₂)₃──C(=O)──NH──Daunorubicin
41
+ └── amide ──┘ └── glutaryl ──┘ └── amide (pre-formed) ──┘
42
+
43
+ Reference: PDC_00190, PDC_00195 (GnRH-III conjugates, GNRHR)
44
+ """
45
+
46
+ # Glutaryl-Daunorubicin SMILES with free carboxylic acid
47
+ # Structure: HOOC-(CH2)3-CO-NH-Daunorubicin
48
+ # The amide bond between glutaric acid and Dau 3'-amine is pre-formed
49
+ DEFAULT_GLUTARYL_DAU_SMILES = (
50
+ "C[C@H]1[C@H]([C@H](C[C@@H](O1)O[C@H]2C[C@@](CC3=C2C(=C4C(=C3O)"
51
+ "C(=O)C5=C(C4=O)C(=CC=C5)OC)O)(C(=O)C)O)NC(=O)CCCC(=O)O)O"
52
+ )
53
+
54
+ def __init__(self, glutaryl_dau_smiles=None):
55
+ """
56
+ Initialize Glutaryl-Daunorubicin builder
57
+
58
+ Args:
59
+ glutaryl_dau_smiles: Optional custom Glutaryl-Dau SMILES.
60
+ If None, uses default.
61
+ Must have free carboxylic acid for conjugation.
62
+ """
63
+ if glutaryl_dau_smiles is None:
64
+ glutaryl_dau_smiles = self.DEFAULT_GLUTARYL_DAU_SMILES
65
+
66
+ super().__init__(glutaryl_dau_smiles, component_name="Glutaryl-Daunorubicin (linker-payload)")
67
+
68
+ def _verify_reactive_group(self):
69
+ """Verify carboxylic acid group is present for amide bond formation"""
70
+ carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
71
+
72
+ if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
73
+ print(" Warning: No carboxylic acid group detected in Glutaryl-Dau")
74
+ else:
75
+ print(" Glutaryl-Daunorubicin loaded with carboxylic acid reactive group")
76
+
77
+ # Verify Dau 3'-amide bond is present (glutaryl-Dau linkage)
78
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH2]')
79
+ if self.linker_payload_mol.HasSubstructMatch(amide_pattern):
80
+ print(" Amide bond (Glutaryl-Dau linkage) detected")
81
+ else:
82
+ print(" Warning: No amide bond detected - check Glutaryl-Dau structure")
83
+
84
+ def find_conjugation_sites(self, peptide_smiles):
85
+ """
86
+ Find all lysine ε-amine groups in peptide
87
+
88
+ Returns list of dicts with site information including atom index
89
+ """
90
+ return self._find_peptide_lysines(peptide_smiles)
91
+
92
+ def _find_peptide_lysines(self, peptide_smiles):
93
+ """
94
+ Find all lysine residues with free ε-amine in peptide
95
+
96
+ Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
97
+
98
+ Returns:
99
+ List of dicts with 'atom_idx' (nitrogen index) and metadata
100
+ """
101
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
102
+ if peptide_mol is None:
103
+ raise ValueError("Invalid peptide SMILES")
104
+
105
+ lysines = []
106
+
107
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
108
+ matches = peptide_mol.GetSubstructMatches(lys_pattern)
109
+
110
+ for match in matches:
111
+ nitrogen_idx = match[4]
112
+ lysines.append({
113
+ 'atom_idx': nitrogen_idx,
114
+ 'side_chain_atoms': list(match),
115
+ 'type': 'Lys_epsilon_amine'
116
+ })
117
+
118
+ n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
119
+ n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
120
+ if n_term_matches:
121
+ print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
122
+
123
+ return lysines
124
+
125
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
126
+ """
127
+ Perform amide bond conjugation between NH2 groups and glutaryl carboxylic acid
128
+
129
+ Args:
130
+ peptide_mol: RDKit Mol object
131
+ site_info: Not used (kept for compatibility)
132
+ exclude_n_terminus: If True, skip N-terminus conjugation
133
+
134
+ Returns: List of all possible conjugate SMILES
135
+ """
136
+ return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
137
+
138
+ def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
139
+ """
140
+ Create ALL possible conjugates by forming amide bond at NH2 sites
141
+
142
+ Chemistry:
143
+ - Any-NH2 + HOOC-glutaryl-Dau → Any-NH-C(=O)-glutaryl-Dau + H2O
144
+ """
145
+ if exclude_n_terminus:
146
+ amide_rxn = AllChem.ReactionFromSmarts(
147
+ '[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>'
148
+ '[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
149
+ )
150
+ else:
151
+ amide_rxn = AllChem.ReactionFromSmarts(
152
+ '[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
153
+ )
154
+
155
+ products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
156
+
157
+ if not products:
158
+ raise ValueError("Amide bond formation reaction failed - no products generated")
159
+
160
+ conjugates = []
161
+ seen_smiles = set()
162
+
163
+ for product_tuple in products:
164
+ try:
165
+ conjugate_mol = product_tuple[0]
166
+ Chem.SanitizeMol(conjugate_mol)
167
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
168
+
169
+ if conjugate_smiles not in seen_smiles:
170
+ seen_smiles.add(conjugate_smiles)
171
+ conjugates.append(conjugate_smiles)
172
+ except Exception:
173
+ continue
174
+
175
+ if not conjugates:
176
+ raise ValueError("All products failed sanitization")
177
+
178
+ return conjugates
179
+
180
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
181
+ """
182
+ Conjugate Glutaryl-Daunorubicin to peptide via amide bond at NH2 groups
183
+
184
+ Args:
185
+ peptide_smiles: SMILES string of peptide (must contain NH2)
186
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
187
+ return_all: If True, return all possible conjugates
188
+
189
+ Returns:
190
+ If return_all=True: List of all possible conjugate SMILES
191
+ If return_all=False: Single conjugate SMILES (first one)
192
+ """
193
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
194
+ if peptide_mol is None:
195
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
196
+
197
+ sites = self.find_conjugation_sites(peptide_smiles)
198
+ print(f"Found {len(sites)} Lys ε-amine site(s)")
199
+
200
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
201
+
202
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
203
+ print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}]")
204
+
205
+ if return_all:
206
+ return conjugates
207
+ else:
208
+ return conjugates[0] if conjugates else None
209
+
210
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
211
+ """Batch conjugate multiple peptides"""
212
+ results = {
213
+ 'conjugates': [],
214
+ 'successful': [],
215
+ 'failed': [],
216
+ 'total_conjugates': 0
217
+ }
218
+
219
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
220
+ try:
221
+ conjugates = self.conjugate_to_peptide(
222
+ peptide_smiles,
223
+ exclude_n_terminus=exclude_n_terminus,
224
+ return_all=return_all
225
+ )
226
+ if return_all:
227
+ results['conjugates'].append(conjugates)
228
+ results['total_conjugates'] += len(conjugates)
229
+ else:
230
+ results['conjugates'].append(conjugates)
231
+ results['total_conjugates'] += 1
232
+ results['successful'].append(i)
233
+ except Exception as e:
234
+ print(f" Peptide {i} failed: {e}")
235
+ results['conjugates'].append(None)
236
+ results['failed'].append((i, str(e)))
237
+
238
+ return results
239
+
240
+ # =========================================================================
241
+ # Attachment Site Compatibility Checks
242
+ # =========================================================================
243
+
244
+ def has_glutaryl_amide_conjugation(self, mol):
245
+ """
246
+ Check if molecule has the glutaryl conjugation pattern
247
+
248
+ Pattern: N-C(=O)-(CH2)3-C(=O)-N (amide-glutaryl-amide linkage)
249
+ This is the signature of successful Lys-Glutaryl-Dau conjugation.
250
+ """
251
+ glutaryl_pattern = Chem.MolFromSmarts(
252
+ '[NX3][CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])[NX3]'
253
+ )
254
+ if glutaryl_pattern:
255
+ return mol.HasSubstructMatch(glutaryl_pattern)
256
+ return False
257
+
258
+ def has_dau_amide_bond(self, mol):
259
+ """
260
+ Check if molecule has the glutaryl-Dau amide bond intact
261
+
262
+ The pre-formed amide: -(CH2)3-C(=O)-NH-Dau
263
+ """
264
+ amide_pattern = Chem.MolFromSmarts('[CH2][CX3](=[OX1])[NX3]')
265
+ if amide_pattern:
266
+ return mol.HasSubstructMatch(amide_pattern)
267
+ return False
268
+
269
+ def has_unreacted_glutaryl_cooh(self, mol):
270
+ """
271
+ Check if molecule has unreacted glutaryl carboxylic acid
272
+
273
+ Specific pattern: Free COOH on the glutaryl chain ending
274
+ """
275
+ unreacted_pattern = Chem.MolFromSmarts(
276
+ '[CX3](=[OX1])[OX2H1][CH2][CH2][CH2][CX3](=[OX1])[NX3]'
277
+ )
278
+ if unreacted_pattern:
279
+ return mol.HasSubstructMatch(unreacted_pattern)
280
+ return False
281
+
282
+ def check_attachment_site_compatibility(self, mol):
283
+ """
284
+ Check if Lys-Glutaryl-Dau attachment site chemistry is valid
285
+
286
+ Validates:
287
+ 1. Has glutaryl bis-amide conjugation pattern
288
+ 2. Has Dau amide bond intact
289
+ 3. No unreacted glutaryl-COOH
290
+ """
291
+ if not self.has_glutaryl_amide_conjugation(mol):
292
+ return False
293
+
294
+ if not self.has_dau_amide_bond(mol):
295
+ return False
296
+
297
+ if self.has_unreacted_glutaryl_cooh(mol):
298
+ return False
299
+
300
+ return True
301
+
302
+ def _basic_validation(self, mol):
303
+ """Basic validation checks for Glutaryl-Daunorubicin conjugates"""
304
+ checks = {}
305
+
306
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
307
+ checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
308
+
309
+ checks['has_glutaryl_conjugation'] = self.has_glutaryl_amide_conjugation(mol)
310
+ checks['has_dau_amide'] = self.has_dau_amide_bond(mol)
311
+ checks['no_unreacted_cooh'] = not self.has_unreacted_glutaryl_cooh(mol)
312
+ checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
313
+
314
+ anthraquinone = Chem.MolFromSmarts(
315
+ '[#6]1[#6][#6][#6]2[#6](=[#8])[#6]3[#6][#6][#6][#6][#6]3[#6](=[#8])[#6]2[#6]1'
316
+ )
317
+ checks['has_daunorubicin_core'] = mol.HasSubstructMatch(anthraquinone)
318
+
319
+ aminosugar = Chem.MolFromSmarts('[OX2]1[#6][#6][#6]([NX3])[#6][#6]1')
320
+ checks['has_aminosugar'] = mol.HasSubstructMatch(aminosugar)
321
+
322
+ checks['_passed'] = checks['attachment_site_valid']
323
+
324
+ return checks
325
+
326
+ def validate_conjugate(self, conjugate_smiles):
327
+ """Validate that the conjugate is chemically reasonable"""
328
+ mol = Chem.MolFromSmiles(conjugate_smiles)
329
+
330
+ if mol is None:
331
+ return False, {'error': 'Could not parse SMILES'}
332
+
333
+ checks = self._basic_validation(mol)
334
+ checks['parseable'] = True
335
+
336
+ passed = checks.get('_passed', True)
337
+ if '_passed' in checks:
338
+ del checks['_passed']
339
+
340
+ return passed, checks
341
+
342
+
343
+ if __name__ == "__main__":
344
+ print("=" * 70)
345
+ print("Glutaryl-Daunorubicin PDC Builder - Test")
346
+ print("=" * 70 + "\n")
347
+
348
+ builder = GlutarylDaunorubicin_PDC_Builder()
349
+
350
+ test_peptides = [
351
+ "NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O", # Gly-Lys-Gly
352
+ ]
353
+
354
+ for pep in test_peptides:
355
+ try:
356
+ conjugates = builder.conjugate_to_peptide(pep, exclude_n_terminus=True)
357
+ print(f" {len(conjugates)} conjugate(s)")
358
+ for c in conjugates:
359
+ valid, checks = builder.validate_conjugate(c)
360
+ print(f" Valid: {valid}")
361
+ except Exception as e:
362
+ print(f" Failed: {e}")
smiles/conjugation/MalHexCpt.py ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Maleimide-Hexanoate-Camptothecin PDC Builder (MalHexCpt)
3
+
4
+ Conjugation chemistry: Lysine ε-amine + Cys-S-Linker-Camptothecin (amide bond formation)
5
+
6
+ Reference PDC:
7
+ - PDC_00336 (SMAC-P1 CPT conjugate 6)
8
+
9
+ Receptor: ERBB2 (Receptor tyrosine-protein kinase erbB-2)
10
+ Linker: LIN00031 - 6-(2,5-dioxo-3-sulfanylpyrrolidin-1-yl)hexanoic acid
11
+ Payload: DRG00009 - Camptothecin
12
+ Architecture: Monomer, Lys side chain conjugation
13
+
14
+ Chemistry Note:
15
+ ===============
16
+ LIN00031 is a maleimide-thiol adduct (succinimide-thioether) with a hexanoic acid spacer:
17
+
18
+ Succinimide ring Hexanoic spacer
19
+ ┌──────────────┐ ┌─────────────────────┐
20
+ O═C─CH₂─CH(SH)─C═O─N─CH₂─CH₂─CH₂─CH₂─CH₂─C(═O)─OH
21
+ └─────────────┘
22
+
23
+ Linker-to-Drug Connection (Ester via hexanoic acid):
24
+ Cys─S─C(succinimide)─N─(CH₂)₅─C(═O)─O─Camptothecin
25
+ │
26
+ ester bond (C-20 OH of CPT)
27
+
28
+ Full PDC Architecture:
29
+ Peptide──Lys──NH──C(═O)──Cys──S──[Succinimide]──N──(CH₂)₅──C(═O)─O──CPT
30
+ └── amide ──┘ └── thioether ──┘ └── hexanoic spacer ──┘
31
+
32
+ This is a NOVEL linker chemistry distinct from the simple disulfide-ester/carbonate
33
+ linkers in SSCpt. The maleimide reacts with Cys thiol via Michael addition to form
34
+ a thioether (C-S) bond. The succinimide ring provides structural rigidity and the
35
+ hexanoic spacer provides distance between the conjugation site and the drug.
36
+
37
+ Usage:
38
+ ======
39
+ builder = MaleimideHexanoateCpt_PDC_Builder()
40
+ conjugates = builder.conjugate_to_peptide(peptide_smiles)
41
+ """
42
+
43
+ from rdkit import Chem
44
+ from rdkit.Chem import AllChem, Descriptors
45
+
46
+ from .pdc_builder import PDC_Builder
47
+
48
+
49
+ # ============================================================================
50
+ # Maleimide-Hexanoate-Camptothecin PDC Builder
51
+ # ============================================================================
52
+
53
+ class MaleimideHexanoateCpt_PDC_Builder(PDC_Builder):
54
+ """
55
+ PDC builder for Maleimide-Hexanoate + Camptothecin conjugates
56
+
57
+ Conjugation chemistry (amide bond formation):
58
+ - Peptide site: Lysine ε-amine (side chain -NH2)
59
+ - Linker-payload site: Carboxylic acid of Cys (Cys-S-Linker-Drug)
60
+ - Reaction: Lys-NH2 + HOOC-Cys-S-Linker-Drug → Lys-NH-CO-Cys-S-Linker-Drug
61
+
62
+ Linker: LIN00031 - 6-(2,5-dioxo-3-sulfanylpyrrolidin-1-yl)hexanoic acid
63
+ SMILES: C1C(C(=O)N(C1=O)CCCCCC(=O)O)S
64
+ IUPAC: 6-(2,5-dioxo-3-sulfanylpyrrolidin-1-yl)hexanoic acid
65
+ MW: 245.3, MF: C10H15NO4S
66
+ Features: Succinimide ring (maleimide-thiol adduct) + hexanoic acid spacer
67
+
68
+ Drug: DRG00009 - Camptothecin
69
+ Canonical SMILES: CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1
70
+ Connection: Ester at C-20 hydroxyl
71
+
72
+ Reference: PDC_00336 (SMAC-P1 CPT conjugate 6, ERBB2)
73
+ """
74
+
75
+ # ========================================================================
76
+ # Cys-S-Linker-Drug SMILES (thioether via Michael addition)
77
+ # ========================================================================
78
+ #
79
+ # Pre-formed Cys-MaleimideHexanoate-Camptothecin assembly:
80
+ #
81
+ # HOOC─Cys(NH₂)─CH₂─S─C(succinimide)─N─(CH₂)₅─C(═O)─O─CPT(C-20)
82
+ #
83
+ # Construction from components:
84
+ # Cys: N[C@H](CS...)C(=O)O
85
+ # S-C: ...S... (thioether, Michael addition of Cys-SH to maleimide)
86
+ # LIN00031 ring: ...C4CC(=O)N(...)C4=O (succinimide from maleimide-thiol adduct)
87
+ # Spacer: N(CCCCCC(=O)O...) (hexanoic acid, COOH → ester to CPT)
88
+ # CPT: ...OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1
89
+ #
90
+ # ========================================================================
91
+
92
+ CYS_S_LINKER_CPT_SMILES = (
93
+ "N[C@H](CSC4CC(=O)N(CCCCCC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C4=O)C(=O)O"
94
+ )
95
+
96
+ def __init__(self, cys_linker_drug_smiles=None):
97
+ """
98
+ Initialize Maleimide-Hexanoate-Camptothecin builder
99
+
100
+ Args:
101
+ cys_linker_drug_smiles: Optional custom Cys-S-Linker-Drug SMILES.
102
+ If None, uses default LIN00031+CPT assembly.
103
+ Must have free carboxylic acid for conjugation.
104
+ """
105
+ if cys_linker_drug_smiles is None:
106
+ cys_linker_drug_smiles = self.CYS_S_LINKER_CPT_SMILES
107
+
108
+ super().__init__(
109
+ cys_linker_drug_smiles,
110
+ component_name="Cys-S-MaleimideHexanoate-Camptothecin (LIN00031+DRG00009)"
111
+ )
112
+
113
+ def _verify_reactive_group(self):
114
+ """Verify carboxylic acid group is present for amide bond formation"""
115
+ # Pattern: Carboxylic acid (COOH)
116
+ carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
117
+
118
+ if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
119
+ print(" Warning: No carboxylic acid group detected in Cys-S-Linker-Drug")
120
+ else:
121
+ print(" Cys-S-MalHex-Camptothecin loaded with carboxylic acid reactive group")
122
+
123
+ # Verify thioether bond is present (Cys-S-C, from Michael addition)
124
+ thioether_pattern = Chem.MolFromSmarts('[CH2][SX2][CH]')
125
+ if self.linker_payload_mol.HasSubstructMatch(thioether_pattern):
126
+ print(" Thioether bond (C-S-C) detected (maleimide-thiol Michael addition)")
127
+ else:
128
+ print(" Warning: No thioether bond detected - check structure")
129
+
130
+ # Verify Cys α-amine is present
131
+ cys_amine_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2])C(=O)')
132
+ if self.linker_payload_mol.HasSubstructMatch(cys_amine_pattern):
133
+ print(" Cys α-amine detected (will be free in conjugate)")
134
+ else:
135
+ print(" Warning: Cys α-amine pattern not detected")
136
+
137
+ # Verify succinimide ring (maleimide-thiol adduct)
138
+ succinimide_pattern = Chem.MolFromSmarts('[CH2]C(=O)N(*)C(=O)')
139
+ if self.linker_payload_mol.HasSubstructMatch(succinimide_pattern):
140
+ print(" Succinimide ring (maleimide-thiol adduct) detected")
141
+ else:
142
+ print(" Warning: No succinimide ring detected - check linker structure")
143
+
144
+ # Verify ester bond to drug
145
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
146
+ if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
147
+ print(" Ester bond (linker-to-drug) detected")
148
+ else:
149
+ print(" Warning: No ester bond detected - check linker-drug connection")
150
+
151
+ def find_conjugation_sites(self, peptide_smiles):
152
+ """
153
+ Find all lysine ε-amine groups in peptide
154
+
155
+ Returns list of dicts with site information including atom index
156
+ """
157
+ return self.find_peptide_lysines(peptide_smiles)
158
+
159
+ def find_peptide_lysines(self, peptide_smiles):
160
+ """
161
+ Find all lysine residues with free ε-amine in peptide
162
+
163
+ Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
164
+
165
+ Returns:
166
+ List of dicts with 'atom_idx' (nitrogen index) and metadata
167
+ """
168
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
169
+ if peptide_mol is None:
170
+ raise ValueError("Invalid peptide SMILES")
171
+
172
+ lysines = []
173
+
174
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
175
+ matches = peptide_mol.GetSubstructMatches(lys_pattern)
176
+
177
+ for match in matches:
178
+ nitrogen_idx = match[4]
179
+ lysines.append({
180
+ 'atom_idx': nitrogen_idx,
181
+ 'side_chain_atoms': list(match),
182
+ 'type': 'Lys_epsilon_amine'
183
+ })
184
+
185
+ n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
186
+ n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
187
+
188
+ if n_term_matches:
189
+ print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
190
+
191
+ return lysines
192
+
193
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
194
+ """
195
+ Perform amide bond conjugation between NH2 groups and Cys carboxylic acid
196
+
197
+ Args:
198
+ peptide_mol: RDKit Mol object
199
+ site_info: Not used (kept for compatibility)
200
+ exclude_n_terminus: If True, skip N-terminus conjugation
201
+
202
+ Returns: List of all possible conjugate SMILES
203
+ """
204
+ if exclude_n_terminus:
205
+ amide_rxn = AllChem.ReactionFromSmarts(
206
+ '[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
207
+ )
208
+ else:
209
+ amide_rxn = AllChem.ReactionFromSmarts(
210
+ '[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
211
+ )
212
+
213
+ products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
214
+
215
+ if not products:
216
+ raise ValueError("Amide bond formation reaction failed - no products generated")
217
+
218
+ conjugates = []
219
+ seen_smiles = set()
220
+
221
+ for product_tuple in products:
222
+ try:
223
+ conjugate_mol = product_tuple[0]
224
+ Chem.SanitizeMol(conjugate_mol)
225
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
226
+
227
+ if conjugate_smiles not in seen_smiles:
228
+ seen_smiles.add(conjugate_smiles)
229
+ conjugates.append(conjugate_smiles)
230
+ except Exception:
231
+ continue
232
+
233
+ if not conjugates:
234
+ raise ValueError("All products failed sanitization")
235
+
236
+ return conjugates
237
+
238
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
239
+ """
240
+ Conjugate Cys-S-MalHex-Camptothecin to peptide via amide bond at NH2 groups
241
+
242
+ Args:
243
+ peptide_smiles: SMILES string of peptide (must contain Lys with NH2)
244
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
245
+ return_all: If True, return all possible conjugates
246
+
247
+ Returns:
248
+ List of all possible conjugate SMILES
249
+ """
250
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
251
+ if peptide_mol is None:
252
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
253
+
254
+ sites = self.find_conjugation_sites(peptide_smiles)
255
+ print(f"Found {len(sites)} Lys ε-amine site(s)")
256
+
257
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
258
+
259
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
260
+ print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}] [maleimide-hexanoate ester]")
261
+
262
+ if return_all:
263
+ return conjugates
264
+ else:
265
+ return conjugates[0] if conjugates else None
266
+
267
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
268
+ """
269
+ Batch conjugate multiple peptides
270
+
271
+ Args:
272
+ peptide_smiles_list: List of peptide SMILES strings
273
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
274
+ return_all: If True, return all possible conjugates per peptide
275
+
276
+ Returns:
277
+ Dict with 'conjugates', 'successful', 'failed', 'total_conjugates'
278
+ """
279
+ results = {
280
+ 'conjugates': [],
281
+ 'successful': [],
282
+ 'failed': [],
283
+ 'total_conjugates': 0
284
+ }
285
+
286
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
287
+ try:
288
+ conjugates = self.conjugate_to_peptide(
289
+ peptide_smiles,
290
+ exclude_n_terminus=exclude_n_terminus,
291
+ return_all=return_all
292
+ )
293
+ if return_all:
294
+ results['conjugates'].append(conjugates)
295
+ results['total_conjugates'] += len(conjugates)
296
+ else:
297
+ results['conjugates'].append(conjugates)
298
+ results['total_conjugates'] += 1
299
+ results['successful'].append(i)
300
+ except Exception as e:
301
+ print(f" Peptide {i} failed: {e}")
302
+ results['conjugates'].append(None)
303
+ results['failed'].append((i, str(e)))
304
+
305
+ print(f"\n=== Summary ===")
306
+ print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
307
+ print(f"Total conjugates generated: {results['total_conjugates']}")
308
+
309
+ return results
310
+
311
+ def flatten_batch_results(self, results):
312
+ """Flatten batch conjugation results into a single list"""
313
+ all_conjugates = []
314
+ for conjugate_list in results['conjugates']:
315
+ if conjugate_list is not None:
316
+ if isinstance(conjugate_list, list):
317
+ all_conjugates.extend(conjugate_list)
318
+ else:
319
+ all_conjugates.append(conjugate_list)
320
+ return all_conjugates
321
+
322
+ # ========================================================================
323
+ # Attachment Site Compatibility Checks
324
+ # ========================================================================
325
+
326
+ def has_lys_cys_amide_conjugation(self, mol):
327
+ """Check if molecule has Lys-Cys amide conjugation bond"""
328
+ lys_cys_amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2])[NX3]')
329
+ return mol.HasSubstructMatch(lys_cys_amide_pattern)
330
+
331
+ def has_thioether_bond(self, mol):
332
+ """Check if molecule has thioether bond (C-S-C, from maleimide-thiol Michael addition)"""
333
+ thioether_pattern = Chem.MolFromSmarts('[CH2][SX2][CH]')
334
+ return mol.HasSubstructMatch(thioether_pattern)
335
+
336
+ def has_succinimide_ring(self, mol):
337
+ """
338
+ Check if molecule has succinimide ring (maleimide-thiol adduct)
339
+
340
+ The succinimide ring is the product of maleimide-thiol Michael addition.
341
+ Pattern: 5-membered ring with N and two C=O groups.
342
+ """
343
+ succinimide_pattern = Chem.MolFromSmarts('C1CC(=O)N(*)C1=O')
344
+ return mol.HasSubstructMatch(succinimide_pattern)
345
+
346
+ def has_hexanoic_spacer(self, mol):
347
+ """
348
+ Check if molecule has hexanoic acid spacer chain
349
+
350
+ Pattern: N-CH2-CH2-CH2-CH2-CH2-C(=O)-O (6-carbon chain from succinimide N)
351
+ """
352
+ hexanoic_pattern = Chem.MolFromSmarts('[NX3]CCCCCC(=O)[OX2]')
353
+ return mol.HasSubstructMatch(hexanoic_pattern)
354
+
355
+ def has_unreacted_lys_epsilon_amine(self, mol):
356
+ """Check for unreacted Lys ε-amine (free NH2 on 4-carbon side chain)"""
357
+ lys_amine_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
358
+ return mol.HasSubstructMatch(lys_amine_pattern)
359
+
360
+ def has_unreacted_cys_carboxylic_acid(self, mol):
361
+ """Check for unreacted Cys carboxylic acid from Cys-S-Linker-Drug"""
362
+ cys_cooh_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2])[CX3](=[OX1])[OX2H1]')
363
+ return mol.HasSubstructMatch(cys_cooh_pattern)
364
+
365
+ def find_amide_conjugation_sites(self, mol):
366
+ """Find all Lys-Cys amide conjugation sites in the molecule"""
367
+ lys_cys_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2])[NX3]')
368
+ matches = mol.GetSubstructMatches(lys_cys_pattern)
369
+
370
+ sites = []
371
+ for match in matches:
372
+ nitrogen_idx = match[0]
373
+ carbonyl_c_idx = match[1]
374
+ cys_alpha_idx = match[3]
375
+ sites.append((nitrogen_idx, carbonyl_c_idx, cys_alpha_idx))
376
+
377
+ return sites
378
+
379
+ def check_valency_at_conjugation_site(self, mol, site_indices):
380
+ """Check that valency is correct at Lys-Cys conjugation site"""
381
+ nitrogen_idx, carbonyl_c_idx, cys_alpha_idx = site_indices
382
+
383
+ try:
384
+ n_atom = mol.GetAtomWithIdx(nitrogen_idx)
385
+ if n_atom.GetTotalDegree() != 3:
386
+ return False
387
+
388
+ c_atom = mol.GetAtomWithIdx(carbonyl_c_idx)
389
+ if c_atom.GetTotalDegree() != 3:
390
+ return False
391
+
392
+ cys_c_atom = mol.GetAtomWithIdx(cys_alpha_idx)
393
+ if cys_c_atom.GetTotalDegree() != 4:
394
+ return False
395
+
396
+ return True
397
+ except Exception:
398
+ return False
399
+
400
+ def check_attachment_site_compatibility(self, conjugate_smiles):
401
+ """
402
+ Check attachment site compatibility for Lys-Cys-S-MalHex-CPT conjugation
403
+
404
+ Validates:
405
+ 1. Lys-Cys amide conjugation is present
406
+ 2. Thioether bond (C-S-C) is intact (maleimide-thiol Michael addition)
407
+ 3. Succinimide ring is present (maleimide-thiol adduct)
408
+ 4. Hexanoic spacer is present
409
+ 5. Valency at conjugation sites is correct
410
+ 6. No unreacted Cys-COOH (unconjugated linker-drug)
411
+
412
+ Returns:
413
+ Tuple of (passed: bool, details: dict)
414
+ """
415
+ mol = Chem.MolFromSmiles(conjugate_smiles)
416
+
417
+ if mol is None:
418
+ return False, {'parseable': False, 'error': 'Could not parse SMILES'}
419
+
420
+ details = {
421
+ 'parseable': True,
422
+ 'validation_method': 'lys_cys_amide_thioether_maleimide_hexanoate_ester',
423
+ 'linker': 'LIN00031 (6-(2,5-dioxo-3-sulfanylpyrrolidin-1-yl)hexanoic acid)',
424
+ 'drug': 'DRG00009 (Camptothecin)',
425
+ 'pdc_ref': 'PDC_00336'
426
+ }
427
+
428
+ details['has_lys_cys_amide'] = self.has_lys_cys_amide_conjugation(mol)
429
+ details['has_thioether_bond'] = self.has_thioether_bond(mol)
430
+ details['has_succinimide_ring'] = self.has_succinimide_ring(mol)
431
+ details['has_hexanoic_spacer'] = self.has_hexanoic_spacer(mol)
432
+
433
+ conjugation_sites = self.find_amide_conjugation_sites(mol)
434
+ details['conjugation_sites'] = len(conjugation_sites)
435
+
436
+ valency_ok = True
437
+ if conjugation_sites:
438
+ for site in conjugation_sites:
439
+ if not self.check_valency_at_conjugation_site(mol, site):
440
+ valency_ok = False
441
+ break
442
+ details['valency_correct'] = valency_ok
443
+
444
+ details['no_unreacted_cys_cooh'] = not self.has_unreacted_cys_carboxylic_acid(mol)
445
+ details['has_unreacted_lys_amine'] = self.has_unreacted_lys_epsilon_amine(mol)
446
+
447
+ passed = all([
448
+ details['has_lys_cys_amide'],
449
+ details['has_thioether_bond'],
450
+ details['has_succinimide_ring'],
451
+ details['valency_correct'],
452
+ details['no_unreacted_cys_cooh']
453
+ ])
454
+
455
+ return passed, details
456
+
457
+ def _basic_validation(self, mol):
458
+ """
459
+ Basic validation checks for MaleimideHexanoate-Camptothecin conjugates
460
+
461
+ Checks:
462
+ 1. Amide bond present (Lys-Cys conjugation)
463
+ 2. Thioether bond present (C-S-C, maleimide-thiol Michael addition)
464
+ 3. Specific Lys-Cys-S conjugation pattern
465
+ 4. Succinimide ring present (maleimide-thiol adduct)
466
+ 5. Camptothecin core present
467
+ 6. Ester bond to drug present
468
+ """
469
+ checks = {}
470
+
471
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
472
+ checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
473
+
474
+ thioether_pattern = Chem.MolFromSmarts('[CH2][SX2][CH]')
475
+ checks['has_thioether_bond'] = mol.HasSubstructMatch(thioether_pattern)
476
+
477
+ lys_cys_s_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2])[NX3]')
478
+ checks['has_lys_cys_s_conjugation'] = mol.HasSubstructMatch(lys_cys_s_pattern)
479
+
480
+ succinimide_pattern = Chem.MolFromSmarts('C1CC(=O)N(*)C1=O')
481
+ checks['has_succinimide_ring'] = mol.HasSubstructMatch(succinimide_pattern)
482
+
483
+ camptothecin_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
484
+ checks['has_camptothecin_core'] = mol.HasSubstructMatch(camptothecin_core)
485
+
486
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
487
+ checks['has_ester_bond'] = mol.HasSubstructMatch(ester_pattern)
488
+
489
+ checks['_passed'] = all([
490
+ checks['has_amide_bond'],
491
+ checks['has_thioether_bond'],
492
+ checks['has_lys_cys_s_conjugation'],
493
+ checks['has_succinimide_ring']
494
+ ])
495
+
496
+ return checks
497
+
498
+ def validate_conjugate(self, conjugate_smiles):
499
+ """
500
+ Validate that the conjugate is chemically reasonable
501
+
502
+ Returns:
503
+ (passed: bool, checks: dict)
504
+ """
505
+ mol = Chem.MolFromSmiles(conjugate_smiles)
506
+
507
+ if mol is None:
508
+ return False, {'error': 'Could not parse SMILES'}
509
+
510
+ checks = self._basic_validation(mol)
511
+ checks['parseable'] = True
512
+
513
+ passed = checks.get('_passed', True)
514
+ if '_passed' in checks:
515
+ del checks['_passed']
516
+
517
+ return passed, checks
518
+
519
+
520
+ # ============================================================================
521
+ # Main - Test Examples
522
+ # ============================================================================
523
+
524
+ if __name__ == "__main__":
525
+
526
+ print("=" * 70)
527
+ print("Maleimide-Hexanoate-Camptothecin PDC Builder - Test")
528
+ print("Reference: PDC_00336 (SMAC-P1 CPT conjugate 6, LIN00031)")
529
+ print("=" * 70 + "\n")
530
+
531
+ # Test peptides
532
+ test_peptides = [
533
+ # Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine)
534
+ "NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
535
+ # Lys-Arg
536
+ "N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
537
+ ]
538
+
539
+ builder = MaleimideHexanoateCpt_PDC_Builder()
540
+
541
+ for i, peptide in enumerate(test_peptides):
542
+ print(f"\n--- Test peptide {i+1} ---")
543
+ print(f"Input: {peptide}")
544
+
545
+ try:
546
+ conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
547
+ print(f" Generated {len(conjugates)} conjugate(s)")
548
+
549
+ for j, conj in enumerate(conjugates):
550
+ valid, checks = builder.validate_conjugate(conj)
551
+ print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'} ({len(conj)} chars)")
552
+ if not valid:
553
+ for k, v in checks.items():
554
+ if v is False:
555
+ print(f" {k}")
556
+
557
+ except Exception as e:
558
+ print(f" FAILED: {e}")
559
+ import traceback
560
+ traceback.print_exc()
smiles/conjugation/SN38Factory.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SN-38 PDC Factory - Drug-level builder with linker sampling
3
+
4
+ Instead of specifying a particular linker-drug combo (e.g. DSPASN38, DSEBSN38),
5
+ the user specifies only the drug (SN-38) and a linker is sampled
6
+ according to NRP1+SN38 entry frequencies from PDCdb.
7
+
8
+ Linker frequencies (NRP1 + SN-38):
9
+ ===========================================
10
+ 3-Disulfanylpropanoic acid (DSPASN38) - 9 entries
11
+ 4-(2-Disulfanylethoxy)-4-oxobutanoic acid (DSEBSN38) - 6 entries
12
+ 5-(2-Disulfanylethoxy)-5-oxopentanoic acid (DSEPSN38) - 2 entries
13
+ Total: 17 entries
14
+
15
+ Sampling probabilities:
16
+ DSPASN38: 9/17 ≈ 0.529
17
+ DSEBSN38: 6/17 ≈ 0.353
18
+ DSEPSN38: 2/17 ≈ 0.118
19
+
20
+ Usage:
21
+ ======
22
+ # Drug-only mode - linker is sampled per peptide
23
+ factory = SN38PDC_Factory()
24
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
25
+
26
+ # Fixed linker mode - use a specific linker
27
+ factory = SN38PDC_Factory(linker='DSPASN38')
28
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
29
+
30
+ # All linkers mode - generate conjugates with every linker
31
+ factory = SN38PDC_Factory(linker='all')
32
+ conjugates = factory.conjugate_to_peptide(peptide_smiles)
33
+ """
34
+
35
+ import numpy as np
36
+ import contextlib
37
+ import io
38
+
39
+ from .DSPASN38 import DisulfanylpropanoicAcidSN38_PDC_Builder
40
+ from .DSEBSN38 import DisulfanylethOxyButanoicAcidSN38_PDC_Builder
41
+ from .DSEPSN38 import DisulfanylethOxyPentanoicAcidSN38_PDC_Builder
42
+
43
+
44
+ # ============================================================================
45
+ # Linker registry: name → (builder_class, PDCdb frequency, description)
46
+ # ============================================================================
47
+
48
+ NRP1_SN38_LINKER_REGISTRY = {
49
+ 'DSPASN38': {
50
+ 'class': DisulfanylpropanoicAcidSN38_PDC_Builder,
51
+ 'frequency': 9,
52
+ 'linker_id': 'LIN00149',
53
+ 'description': '3-Disulfanylpropanoic acid (disulfide + ester)',
54
+ 'pdc_refs': ['PDC_02099', 'PDC_02104', 'PDC_02102', 'PDC_02107',
55
+ 'PDC_02110', 'PDC_02112', 'PDC_02113', 'PDC_02114', 'PDC_02115'],
56
+ },
57
+ 'DSEBSN38': {
58
+ 'class': DisulfanylethOxyButanoicAcidSN38_PDC_Builder,
59
+ 'frequency': 6,
60
+ 'linker_id': 'LIN00168',
61
+ 'description': '4-(2-Disulfanylethoxy)-4-oxobutanoic acid (succinic spacer)',
62
+ 'pdc_refs': ['PDC_02100', 'PDC_02103', 'PDC_02105',
63
+ 'PDC_02108', 'PDC_02109', 'PDC_02111'],
64
+ },
65
+ 'DSEPSN38': {
66
+ 'class': DisulfanylethOxyPentanoicAcidSN38_PDC_Builder,
67
+ 'frequency': 2,
68
+ 'linker_id': 'LIN00169',
69
+ 'description': '5-(2-Disulfanylethoxy)-5-oxopentanoic acid (glutaric spacer)',
70
+ 'pdc_refs': ['PDC_02101', 'PDC_02106'],
71
+ },
72
+ }
73
+
74
+
75
+ def _compute_linker_probabilities(registry=None):
76
+ """Compute sampling probabilities from frequency counts."""
77
+ if registry is None:
78
+ registry = NRP1_SN38_LINKER_REGISTRY
79
+ names = list(registry.keys())
80
+ freqs = np.array([registry[n]['frequency'] for n in names], dtype=np.float64)
81
+ probs = freqs / freqs.sum()
82
+ return names, probs
83
+
84
+
85
+ class SN38PDC_Factory:
86
+ """
87
+ Drug-level PDC factory for SN-38 (7-Ethyl-10-hydroxycamptothecin) conjugates.
88
+
89
+ Wraps all three NRP1+SN38 linker builders and provides:
90
+ - 'sample' mode (default): randomly pick a linker per call according to
91
+ PDCdb entry frequencies
92
+ - 'all' mode: generate conjugates with every linker
93
+ - fixed linker mode: always use a specific linker (e.g. 'DSPASN38')
94
+
95
+ Conjugation chemistry: Cysteine thiol + Linker thiol → Disulfide bond
96
+ Target receptor: NRP1 (Neuropilin-1)
97
+
98
+ The interface mirrors the individual builder's `conjugate_to_peptide` so it
99
+ can be used as a drop-in replacement in the Conjugation classifier.
100
+ """
101
+
102
+ def __init__(self, linker='sample', rng_seed=None):
103
+ """
104
+ Args:
105
+ linker: One of:
106
+ - 'sample' (default): sample linker per peptide according to
107
+ NRP1+SN38 frequency weights
108
+ - 'all': use every linker and pool conjugates
109
+ - A specific linker name ('DSPASN38', 'DSEBSN38', 'DSEPSN38')
110
+ rng_seed: Optional seed for reproducible linker sampling
111
+ """
112
+ self.linker_mode = linker
113
+ self.registry = NRP1_SN38_LINKER_REGISTRY
114
+ self.rng = np.random.default_rng(rng_seed)
115
+
116
+ # Pre-compute probabilities
117
+ self._linker_names, self._linker_probs = _compute_linker_probabilities(self.registry)
118
+
119
+ # Validate fixed linker
120
+ if linker not in ('sample', 'all') and linker not in self.registry:
121
+ raise ValueError(
122
+ f"Unknown linker '{linker}'. "
123
+ f"Choose from: 'sample', 'all', {list(self.registry.keys())}"
124
+ )
125
+
126
+ # Pre-instantiate builders (suppress verbose init output)
127
+ self._builders = {}
128
+ for name, entry in self.registry.items():
129
+ with contextlib.redirect_stdout(io.StringIO()):
130
+ self._builders[name] = entry['class']()
131
+
132
+ # Track which linker was last sampled (useful for logging)
133
+ self.last_sampled_linker = None
134
+
135
+ # ------------------------------------------------------------------
136
+ # Linker selection
137
+ # ------------------------------------------------------------------
138
+
139
+ def sample_linker(self):
140
+ """Sample a linker name according to frequency weights."""
141
+ idx = self.rng.choice(len(self._linker_names), p=self._linker_probs)
142
+ return self._linker_names[idx]
143
+
144
+ def get_builder(self, linker_name):
145
+ """Return the pre-instantiated builder for a given linker name."""
146
+ return self._builders[linker_name]
147
+
148
+ @property
149
+ def linker_probabilities(self):
150
+ """Return dict of linker name → sampling probability."""
151
+ return dict(zip(self._linker_names, self._linker_probs.tolist()))
152
+
153
+ # ------------------------------------------------------------------
154
+ # Conjugation interface (mirrors individual builders)
155
+ # ------------------------------------------------------------------
156
+
157
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
158
+ """
159
+ Conjugate SN-38 to peptide, selecting linker by mode.
160
+
161
+ Args:
162
+ peptide_smiles: SMILES string of peptide (must contain Cys thiol)
163
+ exclude_n_terminus: Not used for Cys conjugation (kept for interface)
164
+ return_all: If True, return all positional conjugates
165
+
166
+ Returns:
167
+ List of conjugate SMILES.
168
+ In 'sample' mode: conjugates from the sampled linker.
169
+ In 'all' mode: conjugates from every linker (pooled).
170
+ In fixed mode: conjugates from the specified linker.
171
+ """
172
+ if self.linker_mode == 'all':
173
+ self.last_sampled_linker = 'all'
174
+ return self._conjugate_all_linkers(peptide_smiles, exclude_n_terminus, return_all)
175
+ elif self.linker_mode == 'sample':
176
+ chosen = self.sample_linker()
177
+ self.last_sampled_linker = chosen
178
+ return self._conjugate_with_linker(chosen, peptide_smiles, exclude_n_terminus, return_all)
179
+ else:
180
+ self.last_sampled_linker = self.linker_mode
181
+ return self._conjugate_with_linker(self.linker_mode, peptide_smiles, exclude_n_terminus, return_all)
182
+
183
+ def _conjugate_with_linker(self, linker_name, peptide_smiles, exclude_n_terminus, return_all):
184
+ """Run conjugation with a single, specific linker."""
185
+ builder = self._builders[linker_name]
186
+ with contextlib.redirect_stdout(io.StringIO()):
187
+ conjugates = builder.conjugate_to_peptide(
188
+ peptide_smiles,
189
+ exclude_n_terminus=exclude_n_terminus,
190
+ return_all=return_all,
191
+ )
192
+ # Normalize to list
193
+ if not isinstance(conjugates, list):
194
+ conjugates = [conjugates] if conjugates else []
195
+ return conjugates
196
+
197
+ def _conjugate_all_linkers(self, peptide_smiles, exclude_n_terminus, return_all):
198
+ """Generate conjugates with every linker and pool them."""
199
+ all_conjugates = []
200
+ seen = set()
201
+ for name in self._linker_names:
202
+ try:
203
+ conjs = self._conjugate_with_linker(name, peptide_smiles, exclude_n_terminus, return_all)
204
+ for c in conjs:
205
+ if c not in seen:
206
+ seen.add(c)
207
+ all_conjugates.append(c)
208
+ except Exception:
209
+ continue
210
+ if not all_conjugates:
211
+ raise ValueError("No conjugates generated with any linker")
212
+ return all_conjugates
213
+
214
+ # ------------------------------------------------------------------
215
+ # Validation (delegates to the appropriate builder)
216
+ # ------------------------------------------------------------------
217
+
218
+ def validate_conjugate(self, conjugate_smiles, linker_name=None):
219
+ """
220
+ Validate a conjugate SMILES.
221
+
222
+ If linker_name is given, validate with that builder.
223
+ Otherwise, try all builders and return the best result.
224
+ """
225
+ if linker_name and linker_name in self._builders:
226
+ return self._builders[linker_name].validate_conjugate(conjugate_smiles)
227
+
228
+ # Try each builder; return first passing result
229
+ checks = None
230
+ for name, builder in self._builders.items():
231
+ passed, checks = builder.validate_conjugate(conjugate_smiles)
232
+ if passed:
233
+ checks['matched_linker'] = name
234
+ return True, checks
235
+
236
+ # None passed - return last failure
237
+ return False, checks
238
+
239
+ def check_attachment_site_compatibility(self, conjugate_smiles, linker_name=None):
240
+ """Delegate to appropriate builder's attachment check."""
241
+ if linker_name and linker_name in self._builders:
242
+ return self._builders[linker_name].check_attachment_site_compatibility(conjugate_smiles)
243
+
244
+ details = None
245
+ for name, builder in self._builders.items():
246
+ passed, details = builder.check_attachment_site_compatibility(conjugate_smiles)
247
+ if passed:
248
+ details['matched_linker'] = name
249
+ return True, details
250
+ return False, details
251
+
252
+ # ------------------------------------------------------------------
253
+ # Batch interface
254
+ # ------------------------------------------------------------------
255
+
256
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
257
+ """
258
+ Batch conjugate multiple peptides.
259
+
260
+ In 'sample' mode each peptide gets a freshly sampled linker.
261
+
262
+ Returns:
263
+ Dict with 'conjugates', 'successful', 'failed', 'total_conjugates',
264
+ and 'linkers_used' (list of linker names, one per peptide).
265
+ """
266
+ results = {
267
+ 'conjugates': [],
268
+ 'successful': [],
269
+ 'failed': [],
270
+ 'total_conjugates': 0,
271
+ 'linkers_used': [],
272
+ }
273
+
274
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
275
+ try:
276
+ conjugates = self.conjugate_to_peptide(
277
+ peptide_smiles,
278
+ exclude_n_terminus=exclude_n_terminus,
279
+ return_all=return_all,
280
+ )
281
+ if return_all:
282
+ results['conjugates'].append(conjugates)
283
+ results['total_conjugates'] += len(conjugates)
284
+ else:
285
+ results['conjugates'].append(conjugates)
286
+ results['total_conjugates'] += 1
287
+ results['successful'].append(i)
288
+ results['linkers_used'].append(
289
+ self.linker_mode if self.linker_mode not in ('sample', 'all') else None
290
+ )
291
+ except Exception as e:
292
+ results['conjugates'].append(None)
293
+ results['failed'].append((i, str(e)))
294
+ results['linkers_used'].append(None)
295
+
296
+ return results
297
+
298
+ # ------------------------------------------------------------------
299
+ # Informational
300
+ # ------------------------------------------------------------------
301
+
302
+ def __repr__(self):
303
+ probs = self.linker_probabilities
304
+ lines = [f"SN38PDC_Factory(mode='{self.linker_mode}')"]
305
+ for name, prob in probs.items():
306
+ desc = self.registry[name]['description']
307
+ lines.append(f" {name:12s} p={prob:.3f} ({desc})")
308
+ return '\n'.join(lines)
smiles/conjugation/SSCpt.py ADDED
@@ -0,0 +1,891 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Disulfide-Camptothecin PDC Builder (SSCpt)
3
+
4
+ Conjugation chemistry: Lysine ε-amine + Cys-SS-Linker-Camptothecin (amide bond formation)
5
+
6
+ Reference PDCs:
7
+ - PDC_00294 (Z7): Ester linker chemistry
8
+ - PDC_00295 (Z8): Carbonate linker chemistry
9
+
10
+ Receptor: ERBB2
11
+ Linker: Disulfide bond (S-S) - cleavable inside cells
12
+ Payload: Camptothecin
13
+ Architecture: Monomer, Lys side chain conjugation
14
+
15
+ Supported Linker-to-Drug Chemistries:
16
+ ======================================
17
+
18
+ ESTER (Z7):
19
+ Cys─S─S─CH2─CH2─C(═O)─O─Camptothecin
20
+ │
21
+ ester
22
+
23
+ CARBONATE (Z8):
24
+ Cys─S─S─CH2─CH2─O─C(═O)─O─Camptothecin
25
+ │
26
+ carbonate
27
+
28
+ Usage:
29
+ ======
30
+ # DEFAULT: Both chemistries (returns combined list)
31
+ builder = DisulfideCamptothecin_PDC_Builder()
32
+ conjugates = builder.conjugate_to_peptide(peptide_smiles)
33
+ # Returns: [ester_conj1, ester_conj2, ..., carbonate_conj1, carbonate_conj2, ...]
34
+
35
+ # Single chemistry only
36
+ builder = DisulfideCamptothecin_PDC_Builder(linker_chemistry='ester')
37
+ builder = DisulfideCamptothecin_PDC_Builder(linker_chemistry='carbonate')
38
+ """
39
+
40
+ from rdkit import Chem
41
+ from rdkit.Chem import AllChem, Descriptors
42
+
43
+ from .pdc_builder import PDC_Builder
44
+
45
+
46
+ # ============================================================================
47
+ # Disulfide-Camptothecin PDC Builder
48
+ # ============================================================================
49
+
50
+ class DisulfideCamptothecin_PDC_Builder(PDC_Builder):
51
+ """
52
+ PDC builder for Disulfide-Camptothecin conjugates
53
+
54
+ Conjugation chemistry (amide bond formation):
55
+ - Peptide site: Lysine ε-amine (side chain -NH2)
56
+ - Linker-payload site: Carboxylic acid of Cys (Cys-SS-Linker-Drug)
57
+ - Reaction: Lys-NH2 + HOOC-Cys-SS-Linker-Drug → Lys-NH-CO-Cys-SS-Linker-Drug
58
+
59
+ Supported linker-to-drug chemistries:
60
+ - 'ester' (Z7, PDC_00294): Cys-S-S-CH2-CH2-C(=O)-O-CPT
61
+ - 'carbonate' (Z8, PDC_00295): Cys-S-S-CH2-CH2-O-C(=O)-O-CPT
62
+
63
+ Architecture:
64
+ Peptide──Lys──NH──C(=O)──Cys──S─S──Linker──Camptothecin
65
+ └── amide ──┘ └── disulfide ──┘
66
+
67
+ Usage:
68
+ # Default: BOTH chemistries (returns combined list)
69
+ builder = DisulfideCamptothecin_PDC_Builder()
70
+ conjugates = builder.conjugate_to_peptide(peptide_smiles)
71
+ # For 1 Lys: returns 2 conjugates (1 ester + 1 carbonate)
72
+
73
+ # Single chemistry only
74
+ builder = DisulfideCamptothecin_PDC_Builder(linker_chemistry='ester')
75
+ """
76
+
77
+ # ========================================================================
78
+ # Linker Chemistry Variants
79
+ # ========================================================================
80
+ #
81
+ # Two linker-to-drug chemistries are supported:
82
+ #
83
+ # ESTER (Z7, PDC_00294):
84
+ # Cys─S─S─CH2─CH2─C(═O)─O─Camptothecin
85
+ # │
86
+ # ester
87
+ #
88
+ # CARBONATE (Z8, PDC_00295):
89
+ # Cys─S─S─CH2─CH2─O─C(═O)─O─Camptothecin
90
+ # │
91
+ # carbonate
92
+ #
93
+ # ========================================================================
94
+
95
+ # Ester variant (Z7, PDC_00294)
96
+ # Structure: HOOC-Cys(α-amine)-CH2-S-S-CH2-CH2-C(=O)-O-Camptothecin
97
+ ESTER_CYS_SS_LINKER_CPT_SMILES = (
98
+ "N[C@H](CSSCCC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C(=O)O"
99
+ )
100
+
101
+ # Carbonate variant (Z8, PDC_00295)
102
+ # Structure: HOOC-Cys(α-amine)-CH2-S-S-CH2-CH2-O-C(=O)-O-Camptothecin
103
+ CARBONATE_CYS_SS_LINKER_CPT_SMILES = (
104
+ "N[C@H](CSSCCOC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C(=O)O"
105
+ )
106
+
107
+ # Default (backward compatibility)
108
+ DEFAULT_CYS_SS_LINKER_CPT_SMILES = ESTER_CYS_SS_LINKER_CPT_SMILES
109
+
110
+ # Available linker chemistries
111
+ LINKER_CHEMISTRIES = {
112
+ 'ester': ESTER_CYS_SS_LINKER_CPT_SMILES,
113
+ 'carbonate': CARBONATE_CYS_SS_LINKER_CPT_SMILES,
114
+ }
115
+
116
+ def __init__(self, cys_linker_drug_smiles=None, linker_chemistry='all'):
117
+ """
118
+ Initialize Disulfide-Camptothecin builder
119
+
120
+ Args:
121
+ cys_linker_drug_smiles: Optional custom Cys-SS-Linker-Drug SMILES.
122
+ If provided, overrides linker_chemistry.
123
+ Must have free carboxylic acid for conjugation.
124
+ linker_chemistry: 'all' (default), 'ester' (Z7), or 'carbonate' (Z8).
125
+ 'all' generates conjugates with both chemistries.
126
+ Ignored if cys_linker_drug_smiles is provided.
127
+ """
128
+ self.linker_chemistry = linker_chemistry
129
+
130
+ if cys_linker_drug_smiles is not None:
131
+ # Custom SMILES provided - use single chemistry mode
132
+ self.linker_chemistry = 'custom'
133
+ super().__init__(cys_linker_drug_smiles, component_name="Cys-SS-Linker-Camptothecin (custom)")
134
+ elif linker_chemistry == 'all':
135
+ # All chemistries mode - initialize with ester as base, but will use both
136
+ super().__init__(self.ESTER_CYS_SS_LINKER_CPT_SMILES, component_name="Cys-SS-Linker-Camptothecin (all)")
137
+ else:
138
+ # Single chemistry mode
139
+ if linker_chemistry not in self.LINKER_CHEMISTRIES:
140
+ raise ValueError(f"Unknown linker_chemistry: {linker_chemistry}. "
141
+ f"Choose from: 'all', {list(self.LINKER_CHEMISTRIES.keys())}")
142
+ cys_linker_drug_smiles = self.LINKER_CHEMISTRIES[linker_chemistry]
143
+ super().__init__(cys_linker_drug_smiles, component_name=f"Cys-SS-Linker-Camptothecin ({linker_chemistry})")
144
+
145
+ @classmethod
146
+ def generate_all_chemistry_variants(cls, peptide_smiles, exclude_n_terminus=True):
147
+ """
148
+ Generate conjugates with ALL available linker chemistries (Ester + Carbonate)
149
+
150
+ This is useful for expanding candidate diversity.
151
+
152
+ Args:
153
+ peptide_smiles: SMILES string of peptide
154
+ exclude_n_terminus: If True, only conjugate at Lys ε-amines
155
+
156
+ Returns:
157
+ Dict with:
158
+ 'conjugates': List of all conjugate SMILES
159
+ 'by_chemistry': Dict mapping chemistry name to list of conjugates
160
+ 'by_site': Dict mapping site index to list of (chemistry, smiles) tuples
161
+ 'total': Total number of conjugates
162
+ """
163
+ results = {
164
+ 'conjugates': [],
165
+ 'by_chemistry': {},
166
+ 'by_site': {},
167
+ 'total': 0
168
+ }
169
+
170
+ for chemistry_name in cls.LINKER_CHEMISTRIES.keys():
171
+ builder = cls(linker_chemistry=chemistry_name)
172
+
173
+ try:
174
+ conjugates = builder.conjugate_to_peptide(
175
+ peptide_smiles,
176
+ exclude_n_terminus=exclude_n_terminus
177
+ )
178
+
179
+ results['by_chemistry'][chemistry_name] = conjugates
180
+
181
+ for i, conj in enumerate(conjugates):
182
+ results['conjugates'].append(conj)
183
+
184
+ # Track by site
185
+ if i not in results['by_site']:
186
+ results['by_site'][i] = []
187
+ results['by_site'][i].append((chemistry_name, conj))
188
+
189
+ except Exception as e:
190
+ print(f" {chemistry_name} variant failed: {e}")
191
+ results['by_chemistry'][chemistry_name] = []
192
+
193
+ results['total'] = len(results['conjugates'])
194
+
195
+ print(f"\n=== All Chemistry Variants ===")
196
+ print(f"Total conjugates: {results['total']}")
197
+ for chem, conjs in results['by_chemistry'].items():
198
+ print(f" {chem}: {len(conjs)} conjugate(s)")
199
+
200
+ return results
201
+
202
+ @classmethod
203
+ def batch_generate_all_variants(cls, peptide_smiles_list, exclude_n_terminus=True):
204
+ """
205
+ Batch generate all chemistry variants for multiple peptides
206
+
207
+ Args:
208
+ peptide_smiles_list: List of peptide SMILES strings
209
+ exclude_n_terminus: If True, only conjugate at Lys ε-amines
210
+
211
+ Returns:
212
+ Dict with:
213
+ 'all_conjugates': Flat list of all conjugate SMILES
214
+ 'by_peptide': List of results dicts (one per peptide)
215
+ 'summary': Overall statistics
216
+ """
217
+ results = {
218
+ 'all_conjugates': [],
219
+ 'by_peptide': [],
220
+ 'summary': {
221
+ 'peptides': len(peptide_smiles_list),
222
+ 'successful': 0,
223
+ 'failed': 0,
224
+ 'total_conjugates': 0
225
+ }
226
+ }
227
+
228
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
229
+ print(f"\n--- Peptide {i+1}/{len(peptide_smiles_list)} ---")
230
+ try:
231
+ pep_results = cls.generate_all_chemistry_variants(
232
+ peptide_smiles,
233
+ exclude_n_terminus=exclude_n_terminus
234
+ )
235
+ results['by_peptide'].append(pep_results)
236
+ results['all_conjugates'].extend(pep_results['conjugates'])
237
+ results['summary']['successful'] += 1
238
+ results['summary']['total_conjugates'] += pep_results['total']
239
+ except Exception as e:
240
+ print(f" Peptide {i} failed completely: {e}")
241
+ results['by_peptide'].append(None)
242
+ results['summary']['failed'] += 1
243
+
244
+ print(f"\n=== Batch Summary ===")
245
+ print(f"Peptides: {results['summary']['peptides']}")
246
+ print(f"Successful: {results['summary']['successful']}")
247
+ print(f"Failed: {results['summary']['failed']}")
248
+ print(f"Total conjugates: {results['summary']['total_conjugates']}")
249
+
250
+ return results
251
+
252
+ def _verify_reactive_group(self):
253
+ """Verify carboxylic acid group is present for amide bond formation"""
254
+ # Pattern: Carboxylic acid (COOH)
255
+ carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
256
+
257
+ if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
258
+ print(" Warning: No carboxylic acid group detected in Cys-SS-Linker-Drug")
259
+ else:
260
+ print(" Cys-SS-Linker-Camptothecin loaded with carboxylic acid reactive group")
261
+
262
+ # Verify disulfide bond is present
263
+ disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
264
+ if self.linker_payload_mol.HasSubstructMatch(disulfide_pattern):
265
+ print(" Disulfide bond (S-S) detected")
266
+ else:
267
+ print(" Warning: No disulfide bond detected - check structure")
268
+
269
+ # Verify Cys α-amine is present
270
+ cys_amine_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2]S)C(=O)')
271
+ if self.linker_payload_mol.HasSubstructMatch(cys_amine_pattern):
272
+ print(" Cys α-amine detected (will be free in conjugate)")
273
+ else:
274
+ print(" Warning: Cys α-amine pattern not detected")
275
+
276
+ def find_conjugation_sites(self, peptide_smiles):
277
+ """
278
+ Find all lysine ε-amine groups in peptide
279
+
280
+ Returns list of dicts with site information including atom index
281
+ """
282
+ return self.find_peptide_lysines(peptide_smiles)
283
+
284
+ def find_peptide_lysines(self, peptide_smiles):
285
+ """
286
+ Find all lysine residues with free ε-amine in peptide
287
+
288
+ Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
289
+
290
+ Returns:
291
+ List of dicts with 'atom_idx' (nitrogen index) and metadata
292
+ """
293
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
294
+ if peptide_mol is None:
295
+ raise ValueError("Invalid peptide SMILES")
296
+
297
+ lysines = []
298
+
299
+ # SMARTS pattern for Lysine ε-amine: 4 CH2 groups ending in NH2
300
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
301
+ matches = peptide_mol.GetSubstructMatches(lys_pattern)
302
+
303
+ for match in matches:
304
+ nitrogen_idx = match[4]
305
+
306
+ lysines.append({
307
+ 'atom_idx': nitrogen_idx,
308
+ 'side_chain_atoms': list(match),
309
+ 'type': 'Lys_epsilon_amine'
310
+ })
311
+
312
+ # Also count N-terminus for informational purposes
313
+ n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
314
+ n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
315
+
316
+ if n_term_matches:
317
+ print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
318
+
319
+ return lysines
320
+
321
+ def count_all_nh2_sites(self, peptide_smiles):
322
+ """
323
+ Count all NH2 sites in peptide (both Lys ε-amines and N-terminus)
324
+
325
+ Returns:
326
+ Dict with counts: {'lys_epsilon': n, 'n_terminus': n, 'other': n, 'total': n}
327
+ """
328
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
329
+ if peptide_mol is None:
330
+ raise ValueError("Invalid peptide SMILES")
331
+
332
+ counts = {'lys_epsilon': 0, 'n_terminus': 0, 'other': 0, 'total': 0}
333
+
334
+ # Lys ε-amine: CH2-CH2-CH2-CH2-NH2 (4-carbon side chain)
335
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
336
+ counts['lys_epsilon'] = len(peptide_mol.GetSubstructMatches(lys_pattern))
337
+
338
+ # N-terminus: NH2 attached to backbone carbon
339
+ n_term_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
340
+ counts['n_terminus'] = len(peptide_mol.GetSubstructMatches(n_term_pattern))
341
+
342
+ # Total NH2 (any primary amine)
343
+ all_nh2_pattern = Chem.MolFromSmarts('[NH2]')
344
+ total_nh2 = len(peptide_mol.GetSubstructMatches(all_nh2_pattern))
345
+
346
+ counts['other'] = total_nh2 - counts['lys_epsilon'] - counts['n_terminus']
347
+ counts['total'] = total_nh2
348
+
349
+ return counts
350
+
351
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
352
+ """
353
+ Perform amide bond conjugation between NH2 groups and Cys carboxylic acid
354
+
355
+ Args:
356
+ peptide_mol: RDKit Mol object
357
+ site_info: Not used (kept for compatibility)
358
+ exclude_n_terminus: If True, skip N-terminus conjugation
359
+
360
+ Returns: List of all possible conjugate SMILES
361
+ """
362
+ return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
363
+
364
+ def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
365
+ """
366
+ Create ALL possible conjugates by forming amide bond at NH2 sites
367
+
368
+ Chemistry:
369
+ - Lys-NH2 + HOOC-Cys-SS-Linker-Drug → Lys-NH-C(=O)-Cys-SS-Linker-Drug + H2O
370
+
371
+ Args:
372
+ peptide_mol: RDKit Mol object of peptide
373
+ lysine_info: Not used (kept for interface compatibility)
374
+ exclude_n_terminus: If True, only conjugate at Lys ε-amines (4-carbon chain),
375
+ not at N-terminus α-amine. Default: True
376
+
377
+ Returns:
378
+ List of all possible conjugate SMILES
379
+ """
380
+ if exclude_n_terminus:
381
+ # SMARTS that matches only Lys ε-amine: requires 4 CH2 chain before NH2
382
+ # Include wildcard (*) to preserve connection to peptide backbone
383
+ amide_rxn = AllChem.ReactionFromSmarts(
384
+ '[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
385
+ )
386
+ else:
387
+ # Match ANY primary amine (includes N-terminus)
388
+ # Include wildcard to preserve the rest of the molecule
389
+ amide_rxn = AllChem.ReactionFromSmarts(
390
+ '[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
391
+ )
392
+
393
+ # Run the reaction - returns all possible products
394
+ products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
395
+
396
+ if not products:
397
+ raise ValueError("Amide bond formation reaction failed - no products generated")
398
+
399
+ # Collect ALL valid conjugates
400
+ conjugates = []
401
+ seen_smiles = set() # Deduplicate
402
+
403
+ for product_tuple in products:
404
+ try:
405
+ conjugate_mol = product_tuple[0]
406
+ Chem.SanitizeMol(conjugate_mol)
407
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
408
+
409
+ # Deduplicate (same SMILES = same molecule)
410
+ if conjugate_smiles not in seen_smiles:
411
+ seen_smiles.add(conjugate_smiles)
412
+ conjugates.append(conjugate_smiles)
413
+ except Exception as e:
414
+ # Skip invalid products
415
+ continue
416
+
417
+ if not conjugates:
418
+ raise ValueError("All products failed sanitization")
419
+
420
+ return conjugates
421
+
422
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
423
+ """
424
+ Conjugate Cys-SS-Linker-Camptothecin to peptide via amide bond at NH2 groups
425
+
426
+ If linker_chemistry='all' (default), generates conjugates with BOTH ester and
427
+ carbonate chemistries, returning a combined list.
428
+
429
+ Args:
430
+ peptide_smiles: SMILES string of peptide (must contain Lys with NH2)
431
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines,
432
+ not at N-terminus. Set to False to include N-terminus.
433
+ return_all: If True, return all possible conjugates. If False, return first only.
434
+
435
+ Returns:
436
+ List of all possible conjugate SMILES (from all chemistries if mode='all')
437
+ """
438
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
439
+ if peptide_mol is None:
440
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
441
+
442
+ sites = self.find_conjugation_sites(peptide_smiles)
443
+ print(f"Found {len(sites)} Lys ε-amine site(s)")
444
+
445
+ # If 'all' mode, generate conjugates with both chemistries
446
+ if self.linker_chemistry == 'all':
447
+ all_conjugates = []
448
+ seen_smiles = set()
449
+
450
+ for chem_name, chem_smiles in self.LINKER_CHEMISTRIES.items():
451
+ # Temporarily set linker_payload_mol to this chemistry
452
+ temp_mol = Chem.MolFromSmiles(chem_smiles)
453
+ original_mol = self.linker_payload_mol
454
+ self.linker_payload_mol = temp_mol
455
+
456
+ try:
457
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
458
+
459
+ for conj in conjugates:
460
+ if conj not in seen_smiles:
461
+ seen_smiles.add(conj)
462
+ all_conjugates.append(conj)
463
+
464
+ print(f" {chem_name}: {len(conjugates)} conjugate(s)")
465
+
466
+ except Exception as e:
467
+ print(f" {chem_name}: failed - {e}")
468
+ finally:
469
+ # Restore original
470
+ self.linker_payload_mol = original_mol
471
+
472
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2"
473
+ print(f"Generated {len(all_conjugates)} total unique conjugate(s) [{site_type}] [all chemistries]")
474
+
475
+ if not all_conjugates:
476
+ raise ValueError("No conjugates generated - no valid conjugation sites found")
477
+
478
+ if return_all:
479
+ return all_conjugates
480
+ else:
481
+ return all_conjugates[0] if all_conjugates else None
482
+
483
+ else:
484
+ # Single chemistry mode
485
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
486
+
487
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
488
+ print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}] [{self.linker_chemistry}]")
489
+
490
+ if return_all:
491
+ return conjugates
492
+ else:
493
+ return conjugates[0] if conjugates else None
494
+
495
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
496
+ """
497
+ Batch conjugate multiple peptides
498
+
499
+ Args:
500
+ peptide_smiles_list: List of peptide SMILES strings
501
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
502
+ return_all: If True, return all possible conjugates per peptide
503
+
504
+ Returns:
505
+ Dict with:
506
+ 'conjugates': List of lists (if return_all=True) or list of SMILES
507
+ 'successful': List of successful peptide indices
508
+ 'failed': List of (index, error) tuples
509
+ 'total_conjugates': Total number of conjugates generated
510
+ """
511
+ results = {
512
+ 'conjugates': [],
513
+ 'successful': [],
514
+ 'failed': [],
515
+ 'total_conjugates': 0
516
+ }
517
+
518
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
519
+ try:
520
+ conjugates = self.conjugate_to_peptide(
521
+ peptide_smiles,
522
+ exclude_n_terminus=exclude_n_terminus,
523
+ return_all=return_all
524
+ )
525
+ if return_all:
526
+ results['conjugates'].append(conjugates)
527
+ results['total_conjugates'] += len(conjugates)
528
+ else:
529
+ results['conjugates'].append(conjugates)
530
+ results['total_conjugates'] += 1
531
+ results['successful'].append(i)
532
+ except Exception as e:
533
+ print(f" Peptide {i} failed: {e}")
534
+ results['conjugates'].append(None)
535
+ results['failed'].append((i, str(e)))
536
+
537
+ print(f"\n=== Summary ===")
538
+ print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
539
+ print(f"Total conjugates generated: {results['total_conjugates']}")
540
+
541
+ return results
542
+
543
+ def flatten_batch_results(self, results):
544
+ """
545
+ Flatten batch conjugation results into a single list of all conjugates
546
+
547
+ Args:
548
+ results: Dict returned by batch_conjugate()
549
+
550
+ Returns:
551
+ List of all conjugate SMILES (flattened)
552
+ """
553
+ all_conjugates = []
554
+ for conjugate_list in results['conjugates']:
555
+ if conjugate_list is not None:
556
+ if isinstance(conjugate_list, list):
557
+ all_conjugates.extend(conjugate_list)
558
+ else:
559
+ all_conjugates.append(conjugate_list)
560
+ return all_conjugates
561
+
562
+ # ========================================================================
563
+ # Attachment Site Compatibility Checks (Lys ε-amine + Cys-COOH amide bond)
564
+ # ========================================================================
565
+
566
+ def has_lys_cys_amide_conjugation(self, mol):
567
+ """
568
+ Check if molecule has Lys-Cys amide conjugation bond
569
+
570
+ Pattern: Lys-ε-N-C(=O)-Cα(Cys)-CH2-S-S
571
+ The amide bond connects Lys side chain to Cys α-carbon
572
+
573
+ Returns:
574
+ bool: True if Lys-Cys amide conjugation is present
575
+ """
576
+ # Pattern: N-C(=O)-C(N)(C-S-S) - amide to Cys with disulfide
577
+ lys_cys_amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
578
+ return mol.HasSubstructMatch(lys_cys_amide_pattern)
579
+
580
+ def has_disulfide_bond(self, mol):
581
+ """
582
+ Check if molecule has disulfide bond (S-S)
583
+
584
+ Returns:
585
+ bool: True if disulfide bond is present
586
+ """
587
+ disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
588
+ return mol.HasSubstructMatch(disulfide_pattern)
589
+
590
+ def has_unreacted_lys_epsilon_amine(self, mol):
591
+ """
592
+ Check for unreacted Lys ε-amine (free NH2 on 4-carbon side chain)
593
+
594
+ In a fully conjugated PDC, all Lys ε-amines should be consumed.
595
+ This checks for remaining free NH2 groups on Lys side chains.
596
+
597
+ Returns:
598
+ bool: True if unreacted Lys ε-amine is present (indicates incomplete conjugation)
599
+ """
600
+ # Lys ε-amine: 4 CH2 chain ending in NH2
601
+ lys_amine_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
602
+ return mol.HasSubstructMatch(lys_amine_pattern)
603
+
604
+ def has_unreacted_cys_carboxylic_acid(self, mol):
605
+ """
606
+ Check for unreacted Cys carboxylic acid (from Cys-SS-Linker-Drug)
607
+
608
+ Pattern: Free COOH on Cys α-carbon adjacent to CH2-S-S
609
+
610
+ Returns:
611
+ bool: True if unreacted Cys-COOH is present (indicates unconjugated linker-drug)
612
+ """
613
+ # Cys with free COOH: H2N-CH(CH2-S-S)-COOH
614
+ cys_cooh_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2][SX2])[CX3](=[OX1])[OX2H1]')
615
+ return mol.HasSubstructMatch(cys_cooh_pattern)
616
+
617
+ def find_amide_conjugation_sites(self, mol):
618
+ """
619
+ Find all Lys-Cys amide conjugation sites in the molecule
620
+
621
+ Returns:
622
+ List of tuples: (nitrogen_idx, carbonyl_carbon_idx, cys_alpha_carbon_idx)
623
+ """
624
+ # Pattern: N-C(=O)-C(N)(C-S-S)
625
+ lys_cys_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
626
+ matches = mol.GetSubstructMatches(lys_cys_pattern)
627
+
628
+ sites = []
629
+ for match in matches:
630
+ nitrogen_idx = match[0] # Lys nitrogen
631
+ carbonyl_c_idx = match[1] # Carbonyl carbon
632
+ cys_alpha_idx = match[3] # Cys α-carbon
633
+ sites.append((nitrogen_idx, carbonyl_c_idx, cys_alpha_idx))
634
+
635
+ return sites
636
+
637
+ def check_valency_at_conjugation_site(self, mol, site_indices):
638
+ """
639
+ Check that valency is correct at Lys-Cys conjugation site
640
+
641
+ Args:
642
+ mol: RDKit Mol object
643
+ site_indices: Tuple of (nitrogen_idx, carbonyl_c_idx, cys_alpha_idx)
644
+
645
+ Returns:
646
+ bool: True if valency is correct
647
+ """
648
+ nitrogen_idx, carbonyl_c_idx, cys_alpha_idx = site_indices
649
+
650
+ try:
651
+ # Lys nitrogen should have 3 bonds (NH connected to amide)
652
+ n_atom = mol.GetAtomWithIdx(nitrogen_idx)
653
+ if n_atom.GetTotalDegree() != 3:
654
+ return False
655
+
656
+ # Carbonyl carbon should have 3 bonds (C=O and two single bonds)
657
+ c_atom = mol.GetAtomWithIdx(carbonyl_c_idx)
658
+ if c_atom.GetTotalDegree() != 3:
659
+ return False
660
+
661
+ # Cys α-carbon should have 4 bonds
662
+ cys_c_atom = mol.GetAtomWithIdx(cys_alpha_idx)
663
+ if cys_c_atom.GetTotalDegree() != 4:
664
+ return False
665
+
666
+ return True
667
+
668
+ except Exception:
669
+ return False
670
+
671
+ def check_attachment_site_compatibility(self, conjugate_smiles):
672
+ """
673
+ Check attachment site compatibility for Lys-Cys-SS-Linker-Drug conjugation
674
+
675
+ Validates:
676
+ 1. Lys-Cys amide conjugation is present
677
+ 2. Disulfide bond (S-S) is intact
678
+ 3. Valency at conjugation sites is correct
679
+ 4. No unreacted Cys-COOH (unconjugated linker-drug)
680
+
681
+ Args:
682
+ conjugate_smiles: SMILES string of the conjugate
683
+
684
+ Returns:
685
+ Tuple of (passed: bool, details: dict)
686
+ """
687
+ mol = Chem.MolFromSmiles(conjugate_smiles)
688
+
689
+ if mol is None:
690
+ return False, {
691
+ 'parseable': False,
692
+ 'error': 'Could not parse SMILES'
693
+ }
694
+
695
+ details = {
696
+ 'parseable': True,
697
+ 'validation_method': 'lys_cys_amide_disulfide'
698
+ }
699
+
700
+ # Check 1: Lys-Cys amide conjugation present
701
+ details['has_lys_cys_amide'] = self.has_lys_cys_amide_conjugation(mol)
702
+
703
+ # Check 2: Disulfide bond intact
704
+ details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
705
+
706
+ # Check 3: Find conjugation sites and validate valency
707
+ conjugation_sites = self.find_amide_conjugation_sites(mol)
708
+ details['conjugation_sites'] = len(conjugation_sites)
709
+
710
+ valency_ok = True
711
+ if conjugation_sites:
712
+ for site in conjugation_sites:
713
+ if not self.check_valency_at_conjugation_site(mol, site):
714
+ valency_ok = False
715
+ break
716
+ details['valency_correct'] = valency_ok
717
+
718
+ # Check 4: No unreacted Cys-COOH (would indicate unconjugated linker-drug)
719
+ details['no_unreacted_cys_cooh'] = not self.has_unreacted_cys_carboxylic_acid(mol)
720
+
721
+ # Optional info: Check for unreacted Lys ε-amines (for multi-Lys peptides)
722
+ details['has_unreacted_lys_amine'] = self.has_unreacted_lys_epsilon_amine(mol)
723
+
724
+ # Overall pass/fail
725
+ passed = all([
726
+ details['has_lys_cys_amide'],
727
+ details['has_disulfide_bond'],
728
+ details['valency_correct'],
729
+ details['no_unreacted_cys_cooh']
730
+ ])
731
+
732
+ return passed, details
733
+
734
+ def _basic_validation(self, mol):
735
+ """
736
+ Basic validation checks for Disulfide-Camptothecin conjugates
737
+
738
+ Checks:
739
+ 1. Amide bond present (Lys-Cys conjugation)
740
+ 2. Disulfide bond present (S-S linker)
741
+ 3. Specific Lys-Cys-SS conjugation pattern
742
+ 4. Camptothecin core present
743
+ 5. Ester bond to drug present
744
+ """
745
+ checks = {}
746
+
747
+ # Check 1: Has amide bond (general check)
748
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
749
+ checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
750
+
751
+ # Check 2: Disulfide bond present (S-S)
752
+ disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
753
+ checks['has_disulfide_bond'] = mol.HasSubstructMatch(disulfide_pattern)
754
+
755
+ # Check 3: Specific Lys-Cys-SS conjugation pattern
756
+ # Looking for: N-C(=O)-C-C-S-S (amide to Cys to disulfide)
757
+ lys_cys_ss_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
758
+ checks['has_lys_cys_ss_conjugation'] = mol.HasSubstructMatch(lys_cys_ss_pattern)
759
+
760
+ # Check 4: Camptothecin core (quinoline fused ring system)
761
+ # Simplified pattern for the fused ring system
762
+ camptothecin_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
763
+ checks['has_camptothecin_core'] = mol.HasSubstructMatch(camptothecin_core)
764
+
765
+ # Check 5: Ester bond (linker to drug)
766
+ ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
767
+ checks['has_ester_bond'] = mol.HasSubstructMatch(ester_pattern)
768
+
769
+ # Overall pass/fail - based on essential conjugation checks
770
+ checks['_passed'] = all([
771
+ checks['has_amide_bond'],
772
+ checks['has_disulfide_bond'],
773
+ checks['has_lys_cys_ss_conjugation']
774
+ ])
775
+
776
+ return checks
777
+
778
+ def validate_conjugate(self, conjugate_smiles):
779
+ """
780
+ Validate that the conjugate is chemically reasonable
781
+
782
+ Returns:
783
+ (passed: bool, checks: dict)
784
+ """
785
+ mol = Chem.MolFromSmiles(conjugate_smiles)
786
+
787
+ if mol is None:
788
+ return False, {'error': 'Could not parse SMILES'}
789
+
790
+ checks = self._basic_validation(mol)
791
+ checks['parseable'] = True
792
+
793
+ passed = checks.get('_passed', True)
794
+ if '_passed' in checks:
795
+ del checks['_passed']
796
+
797
+ return passed, checks
798
+
799
+
800
+ # ============================================================================
801
+ # Main - Test Examples
802
+ # ============================================================================
803
+
804
+ if __name__ == "__main__":
805
+
806
+ print("=" * 70)
807
+ print("Disulfide-Camptothecin PDC Builder - Test")
808
+ print("Reference: PDC_00294 (Z7, Ester) & PDC_00295 (Z8, Carbonate)")
809
+ print("=" * 70 + "\n")
810
+
811
+ # Test peptides
812
+ test_peptides = [
813
+ # Simple test: Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine)
814
+ "NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
815
+ # Lys-Arg
816
+ "N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
817
+ ]
818
+
819
+ print("\n" + "=" * 70)
820
+ print("Test 1: DEFAULT MODE (all chemistries: Ester + Carbonate)")
821
+ print("=" * 70 + "\n")
822
+
823
+ # Default: linker_chemistry='all'
824
+ builder = DisulfideCamptothecin_PDC_Builder()
825
+
826
+ peptide = test_peptides[0]
827
+ print(f"Input peptide (Gly-Lys-Gly): {peptide}")
828
+
829
+ try:
830
+ conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
831
+ print(f"\n Total conjugates: {len(conjugates)} (should be 2: 1 ester + 1 carbonate)")
832
+
833
+ for i, conj in enumerate(conjugates):
834
+ valid, checks = builder.validate_conjugate(conj)
835
+ print(f" Conjugate {i+1}: {'PASSED' if valid else 'FAILED'} ({len(conj)} chars)")
836
+
837
+ except Exception as e:
838
+ print(f"\n FAILED: {e}")
839
+ import traceback
840
+ traceback.print_exc()
841
+
842
+ print("\n" + "=" * 70)
843
+ print("Test 2: Single Chemistry Mode (Ester only)")
844
+ print("=" * 70 + "\n")
845
+
846
+ builder_ester = DisulfideCamptothecin_PDC_Builder(linker_chemistry='ester')
847
+
848
+ print(f"Input peptide (Gly-Lys-Gly): {peptide}")
849
+
850
+ try:
851
+ conjugates = builder_ester.conjugate_to_peptide(peptide, exclude_n_terminus=True)
852
+ print(f"\n Ester only: {len(conjugates)} conjugate(s)")
853
+
854
+ except Exception as e:
855
+ print(f"\n FAILED: {e}")
856
+
857
+ print("\n" + "=" * 70)
858
+ print("Test 3: Single Chemistry Mode (Carbonate only)")
859
+ print("=" * 70 + "\n")
860
+
861
+ builder_carbonate = DisulfideCamptothecin_PDC_Builder(linker_chemistry='carbonate')
862
+
863
+ print(f"Input peptide (Gly-Lys-Gly): {peptide}")
864
+
865
+ try:
866
+ conjugates = builder_carbonate.conjugate_to_peptide(peptide, exclude_n_terminus=True)
867
+ print(f"\n Carbonate only: {len(conjugates)} conjugate(s)")
868
+
869
+ except Exception as e:
870
+ print(f"\n FAILED: {e}")
871
+
872
+ print("\n" + "=" * 70)
873
+ print("Test 4: Batch Conjugation (default: all chemistries)")
874
+ print("=" * 70 + "\n")
875
+
876
+ builder = DisulfideCamptothecin_PDC_Builder() # default: all
877
+
878
+ try:
879
+ results = builder.batch_conjugate(test_peptides, exclude_n_terminus=True)
880
+
881
+ print(f"\nResults breakdown:")
882
+ for i in results['successful']:
883
+ conjugates = results['conjugates'][i]
884
+ num_valid = sum(1 for c in conjugates if builder.validate_conjugate(c)[0])
885
+ name = ["Gly-Lys-Gly", "Lys-Arg"][i]
886
+ print(f" {name}: {len(conjugates)} conjugate(s), {num_valid} valid")
887
+
888
+ except Exception as e:
889
+ print(f"\n FAILED: {e}")
890
+ import traceback
891
+ traceback.print_exc()
smiles/conjugation/ValAlaDau.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Val-Ala-PABC-Daunorubicin PDC Builder
3
+
4
+ Conjugation chemistry: Lysine ε-amine + Glutaryl-Val-Ala-PABC-Daunorubicin (amide bond formation)
5
+
6
+ Reference PDCs:
7
+ - PDC_00185 (GnRH-III-[2ΔHis-3D-Tic, 8Lys(glutaryl-Val-Ala-PABC-Dau)])
8
+ - PDC_00193 (GnRH-III-[2His-3Trp,8Lys(glutaryl-Val-Ala-PABC-Dau)])
9
+ - Receptor: GNRHR
10
+ - Linker: Val-Ala (LIN00028)
11
+ - Payload: Daunorubicin (DRG00010)
12
+
13
+ Architecture:
14
+ Peptide──Lys──NH──C(=O)──(CH₂)₃──C(=O)──Val──Ala──PABC──O──C(=O)──NH──Dau
15
+ │ │ │ │
16
+ amide bond glutaryl dipeptide carbamate
17
+ (reaction) spacer cleavable self-immolative
18
+
19
+ The cleavable dipeptide Val-Ala is recognized by cathepsin B (lysosomal protease).
20
+ After cleavage, PABC self-immolation releases free Daunorubicin.
21
+ """
22
+
23
+ from rdkit import Chem
24
+ from rdkit.Chem import RWMol, AllChem
25
+
26
+ from .pdc_builder import PDC_Builder
27
+
28
+
29
+ class ValAlaPABCDaunorubicin_PDC_Builder(PDC_Builder):
30
+ """
31
+ PDC builder for Glutaryl-Val-Ala-PABC-Daunorubicin conjugates
32
+
33
+ Conjugation chemistry (amide bond formation):
34
+ - Peptide site: Lysine ε-amine (side chain -NH2)
35
+ - Linker-payload site: Free carboxylic acid on glutaryl spacer
36
+ - Reaction: Amide bond (Lys-NH2 + HOOC-glutaryl-Val-Ala-PABC-Dau)
37
+
38
+ Architecture:
39
+ Peptide──Lys──NH──CO──(CH₂)₃──CO──Val──Ala──PABC──OCO──NH──Dau
40
+ └── amide ──┘ └── cleavable ──┘ └─ carbamate ─┘
41
+
42
+ Reference: PDC_00185, PDC_00193 (GnRH-III conjugates, GNRHR)
43
+ """
44
+
45
+ # Val-Ala-PABC-Daunorubicin with free glutaryl COOH
46
+ # Structure: HOOC-(CH2)3-CO-Val-Ala-PABC-OCO-NH-Dau
47
+ # - Glutaryl spacer connects to Val N-terminus via amide
48
+ # - Val-Ala dipeptide (cathepsin B cleavable)
49
+ # - PABC (para-aminobenzylcarbamate) self-immolative spacer
50
+ # - Carbamate bond from PABC to Dau 3'-amine
51
+ DEFAULT_VALA_PABC_DAU_SMILES = (
52
+ "C[C@H]1[C@H]([C@H](C[C@@H](O1)O[C@H]2C[C@@](CC3=C2C(=C4C(=C3O)"
53
+ "C(=O)C5=C(C4=O)C(=CC=C5)OC)O)(C(=O)C)O)NC(=O)OCc6ccc(NC(=O)"
54
+ "[C@@H](C)NC(=O)[C@@H](C(C)C)NC(=O)CCCC(=O)O)cc6)O"
55
+ )
56
+
57
+ def __init__(self, vala_pabc_dau_smiles=None):
58
+ """
59
+ Initialize Val-Ala-PABC-Daunorubicin builder
60
+
61
+ Args:
62
+ vala_pabc_dau_smiles: Optional custom SMILES. If None, uses default.
63
+ Must have free carboxylic acid for conjugation.
64
+ """
65
+ if vala_pabc_dau_smiles is None:
66
+ vala_pabc_dau_smiles = self.DEFAULT_VALA_PABC_DAU_SMILES
67
+
68
+ super().__init__(vala_pabc_dau_smiles,
69
+ component_name="Val-Ala-PABC-Daunorubicin (linker-payload)")
70
+
71
+ def _verify_reactive_group(self):
72
+ """Verify carboxylic acid group is present for amide bond formation"""
73
+ carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
74
+
75
+ if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
76
+ print(" Warning: No carboxylic acid group detected in Val-Ala-PABC-Dau")
77
+ else:
78
+ print(" Val-Ala-PABC-Daunorubicin loaded with carboxylic acid reactive group")
79
+
80
+ # Verify PABC benzyl carbamate
81
+ pabc_pattern = Chem.MolFromSmarts('c1ccc(COC(=O)N)cc1')
82
+ if self.linker_payload_mol.HasSubstructMatch(pabc_pattern):
83
+ print(" PABC self-immolative spacer detected")
84
+ else:
85
+ print(" Warning: No PABC pattern detected")
86
+
87
+ # Verify dipeptide amide bonds
88
+ dipeptide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH][NX3][CX3](=[OX1])')
89
+ if self.linker_payload_mol.HasSubstructMatch(dipeptide_pattern):
90
+ print(" Dipeptide (Val-Ala) linkage detected")
91
+
92
+ def find_conjugation_sites(self, peptide_smiles):
93
+ """Find all lysine ε-amine groups in peptide"""
94
+ return self._find_peptide_lysines(peptide_smiles)
95
+
96
+ def _find_peptide_lysines(self, peptide_smiles):
97
+ """Find all lysine residues with free ε-amine in peptide"""
98
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
99
+ if peptide_mol is None:
100
+ raise ValueError("Invalid peptide SMILES")
101
+
102
+ lysines = []
103
+
104
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
105
+ matches = peptide_mol.GetSubstructMatches(lys_pattern)
106
+
107
+ for match in matches:
108
+ nitrogen_idx = match[4]
109
+ lysines.append({
110
+ 'atom_idx': nitrogen_idx,
111
+ 'side_chain_atoms': list(match),
112
+ 'type': 'Lys_epsilon_amine'
113
+ })
114
+
115
+ n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
116
+ n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
117
+ if n_term_matches:
118
+ print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
119
+
120
+ return lysines
121
+
122
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
123
+ """Perform amide bond conjugation"""
124
+ return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
125
+
126
+ def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
127
+ """
128
+ Create ALL possible conjugates by forming amide bond at NH2 sites
129
+
130
+ Chemistry:
131
+ - Any-NH2 + HOOC-glutaryl-Val-Ala-PABC-Dau → amide bond + H2O
132
+ """
133
+ if exclude_n_terminus:
134
+ amide_rxn = AllChem.ReactionFromSmarts(
135
+ '[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>'
136
+ '[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
137
+ )
138
+ else:
139
+ amide_rxn = AllChem.ReactionFromSmarts(
140
+ '[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
141
+ )
142
+
143
+ products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
144
+
145
+ if not products:
146
+ raise ValueError("Amide bond formation reaction failed - no products generated")
147
+
148
+ conjugates = []
149
+ seen_smiles = set()
150
+
151
+ for product_tuple in products:
152
+ try:
153
+ conjugate_mol = product_tuple[0]
154
+ Chem.SanitizeMol(conjugate_mol)
155
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
156
+
157
+ if conjugate_smiles not in seen_smiles:
158
+ seen_smiles.add(conjugate_smiles)
159
+ conjugates.append(conjugate_smiles)
160
+ except Exception:
161
+ continue
162
+
163
+ if not conjugates:
164
+ raise ValueError("All products failed sanitization")
165
+
166
+ return conjugates
167
+
168
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
169
+ """
170
+ Conjugate Val-Ala-PABC-Daunorubicin to peptide via amide bond
171
+
172
+ Args:
173
+ peptide_smiles: SMILES string of peptide (must contain NH2)
174
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
175
+ return_all: If True, return all possible conjugates
176
+
177
+ Returns:
178
+ If return_all=True: List of all possible conjugate SMILES
179
+ If return_all=False: Single conjugate SMILES
180
+ """
181
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
182
+ if peptide_mol is None:
183
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
184
+
185
+ sites = self.find_conjugation_sites(peptide_smiles)
186
+ print(f"Found {len(sites)} Lys ε-amine site(s)")
187
+
188
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
189
+
190
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
191
+ print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}]")
192
+
193
+ if return_all:
194
+ return conjugates
195
+ else:
196
+ return conjugates[0] if conjugates else None
197
+
198
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
199
+ """Batch conjugate multiple peptides"""
200
+ results = {
201
+ 'conjugates': [],
202
+ 'successful': [],
203
+ 'failed': [],
204
+ 'total_conjugates': 0
205
+ }
206
+
207
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
208
+ try:
209
+ conjugates = self.conjugate_to_peptide(
210
+ peptide_smiles,
211
+ exclude_n_terminus=exclude_n_terminus,
212
+ return_all=return_all
213
+ )
214
+ if return_all:
215
+ results['conjugates'].append(conjugates)
216
+ results['total_conjugates'] += len(conjugates)
217
+ else:
218
+ results['conjugates'].append(conjugates)
219
+ results['total_conjugates'] += 1
220
+ results['successful'].append(i)
221
+ except Exception as e:
222
+ print(f" Peptide {i} failed: {e}")
223
+ results['conjugates'].append(None)
224
+ results['failed'].append((i, str(e)))
225
+
226
+ return results
227
+
228
+ # =========================================================================
229
+ # Attachment Site Compatibility Checks
230
+ # =========================================================================
231
+
232
+ def has_pabc_carbamate(self, mol):
233
+ """Check for PABC benzyl carbamate: Ar-CH2-O-C(=O)-N"""
234
+ pabc_pattern = Chem.MolFromSmarts('c1ccc(COC(=O)[NX3])cc1')
235
+ if pabc_pattern:
236
+ return mol.HasSubstructMatch(pabc_pattern)
237
+ return False
238
+
239
+ def has_val_ala_dipeptide(self, mol):
240
+ """
241
+ Check for Val-Ala dipeptide fragment
242
+
243
+ Pattern: isopropyl-CH-C(=O)-NH-CH(CH3)-C(=O)
244
+ """
245
+ # General dipeptide amide pattern
246
+ dipeptide = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH][NX3][CX3](=[OX1])')
247
+ if dipeptide:
248
+ return mol.HasSubstructMatch(dipeptide)
249
+ return False
250
+
251
+ def has_glutaryl_spacer(self, mol):
252
+ """Check for glutaryl spacer: -C(=O)-(CH2)3-C(=O)-"""
253
+ glutaryl = Chem.MolFromSmarts('[CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])')
254
+ if glutaryl:
255
+ return mol.HasSubstructMatch(glutaryl)
256
+ return False
257
+
258
+ def has_unreacted_glutaryl_cooh(self, mol):
259
+ """Check if free COOH on glutaryl end remains (should be consumed)"""
260
+ unreacted = Chem.MolFromSmarts(
261
+ '[OX2H1][CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])[NX3]'
262
+ )
263
+ if unreacted:
264
+ return mol.HasSubstructMatch(unreacted)
265
+ return False
266
+
267
+ def check_attachment_site_compatibility(self, mol):
268
+ """
269
+ Check if attachment site chemistry is valid
270
+
271
+ Validates:
272
+ 1. Has PABC carbamate (intact self-immolative spacer)
273
+ 2. Has glutaryl spacer
274
+ 3. No unreacted glutaryl-COOH
275
+ """
276
+ if not self.has_pabc_carbamate(mol):
277
+ return False
278
+
279
+ if not self.has_glutaryl_spacer(mol):
280
+ return False
281
+
282
+ if self.has_unreacted_glutaryl_cooh(mol):
283
+ return False
284
+
285
+ return True
286
+
287
+ def _basic_validation(self, mol):
288
+ """Basic validation checks for Val-Ala-PABC-Daunorubicin conjugates"""
289
+ checks = {}
290
+
291
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
292
+ checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
293
+
294
+ checks['has_pabc_carbamate'] = self.has_pabc_carbamate(mol)
295
+ checks['has_val_ala_dipeptide'] = self.has_val_ala_dipeptide(mol)
296
+ checks['has_glutaryl_spacer'] = self.has_glutaryl_spacer(mol)
297
+ checks['no_unreacted_cooh'] = not self.has_unreacted_glutaryl_cooh(mol)
298
+ checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
299
+
300
+ anthraquinone = Chem.MolFromSmarts(
301
+ '[#6]1[#6][#6][#6]2[#6](=[#8])[#6]3[#6][#6][#6][#6][#6]3[#6](=[#8])[#6]2[#6]1'
302
+ )
303
+ checks['has_daunorubicin_core'] = mol.HasSubstructMatch(anthraquinone)
304
+
305
+ aminosugar = Chem.MolFromSmarts('[OX2]1[#6][#6][#6]([NX3])[#6][#6]1')
306
+ checks['has_aminosugar'] = mol.HasSubstructMatch(aminosugar)
307
+
308
+ checks['_passed'] = checks['attachment_site_valid']
309
+
310
+ return checks
311
+
312
+ def validate_conjugate(self, conjugate_smiles):
313
+ """Validate that the conjugate is chemically reasonable"""
314
+ mol = Chem.MolFromSmiles(conjugate_smiles)
315
+
316
+ if mol is None:
317
+ return False, {'error': 'Could not parse SMILES'}
318
+
319
+ checks = self._basic_validation(mol)
320
+ checks['parseable'] = True
321
+
322
+ passed = checks.get('_passed', True)
323
+ if '_passed' in checks:
324
+ del checks['_passed']
325
+
326
+ return passed, checks
327
+
328
+
329
+ if __name__ == "__main__":
330
+ print("=" * 70)
331
+ print("Val-Ala-PABC-Daunorubicin PDC Builder - Test")
332
+ print("=" * 70 + "\n")
333
+
334
+ builder = ValAlaPABCDaunorubicin_PDC_Builder()
335
+
336
+ test_peptides = [
337
+ "NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O", # Gly-Lys-Gly
338
+ ]
339
+
340
+ for pep in test_peptides:
341
+ try:
342
+ conjugates = builder.conjugate_to_peptide(pep, exclude_n_terminus=True)
343
+ print(f" {len(conjugates)} conjugate(s)")
344
+ for c in conjugates:
345
+ valid, checks = builder.validate_conjugate(c)
346
+ print(f" Valid: {valid}")
347
+ except Exception as e:
348
+ print(f" Failed: {e}")
smiles/conjugation/ValCitDau.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Val-Cit-PABC-Daunorubicin PDC Builder
3
+
4
+ Conjugation chemistry: Lysine ε-amine + Glutaryl-Val-Cit-PABC-Daunorubicin (amide bond formation)
5
+
6
+ Reference PDCs:
7
+ - PDC_00189 (GnRH-III-[2ΔHis-3D-Tic,8Lys(glutaryl-Val-Cit-PABC-Dau)])
8
+ - PDC_00194 (GnRH-III-[2His-3Trp,8Lys(glutaryl-Val-Cit-PABC-Dau)])
9
+ - Receptor: GNRHR
10
+ - Linker: Val-Cit (LIN00005)
11
+ - Payload: Daunorubicin (DRG00010)
12
+
13
+ Architecture:
14
+ Peptide──Lys──NH──C(=O)──(CH₂)₃──C(=O)──Val──Cit──PABC──O──C(=O)──NH──Dau
15
+ │ │ │ │
16
+ amide bond glutaryl dipeptide carbamate
17
+ (reaction) spacer cleavable self-immolative
18
+
19
+ The cleavable dipeptide Val-Cit (valine-citrulline) is recognized by
20
+ cathepsin B (lysosomal protease). After cleavage, PABC self-immolation
21
+ releases free Daunorubicin.
22
+
23
+ Citrulline (Cit) is a non-coded amino acid with a ureido side chain:
24
+ -CH₂-CH₂-CH₂-NH-C(=O)-NH₂
25
+ """
26
+
27
+ from rdkit import Chem
28
+ from rdkit.Chem import RWMol, AllChem
29
+
30
+ from .pdc_builder import PDC_Builder
31
+
32
+
33
+ class ValCitPABCDaunorubicin_PDC_Builder(PDC_Builder):
34
+ """
35
+ PDC builder for Glutaryl-Val-Cit-PABC-Daunorubicin conjugates
36
+
37
+ Conjugation chemistry (amide bond formation):
38
+ - Peptide site: Lysine ε-amine (side chain -NH2)
39
+ - Linker-payload site: Free carboxylic acid on glutaryl spacer
40
+ - Reaction: Amide bond (Lys-NH2 + HOOC-glutaryl-Val-Cit-PABC-Dau)
41
+
42
+ Architecture:
43
+ Peptide──Lys──NH──CO──(CH₂)₃──CO──Val──Cit──PABC──OCO──NH──Dau
44
+ └── amide ──┘ └── cleavable ──┘ └─ carbamate ─┘
45
+
46
+ Reference: PDC_00189, PDC_00194 (GnRH-III conjugates, GNRHR)
47
+ """
48
+
49
+ # Val-Cit-PABC-Daunorubicin with free glutaryl COOH
50
+ # Structure: HOOC-(CH2)3-CO-Val-Cit-PABC-OCO-NH-Dau
51
+ # - Glutaryl spacer connects to Val N-terminus via amide
52
+ # - Val-Cit dipeptide (cathepsin B cleavable, Cit has ureido side chain)
53
+ # - PABC (para-aminobenzylcarbamate) self-immolative spacer
54
+ # - Carbamate bond from PABC to Dau 3'-amine
55
+ DEFAULT_VALCIT_PABC_DAU_SMILES = (
56
+ "C[C@H]1[C@H]([C@H](C[C@@H](O1)O[C@H]2C[C@@](CC3=C2C(=C4C(=C3O)"
57
+ "C(=O)C5=C(C4=O)C(=CC=C5)OC)O)(C(=O)C)O)NC(=O)OCc6ccc(NC(=O)"
58
+ "[C@@H](CCCNC(=O)N)NC(=O)[C@@H](C(C)C)NC(=O)CCCC(=O)O)cc6)O"
59
+ )
60
+
61
+ def __init__(self, valcit_pabc_dau_smiles=None):
62
+ """
63
+ Initialize Val-Cit-PABC-Daunorubicin builder
64
+
65
+ Args:
66
+ valcit_pabc_dau_smiles: Optional custom SMILES. If None, uses default.
67
+ Must have free carboxylic acid for conjugation.
68
+ """
69
+ if valcit_pabc_dau_smiles is None:
70
+ valcit_pabc_dau_smiles = self.DEFAULT_VALCIT_PABC_DAU_SMILES
71
+
72
+ super().__init__(valcit_pabc_dau_smiles,
73
+ component_name="Val-Cit-PABC-Daunorubicin (linker-payload)")
74
+
75
+ def _verify_reactive_group(self):
76
+ """Verify carboxylic acid group is present for amide bond formation"""
77
+ carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
78
+
79
+ if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
80
+ print(" Warning: No carboxylic acid group detected in Val-Cit-PABC-Dau")
81
+ else:
82
+ print(" Val-Cit-PABC-Daunorubicin loaded with carboxylic acid reactive group")
83
+
84
+ # Verify PABC benzyl carbamate
85
+ pabc_pattern = Chem.MolFromSmarts('c1ccc(COC(=O)N)cc1')
86
+ if self.linker_payload_mol.HasSubstructMatch(pabc_pattern):
87
+ print(" PABC self-immolative spacer detected")
88
+ else:
89
+ print(" Warning: No PABC pattern detected")
90
+
91
+ # Verify citrulline ureido group
92
+ urea_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[NX3]')
93
+ if self.linker_payload_mol.HasSubstructMatch(urea_pattern):
94
+ print(" Citrulline ureido group detected")
95
+ else:
96
+ print(" Warning: No citrulline ureido group detected")
97
+
98
+ def find_conjugation_sites(self, peptide_smiles):
99
+ """Find all lysine ε-amine groups in peptide"""
100
+ return self._find_peptide_lysines(peptide_smiles)
101
+
102
+ def _find_peptide_lysines(self, peptide_smiles):
103
+ """Find all lysine residues with free ε-amine in peptide"""
104
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
105
+ if peptide_mol is None:
106
+ raise ValueError("Invalid peptide SMILES")
107
+
108
+ lysines = []
109
+
110
+ lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
111
+ matches = peptide_mol.GetSubstructMatches(lys_pattern)
112
+
113
+ for match in matches:
114
+ nitrogen_idx = match[4]
115
+ lysines.append({
116
+ 'atom_idx': nitrogen_idx,
117
+ 'side_chain_atoms': list(match),
118
+ 'type': 'Lys_epsilon_amine'
119
+ })
120
+
121
+ n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
122
+ n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
123
+ if n_term_matches:
124
+ print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
125
+
126
+ return lysines
127
+
128
+ def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
129
+ """Perform amide bond conjugation"""
130
+ return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
131
+
132
+ def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
133
+ """
134
+ Create ALL possible conjugates by forming amide bond at NH2 sites
135
+
136
+ Chemistry:
137
+ - Any-NH2 + HOOC-glutaryl-Val-Cit-PABC-Dau → amide bond + H2O
138
+ """
139
+ if exclude_n_terminus:
140
+ amide_rxn = AllChem.ReactionFromSmarts(
141
+ '[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>'
142
+ '[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
143
+ )
144
+ else:
145
+ amide_rxn = AllChem.ReactionFromSmarts(
146
+ '[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
147
+ )
148
+
149
+ products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
150
+
151
+ if not products:
152
+ raise ValueError("Amide bond formation reaction failed - no products generated")
153
+
154
+ conjugates = []
155
+ seen_smiles = set()
156
+
157
+ for product_tuple in products:
158
+ try:
159
+ conjugate_mol = product_tuple[0]
160
+ Chem.SanitizeMol(conjugate_mol)
161
+ conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
162
+
163
+ if conjugate_smiles not in seen_smiles:
164
+ seen_smiles.add(conjugate_smiles)
165
+ conjugates.append(conjugate_smiles)
166
+ except Exception:
167
+ continue
168
+
169
+ if not conjugates:
170
+ raise ValueError("All products failed sanitization")
171
+
172
+ return conjugates
173
+
174
+ def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
175
+ """
176
+ Conjugate Val-Cit-PABC-Daunorubicin to peptide via amide bond
177
+
178
+ Args:
179
+ peptide_smiles: SMILES string of peptide (must contain NH2)
180
+ exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
181
+ return_all: If True, return all possible conjugates
182
+
183
+ Returns:
184
+ If return_all=True: List of all possible conjugate SMILES
185
+ If return_all=False: Single conjugate SMILES
186
+ """
187
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
188
+ if peptide_mol is None:
189
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
190
+
191
+ sites = self.find_conjugation_sites(peptide_smiles)
192
+ print(f"Found {len(sites)} Lys ε-amine site(s)")
193
+
194
+ conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
195
+
196
+ site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
197
+ print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}]")
198
+
199
+ if return_all:
200
+ return conjugates
201
+ else:
202
+ return conjugates[0] if conjugates else None
203
+
204
+ def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
205
+ """Batch conjugate multiple peptides"""
206
+ results = {
207
+ 'conjugates': [],
208
+ 'successful': [],
209
+ 'failed': [],
210
+ 'total_conjugates': 0
211
+ }
212
+
213
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
214
+ try:
215
+ conjugates = self.conjugate_to_peptide(
216
+ peptide_smiles,
217
+ exclude_n_terminus=exclude_n_terminus,
218
+ return_all=return_all
219
+ )
220
+ if return_all:
221
+ results['conjugates'].append(conjugates)
222
+ results['total_conjugates'] += len(conjugates)
223
+ else:
224
+ results['conjugates'].append(conjugates)
225
+ results['total_conjugates'] += 1
226
+ results['successful'].append(i)
227
+ except Exception as e:
228
+ print(f" Peptide {i} failed: {e}")
229
+ results['conjugates'].append(None)
230
+ results['failed'].append((i, str(e)))
231
+
232
+ return results
233
+
234
+ # =========================================================================
235
+ # Attachment Site Compatibility Checks
236
+ # =========================================================================
237
+
238
+ def has_pabc_carbamate(self, mol):
239
+ """Check for PABC benzyl carbamate: Ar-CH2-O-C(=O)-N"""
240
+ pabc_pattern = Chem.MolFromSmarts('c1ccc(COC(=O)[NX3])cc1')
241
+ if pabc_pattern:
242
+ return mol.HasSubstructMatch(pabc_pattern)
243
+ return False
244
+
245
+ def has_val_cit_dipeptide(self, mol):
246
+ """
247
+ Check for Val-Cit dipeptide fragment
248
+
249
+ Citrulline has a ureido side chain: -CH₂CH₂CH₂-NH-C(=O)-NH₂
250
+ """
251
+ # Check for ureido (citrulline marker)
252
+ urea_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[NX3]')
253
+ if urea_pattern:
254
+ return mol.HasSubstructMatch(urea_pattern)
255
+ return False
256
+
257
+ def has_glutaryl_spacer(self, mol):
258
+ """Check for glutaryl spacer: -C(=O)-(CH2)3-C(=O)-"""
259
+ glutaryl = Chem.MolFromSmarts('[CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])')
260
+ if glutaryl:
261
+ return mol.HasSubstructMatch(glutaryl)
262
+ return False
263
+
264
+ def has_unreacted_glutaryl_cooh(self, mol):
265
+ """Check if free COOH on glutaryl end remains (should be consumed)"""
266
+ unreacted = Chem.MolFromSmarts(
267
+ '[OX2H1][CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])[NX3]'
268
+ )
269
+ if unreacted:
270
+ return mol.HasSubstructMatch(unreacted)
271
+ return False
272
+
273
+ def check_attachment_site_compatibility(self, mol):
274
+ """
275
+ Check if attachment site chemistry is valid
276
+
277
+ Validates:
278
+ 1. Has PABC carbamate
279
+ 2. Has glutaryl spacer
280
+ 3. Has citrulline ureido group
281
+ 4. No unreacted glutaryl-COOH
282
+ """
283
+ if not self.has_pabc_carbamate(mol):
284
+ return False
285
+
286
+ if not self.has_glutaryl_spacer(mol):
287
+ return False
288
+
289
+ if not self.has_val_cit_dipeptide(mol):
290
+ return False
291
+
292
+ if self.has_unreacted_glutaryl_cooh(mol):
293
+ return False
294
+
295
+ return True
296
+
297
+ def _basic_validation(self, mol):
298
+ """Basic validation checks for Val-Cit-PABC-Daunorubicin conjugates"""
299
+ checks = {}
300
+
301
+ amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
302
+ checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
303
+
304
+ checks['has_pabc_carbamate'] = self.has_pabc_carbamate(mol)
305
+ checks['has_val_cit_dipeptide'] = self.has_val_cit_dipeptide(mol)
306
+ checks['has_glutaryl_spacer'] = self.has_glutaryl_spacer(mol)
307
+ checks['no_unreacted_cooh'] = not self.has_unreacted_glutaryl_cooh(mol)
308
+ checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
309
+
310
+ anthraquinone = Chem.MolFromSmarts(
311
+ '[#6]1[#6][#6][#6]2[#6](=[#8])[#6]3[#6][#6][#6][#6][#6]3[#6](=[#8])[#6]2[#6]1'
312
+ )
313
+ checks['has_daunorubicin_core'] = mol.HasSubstructMatch(anthraquinone)
314
+
315
+ aminosugar = Chem.MolFromSmarts('[OX2]1[#6][#6][#6]([NX3])[#6][#6]1')
316
+ checks['has_aminosugar'] = mol.HasSubstructMatch(aminosugar)
317
+
318
+ checks['_passed'] = checks['attachment_site_valid']
319
+
320
+ return checks
321
+
322
+ def validate_conjugate(self, conjugate_smiles):
323
+ """Validate that the conjugate is chemically reasonable"""
324
+ mol = Chem.MolFromSmiles(conjugate_smiles)
325
+
326
+ if mol is None:
327
+ return False, {'error': 'Could not parse SMILES'}
328
+
329
+ checks = self._basic_validation(mol)
330
+ checks['parseable'] = True
331
+
332
+ passed = checks.get('_passed', True)
333
+ if '_passed' in checks:
334
+ del checks['_passed']
335
+
336
+ return passed, checks
337
+
338
+
339
+ if __name__ == "__main__":
340
+ print("=" * 70)
341
+ print("Val-Cit-PABC-Daunorubicin PDC Builder - Test")
342
+ print("=" * 70 + "\n")
343
+
344
+ builder = ValCitPABCDaunorubicin_PDC_Builder()
345
+
346
+ test_peptides = [
347
+ "NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O", # Gly-Lys-Gly
348
+ ]
349
+
350
+ for pep in test_peptides:
351
+ try:
352
+ conjugates = builder.conjugate_to_peptide(pep, exclude_n_terminus=True)
353
+ print(f" {len(conjugates)} conjugate(s)")
354
+ for c in conjugates:
355
+ valid, checks = builder.validate_conjugate(c)
356
+ print(f" Valid: {valid}")
357
+ except Exception as e:
358
+ print(f" Failed: {e}")
smiles/conjugation/VcMMAE.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from rdkit import Chem
2
+ from rdkit.Chem import RWMol
3
+
4
+ from .pdc_builder import PDC_Builder
5
+
6
+
7
+ class VcMMAE_PDC_Builder(PDC_Builder):
8
+ """
9
+ Specialized builder for vcMMAE (valine-citrulline-MMAE) conjugates
10
+
11
+ vcMMAE consists of:
12
+ - Linker: Val-Cit-PABC (valine-citrulline-para-aminobenzylcarbamate) - cleavable dipeptide
13
+ - Payload: MMAE (monomethyl auristatin E) - cytotoxic drug
14
+ - Reactive group: Maleimide for cysteine conjugation
15
+
16
+ Conjugation chemistry (thiol-maleimide Michael addition):
17
+ - Peptide site: Free cysteine thiol (-SH)
18
+ - vcMMAE site: Maleimide group (C=C in 5-membered ring)
19
+ - Reaction: Thiol adds to maleimide C=C, forming stable thioether bond
20
+ - Product: Peptide-S-vcMMAE conjugate
21
+ """
22
+
23
+ def __init__(self, vcmmae_smiles=None):
24
+ """
25
+ Initialize vcMMAE builder
26
+
27
+ Args:
28
+ vcmmae_smiles: Optional custom vcMMAE (linker-payload) SMILES. If None, uses default.
29
+ """
30
+ # Default vcMMAE SMILES: maleimide-(6-carbon spacer)-Val-Cit-PABC-MMAE
31
+ # Structure: Maleimide-C6-Val-Cit-PABC-MMAE
32
+ if vcmmae_smiles is None:
33
+ vcmmae_smiles = "O[C@@H](C1=CC=CC=C1)[C@@H](C)NC([C@H](C)[C@H]([C@@]2([H])N(CCC2)C(C[C@@H](OC)[C@H]([C@@H](C)CC)N(C)C([C@H](C(C)C)NC([C@H](C(C)C)N(C)C(OCC3=CC=C(C=C3)NC([C@H](CCCNC(N)=O)NC([C@H](C(C)C)NC(CCCCCN4C(C=CC4=O)=O)=O)=O)=O)=O)=O)=O)=O)OC)=O"
34
+
35
+ # Initialize base class
36
+ super().__init__(vcmmae_smiles, component_name="vcMMAE (linker-payload)")
37
+
38
+ def _verify_reactive_group(self):
39
+ """Verify maleimide group is present"""
40
+ maleimide_pattern = Chem.MolFromSmarts('[#7]1[#6](=[#8])[#6]=[#6][#6]1=[#8]')
41
+ if not self.linker_payload_mol.HasSubstructMatch(maleimide_pattern):
42
+ print(" Warning: No maleimide group detected")
43
+ else:
44
+ print(" vcMMAE (linker-payload) loaded with maleimide reactive group")
45
+
46
+ def find_conjugation_sites(self, peptide_smiles):
47
+ """
48
+ Find all free cysteine residues (thiol groups) in peptide
49
+
50
+ Returns list of dicts with site information including atom index
51
+ """
52
+ return self.find_peptide_cysteines(peptide_smiles)
53
+
54
+ def find_peptide_cysteines(self, peptide_smiles):
55
+ """
56
+ Find all free cysteine residues in peptide
57
+
58
+ FIX: Iterate over all atoms instead of using restrictive SMARTS
59
+ """
60
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
61
+ if peptide_mol is None:
62
+ raise ValueError("Invalid peptide SMILES")
63
+
64
+ cysteines = []
65
+
66
+ # FIX: Directly check each sulfur atom
67
+ for atom in peptide_mol.GetAtoms():
68
+ if atom.GetAtomicNum() == 16: # Sulfur
69
+ # Check if it's a thiol (has hydrogen attached)
70
+ total_hs = atom.GetTotalNumHs() # Includes implicit H
71
+ degree = atom.GetDegree() # Number of bonds to other heavy atoms
72
+
73
+ # Thiol: S with 1 H and bonded to 1 carbon
74
+ # (vs disulfide: S-S with no H)
75
+ if total_hs >= 1 and degree == 1:
76
+ cysteines.append({
77
+ 'atom_idx': atom.GetIdx(),
78
+ 'num_hydrogens': total_hs,
79
+ 'neighbors': [peptide_mol.GetAtomWithIdx(n.GetIdx()).GetSymbol()
80
+ for n in atom.GetNeighbors()]
81
+ })
82
+
83
+ return cysteines
84
+
85
+ def _find_maleimide_atoms(self, mol):
86
+ """
87
+ Find the maleimide C=C double bond atoms
88
+
89
+ Returns: (carbon1_idx, carbon2_idx) of the C=C in maleimide
90
+ """
91
+ # Pattern: N in ring, with two C=O, and C=C
92
+ # O=C-C=C-C=O with N closing the ring
93
+ maleimide_pattern = Chem.MolFromSmarts('[#7]1[#6](=[#8])[#6]=[#6][#6]1=[#8]')
94
+ matches = mol.GetSubstructMatches(maleimide_pattern)
95
+
96
+ if not matches:
97
+ return None, None
98
+
99
+ # Pattern indices: 0=N, 1=C(=O), 2=C(of C=C), 3=C(of C=C), 4=C(=O)
100
+ match = matches[0]
101
+ return match[2], match[3]
102
+
103
+ def _perform_conjugation(self, peptide_mol, site_info):
104
+ """
105
+ Perform thiol-maleimide conjugation
106
+
107
+ Uses manual bond formation for reliability with complex molecules
108
+ """
109
+ return self._manual_conjugation(peptide_mol, site_info)
110
+
111
+ def conjugate_to_peptide(self, peptide_smiles, cys_position=0):
112
+ """
113
+ Conjugate vcMMAE to peptide via thiol-maleimide Michael addition
114
+
115
+ Compatibility wrapper - calls parent class method with renamed parameter
116
+ """
117
+ return super().conjugate_to_peptide(peptide_smiles, site_position=cys_position)
118
+
119
+ def batch_conjugate(self, peptide_smiles_list, cys_position=0):
120
+ """
121
+ Batch conjugate multiple peptides
122
+
123
+ Compatibility wrapper - calls parent class method with renamed parameter
124
+ """
125
+ return super().batch_conjugate(peptide_smiles_list, site_position=cys_position)
126
+
127
+ # =========================================================================
128
+ # Attachment Site Compatibility Checks (thiol-maleimide specific)
129
+ # =========================================================================
130
+
131
+ def has_free_thiol(self, mol):
132
+ """
133
+ Check if molecule has unconjugated (free) thiol groups
134
+
135
+ A valid PDC should NOT have free thiols - they should be conjugated
136
+ """
137
+ # SMARTS pattern check
138
+ free_thiol_pattern = Chem.MolFromSmarts('[CH2][SH1]')
139
+ free_thiol_alt = Chem.MolFromSmarts('[CH2][S;H1;v2]')
140
+
141
+ if free_thiol_pattern and mol.HasSubstructMatch(free_thiol_pattern):
142
+ return True
143
+ if free_thiol_alt and mol.HasSubstructMatch(free_thiol_alt):
144
+ return True
145
+
146
+ # Also check by iterating atoms (more reliable)
147
+ for atom in mol.GetAtoms():
148
+ if atom.GetAtomicNum() == 16: # Sulfur
149
+ if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
150
+ # Sulfur with H and only one bond = free thiol
151
+ return True
152
+
153
+ return False
154
+
155
+ def has_unreacted_maleimide(self, mol):
156
+ """
157
+ Check if molecule has unreacted maleimide (C=C in ring)
158
+
159
+ After thiol-maleimide conjugation, the C=C should become C-C
160
+ """
161
+ maleimide_unreacted = Chem.MolFromSmarts('[#7]1[#6](=[#8])[#6]=[#6][#6]1=[#8]')
162
+ if maleimide_unreacted:
163
+ return mol.HasSubstructMatch(maleimide_unreacted)
164
+ return False
165
+
166
+ def find_thioether_conjugation_sites(self, mol):
167
+ """
168
+ Identify thioether (C-S-C) conjugation sites in the molecule
169
+
170
+ For thiol-maleimide conjugation, the sulfur should be bonded to:
171
+ - One carbon from the peptide cysteine
172
+ - One carbon from the (former) maleimide ring
173
+
174
+ Returns list of sulfur atom indices that are conjugation sites
175
+ """
176
+ thioether_pattern = Chem.MolFromSmarts('[#6][#16][#6]')
177
+ conjugation_sites = []
178
+
179
+ if thioether_pattern:
180
+ matches = mol.GetSubstructMatches(thioether_pattern)
181
+ for match in matches:
182
+ sulfur_idx = match[1] # S is the middle atom in C-S-C
183
+ sulfur_atom = mol.GetAtomWithIdx(sulfur_idx)
184
+
185
+ # Check if one neighbor is in a 5-membered ring (succinimide)
186
+ neighbors = [mol.GetAtomWithIdx(n.GetIdx()) for n in sulfur_atom.GetNeighbors()]
187
+ for neighbor in neighbors:
188
+ if neighbor.IsInRingSize(5):
189
+ conjugation_sites.append(sulfur_idx)
190
+ break
191
+ else:
192
+ # Even if not clearly in succinimide, thioether is likely conjugation
193
+ conjugation_sites.append(sulfur_idx)
194
+
195
+ return list(set(conjugation_sites)) # Remove duplicates
196
+
197
+ def check_valency_at_conjugation_site(self, mol, conjugation_atom_idx):
198
+ """
199
+ Check valency specifically at a sulfur conjugation site atom
200
+
201
+ Valid thioether states for thiol-maleimide conjugation:
202
+ - Thioether: S with 2 single bonds to C, no H (C-S-C)
203
+ - NOT: free thiol, sulfonium, hypervalent sulfur
204
+ """
205
+ atom = mol.GetAtomWithIdx(conjugation_atom_idx)
206
+
207
+ if atom.GetSymbol() != 'S':
208
+ return True # Not sulfur, skip this check
209
+
210
+ degree = atom.GetDegree() # Number of bonds to other heavy atoms
211
+ total_hs = atom.GetTotalNumHs()
212
+ formal_charge = atom.GetFormalCharge()
213
+
214
+ # Get neighbor information
215
+ neighbors = [mol.GetAtomWithIdx(n.GetIdx()) for n in atom.GetNeighbors()]
216
+ neighbor_symbols = [n.GetSymbol() for n in neighbors]
217
+
218
+ # Count double bonds to oxygen (for sulfoxide/sulfone)
219
+ double_bonded_oxygens = 0
220
+ for neighbor in neighbors:
221
+ if neighbor.GetSymbol() == 'O':
222
+ bond = mol.GetBondBetweenAtoms(conjugation_atom_idx, neighbor.GetIdx())
223
+ if bond and bond.GetBondType() == Chem.BondType.DOUBLE:
224
+ double_bonded_oxygens += 1
225
+
226
+ # ========== Valid Sulfur States ==========
227
+
228
+ # 1. Thioether: S with 2 single bonds to C, no H
229
+ # C-S-C (degree=2, no O, no charge)
230
+ if degree == 2 and 'O' not in neighbor_symbols and formal_charge == 0:
231
+ if total_hs == 0:
232
+ return True # Valid thioether
233
+ else:
234
+ return False # Has H = still partially a thiol
235
+
236
+ # 2. Sulfoxide: S with 1 double-bonded O
237
+ # C-S(=O)-C (degree=3, one =O)
238
+ if degree == 3 and double_bonded_oxygens == 1 and formal_charge == 0:
239
+ return True
240
+
241
+ # 3. Sulfone: S with 2 double-bonded O
242
+ # C-S(=O)(=O)-C (degree=4, two =O)
243
+ if degree == 4 and double_bonded_oxygens == 2 and formal_charge == 0:
244
+ return True
245
+
246
+ # ========== Invalid Sulfur States ==========
247
+
248
+ # Sulfonium (positively charged S with 3+ bonds) - INVALID
249
+ if degree >= 3 and formal_charge > 0:
250
+ return False
251
+
252
+ # Hypervalent sulfur without proper oxygen - INVALID
253
+ if degree > 2 and double_bonded_oxygens == 0:
254
+ return False
255
+
256
+ # If none of the valid patterns matched, it's invalid
257
+ return False
258
+
259
+ def check_attachment_site_compatibility(self, mol):
260
+ """
261
+ Check if thiol-maleimide attachment site chemistry is valid
262
+
263
+ For vcMMAE conjugation, validates:
264
+ 1. No free thiols (conjugation must have happened)
265
+ 2. Has thioether bond (C-S-C)
266
+ 3. No unreacted maleimide (C=C should be reduced)
267
+ 4. Valid valency at conjugation sites
268
+
269
+ Args:
270
+ mol: RDKit Mol object of the conjugate
271
+
272
+ Returns:
273
+ bool: True if attachment site is valid, False otherwise
274
+ """
275
+ # ===== CRITICAL CHECK: Reject if free thiol present =====
276
+ # A valid PDC should have the thiol CONSUMED by conjugation
277
+ if self.has_free_thiol(mol):
278
+ return False
279
+
280
+ # ===== Check for thioether (C-S-C) =====
281
+ thioether_pattern = Chem.MolFromSmarts('[#6][#16][#6]')
282
+ if not thioether_pattern or not mol.HasSubstructMatch(thioether_pattern):
283
+ return False # No thioether = no conjugation
284
+
285
+ # ===== Check valency at conjugation sites =====
286
+ conjugation_sites = self.find_thioether_conjugation_sites(mol)
287
+
288
+ for site_idx in conjugation_sites:
289
+ if not self.check_valency_at_conjugation_site(mol, site_idx):
290
+ return False
291
+
292
+ return True
293
+
294
+ # =========================================================================
295
+ # Manual Conjugation
296
+ # =========================================================================
297
+
298
+ def _manual_conjugation(self, peptide_mol, cysteine_info):
299
+ """
300
+ Create conjugate by manually forming bonds
301
+
302
+ Chemistry:
303
+ - Maleimide C=C becomes C-C (single bond)
304
+ - Sulfur bonds to one of these carbons
305
+ - Proton transfers (S loses H, C gains H)
306
+
307
+ Result: Peptide-Cys-S-(linker-payload)
308
+ """
309
+
310
+ # Find maleimide C=C in vcMMAE linker-payload
311
+ maleimide_c1, maleimide_c2 = self._find_maleimide_atoms(self.linker_payload_mol)
312
+
313
+ if maleimide_c1 is None:
314
+ raise ValueError("Could not locate maleimide in vcMMAE linker-payload")
315
+
316
+ sulfur_idx = cysteine_info['atom_idx']
317
+
318
+ # Step 1: Combine molecules
319
+ combined = Chem.CombineMols(self.linker_payload_mol, peptide_mol)
320
+ combined_rw = RWMol(combined)
321
+
322
+ # Step 2: Adjust indices (peptide atoms are shifted)
323
+ linker_payload_atom_count = self.linker_payload_mol.GetNumAtoms()
324
+ new_sulfur_idx = sulfur_idx + linker_payload_atom_count
325
+
326
+ # Step 3: Change maleimide C=C to C-C
327
+ bond = combined_rw.GetBondBetweenAtoms(maleimide_c1, maleimide_c2)
328
+ if bond is not None:
329
+ bond.SetBondType(Chem.BondType.SINGLE)
330
+
331
+ # Step 4: Add S-C bond (sulfur to one maleimide carbon)
332
+ combined_rw.AddBond(new_sulfur_idx, maleimide_c2, Chem.BondType.SINGLE)
333
+
334
+ # Step 5: Sanitize and clean up
335
+ try:
336
+ mol = combined_rw.GetMol()
337
+ Chem.SanitizeMol(mol)
338
+ conjugate_smiles = Chem.MolToSmiles(mol)
339
+ return conjugate_smiles
340
+
341
+ except Exception as e:
342
+ # Try alternative sanitization
343
+ try:
344
+ mol = combined_rw.GetMol()
345
+ mol = Chem.RemoveAllHs(mol)
346
+ Chem.SanitizeMol(mol)
347
+ conjugate_smiles = Chem.MolToSmiles(mol)
348
+ return conjugate_smiles
349
+ except Exception as e2:
350
+ raise ValueError(f"Sanitization failed: {e2}")
351
+
352
+ def _basic_validation(self, mol):
353
+ """
354
+ Basic validation checks for thiol-maleimide conjugates
355
+
356
+ Includes attachment site compatibility check with valency validation.
357
+
358
+ Returns dict of check_name: bool pairs
359
+ """
360
+ checks = {}
361
+
362
+ # Check 1: Has thioether (the new C-S-C bond)
363
+ thioether = Chem.MolFromSmarts('[#6]-[#16]-[#6]')
364
+ checks['has_thioether'] = mol.HasSubstructMatch(thioether)
365
+
366
+ # Check 2: Maleimide C=C is gone (should be succinimide now)
367
+ checks['maleimide_consumed'] = not self.has_unreacted_maleimide(mol)
368
+
369
+ # Check 3: Has succinimide (reduced maleimide)
370
+ succinimide = Chem.MolFromSmarts('[#7]1[#6](=[#8])[#6][#6][#6]1=[#8]')
371
+ checks['has_succinimide'] = mol.HasSubstructMatch(succinimide)
372
+
373
+ # Check 4: No free thiol remaining
374
+ checks['no_free_thiol'] = not self.has_free_thiol(mol)
375
+
376
+ # Check 5: Full attachment site compatibility (includes valency)
377
+ checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
378
+
379
+ # Find and report conjugation sites for diagnostics
380
+ conjugation_sites = self.find_thioether_conjugation_sites(mol)
381
+ checks['conjugation_sites'] = conjugation_sites
382
+
383
+ # Overall pass/fail - attachment_site_valid includes thioether and no_free_thiol checks
384
+ checks['_passed'] = checks['attachment_site_valid']
385
+
386
+ return checks
387
+
388
+
389
+
390
+ # ============================================================================
391
+ # Main - Test Examples
392
+ # ============================================================================
393
+
394
+ if __name__ == "__main__":
395
+
396
+ print("=" * 70)
397
+ print("PDC Builder Framework - vcMMAE Example")
398
+ print("=" * 70 + "\n")
399
+
400
+ builder = VcMMAE_PDC_Builder()
401
+
402
+ # Test peptides with free cysteine
403
+ test_peptides = [
404
+ "NCC(=O)N[C@@H](CS)C(=O)NCC(=O)O", # Gly-Cys-Gly
405
+ "N[C@@H](CS)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O", # Cys-Arg
406
+ "N[C@@H](CCCNC(=N)N)C(=O)N[C@@H](CS)C(=O)N[C@@H](CC(C)C)C(=O)O", # Arg-Cys-Leu
407
+ ]
408
+
409
+ print("\n" + "=" * 70)
410
+ print("Testing Single Conjugation")
411
+ print("=" * 70 + "\n")
412
+
413
+ peptide = test_peptides[0]
414
+ print(f"Input peptide: {peptide}")
415
+
416
+ try:
417
+ conjugate = builder.conjugate_to_peptide(peptide)
418
+ print(f"\n SUCCESS!")
419
+ print(f"Conjugate SMILES: {conjugate[:80]}...")
420
+
421
+ valid, checks = builder.validate_conjugate(conjugate, use_analyzer=False)
422
+ print(f"\n--- Basic Validation ---")
423
+ print(f"Result: {'PASSED' if valid else 'FAILED'}")
424
+ for k, v in checks.items():
425
+ print(f" {k}: {v}")
426
+
427
+ except Exception as e:
428
+ print(f"\n FAILED: {e}")
429
+ import traceback
430
+ traceback.print_exc()
431
+
432
+ print("\n" + "=" * 70)
433
+ print("Batch Conjugation & Validation")
434
+ print("=" * 70 + "\n")
435
+
436
+ # Batch conjugate
437
+ results = builder.batch_conjugate(test_peptides)
438
+
439
+ # Get successful conjugates
440
+ successful_conjugates = [c for c in results['conjugates'] if c is not None]
441
+
442
+ if len(successful_conjugates) > 0:
443
+ print(f"\nValidating {len(successful_conjugates)} conjugates...")
444
+
445
+ # Basic validation
446
+ print("\n--- Basic Validation ---")
447
+ basic_results = builder.batch_validate(successful_conjugates, use_analyzer=False)
448
+
449
+ for i, (passed, checks) in enumerate(basic_results):
450
+ status = " VALID" if passed else " INVALID"
451
+ print(f" Conjugate {i+1}: {status}")
452
+
453
+ if not passed:
454
+ # Show which checks failed
455
+ for check_name, check_val in checks.items():
456
+ if check_name not in ['validation_method', 'parseable', 'conjugation_sites'] and not check_val:
457
+ print(f" Failed: {check_name}")
smiles/conjugation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """PDC conjugation builders."""
smiles/conjugation/pdc_builder.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDC Builders - General and Specialized
3
+ """
4
+
5
+ from rdkit import Chem
6
+ from rdkit.Chem import Descriptors
7
+ from abc import ABC, abstractmethod
8
+
9
+
10
+ class PDC_Builder(ABC):
11
+ """
12
+ Base class for Peptide-Drug Conjugate (PDC) builders
13
+
14
+ This abstract class defines the interface for building PDCs with different
15
+ conjugation chemistries. Subclasses must implement specific conjugation rules.
16
+
17
+ The conjugation component can be:
18
+ - Just a drug payload with reactive group
19
+ - A linker-payload combination (e.g., vcMMAE = Val-Cit linker + MMAE payload)
20
+ - A linker alone (for later payload attachment)
21
+ """
22
+
23
+ def __init__(self, linker_payload_smiles, component_name="Drug-Linker"):
24
+ """
25
+ Initialize PDC builder with a linker-payload component
26
+
27
+ Args:
28
+ linker_payload_smiles: SMILES string of the linker-payload component
29
+ component_name: Name of the component (for display)
30
+ """
31
+ self.linker_payload_smiles = linker_payload_smiles
32
+ self.component_name = component_name
33
+ self.linker_payload_mol = Chem.MolFromSmiles(linker_payload_smiles)
34
+
35
+ if self.linker_payload_mol is None:
36
+ raise ValueError(f"Could not parse {component_name} SMILES: {linker_payload_smiles}")
37
+
38
+ # Verify reactive group is present
39
+ self._verify_reactive_group()
40
+
41
+ @abstractmethod
42
+ def _verify_reactive_group(self):
43
+ """Verify that the linker-payload component has the expected reactive group"""
44
+ pass
45
+
46
+ @abstractmethod
47
+ def find_conjugation_sites(self, peptide_smiles):
48
+ """
49
+ Find conjugation sites in the peptide
50
+
51
+ Args:
52
+ peptide_smiles: SMILES string of the peptide
53
+
54
+ Returns:
55
+ List of dicts with conjugation site information
56
+ """
57
+ pass
58
+
59
+ @abstractmethod
60
+ def _perform_conjugation(self, peptide_mol, site_info):
61
+ """
62
+ Perform the actual conjugation chemistry
63
+
64
+ Args:
65
+ peptide_mol: RDKit Mol object of the peptide
66
+ site_info: Dict with conjugation site information
67
+
68
+ Returns:
69
+ SMILES string of the conjugate
70
+ """
71
+ pass
72
+
73
+ @abstractmethod
74
+ def check_attachment_site_compatibility(self, mol):
75
+ """
76
+ Check if attachment site chemistry is valid for this conjugation type
77
+
78
+ This is conjugation-method specific and must be implemented by subclasses.
79
+ Checks should include:
80
+ - Presence of expected conjugation bond (e.g., thioether for thiol-maleimide)
81
+ - Absence of unreacted precursors (e.g., free thiol, unreacted maleimide)
82
+ - Valency at conjugation sites
83
+
84
+ Args:
85
+ mol: RDKit Mol object of the conjugate
86
+
87
+ Returns:
88
+ bool: True if attachment site is valid, False otherwise
89
+ """
90
+ pass
91
+
92
+ def conjugate_to_peptide(self, peptide_smiles, site_position=0):
93
+ """
94
+ Conjugate linker-payload to peptide at specified conjugation site
95
+
96
+ Args:
97
+ peptide_smiles: SMILES string of the peptide
98
+ site_position: Index of the conjugation site to use (default: 0)
99
+
100
+ Returns:
101
+ SMILES string of the PDC conjugate
102
+ """
103
+ peptide_mol = Chem.MolFromSmiles(peptide_smiles)
104
+ if peptide_mol is None:
105
+ raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
106
+
107
+ sites = self.find_conjugation_sites(peptide_smiles)
108
+
109
+ if len(sites) == 0:
110
+ raise ValueError(f"No conjugation sites found in peptide")
111
+
112
+ if site_position >= len(sites):
113
+ raise ValueError(f"Site position {site_position} out of range (found {len(sites)} sites)")
114
+
115
+ print(f"Found {len(sites)} conjugation site(s) in peptide")
116
+ print(f"Conjugating at site position {site_position}")
117
+
118
+ target_site = sites[site_position]
119
+ return self._perform_conjugation(peptide_mol, target_site)
120
+
121
+ def batch_conjugate(self, peptide_smiles_list, site_position=0):
122
+ """
123
+ Conjugate multiple peptides in batch
124
+
125
+ Args:
126
+ peptide_smiles_list: List of peptide SMILES strings
127
+ site_position: Index of conjugation site to use
128
+
129
+ Returns:
130
+ Dict with 'conjugates', 'successful', and 'failed' keys
131
+ """
132
+ results = {
133
+ 'conjugates': [],
134
+ 'successful': [],
135
+ 'failed': [],
136
+ }
137
+
138
+ for i, peptide_smiles in enumerate(peptide_smiles_list):
139
+ try:
140
+ conjugate = self.conjugate_to_peptide(peptide_smiles, site_position)
141
+ results['conjugates'].append(conjugate)
142
+ results['successful'].append(i)
143
+ except Exception as e:
144
+ print(f" Peptide {i} failed: {e}")
145
+ results['conjugates'].append(None)
146
+ results['failed'].append((i, str(e)))
147
+
148
+ print(f"\n=== Summary ===")
149
+ print(f"Total: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
150
+
151
+ return results
152
+
153
+ def get_conjugate_properties(self, conjugate_smiles):
154
+ """Calculate molecular properties of the conjugate"""
155
+ mol = Chem.MolFromSmiles(conjugate_smiles)
156
+ if mol is None:
157
+ return None
158
+
159
+ return {
160
+ 'molecular_weight': Descriptors.MolWt(mol),
161
+ 'num_heavy_atoms': mol.GetNumHeavyAtoms(),
162
+ 'logp': Descriptors.MolLogP(mol),
163
+ 'tpsa': Descriptors.TPSA(mol), # Topological Polar Surface Area
164
+ }
165
+
166
+ def validate_conjugate(self, conjugate_smiles):
167
+ """
168
+ Validate that the conjugate is chemically reasonable
169
+
170
+ Args:
171
+ conjugate_smiles: SMILES string of the conjugate
172
+
173
+ Returns:
174
+ (passed: bool, checks: dict)
175
+ """
176
+ mol = Chem.MolFromSmiles(conjugate_smiles)
177
+
178
+ if mol is None:
179
+ return False, {'error': 'Could not parse SMILES'}
180
+
181
+ # Basic validation (delegated to subclass)
182
+ checks = self._basic_validation(mol)
183
+ checks['validation_method'] = 'basic'
184
+ checks['parseable'] = True
185
+
186
+ # Get pass/fail from subclass implementation
187
+ passed = checks.get('_passed', True)
188
+ if '_passed' in checks:
189
+ del checks['_passed'] # Remove internal flag from output
190
+
191
+ return passed, checks
192
+
193
+ @abstractmethod
194
+ def _basic_validation(self, mol):
195
+ """
196
+ Perform basic validation checks specific to conjugation type
197
+
198
+ Should include check_attachment_site_compatibility() and any other
199
+ conjugation-specific checks.
200
+
201
+ Args:
202
+ mol: RDKit Mol object of the conjugate
203
+
204
+ Returns:
205
+ Dict of check_name: bool pairs. Include '_passed': bool for overall result.
206
+ """
207
+ pass
208
+
209
+ def batch_validate(self, conjugate_smiles_list):
210
+ """Validate multiple conjugates"""
211
+ results = []
212
+ for smiles in conjugate_smiles_list:
213
+ if smiles is None:
214
+ results.append((False, {'error': 'None SMILES'}))
215
+ else:
216
+ passed, checks = self.validate_conjugate(smiles)
217
+ results.append((passed, checks))
218
+
219
+ num_passed = sum(1 for passed, _ in results if passed)
220
+ print(f"\nValidation Summary: {num_passed}/{len(results)} passed")
221
+
222
+ return results
223
+
smiles/evaluation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """PDC evaluation and structure-analysis utilities."""
smiles/evaluation/metrics.py ADDED
@@ -0,0 +1,570 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import warnings
3
+ import numpy as np
4
+ import pandas as pd
5
+ from rdkit import Chem, rdBase, DataStructs
6
+ from rdkit.Chem import AllChem
7
+ import os
8
+ import sys
9
+
10
+ # Add current directory to path for imports
11
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ # Import custom analyzers and conjugation builders
14
+ from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
15
+ from smiles.evaluation.pdc_analyzer import PDCAnalyzer
16
+ from smiles.conjugation.AoaDau import AoaDaunorubicin_PDC_Builder
17
+ from smiles.conjugation.SSCpt import DisulfideCamptothecin_PDC_Builder
18
+ from smiles.conjugation.DSPASN38 import DisulfanylpropanoicAcidSN38_PDC_Builder
19
+ from smiles.scoring.classifiers import BindingAffinity
20
+
21
+ rdBase.DisableLog('rdApp.error')
22
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
23
+ warnings.filterwarnings("ignore", category=UserWarning)
24
+ warnings.filterwarnings("ignore", category=FutureWarning)
25
+
26
+ def fingerprints_from_mol(molecule, radius=3, size=2048, hashed=False):
27
+ """ Create ECFP fingerprint of a molecule """
28
+ if hashed:
29
+ fp_bits = AllChem.GetHashedMorganFingerprint(molecule, radius, nBits=size)
30
+ else:
31
+ fp_bits = AllChem.GetMorganFingerprintAsBitVect(molecule, radius, nBits=size)
32
+ fp_np = np.zeros((1,))
33
+ DataStructs.ConvertToNumpyArray(fp_bits, fp_np)
34
+ return fp_np.reshape(1, -1)
35
+
36
+ def average_agg_tanimoto(stock_vecs, gen_vecs, batch_size=5000, agg='max', device='cuda', p=1):
37
+ """
38
+ For each molecule in gen_vecs finds closest molecule in stock_vecs.
39
+ Returns average tanimoto score for between these molecules
40
+
41
+ Parameters:
42
+ stock_vecs: numpy array <n_vectors x dim>
43
+ gen_vecs: numpy array <n_vectors' x dim>
44
+ agg: max or mean
45
+ p: power for averaging: (mean x^p)^(1/p)
46
+ """
47
+ assert agg in ['max', 'mean'], "Can aggregate only max or mean"
48
+ agg_tanimoto = np.zeros(len(gen_vecs))
49
+ total = np.zeros(len(gen_vecs))
50
+ for j in range(0, stock_vecs.shape[0], batch_size):
51
+ x_stock = torch.tensor(stock_vecs[j:j + batch_size]).to(device).float()
52
+ for i in range(0, gen_vecs.shape[0], batch_size):
53
+ y_gen = torch.tensor(gen_vecs[i:i + batch_size]).to(device).float()
54
+ y_gen = y_gen.transpose(0, 1)
55
+ tp = torch.mm(x_stock, y_gen)
56
+ jac = (tp / (x_stock.sum(1, keepdim=True) + y_gen.sum(0, keepdim=True) - tp)).cpu().numpy()
57
+ jac[np.isnan(jac)] = 1
58
+ if p != 1:
59
+ jac = jac**p
60
+ if agg == 'max':
61
+ agg_tanimoto[i:i + y_gen.shape[1]] = np.maximum(agg_tanimoto[i:i + y_gen.shape[1]], jac.max(0))
62
+ elif agg == 'mean':
63
+ agg_tanimoto[i:i + y_gen.shape[1]] += jac.sum(0)
64
+ total[i:i + y_gen.shape[1]] += jac.shape[0]
65
+ if agg == 'mean':
66
+ agg_tanimoto /= total
67
+ if p != 1:
68
+ agg_tanimoto = (agg_tanimoto)**(1/p)
69
+ return np.mean(agg_tanimoto)
70
+
71
+ def get_mol(smiles_or_mol):
72
+ '''
73
+ Loads SMILES/molecule into RDKit's object
74
+ '''
75
+ if isinstance(smiles_or_mol, str):
76
+ if len(smiles_or_mol) == 0:
77
+ return None
78
+ mol = Chem.MolFromSmiles(smiles_or_mol)
79
+ if mol is None:
80
+ return None
81
+ try:
82
+ Chem.SanitizeMol(mol)
83
+ except ValueError:
84
+ return None
85
+ return mol
86
+ return smiles_or_mol
87
+
88
+ def canonic_smiles(smiles_or_mol):
89
+ mol = get_mol(smiles_or_mol)
90
+ if mol is None:
91
+ return None
92
+ return Chem.MolToSmiles(mol)
93
+
94
+
95
+ class Metrics:
96
+
97
+ def __init__(self, prior_path='./data/smiles/30K_all.csv', n_jobs=100, input_type='smiles', device='cuda'):
98
+ self.device = device
99
+ self.input_type = input_type # 'smiles', 'peptide', or 'conjugate'
100
+ self.n_jobs = n_jobs
101
+
102
+ # Initialize analyzers
103
+ self.peptide_analyzer = PeptideAnalyzer()
104
+ self.pdc_analyzer = PDCAnalyzer(device)
105
+
106
+ # Initialize conjugation builders
107
+ self.aoa_dau_builder = AoaDaunorubicin_PDC_Builder()
108
+ self.ss_cpt_builder = DisulfideCamptothecin_PDC_Builder()
109
+ self.dspa_sn38_builder = DisulfanylpropanoicAcidSN38_PDC_Builder()
110
+
111
+ # Load reference set for similarity calculations
112
+ train_set_cano_smi = pd.read_csv(prior_path)['SMILES'].astype(str).tolist()
113
+
114
+ for smi in train_set_cano_smi:
115
+ mol = Chem.MolFromSmiles(smi)
116
+
117
+ if mol is None:
118
+ print(f"Invalid SMILES: {smi}")
119
+
120
+ self.train_set_cano_smi = train_set_cano_smi
121
+
122
+ self.ref_fps = np.vstack([
123
+ fingerprints_from_mol(Chem.MolFromSmiles(smi))
124
+ for smi in train_set_cano_smi
125
+ if Chem.MolFromSmiles(smi) is not None
126
+ ])
127
+
128
+ def _determine_target_from_filename(self, generated_path):
129
+ """Determine target protein from filename"""
130
+ filename = os.path.basename(generated_path).upper()
131
+ if 'GNRHR' in filename:
132
+ return 'GNRHR'
133
+ elif 'ERBB2' in filename:
134
+ return 'ERBB2'
135
+ elif 'NRP1' in filename:
136
+ return 'NRP1'
137
+ else:
138
+ raise ValueError(f"Cannot determine target from filename: {filename}. Expected GNRHR / ERBB2 / NRP1 in filename.")
139
+
140
+ def _get_target_sequence(self, target):
141
+ """Get target protein sequence for binding affinity calculation"""
142
+ # Define target sequences (you may need to update these)
143
+ target_sequences = {
144
+ 'GNRHR': 'MANSASPEQNQNHCSAINNSIPLMQGNLPTLTLSGKIRVTVTFFLFLLSATFNASFLLKLQKWTQKKEKGKKLSRMKLLLKHLTLANLLETLIVMPLDGMWNITVQWYAGELLCKVLSYLKLFSMYAPAFMMVVISLDRSLAITRPLALKSNSKVGQSMVGLAWILSSVFAGPQLYIFRMIHLADSSGQTKVFSQCVTHCSFSQWWHQAFYNFFTFSCLFIIPLFIMLICNAKIIFTLTRVLHQDPHELQLNQSKNNIPRARLKTLKMTVAFATSFTVCWTPYYVLGIWYWFDPEMLNRLSDPVNHFFFLFAFLNPCFDPLIYGYFSL',
145
+ 'ERBB2': 'MELAALCRWGLLLALLPPGAASTQVCTGTDMKLRLPASPETHLDMLRHLYQGCQVVQGNLELTYLPTNASLSFLQDIQEVQGYVLIAHNQVRQVPLQRLRIVRGTQLFEDNYALAVLDNGDPLNNTTPVTGASPGGLRELQLRSLTEILKGGVLIQRNPQLCYQDTILWKDIFHKNNQLALTLIDTNRSRACHPCSPMCKGSRCWGESSEDCQSLTRTVCAGGCARCKGPLPTDCCHEQCAAGCTGPKHSDCLACLHFNHSGICELHCPALVTYNTDTFESMPNPEGRYTFGASCVTACPYNYLSTDVGSCTLVCPLHNQEVTAEDGTQRCEKCSKPCARVCYGLGMEHLREVRAVTSANIQEFAGCKKIFGSLAFLPESFDGDPASNTAPLQPEQLQVFETLEEITGYLYISAWPDSLPDLSVFQNLQVIRGRILHNGAYSLTLQGLGISWLGLRSLRELGSGLALIHHNTHLCFVHTVPWDQLFRNPHQALLHTANRPEDECVGEGLACHQLCARGHCWGPGPTQCVNCSQFLRGQECVEECRVLQGLPREYVNARHCLPCHPECQPQNGSVTCFGPEADQCVACAHYKDPPFCVARCPSGVKPDLSYMPIWKFPDEEGACQPCPINCTHSCVDLDDKGCPAEQRASPLTSIISAVVGILLVVVLGVVFGILIKRRQQKIRKYTMRRLLQETELVEPLTPSGAMPNQAQMRILKETELRKVKVLGSGAFGTVYKGIWIPDGENVKIPVAIKVLRENTSPKANKEILDEAYVMAGVGSPYVSRLLGICLTSTVQLVTQLMPYGCLLDHVRENRGRLGSQDLLNWCMQIAKGMSYLEDVRLVHRDLAARNVLVKSPNHVKITDFGLARLLDIDETEYHADGGKVPIKWMALESILRRRFTHQSDVWSYGVTVWELMTFGAKPYDGIPAREIPDLLEKGERLPQPPICTIDVYMIMVKCWMIDSECRPRFRELVSEFSRMARDPQRFVVIQNEDLGPASPLDSTFYRSLLEDDDMGDLVDAEEYLVPQQGFFCPDPAPGAGGMVHHRHRSSSTRSGGGDLTLGLEPSEEEAPRSPLAPSEGAGSDVFDGDLGMGAAKGLQSLPTHDPSPLQRYSEDPTVPLPSETDGYVAPLTCSPQPEYVNQPDVRPQPPSPREGPLPAARPAGATLERPKTLSPGKNGVVKDVFAFGGAVENPEYLTPQGGAAPQPHPPPAFSPAFDNLYYWDQDPPERGAPPSTFKGTPTAENPEYLGLDVPV',
146
+ 'NRP1': 'MERGLPLLCAVLALVLAPAGAFRNDKCGDTIKIESPGYLTSPGYPHSYHPSEKCEWLIQAPDPYQRIMINFNPHFDLEDRDCKYDYVEVFDGENENGHFRGKFCGKIAPPPVVSSGPFLFIKFVSDYETHGAGFSIRYEIFKRGPECSQNYTTPSGVIKSPGFPEKYPNSLECTYIVFVPKMSEIILEFESFDLEPDSNPPGGMFCRYDRLEIWDGFPDVGPHIGRYCGQKTPGRIRSSSGILSMVFYTDSAIAKEGFSANYSVLQSSVSEDFKCMEALGMESGEIHSDQITASSQYSTNWSAERSRLNYPENGWTPGEDSYREWIQVDLGLLRFVTAVGTQGAISKETKKKYYVKTYKIDVSSNGEDWITIKEGNKPVLFQGNTNPTDVVVAVFPKPLITRFVRIKPATWETGISMRFEVYGCKITDYPCSGMLGMVSGLISDSQITSSNQGDRNWMPENIRLVTSRSGWALPPAPHSYINEWLQIDLGEEKIVRGIIIQGGKHRENKVFMRKFKIGYSNNGSDWKMIMDDSKRKAKSFEGNNNYDTPELRTFPALSTRFIRIYPERATHGGLGLRMELLGCEVEAPTAGPTTPNGNLVDECDDDQANCHSGTGDDFQLTGGTTVLATEKPTVIDSTIQSEFPTYGFNCEFGWGSHKTFCHWEHDNHVQLKWSVLTSKTGPIQDHTGDGNFIYSQADENQKGKVARLVSPVVYSQNSAHCMTFWYHMSGSHVGTLRVKLRYQKPEEYDQLVWMAIGHQGDHWKEGRVLLHKSLKLYQVIFEGEIGKGNLGGIAVDDISINNHISQEDCAKPADLDKKNPEIKIDETGSTPGYEGEGEGDKNISRKPGNVLKTLDPILITIIAMSALGVLLGAVCGVVLYCACWHNGMSERNLSALENYNFELVDGVKLKKDKLNTQSTYSEA'
147
+ }
148
+ return target_sequences.get(target, '')
149
+
150
+ def get_metrics_for_peptides(self, generated_path, save_csv=True):
151
+ """
152
+ Analyze peptide sequences with conjugation, validity, binding affinity, and drug-likeness
153
+ """
154
+ print(f"Analyzing peptides from: {generated_path}")
155
+
156
+ # Determine target from filename
157
+ target = self._determine_target_from_filename(generated_path)
158
+ print(f"Detected target: {target}")
159
+
160
+ # Load data
161
+ df = pd.read_csv(generated_path)
162
+ if 'sequence' in df.columns:
163
+ peptide_sequences = df['sequence'].astype(str).tolist()
164
+ elif 'SMILES' in df.columns:
165
+ peptide_sequences = df['SMILES'].astype(str).tolist()
166
+ else:
167
+ raise ValueError("No 'sequence' or 'SMILES' column found in the input file")
168
+
169
+ total_sequences = len(peptide_sequences)
170
+ print(f"Total sequences: {total_sequences}")
171
+
172
+ # Step 1: Filter valid peptides
173
+ print("Step 1: Filtering valid peptides...")
174
+ valid_peptides = []
175
+ valid_indices = []
176
+
177
+ for i, seq in enumerate(peptide_sequences):
178
+ if self.peptide_analyzer.is_peptide(seq):
179
+ valid_peptides.append(seq)
180
+ valid_indices.append(i)
181
+
182
+ print(f"Valid peptides: {len(valid_peptides)}/{total_sequences}")
183
+
184
+ if len(valid_peptides) == 0:
185
+ print("No valid peptides found!")
186
+ return {
187
+ 'total_sequences': total_sequences,
188
+ 'valid_peptides': 0,
189
+ 'validity_rate': 0.0,
190
+ 'conjugation_success_rate': 0.0,
191
+ 'binding_affinity_mean': 0.0,
192
+ 'binding_affinity_std': 0.0,
193
+ }
194
+
195
+ # Step 2: Conjugate peptides
196
+ print("Step 2: Conjugating peptides...")
197
+ if target == 'GNRHR':
198
+ print("Using Aoa-Dau conjugation builder for GNRHR")
199
+ builder = self.aoa_dau_builder
200
+ elif target == 'ERBB2':
201
+ builder = self.ss_cpt_builder
202
+ elif target == 'NRP1':
203
+ builder = self.dspa_sn38_builder
204
+
205
+ conjugate_results = []
206
+
207
+ for i, peptide in enumerate(valid_peptides):
208
+ try:
209
+ print(f" Conjugating peptide {i+1}/{len(valid_peptides)}: {peptide[:50]}...")
210
+ # Try different methods to get conjugates
211
+ try:
212
+ conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
213
+ if conjugates and len(conjugates) > 0:
214
+ print(f" Success: Generated {len(conjugates)} conjugate(s)")
215
+ first_conjugate = conjugates[0]
216
+ print(f" DEBUG: First conjugate length: {len(first_conjugate)} chars")
217
+ print(f" DEBUG: First conjugate preview: {first_conjugate[:100]}...")
218
+ print(f" DEBUG: Expected peptide in conjugate: {peptide[:30] in first_conjugate}")
219
+ conjugate_results.append(first_conjugate) # Take first conjugate
220
+ else:
221
+ print(f" No conjugates generated")
222
+ conjugate_results.append(None)
223
+ except AttributeError:
224
+ # Fallback to batch_conjugate without return_all parameter
225
+ result = builder.batch_conjugate([peptide])
226
+ if result and 'conjugates' in result and len(result['conjugates']) > 0:
227
+ conjugates = result['conjugates'][0] # First peptide's conjugates
228
+ if conjugates:
229
+ print(f" Success: Generated {len(conjugates)} conjugate(s)")
230
+ first_conjugate = conjugates[0]
231
+ print(f" DEBUG: Fallback conjugate length: {len(first_conjugate)} chars")
232
+ print(f" DEBUG: Fallback conjugate preview: {first_conjugate[:100]}...")
233
+ conjugate_results.append(first_conjugate) # Take first conjugate
234
+ else:
235
+ print(f" No conjugates generated")
236
+ conjugate_results.append(None)
237
+ else:
238
+ print(f" Batch conjugation failed")
239
+ conjugate_results.append(None)
240
+
241
+ except Exception as e:
242
+ print(f" Conjugation failed for peptide {i+1}: {e}")
243
+ conjugate_results.append(None)
244
+
245
+ print(f"Total conjugation attempts: {len(conjugate_results)}")
246
+ print(f"Successful conjugations: {sum(1 for c in conjugate_results if c is not None)}")
247
+
248
+ # Step 3: Analyze conjugates with PDC analyzer
249
+ print("Step 3: Analyzing conjugates...")
250
+ valid_conjugates = []
251
+ conjugate_properties = []
252
+
253
+ for i, conjugate in enumerate(conjugate_results):
254
+ if conjugate is not None:
255
+ print(f" Analyzing conjugate {i+1}: {conjugate[:60]}...")
256
+ # Check if SMILES is valid
257
+ mol = Chem.MolFromSmiles(conjugate)
258
+ if mol is not None:
259
+ print(f" Valid SMILES structure")
260
+
261
+ # Try PDC analyzer validation
262
+ try:
263
+ linker_feasible = self.pdc_analyzer.check_linker_feasibility(mol)
264
+ repr_valid = self.pdc_analyzer.check_representational_validity(mol)
265
+ print(f" PDC checks - Linker feasible: {linker_feasible}, Repr valid: {repr_valid}")
266
+
267
+ is_valid = linker_feasible and repr_valid
268
+ if not is_valid:
269
+ print(f" PDC validation failed, dropped this molecule")
270
+ is_valid = False
271
+
272
+ except Exception as e:
273
+ print(f" PDC validation error: {e}, dropped this molecule")
274
+ is_valid = False
275
+
276
+ if is_valid:
277
+ valid_conjugates.append(conjugate)
278
+ try:
279
+ # DEBUG: Check the molecule being analyzed
280
+ print(f" DEBUG: SMILES to analyze: {conjugate[:100]}...")
281
+
282
+ # CRITICAL FIX: Create molecule from conjugate SMILES
283
+ conjugate_mol = Chem.MolFromSmiles(conjugate)
284
+ conjugate_formula = Chem.rdMolDescriptors.CalcMolFormula(conjugate_mol)
285
+ conjugate_mw = Chem.Descriptors.MolWt(conjugate_mol)
286
+ print(f" DEBUG: Analyzing molecule - Formula: {conjugate_formula}, MW: {conjugate_mw:.2f}")
287
+ print(f" DEBUG: SMILES length: {len(conjugate)} chars")
288
+
289
+ # DEBUG: Double-check we're analyzing the right molecule
290
+ if conjugate_formula == 'C33H40N3O11':
291
+ print(f" WARNING: This is the linker-drug formula, not full conjugate!")
292
+ else:
293
+ print(f" This appears to be the full conjugate formula")
294
+
295
+ # Use the correctly created molecule for property calculation
296
+ props = self.pdc_analyzer.calculate_molecular_properties(conjugate_mol)
297
+ # Ensure molecular weight is included
298
+ if 'molecular_weight' not in props:
299
+ from rdkit.Chem import Descriptors
300
+ props['molecular_weight'] = Descriptors.MolWt(conjugate_mol)
301
+ print(f" Calculated properties successfully: {props.get('mf', 'No MF')}, MW: {props.get('molecular_weight', 'N/A'):.2f}")
302
+ except Exception as e:
303
+ print(f" Property calculation failed: {e}, using basic properties")
304
+ # Fallback to basic RDKit properties - use conjugate molecule
305
+ conjugate_mol = Chem.MolFromSmiles(conjugate)
306
+ if conjugate_mol:
307
+ from rdkit.Chem import Descriptors
308
+ props = {
309
+ 'molecular_weight': Descriptors.MolWt(conjugate_mol),
310
+ 'logp': Descriptors.MolLogP(conjugate_mol),
311
+ 'num_atoms': conjugate_mol.GetNumHeavyAtoms(),
312
+ 'num_bonds': conjugate_mol.GetNumBonds()
313
+ }
314
+ else:
315
+ props = {
316
+ 'molecular_weight': 0,
317
+ 'logp': 0,
318
+ 'num_atoms': 0,
319
+ 'num_bonds': 0
320
+ }
321
+
322
+ # Get amino acid sequence from peptide analyzer
323
+ try:
324
+ aa_seq, seq_length = self.peptide_analyzer.analyze_structure(valid_peptides[i])
325
+ except Exception as e:
326
+ print(f" Failed to analyze structure: {e}")
327
+ aa_seq, seq_length = None, None
328
+
329
+ conjugate_properties.append({
330
+ 'peptide_index': valid_indices[i],
331
+ 'peptide_smiles': valid_peptides[i],
332
+ 'aa_seq': aa_seq,
333
+ 'seq_length': seq_length,
334
+ 'conjugate_smiles': conjugate,
335
+ **props
336
+ })
337
+ print(f" Added to valid conjugates")
338
+ else:
339
+ print(f" Failed validation")
340
+ else:
341
+ print(f" Invalid SMILES structure")
342
+ else:
343
+ print(f" Conjugate {i+1}: None (conjugation failed)")
344
+
345
+ print(f"Valid conjugates: {len(valid_conjugates)}")
346
+ if len(valid_conjugates) > 0:
347
+ print(f"First few valid conjugates:")
348
+ for i, conj in enumerate(valid_conjugates[:3]):
349
+ print(f" {i+1}: {conj[:80]}...")
350
+ else:
351
+ print("No valid conjugates found!")
352
+
353
+ # Step 4: Calculate binding affinity
354
+ print("Step 4: Calculating binding affinity...")
355
+ target_seq = self._get_target_sequence(target)
356
+ if not target_seq:
357
+ print(f"Warning: No target sequence found for {target}")
358
+ affinity_scores_all = []
359
+ affinity_scores_conjugatable = []
360
+ # Add None for binding affinity when no target sequence
361
+ for prop in conjugate_properties:
362
+ prop['binding_affinity'] = None
363
+ else:
364
+ print(f"Using target sequence (length: {len(target_seq)})")
365
+ try:
366
+ # Initialize binding affinity model
367
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
368
+ tokenizer = SMILES_SPE_Tokenizer('./smiles/smiles_tokenizer/new_vocab.txt', './smiles/smiles_tokenizer/new_splits.txt')
369
+ binding_model = BindingAffinity(target_seq, self.device)
370
+ print(f"Binding model initialized successfully")
371
+
372
+ # Tokenize valid peptides
373
+ valid_peptide_tokens = []
374
+ for peptide in valid_peptides:
375
+ tokens = tokenizer(peptide)
376
+ # Handle BatchEncoding object from tokenizer
377
+ if hasattr(tokens, 'input_ids'):
378
+ tokens = tokens.input_ids # Extract token IDs from BatchEncoding
379
+ if isinstance(tokens, list):
380
+ tokens = torch.tensor(tokens)
381
+ valid_peptide_tokens.append(tokens)
382
+
383
+ print(f"Tokenized {len(valid_peptide_tokens)} valid peptides")
384
+
385
+ # Calculate affinity for all valid peptides
386
+ if valid_peptide_tokens:
387
+ # Pad sequences
388
+ max_len = max(len(tokens) for tokens in valid_peptide_tokens)
389
+ padded_tokens = torch.zeros((len(valid_peptide_tokens), max_len), dtype=torch.long)
390
+ for i, tokens in enumerate(valid_peptide_tokens):
391
+ padded_tokens[i, :len(tokens)] = tokens
392
+
393
+ print(f"Calculating affinity for {len(valid_peptide_tokens)} peptides...")
394
+ affinity_scores_all = binding_model(padded_tokens.to(self.device)).cpu().numpy()
395
+ print(f"Calculated {len(affinity_scores_all)} affinity scores")
396
+ else:
397
+ affinity_scores_all = []
398
+
399
+ # ===== NEW CODE STARTS HERE =====
400
+ # Create mapping from valid_indices to affinity scores
401
+ affinity_map = {}
402
+ for i, valid_idx in enumerate(valid_indices):
403
+ if i < len(affinity_scores_all):
404
+ affinity_map[valid_idx] = float(affinity_scores_all[i])
405
+
406
+ # Add binding affinity to conjugate_properties
407
+ print(f"Adding binding affinity to conjugate properties...")
408
+ for prop in conjugate_properties:
409
+ peptide_idx = prop['peptide_index']
410
+ if peptide_idx in affinity_map:
411
+ prop['binding_affinity'] = affinity_map[peptide_idx]
412
+ print(f" Added affinity {affinity_map[peptide_idx]:.4f} for peptide index {peptide_idx}")
413
+ else:
414
+ prop['binding_affinity'] = None
415
+ print(f" No affinity score for peptide index {peptide_idx}")
416
+
417
+ # Calculate affinity for peptides with valid conjugates
418
+ # NOTE: pre-initialize so the variable always exists even when
419
+ # conjugate_properties is empty (zero valid conjugates).
420
+ # Also: this block runs ONCE here, not once per prop above.
421
+ affinity_scores_conjugatable = []
422
+ conjugatable_indices = [prop['peptide_index'] for prop in conjugate_properties]
423
+ valid_conjugatable_indices = [
424
+ valid_indices.index(idx)
425
+ for idx in conjugatable_indices
426
+ if idx in valid_indices
427
+ ]
428
+
429
+ print(f"Found {len(valid_conjugatable_indices)} conjugatable peptides")
430
+
431
+ if valid_conjugatable_indices:
432
+ conjugatable_tokens = [valid_peptide_tokens[i] for i in valid_conjugatable_indices]
433
+ max_len = max(len(tokens) for tokens in conjugatable_tokens)
434
+ padded_tokens = torch.zeros((len(conjugatable_tokens), max_len), dtype=torch.long)
435
+ for i, tokens in enumerate(conjugatable_tokens):
436
+ padded_tokens[i, :len(tokens)] = tokens
437
+
438
+ affinity_scores_conjugatable = binding_model(padded_tokens.to(self.device)).cpu().numpy()
439
+ print(f"Calculated {len(affinity_scores_conjugatable)} conjugatable affinity scores")
440
+
441
+ except Exception as e:
442
+ print(f"Binding affinity calculation failed: {e}")
443
+ import traceback
444
+ traceback.print_exc()
445
+ affinity_scores_all = []
446
+ affinity_scores_conjugatable = []
447
+ # ===== NEW CODE: Handle errors =====
448
+ # Still add None values for binding affinity
449
+ for prop in conjugate_properties:
450
+ prop['binding_affinity'] = None
451
+ # ===== NEW CODE ENDS =====
452
+
453
+
454
+ # Save detailed results to CSV
455
+ csv_path = None
456
+ if save_csv and conjugate_properties:
457
+ output_dir = os.path.dirname(generated_path)
458
+ base_name = os.path.splitext(os.path.basename(generated_path))[0]
459
+ csv_path = os.path.join(output_dir, f"{base_name}_conjugate_analysis.csv")
460
+
461
+ df_results = pd.DataFrame(conjugate_properties)
462
+ df_results.to_csv(csv_path, index=False)
463
+ print(f"Detailed results saved to: {csv_path}")
464
+
465
+ # Calculate summary statistics
466
+ validity_rate = len(valid_peptides) / total_sequences
467
+ conjugation_success_rate = len(valid_conjugates) / total_sequences
468
+
469
+ results = {
470
+ 'total_sequences': total_sequences,
471
+ 'valid_peptides': len(valid_peptides),
472
+ 'validity_rate': validity_rate,
473
+ 'valid_conjugates': len(valid_conjugates),
474
+ 'conjugation_success_rate': conjugation_success_rate,
475
+ 'target': target,
476
+ 'csv_path': csv_path, # Add CSV path to results
477
+ }
478
+
479
+ # Binding affinity statistics
480
+ if len(affinity_scores_all) > 0:
481
+ results.update({
482
+ 'binding_affinity_all_mean': float(np.mean(affinity_scores_all)),
483
+ 'binding_affinity_all_std': float(np.std(affinity_scores_all)),
484
+ 'binding_affinity_all_min': float(np.min(affinity_scores_all)),
485
+ 'binding_affinity_all_max': float(np.max(affinity_scores_all)),
486
+ })
487
+
488
+ if len(affinity_scores_conjugatable) > 0:
489
+ results.update({
490
+ 'binding_affinity_conjugatable_mean': float(np.mean(affinity_scores_conjugatable)),
491
+ 'binding_affinity_conjugatable_std': float(np.std(affinity_scores_conjugatable)),
492
+ 'binding_affinity_conjugatable_min': float(np.min(affinity_scores_conjugatable)),
493
+ 'binding_affinity_conjugatable_max': float(np.max(affinity_scores_conjugatable)),
494
+ })
495
+
496
+
497
+ # Print summary
498
+ print("\n" + "="*60)
499
+ print("PEPTIDE ANALYSIS SUMMARY")
500
+ print("="*60)
501
+ print(f"Target: {target}")
502
+ print(f"Total sequences: {total_sequences}")
503
+ print(f"Valid peptides: {len(valid_peptides)} ({validity_rate:.1%})")
504
+ print(f"Valid conjugates: {len(valid_conjugates)} ({conjugation_success_rate:.1%})")
505
+
506
+ print(f"\nBINDING AFFINITY ANALYSIS:")
507
+ if len(affinity_scores_all) > 0:
508
+ print(f" All valid peptides ({len(affinity_scores_all)} peptides):")
509
+ print(f" Mean: {np.mean(affinity_scores_all):.4f} ± {np.std(affinity_scores_all):.4f}")
510
+ print(f" Range: {np.min(affinity_scores_all):.4f} to {np.max(affinity_scores_all):.4f}")
511
+ else:
512
+ print(f" All valid peptides: No scores calculated (binding affinity model failed)")
513
+
514
+ if len(affinity_scores_conjugatable) > 0:
515
+ print(f" Conjugatable peptides ({len(affinity_scores_conjugatable)} peptides):")
516
+ print(f" Mean: {np.mean(affinity_scores_conjugatable):.4f} ± {np.std(affinity_scores_conjugatable):.4f}")
517
+ print(f" Range: {np.min(affinity_scores_conjugatable):.4f} to {np.max(affinity_scores_conjugatable):.4f}")
518
+ else:
519
+ print(f" Conjugatable peptides: No scores calculated")
520
+
521
+ return results
522
+
523
+ def get_metrics(self, generated_path):
524
+ """Main method to get metrics based on input type"""
525
+ if self.input_type == 'peptide':
526
+ return self.get_metrics_for_peptides(generated_path)
527
+ elif self.input_type == 'conjugate':
528
+ # Placeholder for future conjugate analysis
529
+ print("Conjugate analysis not yet implemented")
530
+ return self._get_basic_metrics(generated_path)
531
+ else:
532
+ # Default SMILES analysis
533
+ return self._get_basic_metrics(generated_path)
534
+
535
+ def _get_basic_metrics(self, generated_path):
536
+ """Original metrics calculation for general SMILES"""
537
+ generated_smi = pd.read_csv(generated_path)['SMILES'].astype(str).tolist()
538
+ #generated_smi = pd.read_csv(generated_path, usecols=['Generated SMILES'])['Generated SMILES'].tolist()
539
+ #mols = [Chem.MolFromSmiles(smi) if smi else None for smi in generated_smi]
540
+ mols = [Chem.MolFromSmiles(smi) if smi else None for smi in generated_smi]
541
+ is_valid = [1 if mol else 0 for mol in mols]
542
+ validity = sum(is_valid) / len(is_valid)
543
+
544
+ valid_canon_smiles = [Chem.MolToSmiles(mol) for mol in mols if mol]
545
+ uniqueness = len(set(valid_canon_smiles)) / len(valid_canon_smiles)
546
+
547
+ uniq_smis = list(set(valid_canon_smiles))
548
+ uniq_mols = [Chem.MolFromSmiles(smi) for smi in uniq_smis]
549
+
550
+ fps = np.vstack([fingerprints_from_mol(mol) for mol in uniq_mols])
551
+ diversity = 1 - (average_agg_tanimoto(fps, fps, agg='mean', p=1)).mean()
552
+
553
+ snn = average_agg_tanimoto(self.ref_fps, fps, agg='max', p=1)
554
+
555
+ # gen_smiles = mapper(self.n_jobs)(canonic_smiles, valid_canon_smiles)
556
+ # gen_smiles_set = set(gen_smiles) - {None}
557
+ # train_set = set(self.train_set_cano_smi)
558
+ # novelty = len(gen_smiles_set - train_set) / len(gen_smiles_set)
559
+
560
+ # print(f"validity\tuniqueness\tdiversity\tsnn\tnovelty")
561
+ # print(f"{validity:.3f}\t{uniqueness:.3f}\t{diversity:.3f}\t{snn:.3f}\t{novelty:.3f}")
562
+ print(f"validity\tuniqueness\tdiversity\tsnn")
563
+ print(f"{validity:.3f}\t{uniqueness:.3f}\t{diversity:.3f}\t{snn:.3f}")
564
+ return {
565
+ "validity": validity,
566
+ "uniqueness": uniqueness,
567
+ "diversity": diversity,
568
+ "snn": snn, # "structural novelty"
569
+ # "novelty": novelty,
570
+ }
smiles/evaluation/pdc_analyzer.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDC Analyzer
3
+ Analyzes Peptide-Drug Conjugates (PDCs) for structural validity
4
+
5
+ Hard constraints (validity = 1 if all pass, 0 if any fails):
6
+ 1. Linker feasibility: ring-closure constraints, long carbon chains
7
+ 2. Representational validity: SMILES parsing, sanitization, chemistry problems
8
+
9
+ Note: Attachment/conjugation site checks are conjugation-method specific
10
+ and should be handled in the conjugation PDC builder (e.g., pdc_builder.py)
11
+ """
12
+
13
+ import torch
14
+ from rdkit import Chem
15
+ from rdkit.Chem import Descriptors, AllChem, rdMolDescriptors
16
+
17
+
18
+ class PDCAnalyzer:
19
+ """
20
+ Analyzes Peptide-Drug Conjugates (PDCs) for structural validity
21
+
22
+ Hard constraint checks (validity = 1 if all pass, 0 if any fails):
23
+ 1. Linker feasibility: ring-closure constraints, long carbon chains
24
+ 2. Representational validity: SMILES parsing, sanitization, chemistry problems
25
+
26
+ Optional molecular properties (toggle on/off):
27
+ - mf: molecular formula
28
+ - polararea: topological polar surface area
29
+ - complexity: molecular complexity (BertzCT)
30
+ - xlogp: calculated LogP (Wildman-Crippen)
31
+ - heavycnt: heavy atom count
32
+ - hbonddonor: hydrogen bond donors
33
+ - hbondacc: hydrogen bond acceptors
34
+ - rotbonds: rotatable bonds
35
+
36
+ Note: Attachment/conjugation site checks are conjugation-method specific
37
+ and should be handled in the conjugation PDC builder.
38
+ """
39
+
40
+ def __init__(self, device,
41
+ max_linker_chain_length=16,
42
+ calculate_properties=True):
43
+ self.device = device
44
+ self.max_linker_chain_length = max_linker_chain_length
45
+ self.calculate_properties = calculate_properties
46
+
47
+ # Property calculation flags (all on by default)
48
+ self.property_flags = {
49
+ 'mf': True, # molecular formula
50
+ 'polararea': True, # topological polar surface area
51
+ 'complexity': True, # BertzCT complexity
52
+ 'xlogp': True, # Wildman-Crippen LogP
53
+ 'heavycnt': True, # heavy atom count
54
+ 'hbonddonor': True, # H-bond donors
55
+ 'hbondacc': True, # H-bond acceptors
56
+ 'rotbonds': True, # rotatable bonds
57
+ }
58
+
59
+ # Pre-compile SMARTS patterns for efficiency
60
+ self._compile_patterns()
61
+
62
+ def set_property_flags(self, **kwargs):
63
+ """
64
+ Enable/disable specific property calculations
65
+
66
+ Args:
67
+ **kwargs: property_name=True/False pairs
68
+
69
+ Example:
70
+ analyzer.set_property_flags(mf=False, xlogp=True, rotbonds=False)
71
+ """
72
+ for key, value in kwargs.items():
73
+ if key in self.property_flags:
74
+ self.property_flags[key] = bool(value)
75
+ else:
76
+ raise ValueError(f"Unknown property: {key}. Valid properties: {list(self.property_flags.keys())}")
77
+
78
+ def enable_all_properties(self):
79
+ """Enable calculation of all optional properties"""
80
+ for key in self.property_flags:
81
+ self.property_flags[key] = True
82
+
83
+ def disable_all_properties(self):
84
+ """Disable calculation of all optional properties"""
85
+ for key in self.property_flags:
86
+ self.property_flags[key] = False
87
+
88
+ def _compile_patterns(self):
89
+ """Pre-compile SMARTS patterns used frequently"""
90
+ self.patterns = {
91
+ # Long carbon chain (indicates potentially problematic linker)
92
+ # 16 consecutive CH2 groups
93
+ 'long_chain': Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2]'),
94
+ }
95
+
96
+ def find_linker_rings(self, mol):
97
+ """
98
+ Identify rings that are part of the linker (not peptide backbone or drug core)
99
+
100
+ Linker rings typically include:
101
+ - Succinimide (from maleimide after thiol addition)
102
+ - Small saturated rings used as spacers
103
+ - NOT aromatic rings (usually part of drug)
104
+ """
105
+ ring_info = mol.GetRingInfo()
106
+ atom_rings = ring_info.AtomRings()
107
+
108
+ linker_rings = []
109
+
110
+ for ring in atom_rings:
111
+ ring_size = len(ring)
112
+ ring_atoms = [mol.GetAtomWithIdx(idx) for idx in ring]
113
+ ring_symbols = [atom.GetSymbol() for atom in ring_atoms]
114
+
115
+ # Skip aromatic rings (likely drug component)
116
+ if all(atom.GetIsAromatic() for atom in ring_atoms):
117
+ continue
118
+
119
+ # Check for succinimide (5-membered, N + 2 C=O)
120
+ if ring_size == 5 and 'N' in ring_symbols:
121
+ carbonyl_count = 0
122
+ for idx in ring:
123
+ atom = mol.GetAtomWithIdx(idx)
124
+ if atom.GetSymbol() == 'C':
125
+ for neighbor in atom.GetNeighbors():
126
+ if neighbor.GetSymbol() == 'O' and neighbor.GetIdx() not in ring:
127
+ bond = mol.GetBondBetweenAtoms(idx, neighbor.GetIdx())
128
+ if bond and bond.GetBondType() == Chem.BondType.DOUBLE:
129
+ carbonyl_count += 1
130
+
131
+ if carbonyl_count >= 2:
132
+ # This is a succinimide - valid linker ring
133
+ linker_rings.append(ring)
134
+ continue
135
+
136
+ # Check for small saturated rings (potential linker spacers)
137
+ if ring_size <= 6:
138
+ is_saturated = not any(atom.GetIsAromatic() for atom in ring_atoms)
139
+ if is_saturated:
140
+ linker_rings.append(ring)
141
+
142
+ # Flag very large non-aromatic rings (unusual in linkers)
143
+ if ring_size > 12:
144
+ linker_rings.append(ring) # Will be checked for validity
145
+
146
+ return linker_rings
147
+
148
+ def check_ring_closure_at_linker(self, mol):
149
+ """
150
+ Check ring-closure constraints in the linker region
151
+ """
152
+ linker_rings = self.find_linker_rings(mol)
153
+
154
+ if len(linker_rings) == 0:
155
+ return True # No linker rings is fine
156
+
157
+ for ring in linker_rings:
158
+ ring_size = len(ring)
159
+
160
+ # Impossible rings
161
+ if ring_size < 3:
162
+ return False
163
+
164
+ # 3-membered rings: check for severe strain
165
+ if ring_size == 3:
166
+ for atom_idx in ring:
167
+ atom = mol.GetAtomWithIdx(atom_idx)
168
+ # sp2 carbon in 3-ring is very strained (except epoxides/aziridines)
169
+ if atom.GetSymbol() == 'C':
170
+ if atom.GetHybridization() == Chem.HybridizationType.SP2:
171
+ # Check if it's part of a C=C (bad) vs just geometry
172
+ for neighbor in atom.GetNeighbors():
173
+ bond = mol.GetBondBetweenAtoms(atom_idx, neighbor.GetIdx())
174
+ if bond and bond.GetBondType() == Chem.BondType.DOUBLE:
175
+ if neighbor.GetSymbol() == 'C':
176
+ return False # C=C in 3-ring is invalid
177
+
178
+ # 4-membered rings: check for excessive sp2 character
179
+ if ring_size == 4:
180
+ sp2_count = sum(1 for idx in ring
181
+ if mol.GetAtomWithIdx(idx).GetHybridization() == Chem.HybridizationType.SP2)
182
+ if sp2_count > 2:
183
+ return False
184
+
185
+ # Very large rings in linker are suspicious
186
+ if ring_size > 12:
187
+ return False
188
+
189
+ return True
190
+
191
+ def check_linker_feasibility(self, mol):
192
+ """
193
+ Check if linker properties are reasonable
194
+
195
+ Hard constraints only:
196
+ - Ring-closure constraints
197
+ - Long carbon chain check
198
+ """
199
+ # Check ring-closure constraints
200
+ if not self.check_ring_closure_at_linker(mol):
201
+ return False
202
+
203
+ # Check for excessively long carbon chains
204
+ if self.patterns['long_chain'] and mol.HasSubstructMatch(self.patterns['long_chain']):
205
+ return False
206
+
207
+ return True
208
+
209
+ def check_representational_validity(self, mol):
210
+ """
211
+ Check if molecule can be properly represented
212
+ """
213
+ # Sanitization
214
+ try:
215
+ Chem.SanitizeMol(mol)
216
+ except:
217
+ return False
218
+
219
+ # Round-trip SMILES
220
+ try:
221
+ smiles = Chem.MolToSmiles(mol)
222
+ mol_reconstructed = Chem.MolFromSmiles(smiles)
223
+ if mol_reconstructed is None:
224
+ return False
225
+ except:
226
+ return False
227
+
228
+ # Check for chemistry problems
229
+ try:
230
+ problems = Chem.DetectChemistryProblems(mol)
231
+ for problem in problems:
232
+ problem_type = problem.GetType()
233
+ if problem_type in ['AtomValenceException', 'KekulizeException']:
234
+ return False
235
+ except:
236
+ return False
237
+
238
+ # 2D coordinates (basic structural validity)
239
+ try:
240
+ AllChem.Compute2DCoords(mol)
241
+ except:
242
+ return False
243
+
244
+ return True
245
+
246
+ def calculate_molecular_properties(self, mol):
247
+ """
248
+ Calculate optional molecular properties based on property_flags
249
+
250
+ Returns dict of property_name: value for enabled properties
251
+ """
252
+ properties = {}
253
+
254
+ if not self.calculate_properties:
255
+ return properties
256
+
257
+ if self.property_flags.get('mf'):
258
+ properties['mf'] = rdMolDescriptors.CalcMolFormula(mol)
259
+
260
+ if self.property_flags.get('polararea'):
261
+ properties['polararea'] = round(Descriptors.TPSA(mol), 2)
262
+
263
+ if self.property_flags.get('complexity'):
264
+ properties['complexity'] = round(Descriptors.BertzCT(mol), 2)
265
+
266
+ if self.property_flags.get('xlogp'):
267
+ properties['xlogp'] = round(Descriptors.MolLogP(mol), 2)
268
+
269
+ if self.property_flags.get('heavycnt'):
270
+ properties['heavycnt'] = mol.GetNumHeavyAtoms()
271
+
272
+ if self.property_flags.get('hbonddonor'):
273
+ properties['hbonddonor'] = Descriptors.NumHDonors(mol)
274
+
275
+ if self.property_flags.get('hbondacc'):
276
+ properties['hbondacc'] = Descriptors.NumHAcceptors(mol)
277
+
278
+ if self.property_flags.get('rotbonds'):
279
+ properties['rotbonds'] = Descriptors.NumRotatableBonds(mol)
280
+
281
+ return properties
282
+
283
+ def get_scores(self, input_seqs_list):
284
+ """
285
+ Validate PDC structures based on hard constraints
286
+
287
+ Hard constraints:
288
+ 1. Linker feasibility (ring-closure, long chains)
289
+ 2. Representational validity (SMILES, sanitization, chemistry)
290
+
291
+ Returns list of 0/1 scores
292
+ """
293
+ results = []
294
+
295
+ for candidates in input_seqs_list:
296
+ for smiles in candidates:
297
+ mol = Chem.MolFromSmiles(smiles)
298
+ if mol is None:
299
+ continue
300
+
301
+ # Run hard constraint checks only
302
+ linker_ok = self.check_linker_feasibility(mol)
303
+ representational_ok = self.check_representational_validity(mol)
304
+
305
+ is_valid = linker_ok and representational_ok
306
+ if is_valid:
307
+ results.append(1)
308
+ break
309
+ else:
310
+ results.append(0)
311
+
312
+ return results
313
+
314
+ def get_detailed_scores(self, input_seqs_list):
315
+ """
316
+ Return detailed validation results with optional properties
317
+ """
318
+ detailed_results = []
319
+
320
+ for candidates in input_seqs_list:
321
+ candidate_results = []
322
+
323
+ for smiles in candidates:
324
+ mol = Chem.MolFromSmiles(smiles)
325
+ if mol is None:
326
+ candidate_results.append({
327
+ 'valid': 0,
328
+ 'smiles': smiles,
329
+ 'error': 'Cannot parse SMILES'
330
+ })
331
+ continue
332
+
333
+ # Run hard constraint checks
334
+ checks = {
335
+ 'linker_feasible': self.check_linker_feasibility(mol),
336
+ 'representational': self.check_representational_validity(mol)
337
+ }
338
+
339
+ is_valid = all(checks.values())
340
+
341
+ # Calculate optional properties if enabled
342
+ properties = self.calculate_molecular_properties(mol)
343
+
344
+ # Add linker ring info for diagnostics
345
+ linker_rings = self.find_linker_rings(mol)
346
+ checks['linker_ring_sizes'] = [len(ring) for ring in linker_rings]
347
+
348
+ candidate_results.append({
349
+ 'valid': 1 if is_valid else 0,
350
+ 'smiles': smiles,
351
+ 'checks': checks,
352
+ 'properties': properties
353
+ })
354
+
355
+ detailed_results.append(candidate_results)
356
+
357
+ return detailed_results
358
+
359
+ def __call__(self, input_seqs):
360
+ scores = self.get_scores(input_seqs)
361
+ return torch.tensor(scores)
362
+
363
+
364
+ # ============================================================================
365
+ # Testing
366
+ # ============================================================================
367
+
368
+ if __name__ == "__main__":
369
+ device = 'cpu'
370
+ pdc_analyzer = PDCAnalyzer(device)
371
+
372
+ print("=" * 70)
373
+ print("PDC Analyzer - Structural Validity Checker")
374
+ print("=" * 70)
375
+ print("\nHard constraints checked:")
376
+ print(" 1. Linker feasibility (ring-closure, long carbon chains)")
377
+ print(" 2. Representational validity (SMILES, sanitization)")
378
+ print("\nNote: Conjugation site checks are method-specific")
379
+ print(" and should be in the conjugation PDC builder.\n")
380
+
381
+ # Test property flag toggling
382
+ print("Testing property flags:")
383
+ print(f" Default flags: {pdc_analyzer.property_flags}")
384
+
385
+ pdc_analyzer.set_property_flags(mf=False, complexity=False)
386
+ print(f" After disabling mf, complexity: {pdc_analyzer.property_flags}")
387
+
388
+ pdc_analyzer.enable_all_properties()
389
+ print(f" After enable_all: {pdc_analyzer.property_flags}")
390
+
391
+ print("\n" + "=" * 70)
392
+
393
+ test_smiles = [
394
+ # Test 1: Valid structure
395
+ ("NCC(=O)N[C@@H](CSC1CC(=O)N(CCOCCOCCOCCOCCN)C1=O)C(=O)NCC(=O)O",
396
+ "Valid structure with succinimide ring"),
397
+
398
+ # Test 2: Invalid - strained 3-membered ring with C=C
399
+ ("NCC(=O)N[C@@H](CSC1=CC1CCCCN)C(=O)NCC(=O)O",
400
+ "Invalid: Strained 3-membered ring with sp2 carbon"),
401
+
402
+ # Test 3: Simple valid peptide
403
+ ("NCC(=O)N[C@@H](C)C(=O)NCC(=O)O",
404
+ "Simple valid peptide structure"),
405
+ ]
406
+
407
+ print("\nTest Results")
408
+ print("=" * 70 + "\n")
409
+
410
+ for i, (smiles, description) in enumerate(test_smiles):
411
+ print(f"Test {i+1}: {description}")
412
+ print(f"SMILES: {smiles[:60]}{'...' if len(smiles) > 60 else ''}")
413
+
414
+ detailed = pdc_analyzer.get_detailed_scores([smiles])[0]
415
+
416
+ valid_str = " VALID" if detailed['valid'] == 1 else " INVALID"
417
+ print(f"Result: {valid_str}")
418
+
419
+ if 'checks' in detailed:
420
+ print("\nHard constraint checks:")
421
+ for check_name in ['linker_feasible', 'representational']:
422
+ if check_name in detailed['checks']:
423
+ status = "" if detailed['checks'][check_name] else ""
424
+ print(f" {status} {check_name}")
425
+
426
+ if 'properties' in detailed and detailed['properties']:
427
+ print("\nMolecular properties:")
428
+ for prop, value in detailed['properties'].items():
429
+ print(f" {prop}: {value}")
430
+
431
+ print("\n" + "-" * 70 + "\n")
smiles/evaluation/peptide_analyzer.py ADDED
@@ -0,0 +1,1259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import pandas as pd
4
+ from io import StringIO
5
+ import rdkit
6
+ from rdkit import Chem
7
+ from rdkit.Chem import AllChem, Draw
8
+ import numpy as np
9
+ from PIL import Image, ImageDraw, ImageFont
10
+ import matplotlib.pyplot as plt
11
+ import matplotlib.patches as patches
12
+ from io import BytesIO
13
+ import tempfile
14
+ from rdkit import Chem, RDLogger
15
+ RDLogger.DisableLog('rdApp.*')
16
+
17
+ import pdb
18
+
19
+ class PeptideAnalyzer:
20
+ def __init__(self):
21
+ self.bond_patterns = [
22
+ (r'OC\(=O\)', 'ester'), # Ester bond
23
+ (r'N\(C\)C\(=O\)', 'n_methyl'), # N-methylated peptide bond
24
+ (r'N[0-9]C\(=O\)', 'proline'), # Proline peptide bond
25
+ (r'NC\(=O\)', 'peptide'), # Standard peptide bond
26
+ (r'C\(=O\)N\(C\)', 'n_methyl_reverse'), # Reverse N-methylated
27
+ (r'C\(=O\)N[12]?', 'peptide_reverse') # Reverse peptide bond
28
+ ]
29
+ # Three to one letter code mapping
30
+ self.three_to_one = {
31
+ 'Ala': 'A', 'Cys': 'C', 'Asp': 'D', 'Glu': 'E',
32
+ 'Phe': 'F', 'Gly': 'G', 'His': 'H', 'Ile': 'I',
33
+ 'Lys': 'K', 'Leu': 'L', 'Met': 'M', 'Asn': 'N',
34
+ 'Pro': 'P', 'Gln': 'Q', 'Arg': 'R', 'Ser': 'S',
35
+ 'Thr': 'T', 'Val': 'V', 'Trp': 'W', 'Tyr': 'Y'
36
+ }
37
+
38
+ def is_peptide(self, smiles):
39
+ """Check if the SMILES represents a peptide structure"""
40
+ # pdb.set_trace()
41
+ mol = Chem.MolFromSmiles(smiles)
42
+ if mol is None:
43
+ return False
44
+
45
+ # Look for peptide bonds: NC(=O) pattern
46
+ peptide_bond_pattern = Chem.MolFromSmarts('[NH][C](=O)')
47
+ if mol.HasSubstructMatch(peptide_bond_pattern):
48
+ return True
49
+
50
+ # Look for N-methylated peptide bonds: N(C)C(=O) pattern
51
+ n_methyl_pattern = Chem.MolFromSmarts('[N;H0;$(NC)](C)[C](=O)')
52
+ if mol.HasSubstructMatch(n_methyl_pattern):
53
+ return True
54
+
55
+ return False
56
+
57
+ def is_cyclic(self, smiles):
58
+ """Improved cyclic peptide detection"""
59
+ # Check for C-terminal carboxyl
60
+ if smiles.endswith('C(=O)O'):
61
+ return False, [], []
62
+
63
+ # Find all numbers used in ring closures
64
+ ring_numbers = re.findall(r'(?:^|[^c])[0-9](?=[A-Z@\(\)])', smiles)
65
+
66
+ # Find aromatic ring numbers
67
+ aromatic_matches = re.findall(r'c[0-9](?:ccccc|c\[nH\]c)[0-9]', smiles)
68
+ aromatic_cycles = []
69
+ for match in aromatic_matches:
70
+ numbers = re.findall(r'[0-9]', match)
71
+ aromatic_cycles.extend(numbers)
72
+
73
+ # Numbers that aren't part of aromatic rings are peptide cycles
74
+ peptide_cycles = [n for n in ring_numbers if n not in aromatic_cycles]
75
+
76
+ is_cyclic = len(peptide_cycles) > 0 and not smiles.endswith('C(=O)O')
77
+ return is_cyclic, peptide_cycles, aromatic_cycles
78
+
79
+ def split_on_bonds(self, smiles):
80
+ """Split SMILES into segments with simplified Pro handling"""
81
+ positions = []
82
+ used = set()
83
+
84
+ # Find Gly pattern first
85
+ gly_pattern = r'NCC\(=O\)'
86
+ for match in re.finditer(gly_pattern, smiles):
87
+ if not any(p in range(match.start(), match.end()) for p in used):
88
+ positions.append({
89
+ 'start': match.start(),
90
+ 'end': match.end(),
91
+ 'type': 'gly',
92
+ 'pattern': match.group()
93
+ })
94
+ used.update(range(match.start(), match.end()))
95
+
96
+ for pattern, bond_type in self.bond_patterns:
97
+ for match in re.finditer(pattern, smiles):
98
+ if not any(p in range(match.start(), match.end()) for p in used):
99
+ positions.append({
100
+ 'start': match.start(),
101
+ 'end': match.end(),
102
+ 'type': bond_type,
103
+ 'pattern': match.group()
104
+ })
105
+ used.update(range(match.start(), match.end()))
106
+
107
+ # Sort by position
108
+ positions.sort(key=lambda x: x['start'])
109
+
110
+ # Create segments
111
+ segments = []
112
+
113
+ if positions:
114
+ # First segment
115
+ if positions[0]['start'] > 0:
116
+ segments.append({
117
+ 'content': smiles[0:positions[0]['start']],
118
+ 'bond_after': positions[0]['pattern']
119
+ })
120
+
121
+ # Process segments
122
+ for i in range(len(positions)-1):
123
+ current = positions[i]
124
+ next_pos = positions[i+1]
125
+
126
+ if current['type'] == 'gly':
127
+ segments.append({
128
+ 'content': 'NCC(=O)',
129
+ 'bond_before': positions[i-1]['pattern'] if i > 0 else None,
130
+ 'bond_after': next_pos['pattern']
131
+ })
132
+ else:
133
+ content = smiles[current['end']:next_pos['start']]
134
+ if content:
135
+ segments.append({
136
+ 'content': content,
137
+ 'bond_before': current['pattern'],
138
+ 'bond_after': next_pos['pattern']
139
+ })
140
+
141
+ # Last segment
142
+ if positions[-1]['end'] < len(smiles):
143
+ segments.append({
144
+ 'content': smiles[positions[-1]['end']:],
145
+ 'bond_before': positions[-1]['pattern']
146
+ })
147
+
148
+ return segments
149
+
150
+ def clean_terminal_carboxyl(self, segment):
151
+ """Remove C-terminal carboxyl only if it's the true terminus"""
152
+ content = segment['content']
153
+
154
+ # Only clean if:
155
+ # 1. Contains C(=O)O
156
+ # 2. No bond_after exists (meaning it's the last segment)
157
+ # 3. C(=O)O is at the end of the content
158
+ if 'C(=O)O' in content and not segment.get('bond_after'):
159
+ print('recognized?')
160
+ # Remove C(=O)O pattern regardless of position
161
+ cleaned = re.sub(r'\(C\(=O\)O\)', '', content)
162
+ # Remove any leftover empty parentheses
163
+ cleaned = re.sub(r'\(\)', '', cleaned)
164
+ print(cleaned)
165
+ return cleaned
166
+ return content
167
+
168
+ def identify_residue(self, segment):
169
+ """Identify residue with Pro reconstruction"""
170
+ # Only clean terminal carboxyl if this is the last segment
171
+ content = self.clean_terminal_carboxyl(segment)
172
+ mods = self.get_modifications(segment)
173
+
174
+ # UAA pattern matching section - before regular residues
175
+ # Phenylglycine and derivatives
176
+ if 'c1ccccc1' in content:
177
+ if '[C@@H](c1ccccc1)' in content or '[C@H](c1ccccc1)' in content:
178
+ return '4', mods # Base phenylglycine
179
+
180
+ # 4-substituted phenylalanines
181
+ if 'Cc1ccc' in content:
182
+ if 'OMe' in content or 'OCc1ccc' in content:
183
+ return '0A1', mods # 4-methoxy-Phenylalanine
184
+ elif 'Clc1ccc' in content:
185
+ return '200', mods # 4-chloro-Phenylalanine
186
+ elif 'Brc1ccc' in content:
187
+ return '4BF', mods # 4-Bromo-phenylalanine
188
+ elif 'C#Nc1ccc' in content:
189
+ return '4CF', mods # 4-cyano-phenylalanine
190
+ elif 'Ic1ccc' in content:
191
+ return 'PHI', mods # 4-Iodo-phenylalanine
192
+ elif 'Fc1ccc' in content:
193
+ return 'PFF', mods # 4-Fluoro-phenylalanine
194
+
195
+ # Modified tryptophans
196
+ if 'c[nH]c2' in content:
197
+ if 'Oc2cccc2' in content:
198
+ return '0AF', mods # 7-hydroxy-tryptophan
199
+ elif 'Fc2cccc2' in content:
200
+ return '4FW', mods # 4-fluoro-tryptophan
201
+ elif 'Clc2cccc2' in content:
202
+ return '6CW', mods # 6-chloro-tryptophan
203
+ elif 'Brc2cccc2' in content:
204
+ return 'BTR', mods # 6-bromo-tryptophan
205
+ elif 'COc2cccc2' in content:
206
+ return 'MOT5', mods # 5-Methoxy-tryptophan
207
+ elif 'Cc2cccc2' in content:
208
+ return 'MTR5', mods # 5-Methyl-tryptophan
209
+
210
+ # Special amino acids
211
+ if 'CC(C)(C)[C@@H]' in content or 'CC(C)(C)[C@H]' in content:
212
+ return 'BUG', mods # Tertleucine
213
+
214
+ if 'CCCNC(=N)N' in content:
215
+ return 'CIR', mods # Citrulline
216
+
217
+ if '[SeH]' in content:
218
+ return 'CSE', mods # Selenocysteine
219
+
220
+ if '[NH3]CC[C@@H]' in content or '[NH3]CC[C@H]' in content:
221
+ return 'DAB', mods # Diaminobutyric acid
222
+
223
+ if 'C1CCCCC1' in content:
224
+ if 'C1CCCCC1[C@@H]' in content or 'C1CCCCC1[C@H]' in content:
225
+ return 'CHG', mods # Cyclohexylglycine
226
+ elif 'C1CCCCC1C[C@@H]' in content or 'C1CCCCC1C[C@H]' in content:
227
+ return 'ALC', mods # 3-cyclohexyl-alanine
228
+
229
+ # Naphthalene derivatives
230
+ if 'c1cccc2c1cccc2' in content:
231
+ if 'c1cccc2c1cccc2[C@@H]' in content or 'c1cccc2c1cccc2[C@H]' in content:
232
+ return 'NAL', mods # 2-Naphthyl-alanine
233
+
234
+ # Heteroaromatic derivatives
235
+ if 'c1cncc' in content:
236
+ return 'PYR4', mods # 3-(4-Pyridyl)-alanine
237
+ if 'c1cscc' in content:
238
+ return 'THA3', mods # 3-(3-thienyl)-alanine
239
+ if 'c1nnc' in content:
240
+ return 'TRZ4', mods # 3-(1,2,4-Triazol-1-yl)-alanine
241
+
242
+ # Modified serines and threonines
243
+ if 'OP(O)(O)O' in content:
244
+ if '[C@@H](COP' in content or '[C@H](COP' in content:
245
+ return 'SEP', mods # phosphoserine
246
+ elif '[C@@H](OP' in content or '[C@H](OP' in content:
247
+ return 'TPO', mods # phosphothreonine
248
+
249
+ # Specialized ring systems
250
+ if 'c1c2ccccc2cc2c1cccc2' in content:
251
+ return 'ANTH', mods # 3-(9-anthryl)-alanine
252
+ if 'c1csc2c1cccc2' in content:
253
+ return 'BTH3', mods # 3-(3-benzothienyl)-alanine
254
+ if '[C@]12C[C@H]3C[C@@H](C2)C[C@@H](C1)C3' in content:
255
+ return 'ADAM', mods # Adamanthane
256
+
257
+ # Fluorinated derivatives
258
+ if 'FC(F)(F)' in content:
259
+ if 'CC(F)(F)F' in content:
260
+ return 'FLA', mods # Trifluoro-alanine
261
+ if 'C(F)(F)F)c1' in content:
262
+ if 'c1ccccc1C(F)(F)F' in content:
263
+ return 'TFG2', mods # 2-(Trifluoromethyl)-phenylglycine
264
+ if 'c1cccc(c1)C(F)(F)F' in content:
265
+ return 'TFG3', mods # 3-(Trifluoromethyl)-phenylglycine
266
+ if 'c1ccc(cc1)C(F)(F)F' in content:
267
+ return 'TFG4', mods # 4-(Trifluoromethyl)-phenylglycine
268
+
269
+ # Multiple halogen patterns
270
+ if 'F' in content and 'c1' in content:
271
+ if 'c1ccc(c(c1)F)F' in content:
272
+ return 'F2F', mods # 3,4-Difluoro-phenylalanine
273
+ if 'cc(F)cc(c1)F' in content:
274
+ return 'WFP', mods # 3,5-Difluoro-phenylalanine
275
+ if 'Cl' in content and 'c1' in content:
276
+ if 'c1ccc(cc1Cl)Cl' in content:
277
+ return 'CP24', mods # 2,4-dichloro-phenylalanine
278
+ if 'c1ccc(c(c1)Cl)Cl' in content:
279
+ return 'CP34', mods # 3,4-dichloro-phenylalanine
280
+
281
+ # Hydroxy and amino derivatives
282
+ if 'O' in content and 'c1' in content:
283
+ if 'c1cc(O)cc(c1)O' in content:
284
+ return '3FG', mods # (2s)-amino(3,5-dihydroxyphenyl)-ethanoic acid
285
+ if 'c1ccc(c(c1)O)O' in content:
286
+ return 'DAH', mods # 3,4-Dihydroxy-phenylalanine
287
+
288
+ # Cyclic amino acids
289
+ if 'C1CCCC1' in content:
290
+ return 'CPA3', mods # 3-Cyclopentyl-alanine
291
+ if 'C1CCCCC1' in content:
292
+ if 'CC1CCCCC1' in content:
293
+ return 'ALC', mods # 3-cyclohexyl-alanine
294
+ else:
295
+ return 'CHG', mods # Cyclohexylglycine
296
+
297
+ # Chain-length variants
298
+ if 'CCC[C@@H]' in content or 'CCC[C@H]' in content:
299
+ return 'NLE', mods # Norleucine
300
+ if 'CC[C@@H]' in content or 'CC[C@H]' in content:
301
+ if not any(x in content for x in ['CC(C)', 'COC', 'CN(']):
302
+ return 'ABA', mods # 2-Aminobutyric acid
303
+
304
+ # Modified histidines
305
+ if 'c1cnc' in content:
306
+ if '[C@@H]1CN[C@@H](N1)F' in content:
307
+ return '2HF', mods # 2-fluoro-l-histidine
308
+ if 'c1cnc([nH]1)F' in content:
309
+ return '2HF1', mods # 2-fluoro-l-histidine variant
310
+ if 'c1c[nH]c(n1)F' in content:
311
+ return '2HF2', mods # 2-fluoro-l-histidine variant
312
+
313
+ # Sulfur and selenium containing
314
+ if '[SeH]' in content:
315
+ return 'CSE', mods # Selenocysteine
316
+ if 'S' in content:
317
+ if 'CSCc1ccccc1' in content:
318
+ return 'BCS', mods # benzylcysteine
319
+ if 'CCSC' in content:
320
+ return 'ESC', mods # Ethionine
321
+ if 'CCS' in content:
322
+ return 'HCS', mods # homocysteine
323
+
324
+ # Additional modifications
325
+ if 'CN=[N]=N' in content:
326
+ return 'AZDA', mods # azido-alanine
327
+ if '[NH]=[C](=[NH2])=[NH2]' in content:
328
+ if 'CCC[NH]=' in content:
329
+ return 'AGM', mods # 5-methyl-arginine
330
+ if 'CC[NH]=' in content:
331
+ return 'GDPR', mods # 2-Amino-3-guanidinopropionic acid
332
+
333
+ if 'CCON' in content:
334
+ return 'CAN', mods # canaline
335
+ if '[C@@H]1C=C[C@@H](C=C1)' in content:
336
+ return 'ACZ', mods # cis-amiclenomycin
337
+ if 'CCC(=O)[NH3]' in content:
338
+ return 'ONL', mods # 5-oxo-l-norleucine
339
+ if 'c1ccncc1' in content:
340
+ return 'PYR4', mods # 3-(4-Pyridyl)-alanine
341
+ if 'c1ccco1' in content:
342
+ return 'FUA2', mods # (2-furyl)-alanine
343
+
344
+ if 'c1ccc' in content:
345
+ if 'c1ccc(cc1)c1ccccc1' in content:
346
+ return 'BIF', mods # 4,4-biphenylalanine
347
+ if 'c1ccc(cc1)C(=O)c1ccccc1' in content:
348
+ return 'PBF', mods # 4-benzoyl-phenylalanine
349
+ if 'c1ccc(cc1)C(C)(C)C' in content:
350
+ return 'TBP4', mods # 4-tert-butyl-phenylalanine
351
+ if 'c1ccc(cc1)[C](=[NH2])=[NH2]' in content:
352
+ return '0BN', mods # 4-carbamimidoyl-l-phenylalanine
353
+ if 'c1cccc(c1)[C](=[NH2])=[NH2]' in content:
354
+ return 'APM', mods # m-amidinophenyl-3-alanine
355
+
356
+ # Multiple hydroxy patterns
357
+ if 'O' in content:
358
+ if '[C@H]([C@H](C)O)O' in content:
359
+ return 'ILX', mods # 4,5-dihydroxy-isoleucine
360
+ if '[C@H]([C@@H](C)O)O' in content:
361
+ return 'ALO', mods # Allo-threonine
362
+ if '[C@H](COP(O)(O)O)' in content:
363
+ return 'SEP', mods # phosphoserine
364
+ if '[C@H]([C@@H](C)OP(O)(O)O)' in content:
365
+ return 'TPO', mods # phosphothreonine
366
+ if '[C@H](c1ccc(O)cc1)O' in content:
367
+ return 'OMX', mods # (betar)-beta-hydroxy-l-tyrosine
368
+ if '[C@H](c1ccc(c(Cl)c1)O)O' in content:
369
+ return 'OMY', mods # (betar)-3-chloro-beta-hydroxy-l-tyrosine
370
+
371
+ # Heterocyclic patterns
372
+ if 'n1' in content:
373
+ if 'n1cccn1' in content:
374
+ return 'PYZ1', mods # 3-(1-Pyrazolyl)-alanine
375
+ if 'n1nncn1' in content:
376
+ return 'TEZA', mods # 3-(2-Tetrazolyl)-alanine
377
+ if 'c2c(n1)cccc2' in content:
378
+ return 'QU32', mods # 3-(2-Quinolyl)-alanine
379
+ if 'c1cnc2c(c1)cccc2' in content:
380
+ return 'QU33', mods # 3-(3-quinolyl)-alanine
381
+ if 'c1ccnc2c1cccc2' in content:
382
+ return 'QU34', mods # 3-(4-quinolyl)-alanine
383
+ if 'c1ccc2c(c1)nccc2' in content:
384
+ return 'QU35', mods # 3-(5-Quinolyl)-alanine
385
+ if 'c1ccc2c(c1)cncc2' in content:
386
+ return 'QU36', mods # 3-(6-Quinolyl)-alanine
387
+ if 'c1cnc2c(n1)cccc2' in content:
388
+ return 'QX32', mods # 3-(2-quinoxalyl)-alanine
389
+
390
+ # Multiple nitrogen patterns
391
+ if 'N' in content:
392
+ if '[NH3]CC[C@@H]' in content:
393
+ return 'DAB', mods # Diaminobutyric acid
394
+ if '[NH3]C[C@@H]' in content:
395
+ return 'DPP', mods # 2,3-Diaminopropanoic acid
396
+ if '[NH3]CCCCCC[C@@H]' in content:
397
+ return 'HHK', mods # (2s)-2,8-diaminooctanoic acid
398
+ if 'CCC[NH]=[C](=[NH2])=[NH2]' in content:
399
+ return 'GBUT', mods # 2-Amino-4-guanidinobutryric acid
400
+ if '[NH]=[C](=S)=[NH2]' in content:
401
+ return 'THIC', mods # Thio-citrulline
402
+
403
+ # Chain modified amino acids
404
+ if 'CC' in content:
405
+ if 'CCCC[C@@H]' in content:
406
+ return 'AHP', mods # 2-Aminoheptanoic acid
407
+ if 'CCC([C@@H])(C)C' in content:
408
+ return 'I2M', mods # 3-methyl-l-alloisoleucine
409
+ if 'CC[C@H]([C@@H])C' in content:
410
+ return 'IIL', mods # Allo-Isoleucine
411
+ if '[C@H](CCC(C)C)' in content:
412
+ return 'HLEU', mods # Homoleucine
413
+ if '[C@@H]([C@@H](C)O)C' in content:
414
+ return 'HLU', mods # beta-hydroxyleucine
415
+
416
+ # Modified glutamate/aspartate patterns
417
+ if '[C@@H]' in content:
418
+ if '[C@@H](C[C@@H](F))' in content:
419
+ return 'FGA4', mods # 4-Fluoro-glutamic acid
420
+ if '[C@@H](C[C@@H](O))' in content:
421
+ return '3GL', mods # 4-hydroxy-glutamic-acid
422
+ if '[C@@H](C[C@H](C))' in content:
423
+ return 'LME', mods # (3r)-3-methyl-l-glutamic acid
424
+ if '[C@@H](CC[C@H](C))' in content:
425
+ return 'MEG', mods # (3s)-3-methyl-l-glutamic acid
426
+
427
+ # Sulfur and selenium modifications
428
+ if 'S' in content:
429
+ if 'SCC[C@@H]' in content:
430
+ return 'HSER', mods # homoserine
431
+ if 'SCCN' in content:
432
+ return 'SLZ', mods # thialysine
433
+ if 'SC(=O)' in content:
434
+ return 'CSA', mods # s-acetonylcysteine
435
+ if '[S@@](=O)' in content:
436
+ return 'SME', mods # Methionine sulfoxide
437
+ if 'S(=O)(=O)' in content:
438
+ return 'OMT', mods # Methionine sulfone
439
+
440
+ # Double bond containing
441
+ if 'C=' in content:
442
+ if 'C=C[C@@H]' in content:
443
+ return '2AG', mods # 2-Allyl-glycine
444
+ if 'C=C[C@@H]' in content:
445
+ return 'LVG', mods # vinylglycine
446
+ if 'C=Cc1ccccc1' in content:
447
+ return 'STYA', mods # Styrylalanine
448
+
449
+ # Special cases
450
+ if '[C@@H]1Cc2c(C1)cccc2' in content:
451
+ return 'IGL', mods # alpha-amino-2-indanacetic acid
452
+ if '[C](=[C](=O)=O)=O' in content:
453
+ return '26P', mods # 2-amino-6-oxopimelic acid
454
+ if '[C](=[C](=O)=O)=C' in content:
455
+ return '2NP', mods # l-2-amino-6-methylene-pimelic acid
456
+ if 'c2cnc[nH]2' in content:
457
+ return 'HIS', mods # histidine core
458
+ if 'c1cccc2c1cc(O)cc2' in content:
459
+ return 'NAO1', mods # 5-hydroxy-1-naphthalene
460
+ if 'c1ccc2c(c1)cc(O)cc2' in content:
461
+ return 'NAO2', mods # 6-hydroxy-2-naphthalene
462
+
463
+ # Proline (P) - flexible ring numbers
464
+ if any([
465
+ # Check for any ring number in bond patterns
466
+ (segment.get('bond_after', '').startswith(f'N{n}C(=O)') and 'CCC' in content and
467
+ any(f'[C@@H]{n}' in content or f'[C@H]{n}' in content for n in '123456789'))
468
+ for n in '123456789'
469
+ ]) or any([
470
+ # Check ending patterns with any ring number
471
+ (f'CCCN{n}' in content and content.endswith('=O') and
472
+ any(f'[C@@H]{n}' in content or f'[C@H]{n}' in content for n in '123456789'))
473
+ for n in '123456789'
474
+ ]) or any([
475
+ # Handle CCC[C@H]n patterns
476
+ (content == f'CCC[C@H]{n}' and segment.get('bond_before', '').startswith(f'C(=O)N{n}')) or
477
+ (content == f'CCC[C@@H]{n}' and segment.get('bond_before', '').startswith(f'C(=O)N{n}')) or
478
+ # N-terminal Pro with any ring number
479
+ (f'N{n}CCC[C@H]{n}' in content) or
480
+ (f'N{n}CCC[C@@H]{n}' in content)
481
+ for n in '123456789'
482
+ ]):
483
+ return 'Pro', mods
484
+
485
+ # Tryptophan (W) - more specific indole pattern
486
+ if re.search(r'c[0-9]c\[nH\]c[0-9]ccccc[0-9][0-9]', content) and \
487
+ 'c[nH]c' in content.replace(' ', ''):
488
+ return 'Trp', mods
489
+
490
+ # Lysine (K) - both patterns
491
+ if '[C@@H](CCCCN)' in content or '[C@H](CCCCN)' in content:
492
+ return 'Lys', mods
493
+
494
+ # Arginine (R) - both patterns
495
+ if '[C@@H](CCCNC(=N)N)' in content or '[C@H](CCCNC(=N)N)' in content:
496
+ return 'Arg', mods
497
+
498
+ if ('C[C@H](CCCC)' in content or 'C[C@@H](CCCC)' in content) and 'CC(C)' not in content:
499
+ return 'Nle', mods
500
+
501
+ # Ornithine (Orn) - 3-carbon chain with NH2
502
+ if ('C[C@H](CCCN)' in content or 'C[C@@H](CCCN)' in content) and 'CC(C)' not in content:
503
+ return 'Orn', mods
504
+
505
+ # 2-Naphthylalanine (2Nal) - distinct from Phe pattern
506
+ if ('Cc3cc2ccccc2c3' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
507
+ return '2Nal', mods
508
+
509
+ # Cyclohexylalanine (Cha) - already in your code but moved here for clarity
510
+ if 'N2CCCCC2' in content or 'CCCCC2' in content:
511
+ return 'Cha', mods
512
+
513
+ # Aminobutyric acid (Abu) - 2-carbon chain
514
+ if ('C[C@H](CC)' in content or 'C[C@@H](CC)' in content) and not any(p in content for p in ['CC(C)', 'CCCC', 'CCC(C)']):
515
+ return 'Abu', mods
516
+
517
+ # Pipecolic acid (Pip) - 6-membered ring like Pro
518
+ if ('N3CCCCC3' in content or 'CCCCC3' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
519
+ return 'Pip', mods
520
+
521
+ # Cyclohexylglycine (Chg) - direct cyclohexyl without CH2
522
+ if ('C[C@H](C1CCCCC1)' in content or 'C[C@@H](C1CCCCC1)' in content):
523
+ return 'Chg', mods
524
+
525
+ # 4-Fluorophenylalanine (4F-Phe)
526
+ if ('Cc2ccc(F)cc2' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
527
+ return '4F-Phe', mods
528
+
529
+ # Regular residue identification
530
+ if ('NCC(=O)' in content) or (content == 'C'):
531
+ # Middle case - between bonds
532
+ if segment.get('bond_before') and segment.get('bond_after'):
533
+ if ('C(=O)N' in segment['bond_before'] or 'C(=O)N(C)' in segment['bond_before']):
534
+ return 'Gly', mods
535
+ # Terminal case - at the end
536
+ elif segment.get('bond_before') and segment.get('bond_before').startswith('C(=O)N'):
537
+ return 'Gly', mods
538
+
539
+ if 'CC(C)C[C@H]' in content or 'CC(C)C[C@@H]' in content:
540
+ return 'Leu', mods
541
+ if '[C@@H](CC(C)C)' in content or '[C@H](CC(C)C)' in content:
542
+ return 'Leu', mods
543
+
544
+ if '[C@@H]([C@@H](C)O)' in content or '[C@H]([C@H](C)O)' in content:
545
+ return 'Thr', mods
546
+
547
+ if '[C@H](Cc2ccccc2)' in content or '[C@@H](Cc2ccccc2)' in content:
548
+ return 'Phe', mods
549
+
550
+ if ('[C@H](C(C)C)' in content or # With outer parentheses
551
+ '[C@@H](C(C)C)' in content or # With outer parentheses
552
+ '[C@H]C(C)C' in content or # Without outer parentheses
553
+ '[C@@H]C(C)C' in content): # Without outer parentheses
554
+ if not any(p in content for p in ['CC(C)C[C@H]', 'CC(C)C[C@@H]']): # Still check not Leu
555
+ return 'Val', mods
556
+
557
+ if '[C@H](COC(C)(C)C)' in content or '[C@@H](COC(C)(C)C)' in content:
558
+ return 'O-tBu', mods
559
+
560
+ if any([
561
+ 'CC[C@H](C)' in content,
562
+ 'CC[C@@H](C)' in content,
563
+ 'C(C)C[C@H]' in content and 'CC(C)C' not in content,
564
+ 'C(C)C[C@@H]' in content and 'CC(C)C' not in content
565
+ ]):
566
+ return 'Ile', mods
567
+
568
+ if ('[C@H](C)' in content or '[C@@H](C)' in content):
569
+ if not any(p in content for p in ['C(C)C', 'COC', 'CN(', 'C(C)O', 'CC[C@H]', 'CC[C@@H]']):
570
+ return 'Ala', mods
571
+
572
+ # Tyrosine (Tyr) - 4-hydroxybenzyl side chain
573
+ if re.search(r'Cc[0-9]ccc\(O\)cc[0-9]', content):
574
+ return 'Tyr', mods
575
+
576
+
577
+ # Serine (Ser) - Hydroxymethyl side chain
578
+ if '[C@H](CO)' in content or '[C@@H](CO)' in content:
579
+ if not ('C(C)O' in content or 'COC' in content):
580
+ return 'Ser', mods
581
+
582
+ # Threonine (Thr) - 1-hydroxyethyl side chain
583
+ if '[C@@H]([C@@H](C)O)' in content or '[C@H]([C@H](C)O)' in content or '[C@@H](C)O' in content or '[C@H](C)O' in content:
584
+ return 'Thr', mods
585
+
586
+ # Cysteine (Cys) - Thiol side chain
587
+ if '[C@H](CS)' in content or '[C@@H](CS)' in content:
588
+ return 'Cys', mods
589
+
590
+ # Methionine (Met) - Methylthioethyl side chain
591
+ if ('C[C@H](CCSC)' in content or 'C[C@@H](CCSC)' in content):
592
+ return 'Met', mods
593
+
594
+ # Asparagine (Asn) - Carbamoylmethyl side chain
595
+ if ('CC(=O)N' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
596
+ return 'Asn', mods
597
+
598
+ # Glutamine (Gln) - Carbamoylethyl side chain
599
+ if ('CCC(=O)N' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
600
+ return 'Gln', mods
601
+
602
+ # Aspartic acid (Asp) - Carboxymethyl side chain
603
+ if ('CC(=O)O' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
604
+ return 'Asp', mods
605
+
606
+ # Glutamic acid (Glu) - Carboxyethyl side chainCC(C(=O)O)N
607
+ if ('CCC(=O)O' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
608
+ return 'Glu', mods
609
+
610
+ # Arginine (Arg) - 3-guanidinopropyl side chain
611
+ if ('CCCNC(=N)N' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
612
+ return 'Arg', mods
613
+
614
+ # Histidine (His) - Imidazole side chain
615
+ if ('Cc2cnc[nH]2' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
616
+ return 'His', mods
617
+
618
+ return None, mods
619
+
620
+ def get_modifications(self, segment):
621
+ """Get modifications based on bond types"""
622
+ mods = []
623
+ if segment.get('bond_after'):
624
+ if 'N(C)' in segment['bond_after'] or segment['bond_after'].startswith('C(=O)N(C)'):
625
+ mods.append('N-Me')
626
+ if 'OC(=O)' in segment['bond_after']:
627
+ mods.append('O-linked')
628
+ return mods
629
+
630
+ def analyze_structure(self, smiles):
631
+ """Main analysis function with debug output"""
632
+ print("\nAnalyzing structure:", smiles)
633
+
634
+ # Split into segments
635
+ segments = self.split_on_bonds(smiles)
636
+
637
+ print("\nSegment Analysis:")
638
+ sequence = []
639
+ for i, segment in enumerate(segments):
640
+ print(f"\nSegment {i}:")
641
+ print(f"Content: {segment['content']}")
642
+ print(f"Bond before: {segment.get('bond_before', 'None')}")
643
+ print(f"Bond after: {segment.get('bond_after', 'None')}")
644
+
645
+ residue, mods = self.identify_residue(segment)
646
+ if residue:
647
+ if mods:
648
+ sequence.append(f"{residue}({','.join(mods)})")
649
+ else:
650
+ sequence.append(residue)
651
+ print(f"Identified as: {residue}")
652
+ print(f"Modifications: {mods}")
653
+ else:
654
+ print(f"Warning: Could not identify residue in segment: {segment['content']}")
655
+
656
+ # Check if cyclic
657
+ is_cyclic, peptide_cycles, aromatic_cycles = self.is_cyclic(smiles)
658
+ three_letter = '-'.join(sequence)
659
+ one_letter = ''.join(self.three_to_one.get(aa.split('(')[0], 'X') for aa in sequence)
660
+
661
+ if is_cyclic:
662
+ three_letter = f"cyclo({three_letter})"
663
+ one_letter = f"cyclo({one_letter})"
664
+
665
+ print(f"\nFinal sequence: {three_letter}")
666
+ print(f"One-letter code: {one_letter}")
667
+ print(f"Is cyclic: {is_cyclic}")
668
+ #print(f"Peptide cycles: {peptide_cycles}")
669
+ #print(f"Aromatic cycles: {aromatic_cycles}")
670
+
671
+ return three_letter, len(segments)
672
+ """return {
673
+ 'three_letter': three_letter,
674
+ #'one_letter': one_letter,
675
+ 'is_cyclic': is_cyclic
676
+ }"""
677
+
678
+ def return_sequence(self, smiles):
679
+ """Main analysis function with debug output"""
680
+ print("\nAnalyzing structure:", smiles)
681
+
682
+ # Split into segments
683
+ segments = self.split_on_bonds(smiles)
684
+
685
+ print("\nSegment Analysis:")
686
+ sequence = []
687
+ for i, segment in enumerate(segments):
688
+ print(f"\nSegment {i}:")
689
+ print(f"Content: {segment['content']}")
690
+ print(f"Bond before: {segment.get('bond_before', 'None')}")
691
+ print(f"Bond after: {segment.get('bond_after', 'None')}")
692
+
693
+ residue, mods = self.identify_residue(segment)
694
+ if residue:
695
+ if mods:
696
+ sequence.append(f"{residue}({','.join(mods)})")
697
+ else:
698
+ sequence.append(residue)
699
+ print(f"Identified as: {residue}")
700
+ print(f"Modifications: {mods}")
701
+ else:
702
+ print(f"Warning: Could not identify residue in segment: {segment['content']}")
703
+
704
+ return sequence
705
+
706
+ """
707
+ def annotate_cyclic_structure(mol, sequence):
708
+ '''Create annotated 2D structure with clear, non-overlapping residue labels'''
709
+ # Generate 2D coordinates
710
+ # Generate 2D coordinates
711
+ AllChem.Compute2DCoords(mol)
712
+
713
+ # Create drawer with larger size for annotations
714
+ drawer = Draw.rdMolDraw2D.MolDraw2DCairo(2000, 2000) # Even larger size
715
+
716
+ # Get residue list and reverse it to match structural representation
717
+ if sequence.startswith('cyclo('):
718
+ residues = sequence[6:-1].split('-')
719
+ else:
720
+ residues = sequence.split('-')
721
+ residues = list(reversed(residues)) # Reverse the sequence
722
+
723
+ # Draw molecule first to get its bounds
724
+ drawer.drawOptions().addAtomIndices = False
725
+ drawer.DrawMolecule(mol)
726
+ drawer.FinishDrawing()
727
+
728
+ # Convert to PIL Image
729
+ img = Image.open(BytesIO(drawer.GetDrawingText()))
730
+ draw = ImageDraw.Draw(img)
731
+
732
+ try:
733
+ # Try to use DejaVuSans as it's commonly available on Linux systems
734
+ font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 60)
735
+ small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 60)
736
+ except OSError:
737
+ try:
738
+ # Fallback to Arial if available (common on Windows)
739
+ font = ImageFont.truetype("arial.ttf", 60)
740
+ small_font = ImageFont.truetype("arial.ttf", 60)
741
+ except OSError:
742
+ # If no TrueType fonts are available, fall back to default
743
+ print("Warning: TrueType fonts not available, using default font")
744
+ font = ImageFont.load_default()
745
+ small_font = ImageFont.load_default()
746
+ # Get molecule bounds
747
+ conf = mol.GetConformer()
748
+ positions = []
749
+ for i in range(mol.GetNumAtoms()):
750
+ pos = conf.GetAtomPosition(i)
751
+ positions.append((pos.x, pos.y))
752
+
753
+ x_coords = [p[0] for p in positions]
754
+ y_coords = [p[1] for p in positions]
755
+ min_x, max_x = min(x_coords), max(x_coords)
756
+ min_y, max_y = min(y_coords), max(y_coords)
757
+
758
+ # Calculate scaling factors
759
+ scale = 150 # Increased scale factor
760
+ center_x = 1000 # Image center
761
+ center_y = 1000
762
+
763
+ # Add residue labels in a circular arrangement around the structure
764
+ n_residues = len(residues)
765
+ radius = 700 # Distance of labels from center
766
+
767
+ # Start from the rightmost point (3 o'clock position) and go counterclockwise
768
+ # Offset by -3 positions to align with structure
769
+ offset = 0 # Adjust this value to match the structure alignment
770
+ for i, residue in enumerate(residues):
771
+ # Calculate position in a circle around the structure
772
+ # Start from 0 (3 o'clock) and go counterclockwise
773
+ angle = -(2 * np.pi * ((i + offset) % n_residues) / n_residues)
774
+
775
+ # Calculate label position
776
+ label_x = center_x + radius * np.cos(angle)
777
+ label_y = center_y + radius * np.sin(angle)
778
+
779
+ # Draw residue label
780
+ text = f"{i+1}. {residue}"
781
+ bbox = draw.textbbox((label_x, label_y), text, font=font)
782
+ padding = 10
783
+ draw.rectangle([bbox[0]-padding, bbox[1]-padding,
784
+ bbox[2]+padding, bbox[3]+padding],
785
+ fill='white', outline='white')
786
+ draw.text((label_x, label_y), text,
787
+ font=font, fill='black', anchor="mm")
788
+
789
+ # Add sequence at the top with white background
790
+ seq_text = f"Sequence: {sequence}"
791
+ bbox = draw.textbbox((center_x, 100), seq_text, font=small_font)
792
+ padding = 10
793
+ draw.rectangle([bbox[0]-padding, bbox[1]-padding,
794
+ bbox[2]+padding, bbox[3]+padding],
795
+ fill='white', outline='white')
796
+ draw.text((center_x, 100), seq_text,
797
+ font=small_font, fill='black', anchor="mm")
798
+
799
+ return img
800
+ """
801
+ def annotate_cyclic_structure(mol, sequence):
802
+ """Create structure visualization with just the sequence header"""
803
+ # Generate 2D coordinates
804
+ AllChem.Compute2DCoords(mol)
805
+
806
+ # Create drawer with larger size for annotations
807
+ drawer = Draw.rdMolDraw2D.MolDraw2DCairo(2000, 2000)
808
+
809
+ # Draw molecule first
810
+ drawer.drawOptions().addAtomIndices = False
811
+ drawer.DrawMolecule(mol)
812
+ drawer.FinishDrawing()
813
+
814
+ # Convert to PIL Image
815
+ img = Image.open(BytesIO(drawer.GetDrawingText()))
816
+ draw = ImageDraw.Draw(img)
817
+ try:
818
+ small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 60)
819
+ except OSError:
820
+ try:
821
+ small_font = ImageFont.truetype("arial.ttf", 60)
822
+ except OSError:
823
+ print("Warning: TrueType fonts not available, using default font")
824
+ small_font = ImageFont.load_default()
825
+
826
+ # Add just the sequence header at the top
827
+ seq_text = f"Sequence: {sequence}"
828
+ bbox = draw.textbbox((1000, 100), seq_text, font=small_font)
829
+ padding = 10
830
+ draw.rectangle([bbox[0]-padding, bbox[1]-padding,
831
+ bbox[2]+padding, bbox[3]+padding],
832
+ fill='white', outline='white')
833
+ draw.text((1000, 100), seq_text,
834
+ font=small_font, fill='black', anchor="mm")
835
+
836
+ return img
837
+
838
+ def create_enhanced_linear_viz(sequence, smiles):
839
+ """Create an enhanced linear representation using PeptideAnalyzer"""
840
+ analyzer = PeptideAnalyzer() # Create analyzer instance
841
+
842
+ # Create figure with two subplots
843
+ fig = plt.figure(figsize=(15, 10))
844
+ gs = fig.add_gridspec(2, 1, height_ratios=[1, 2])
845
+ ax_struct = fig.add_subplot(gs[0])
846
+ ax_detail = fig.add_subplot(gs[1])
847
+
848
+ # Parse sequence and get residues
849
+ if sequence.startswith('cyclo('):
850
+ residues = sequence[6:-1].split('-')
851
+ else:
852
+ residues = sequence.split('-')
853
+
854
+ # Get segments using analyzer
855
+ segments = analyzer.split_on_bonds(smiles)
856
+
857
+ # Debug print
858
+ print(f"Number of residues: {len(residues)}")
859
+ print(f"Number of segments: {len(segments)}")
860
+
861
+ # Top subplot - Basic structure
862
+ ax_struct.set_xlim(0, 10)
863
+ ax_struct.set_ylim(0, 2)
864
+
865
+ num_residues = len(residues)
866
+ spacing = 9.0 / (num_residues - 1) if num_residues > 1 else 9.0
867
+
868
+ # Draw basic structure
869
+ y_pos = 1.5
870
+ for i in range(num_residues):
871
+ x_pos = 0.5 + i * spacing
872
+
873
+ # Draw amino acid box
874
+ rect = patches.Rectangle((x_pos-0.3, y_pos-0.2), 0.6, 0.4,
875
+ facecolor='lightblue', edgecolor='black')
876
+ ax_struct.add_patch(rect)
877
+
878
+ # Draw connecting bonds if not the last residue
879
+ if i < num_residues - 1:
880
+ segment = segments[i] if i < len(segments) else None
881
+ if segment:
882
+ # Determine bond type from segment info
883
+ bond_type = 'ester' if 'O-linked' in segment.get('bond_after', '') else 'peptide'
884
+ is_n_methylated = 'N-Me' in segment.get('bond_after', '')
885
+
886
+ bond_color = 'red' if bond_type == 'ester' else 'black'
887
+ linestyle = '--' if bond_type == 'ester' else '-'
888
+
889
+ # Draw bond line
890
+ ax_struct.plot([x_pos+0.3, x_pos+spacing-0.3], [y_pos, y_pos],
891
+ color=bond_color, linestyle=linestyle, linewidth=2)
892
+
893
+ # Add bond type label
894
+ mid_x = x_pos + spacing/2
895
+ bond_label = f"{bond_type}"
896
+ if is_n_methylated:
897
+ bond_label += "\n(N-Me)"
898
+ ax_struct.text(mid_x, y_pos+0.1, bond_label,
899
+ ha='center', va='bottom', fontsize=10,
900
+ color=bond_color)
901
+
902
+ # Add residue label
903
+ ax_struct.text(x_pos, y_pos-0.5, residues[i],
904
+ ha='center', va='top', fontsize=14)
905
+
906
+ # Bottom subplot - Detailed breakdown
907
+ ax_detail.set_ylim(0, len(segments)+1)
908
+ ax_detail.set_xlim(0, 1)
909
+
910
+ # Create detailed breakdown
911
+ segment_y = len(segments) # Start from top
912
+ for i, segment in enumerate(segments):
913
+ y = segment_y - i
914
+
915
+ # Check if this is a bond or residue
916
+ residue, mods = analyzer.identify_residue(segment)
917
+ if residue:
918
+ text = f"Residue {i+1}: {residue}"
919
+ if mods:
920
+ text += f" ({', '.join(mods)})"
921
+ color = 'blue'
922
+ else:
923
+ # Must be a bond
924
+ text = f"Bond {i}: "
925
+ if 'O-linked' in segment.get('bond_after', ''):
926
+ text += "ester"
927
+ elif 'N-Me' in segment.get('bond_after', ''):
928
+ text += "peptide (N-methylated)"
929
+ else:
930
+ text += "peptide"
931
+ color = 'red'
932
+
933
+ # Add segment analysis
934
+ ax_detail.text(0.05, y, text, fontsize=12, color=color)
935
+ ax_detail.text(0.5, y, f"SMILES: {segment.get('content', '')}", fontsize=10, color='gray')
936
+
937
+ # If cyclic, add connection indicator
938
+ if sequence.startswith('cyclo('):
939
+ ax_struct.annotate('', xy=(9.5, y_pos), xytext=(0.5, y_pos),
940
+ arrowprops=dict(arrowstyle='<->', color='red', lw=2))
941
+ ax_struct.text(5, y_pos+0.3, 'Cyclic Connection',
942
+ ha='center', color='red', fontsize=14)
943
+
944
+ # Add titles and adjust layout
945
+ ax_struct.set_title("Peptide Structure Overview", pad=20)
946
+ ax_detail.set_title("Segment Analysis Breakdown", pad=20)
947
+
948
+ # Remove axes
949
+ for ax in [ax_struct, ax_detail]:
950
+ ax.set_xticks([])
951
+ ax.set_yticks([])
952
+ ax.axis('off')
953
+
954
+ plt.tight_layout()
955
+ return fig
956
+
957
+ class PeptideStructureGenerator:
958
+ """A class to generate 3D structures of peptides using different embedding methods"""
959
+
960
+ @staticmethod
961
+ def prepare_molecule(smiles):
962
+ """Prepare molecule with proper hydrogen handling"""
963
+ mol = Chem.MolFromSmiles(smiles, sanitize=False)
964
+ if mol is None:
965
+ raise ValueError("Failed to create molecule from SMILES")
966
+
967
+ # Calculate valence for each atom
968
+ for atom in mol.GetAtoms():
969
+ atom.UpdatePropertyCache(strict=False)
970
+
971
+ # Sanitize with reduced requirements
972
+ Chem.SanitizeMol(mol,
973
+ sanitizeOps=Chem.SANITIZE_FINDRADICALS|
974
+ Chem.SANITIZE_KEKULIZE|
975
+ Chem.SANITIZE_SETAROMATICITY|
976
+ Chem.SANITIZE_SETCONJUGATION|
977
+ Chem.SANITIZE_SETHYBRIDIZATION|
978
+ Chem.SANITIZE_CLEANUPCHIRALITY)
979
+
980
+ mol = Chem.AddHs(mol)
981
+ return mol
982
+
983
+ @staticmethod
984
+ def get_etkdg_params(attempt=0):
985
+ """Get ETKDG parameters with optional modifications based on attempt number"""
986
+ params = AllChem.ETKDGv3()
987
+ params.randomSeed = -1
988
+ params.maxIterations = 200
989
+ params.numThreads = 4 # Reduced for web interface
990
+ params.useBasicKnowledge = True
991
+ params.enforceChirality = True
992
+ params.useExpTorsionAnglePrefs = True
993
+ params.useSmallRingTorsions = True
994
+ params.useMacrocycleTorsions = True
995
+ params.ETversion = 2
996
+ params.pruneRmsThresh = -1
997
+ params.embedRmsThresh = 0.5
998
+
999
+ if attempt > 10:
1000
+ params.bondLength = 1.5 + (attempt - 10) * 0.02
1001
+ params.useExpTorsionAnglePrefs = False
1002
+
1003
+ return params
1004
+
1005
+ def generate_structure_etkdg(self, smiles, max_attempts=20):
1006
+ """Generate 3D structure using ETKDG without UFF optimization"""
1007
+ success = False
1008
+ mol = None
1009
+
1010
+ for attempt in range(max_attempts):
1011
+ try:
1012
+ mol = self.prepare_molecule(smiles)
1013
+ params = self.get_etkdg_params(attempt)
1014
+
1015
+ if AllChem.EmbedMolecule(mol, params) == 0:
1016
+ success = True
1017
+ break
1018
+ except Exception as e:
1019
+ continue
1020
+
1021
+ if not success:
1022
+ raise ValueError("Failed to generate structure with ETKDG")
1023
+
1024
+ return mol
1025
+
1026
+ def generate_structure_uff(self, smiles, max_attempts=20):
1027
+ """Generate 3D structure using ETKDG followed by UFF optimization"""
1028
+ best_mol = None
1029
+ lowest_energy = float('inf')
1030
+
1031
+ for attempt in range(max_attempts):
1032
+ try:
1033
+ test_mol = self.prepare_molecule(smiles)
1034
+ params = self.get_etkdg_params(attempt)
1035
+
1036
+ if AllChem.EmbedMolecule(test_mol, params) == 0:
1037
+ res = AllChem.UFFOptimizeMolecule(test_mol, maxIters=2000,
1038
+ vdwThresh=10.0, confId=0,
1039
+ ignoreInterfragInteractions=True)
1040
+
1041
+ if res == 0:
1042
+ ff = AllChem.UFFGetMoleculeForceField(test_mol)
1043
+ if ff:
1044
+ current_energy = ff.CalcEnergy()
1045
+ if current_energy < lowest_energy:
1046
+ lowest_energy = current_energy
1047
+ best_mol = Chem.Mol(test_mol)
1048
+ except Exception:
1049
+ continue
1050
+
1051
+ if best_mol is None:
1052
+ raise ValueError("Failed to generate optimized structure")
1053
+
1054
+ return best_mol
1055
+
1056
+ @staticmethod
1057
+ def mol_to_sdf_bytes(mol):
1058
+ """Convert RDKit molecule to SDF file bytes"""
1059
+ # First write to StringIO in text mode
1060
+ sio = StringIO()
1061
+ writer = Chem.SDWriter(sio)
1062
+ writer.write(mol)
1063
+ writer.close()
1064
+
1065
+ # Convert the string to bytes
1066
+ return sio.getvalue().encode('utf-8')
1067
+
1068
+ def process_input(smiles_input=None, file_obj=None, show_linear=False,
1069
+ show_segment_details=False, generate_3d=False, use_uff=False):
1070
+ """Process input and create visualizations using PeptideAnalyzer"""
1071
+ analyzer = PeptideAnalyzer()
1072
+ temp_dir = tempfile.mkdtemp() if generate_3d else None
1073
+ structure_files = []
1074
+
1075
+ # Handle direct SMILES input
1076
+ if smiles_input:
1077
+ smiles = smiles_input.strip()
1078
+
1079
+ # First check if it's a peptide using analyzer's method
1080
+ if not analyzer.is_peptide(smiles):
1081
+ return "Error: Input SMILES does not appear to be a peptide structure.", None, None
1082
+
1083
+ try:
1084
+ # Create molecule
1085
+ mol = Chem.MolFromSmiles(smiles)
1086
+ if mol is None:
1087
+ return "Error: Invalid SMILES notation.", None, None
1088
+
1089
+ # Generate 3D structures if requested
1090
+ if generate_3d:
1091
+ generator = PeptideStructureGenerator()
1092
+
1093
+ try:
1094
+ # Generate ETKDG structure
1095
+ mol_etkdg = generator.generate_structure_etkdg(smiles)
1096
+ etkdg_path = os.path.join(temp_dir, "structure_etkdg.sdf")
1097
+ writer = Chem.SDWriter(etkdg_path)
1098
+ writer.write(mol_etkdg)
1099
+ writer.close()
1100
+ structure_files.append(etkdg_path)
1101
+
1102
+ # Generate UFF structure if requested
1103
+ if use_uff:
1104
+ mol_uff = generator.generate_structure_uff(smiles)
1105
+ uff_path = os.path.join(temp_dir, "structure_uff.sdf")
1106
+ writer = Chem.SDWriter(uff_path)
1107
+ writer.write(mol_uff)
1108
+ writer.close()
1109
+ structure_files.append(uff_path)
1110
+
1111
+ except Exception as e:
1112
+ return f"Error generating 3D structures: {str(e)}", None, None, None
1113
+
1114
+ # Use analyzer to get sequence
1115
+ segments = analyzer.split_on_bonds(smiles)
1116
+
1117
+ # Process segments and build sequence
1118
+ sequence_parts = []
1119
+ output_text = ""
1120
+
1121
+ # Only include segment analysis in output if requested
1122
+ if show_segment_details:
1123
+ output_text += "Segment Analysis:\n"
1124
+ for i, segment in enumerate(segments):
1125
+ output_text += f"\nSegment {i}:\n"
1126
+ output_text += f"Content: {segment['content']}\n"
1127
+ output_text += f"Bond before: {segment.get('bond_before', 'None')}\n"
1128
+ output_text += f"Bond after: {segment.get('bond_after', 'None')}\n"
1129
+
1130
+ residue, mods = analyzer.identify_residue(segment)
1131
+ if residue:
1132
+ if mods:
1133
+ sequence_parts.append(f"{residue}({','.join(mods)})")
1134
+ else:
1135
+ sequence_parts.append(residue)
1136
+ output_text += f"Identified as: {residue}\n"
1137
+ output_text += f"Modifications: {mods}\n"
1138
+ else:
1139
+ output_text += f"Warning: Could not identify residue in segment: {segment['content']}\n"
1140
+ output_text += "\n"
1141
+ else:
1142
+ # Just build sequence without detailed analysis in output
1143
+ for segment in segments:
1144
+ residue, mods = analyzer.identify_residue(segment)
1145
+ if residue:
1146
+ if mods:
1147
+ sequence_parts.append(f"{residue}({','.join(mods)})")
1148
+ else:
1149
+ sequence_parts.append(residue)
1150
+
1151
+ # Check if cyclic using analyzer's method
1152
+ is_cyclic, peptide_cycles, aromatic_cycles = analyzer.is_cyclic(smiles)
1153
+ three_letter = '-'.join(sequence_parts)
1154
+ one_letter = ''.join(analyzer.three_to_one.get(aa.split('(')[0], 'X') for aa in sequence_parts)
1155
+
1156
+ if is_cyclic:
1157
+ three_letter = f"cyclo({three_letter})"
1158
+ one_letter = f"cyclo({one_letter})"
1159
+
1160
+ # Create cyclic structure visualization
1161
+ img_cyclic = annotate_cyclic_structure(mol, three_letter)
1162
+
1163
+ # Create linear representation if requested
1164
+ img_linear = None
1165
+ if show_linear:
1166
+ fig_linear = create_enhanced_linear_viz(three_letter, smiles)
1167
+ buf = BytesIO()
1168
+ fig_linear.savefig(buf, format='png', bbox_inches='tight', dpi=300)
1169
+ buf.seek(0)
1170
+ img_linear = Image.open(buf)
1171
+ plt.close(fig_linear)
1172
+
1173
+ # Add summary to output
1174
+ summary = "Summary:\n"
1175
+ summary += f"Sequence: {three_letter}\n"
1176
+ summary += f"One-letter code: {one_letter}\n"
1177
+ summary += f"Is Cyclic: {'Yes' if is_cyclic else 'No'}\n"
1178
+ #if is_cyclic:
1179
+ #summary += f"Peptide Cycles: {', '.join(peptide_cycles)}\n"
1180
+ #summary += f"Aromatic Cycles: {', '.join(aromatic_cycles)}\n"
1181
+
1182
+ if structure_files:
1183
+ summary += "\n3D Structures Generated:\n"
1184
+ for filepath in structure_files:
1185
+ summary += f"- {os.path.basename(filepath)}\n"
1186
+
1187
+ return summary + output_text, img_cyclic, img_linear, structure_files if structure_files else None
1188
+
1189
+ except Exception as e:
1190
+ return f"Error processing SMILES: {str(e)}", None, None, None
1191
+
1192
+ # Handle file input
1193
+ if file_obj is not None:
1194
+ try:
1195
+ # Handle file content
1196
+ if hasattr(file_obj, 'name'):
1197
+ with open(file_obj.name, 'r') as f:
1198
+ content = f.read()
1199
+ else:
1200
+ content = file_obj.decode('utf-8') if isinstance(file_obj, bytes) else str(file_obj)
1201
+
1202
+ output_text = ""
1203
+ for line in content.splitlines():
1204
+ smiles = line.strip()
1205
+ if smiles:
1206
+ # Check if it's a peptide
1207
+ if not analyzer.is_peptide(smiles):
1208
+ output_text += f"Skipping non-peptide SMILES: {smiles}\n"
1209
+ continue
1210
+
1211
+ # Process this SMILES
1212
+ segments = analyzer.split_on_bonds(smiles)
1213
+ sequence_parts = []
1214
+
1215
+ # Add segment details if requested
1216
+ if show_segment_details:
1217
+ output_text += f"\nSegment Analysis for SMILES: {smiles}\n"
1218
+ for i, segment in enumerate(segments):
1219
+ output_text += f"\nSegment {i}:\n"
1220
+ output_text += f"Content: {segment['content']}\n"
1221
+ output_text += f"Bond before: {segment.get('bond_before', 'None')}\n"
1222
+ output_text += f"Bond after: {segment.get('bond_after', 'None')}\n"
1223
+ residue, mods = analyzer.identify_residue(segment)
1224
+ if residue:
1225
+ if mods:
1226
+ sequence_parts.append(f"{residue}({','.join(mods)})")
1227
+ else:
1228
+ sequence_parts.append(residue)
1229
+ output_text += f"Identified as: {residue}\n"
1230
+ output_text += f"Modifications: {mods}\n"
1231
+ else:
1232
+ for segment in segments:
1233
+ residue, mods = analyzer.identify_residue(segment)
1234
+ if residue:
1235
+ if mods:
1236
+ sequence_parts.append(f"{residue}({','.join(mods)})")
1237
+ else:
1238
+ sequence_parts.append(residue)
1239
+
1240
+ # Get cyclicity and create sequence
1241
+ is_cyclic, peptide_cycles, aromatic_cycles = analyzer.is_cyclic(smiles)
1242
+ sequence = f"cyclo({'-'.join(sequence_parts)})" if is_cyclic else '-'.join(sequence_parts)
1243
+
1244
+ output_text += f"\nSummary for SMILES: {smiles}\n"
1245
+ output_text += f"Sequence: {sequence}\n"
1246
+ output_text += f"Is Cyclic: {'Yes' if is_cyclic else 'No'}\n"
1247
+ if is_cyclic:
1248
+ output_text += f"Peptide Cycles: {', '.join(peptide_cycles)}\n"
1249
+ #output_text += f"Aromatic Cycles: {', '.join(aromatic_cycles)}\n"
1250
+ output_text += "-" * 50 + "\n"
1251
+
1252
+ return output_text, None, None
1253
+
1254
+ except Exception as e:
1255
+ return f"Error processing file: {str(e)}", None, None
1256
+
1257
+ return "No input provided.", None, None
1258
+
1259
+
smiles/evaluation/run_metrics.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Script to run peptide metrics analysis on SMILES text files
4
+
5
+ Usage:
6
+ python run_metrics.py input.txt [--target TARGET] [--output OUTPUT_DIR] [--device DEVICE]
7
+
8
+ Arguments:
9
+ input.txt: Text file containing SMILES sequences (one per line)
10
+ --target: Target protein (GNRHR / ERBB2 / NRP1). If not specified, will try to detect from filename
11
+ --output: Output directory for results (default: same as input file directory)
12
+ --device: Device for computation (cuda or cpu, default: cuda if available)
13
+ """
14
+
15
+ import argparse
16
+ import os
17
+ import sys
18
+ import pandas as pd
19
+ import tempfile
20
+ import torch
21
+ import shutil
22
+ from pathlib import Path
23
+
24
+ # Add current directory to path for imports
25
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
26
+
27
+ from smiles.evaluation.metrics import Metrics
28
+
29
+ def read_smiles_file(input_path):
30
+ """
31
+ Read SMILES sequences from a text file
32
+
33
+ Args:
34
+ input_path: Path to input text file
35
+
36
+ Returns:
37
+ List of SMILES strings
38
+ """
39
+ smiles_list = []
40
+
41
+ with open(input_path, 'r') as f:
42
+ for line_num, line in enumerate(f, 1):
43
+ line = line.strip()
44
+ if line and not line.startswith('#'): # Skip empty lines and comments
45
+ smiles_list.append(line)
46
+
47
+ print(f"Read {len(smiles_list)} SMILES sequences from {input_path}")
48
+ return smiles_list
49
+
50
+ def create_temp_csv(smiles_list, target=None):
51
+ """
52
+ Create a temporary CSV file with SMILES data
53
+
54
+ Args:
55
+ smiles_list: List of SMILES strings
56
+ target: Target protein name (for filename)
57
+
58
+ Returns:
59
+ Path to temporary CSV file
60
+ """
61
+ # Create DataFrame
62
+ df = pd.DataFrame({'SMILES': smiles_list})
63
+
64
+ # Create temporary file
65
+ suffix = f"_{target}" if target else ""
66
+ temp_file = tempfile.NamedTemporaryFile(
67
+ mode='w',
68
+ suffix=f"{suffix}_smiles.csv",
69
+ delete=False
70
+ )
71
+
72
+ # Save to CSV
73
+ df.to_csv(temp_file.name, index=False)
74
+ temp_file.close()
75
+
76
+ print(f"Created temporary CSV: {temp_file.name}")
77
+ return temp_file.name
78
+
79
+ def determine_target(input_path, target_arg):
80
+ """
81
+ Determine target protein from filename or argument
82
+
83
+ Args:
84
+ input_path: Path to input file
85
+ target_arg: Target specified via command line argument
86
+
87
+ Returns:
88
+ Target protein name or None
89
+ """
90
+ if target_arg:
91
+ target = target_arg.upper()
92
+ if target not in ['GNRHR', 'ERBB2', 'NRP1']:
93
+ print(f"Warning: Unknown target '{target}'. Supported targets: GNRHR, ERBB2, NRP1")
94
+ return None
95
+ return target
96
+
97
+ # Try to detect from filename
98
+ filename = os.path.basename(input_path).upper()
99
+ if 'GNRHR' in filename:
100
+ return 'GNRHR'
101
+ elif 'ERBB2' in filename:
102
+ return 'ERBB2'
103
+ elif 'NRP1' in filename:
104
+ return 'NRP1'
105
+ else:
106
+ print("Warning: Could not determine target from filename. Specify with --target")
107
+ print("Supported targets: GNRHR, ERBB2, NRP1")
108
+ return None
109
+
110
+ def save_results(results, output_path):
111
+ """
112
+ Save analysis results to a text file
113
+
114
+ Args:
115
+ results: Dictionary of analysis results
116
+ output_path: Path to output file
117
+ """
118
+ with open(output_path, 'w') as f:
119
+ f.write("PEPTIDE METRICS ANALYSIS RESULTS\n")
120
+ f.write("=" * 50 + "\n\n")
121
+
122
+ # Basic statistics
123
+ f.write("BASIC STATISTICS:\n")
124
+ f.write(f" Total sequences: {results.get('total_sequences', 0)}\n")
125
+ f.write(f" Valid peptides: {results.get('valid_peptides', 0)}\n")
126
+ f.write(f" Validity rate: {results.get('validity_rate', 0):.1%}\n")
127
+ f.write(f" Valid conjugates: {results.get('valid_conjugates', 0)}\n")
128
+ f.write(f" Conjugation success rate: {results.get('conjugation_success_rate', 0):.1%}\n")
129
+ f.write(f" Target protein: {results.get('target', 'Unknown')}\n\n")
130
+
131
+ # Binding affinity
132
+ if 'binding_affinity_all_mean' in results:
133
+ f.write("BINDING AFFINITY (All valid peptides):\n")
134
+ f.write(f" Mean: {results['binding_affinity_all_mean']:.4f}\n")
135
+ f.write(f" Std: {results['binding_affinity_all_std']:.4f}\n")
136
+ f.write(f" Min: {results['binding_affinity_all_min']:.4f}\n")
137
+ f.write(f" Max: {results['binding_affinity_all_max']:.4f}\n\n")
138
+
139
+ if 'binding_affinity_conjugatable_mean' in results:
140
+ f.write("BINDING AFFINITY (Conjugatable peptides):\n")
141
+ f.write(f" Mean: {results['binding_affinity_conjugatable_mean']:.4f}\n")
142
+ f.write(f" Std: {results['binding_affinity_conjugatable_std']:.4f}\n")
143
+ f.write(f" Min: {results['binding_affinity_conjugatable_min']:.4f}\n")
144
+ f.write(f" Max: {results['binding_affinity_conjugatable_max']:.4f}\n\n")
145
+
146
+ # Raw results (for programmatic access)
147
+ f.write("RAW RESULTS (JSON format):\n")
148
+ f.write(str(results))
149
+
150
+ def main():
151
+ parser = argparse.ArgumentParser(
152
+ description="Run peptide metrics analysis on SMILES text files",
153
+ formatter_class=argparse.RawDescriptionHelpFormatter,
154
+ epilog="""
155
+ Examples:
156
+ python run_metrics.py peptides.txt
157
+ python run_metrics.py gnrhr_peptides.txt --target GNRHR
158
+ python run_metrics.py peptides.txt --target ERBB2 --output ./results
159
+ python run_metrics.py peptides.txt --target NRP1 --device cpu
160
+ """
161
+ )
162
+
163
+ parser.add_argument('input_file', type=str,
164
+ help='Text file containing SMILES sequences (one per line)')
165
+ parser.add_argument('--target', type=str, choices=['GNRHR', 'ERBB2', 'NRP1'], default=None,
166
+ help='Target protein (GNRHR, ERBB2, NRP1). If not specified, will try to detect from filename')
167
+ parser.add_argument('--output', type=str, default=None,
168
+ help='Output directory for results (default: same as input file directory)')
169
+ parser.add_argument('--device', type=str, default='auto',
170
+ help='Device for computation (cuda, cpu, or auto)')
171
+ parser.add_argument('--no-csv', action='store_true',
172
+ help='Skip saving detailed CSV results')
173
+
174
+ args = parser.parse_args()
175
+
176
+ # Validate input file
177
+ if not os.path.exists(args.input_file):
178
+ print(f"Error: Input file '{args.input_file}' does not exist")
179
+ return 1
180
+
181
+ # Determine device
182
+ if args.device == 'auto':
183
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
184
+ else:
185
+ device = args.device
186
+
187
+ print(f"Using device: {device}")
188
+
189
+ # Set up output directory
190
+ if args.output:
191
+ output_dir = args.output
192
+ os.makedirs(output_dir, exist_ok=True)
193
+ else:
194
+ output_dir = os.path.dirname(os.path.abspath(args.input_file))
195
+
196
+ # Generate output filenames
197
+ base_name = Path(args.input_file).stem
198
+ results_file = os.path.join(output_dir, f"{base_name}_metrics.txt")
199
+
200
+ try:
201
+ # Read SMILES sequences
202
+ print("Reading SMILES sequences...")
203
+ smiles_list = read_smiles_file(args.input_file)
204
+
205
+ if not smiles_list:
206
+ print("Error: No SMILES sequences found in input file")
207
+ return 1
208
+
209
+ # Determine target
210
+ target = determine_target(args.input_file, args.target)
211
+ if not target:
212
+ print("Error: Could not determine target protein. Use --target to specify.")
213
+ return 1
214
+
215
+ # Create temporary CSV file
216
+ temp_csv_path = create_temp_csv(smiles_list, target)
217
+
218
+ try:
219
+ # Initialize metrics calculator
220
+ print("Initializing metrics calculator...")
221
+ metrics_calculator = Metrics(
222
+ input_type='peptide',
223
+ device=device
224
+ )
225
+
226
+ # Run analysis
227
+ print("Running peptide analysis...")
228
+ results = metrics_calculator.get_metrics_for_peptides(
229
+ temp_csv_path,
230
+ save_csv=(not args.no_csv)
231
+ )
232
+
233
+ # Save results
234
+ save_results(results, results_file)
235
+ print(f"\nResults saved to: {results_file}")
236
+
237
+ # Handle detailed CSV if it was created
238
+ if not args.no_csv and results.get('csv_path'):
239
+ source_csv = results['csv_path']
240
+
241
+ if os.path.exists(source_csv):
242
+ final_csv = os.path.join(output_dir, f"{base_name}_conjugate_analysis.csv")
243
+
244
+ # If output_dir is different from source directory, move the file
245
+ if os.path.abspath(os.path.dirname(source_csv)) != os.path.abspath(output_dir):
246
+ try:
247
+ shutil.move(source_csv, final_csv)
248
+ print(f"Detailed CSV moved to: {final_csv}")
249
+ except Exception as e:
250
+ # Fallback: copy then delete
251
+ try:
252
+ shutil.copy2(source_csv, final_csv)
253
+ os.remove(source_csv)
254
+ print(f"Detailed CSV copied to: {final_csv}")
255
+ except Exception as e2:
256
+ print(f"Warning: Failed to move CSV file: {e2}")
257
+ print(f"CSV remains at: {source_csv}")
258
+ else:
259
+ print(f"Detailed CSV already saved at: {source_csv}")
260
+ else:
261
+ print(f"Warning: Expected detailed CSV not found at: {source_csv}")
262
+ elif not args.no_csv:
263
+ print("Warning: No detailed CSV was created (possibly no valid conjugates found)")
264
+
265
+ finally:
266
+ # Clean up temporary CSV
267
+ if os.path.exists(temp_csv_path):
268
+ os.unlink(temp_csv_path)
269
+ print(f"Cleaned up temporary file: {temp_csv_path}")
270
+
271
+ print("\nAnalysis complete!")
272
+ return 0
273
+
274
+ except Exception as e:
275
+ print(f"Error during analysis: {e}")
276
+ import traceback
277
+ traceback.print_exc()
278
+ return 1
279
+
280
+ if __name__ == "__main__":
281
+ exit_code = main()
282
+ sys.exit(exit_code)
smiles/evaluation/run_metrics_uaa.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ run_metrics_uaa.py
4
+ ==================
5
+
6
+ Re-evaluates already-generated UAA peptide-drug conjugates after stripping
7
+ Fmoc-strategy side-chain protecting groups, and reports protected-vs-deprotected
8
+ correlation for the binding-affinity oracle.
9
+
10
+ This script does NOT re-run guidance / generation. It works entirely on the
11
+ existing `*_conjugate_analysis.csv` files produced by run_metrics.py.
12
+
13
+ Pipeline per input CSV
14
+ ----------------------
15
+ 1. Load CSV (must contain `peptide_smiles`, `conjugate_smiles`, `binding_affinity`).
16
+ 2. Strip protecting groups from `peptide_smiles` and `conjugate_smiles`
17
+ using a small library of RDKit reactions (Mmt, Trt, Boc, OtBu ester,
18
+ OtBu/OBn ether, OBn ester, OBn-phosphate, Pbf).
19
+ 3. Recompute molecular properties (MW, LogP, TPSA, HBD/HBA, rotbonds,
20
+ heavy atoms) on the deprotected conjugate.
21
+ 4. Re-tokenize the deprotected peptide SMILES and re-score with the same
22
+ `BindingAffinity` model used originally. (Skip with --no-rescore.)
23
+ 5. Optionally pair each row with the matching NCAA sequence string from the
24
+ original `.txt` file (--ncaa-txt or auto-detect).
25
+ 6. Save `<input>_deprot.csv` with both protected and deprotected columns,
26
+ plus a `uaa_deprotection_summary.csv` aggregating across all inputs:
27
+ - n
28
+ - mean / std of binding (×10 pKd) protected and deprotected
29
+ - Pearson and Spearman correlation across the two
30
+ - per-rule strip counts on conjugates
31
+
32
+ Usage
33
+ -----
34
+ python run_metrics_uaa.py INPUT_CSV [INPUT_CSV ...] \
35
+ [--output OUTPUT_DIR] [--target {ERBB2,GNRHR,NRP1}] \
36
+ [--device {cuda,cpu,auto}] [--no-rescore] \
37
+ [--vocab PATH] [--splits PATH] [--ncaa-txt-dir PATH]
38
+
39
+ Examples
40
+ --------
41
+ # Re-evaluate one CSV
42
+ python -m smiles.evaluation.run_metrics_uaa \
43
+ smiles/results/uaa_results/cupofmoca_guided_uaa_ERBB2_SSCpt_k8_len10_smiles_conjugate_analysis.csv \
44
+ --output smiles/results/uaa_results_deprot
45
+
46
+ # Re-evaluate everything (see scripts/analyze_smiles_uaa_deprotect.sh)
47
+ """
48
+
49
+ import argparse
50
+ import os
51
+ import sys
52
+ from pathlib import Path
53
+
54
+ import numpy as np
55
+ import pandas as pd
56
+
57
+ from rdkit import Chem, RDLogger
58
+ from rdkit.Chem import AllChem, Descriptors
59
+
60
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
61
+
62
+ RDLogger.DisableLog("rdApp.*")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Target protein sequences (must match metrics.py - copied here so this script
67
+ # is self-contained and doesn't trigger the full Metrics(...) construction
68
+ # which loads a 30K reference SMILES set we don't need.)
69
+ # ---------------------------------------------------------------------------
70
+ TARGET_SEQUENCES = {
71
+ 'GNRHR': (
72
+ 'MANSASPEQNQNHCSAINNSIPLMQGNLPTLTLSGKIRVTVTFFLFLLSATFNASFLLKLQKWTQKKEKGKK'
73
+ 'LSRMKLLLKHLTLANLLETLIVMPLDGMWNITVQWYAGELLCKVLSYLKLFSMYAPAFMMVVISLDRSLAIT'
74
+ 'RPLALKSNSKVGQSMVGLAWILSSVFAGPQLYIFRMIHLADSSGQTKVFSQCVTHCSFSQWWHQAFYNFFTF'
75
+ 'SCLFIIPLFIMLICNAKIIFTLTRVLHQDPHELQLNQSKNNIPRARLKTLKMTVAFATSFTVCWTPYYVLGI'
76
+ 'WYWFDPEMLNRLSDPVNHFFFLFAFLNPCFDPLIYGYFSL'
77
+ ),
78
+ 'ERBB2': (
79
+ 'MELAALCRWGLLLALLPPGAASTQVCTGTDMKLRLPASPETHLDMLRHLYQGCQVVQGNLELTYLPTNASLS'
80
+ 'FLQDIQEVQGYVLIAHNQVRQVPLQRLRIVRGTQLFEDNYALAVLDNGDPLNNTTPVTGASPGGLRELQLRS'
81
+ 'LTEILKGGVLIQRNPQLCYQDTILWKDIFHKNNQLALTLIDTNRSRACHPCSPMCKGSRCWGESSEDCQSLT'
82
+ 'RTVCAGGCARCKGPLPTDCCHEQCAAGCTGPKHSDCLACLHFNHSGICELHCPALVTYNTDTFESMPNPEGR'
83
+ 'YTFGASCVTACPYNYLSTDVGSCTLVCPLHNQEVTAEDGTQRCEKCSKPCARVCYGLGMEHLREVRAVTSAN'
84
+ 'IQEFAGCKKIFGSLAFLPESFDGDPASNTAPLQPEQLQVFETLEEITGYLYISAWPDSLPDLSVFQNLQVIR'
85
+ 'GRILHNGAYSLTLQGLGISWLGLRSLRELGSGLALIHHNTHLCFVHTVPWDQLFRNPHQALLHTANRPEDEC'
86
+ 'VGEGLACHQLCARGHCWGPGPTQCVNCSQFLRGQECVEECRVLQGLPREYVNARHCLPCHPECQPQNGSVTC'
87
+ 'FGPEADQCVACAHYKDPPFCVARCPSGVKPDLSYMPIWKFPDEEGACQPCPINCTHSCVDLDDKGCPAEQRA'
88
+ 'SPLTSIISAVVGILLVVVLGVVFGILIKRRQQKIRKYTMRRLLQETELVEPLTPSGAMPNQAQMRILKETEL'
89
+ 'RKVKVLGSGAFGTVYKGIWIPDGENVKIPVAIKVLRENTSPKANKEILDEAYVMAGVGSPYVSRLLGICLTS'
90
+ 'TVQLVTQLMPYGCLLDHVRENRGRLGSQDLLNWCMQIAKGMSYLEDVRLVHRDLAARNVLVKSPNHVKITDF'
91
+ 'GLARLLDIDETEYHADGGKVPIKWMALESILRRRFTHQSDVWSYGVTVWELMTFGAKPYDGIPAREIPDLLE'
92
+ 'KGERLPQPPICTIDVYMIMVKCWMIDSECRPRFRELVSEFSRMARDPQRFVVIQNEDLGPASPLDSTFYRSL'
93
+ 'LEDDDMGDLVDAEEYLVPQQGFFCPDPAPGAGGMVHHRHRSSSTRSGGGDLTLGLEPSEEEAPRSPLAPSEG'
94
+ 'AGSDVFDGDLGMGAAKGLQSLPTHDPSPLQRYSEDPTVPLPSETDGYVAPLTCSPQPEYVNQPDVRPQPPSP'
95
+ 'REGPLPAARPAGATLERPKTLSPGKNGVVKDVFAFGGAVENPEYLTPQGGAAPQPHPPPAFSPAFDNLYYWD'
96
+ 'QDPPERGAPPSTFKGTPTAENPEYLGLDVPV'
97
+ ),
98
+ 'NRP1': (
99
+ 'MERGLPLLCAVLALVLAPAGAFRNDKCGDTIKIESPGYLTSPGYPHSYHPSEKCEWLIQAPDPYQRIMINFN'
100
+ 'PHFDLEDRDCKYDYVEVFDGENENGHFRGKFCGKIAPPPVVSSGPFLFIKFVSDYETHGAGFSIRYEIFKRG'
101
+ 'PECSQNYTTPSGVIKSPGFPEKYPNSLECTYIVFVPKMSEIILEFESFDLEPDSNPPGGMFCRYDRLEIWDG'
102
+ 'FPDVGPHIGRYCGQKTPGRIRSSSGILSMVFYTDSAIAKEGFSANYSVLQSSVSEDFKCMEALGMESGEIHS'
103
+ 'DQITASSQYSTNWSAERSRLNYPENGWTPGEDSYREWIQVDLGLLRFVTAVGTQGAISKETKKKYYVKTYKI'
104
+ 'DVSSNGEDWITIKEGNKPVLFQGNTNPTDVVVAVFPKPLITRFVRIKPATWETGISMRFEVYGCKITDYPCS'
105
+ 'GMLGMVSGLISDSQITSSNQGDRNWMPENIRLVTSRSGWALPPAPHSYINEWLQIDLGEEKIVRGIIIQGGK'
106
+ 'HRENKVFMRKFKIGYSNNGSDWKMIMDDSKRKAKSFEGNNNYDTPELRTFPALSTRFIRIYPERATHGGLGL'
107
+ 'RMELLGCEVEAPTAGPTTPNGNLVDECDDDQANCHSGTGDDFQLTGGTTVLATEKPTVIDSTIQSEFPTYGF'
108
+ 'NCEFGWGSHKTFCHWEHDNHVQLKWSVLTSKTGPIQDHTGDGNFIYSQADENQKGKVARLVSPVVYSQNSAH'
109
+ 'CMTFWYHMSGSHVGTLRVKLRYQKPEEYDQLVWMAIGHQGDHWKEGRVLLHKSLKLYQVIFEGEIGKGNLGG'
110
+ 'IAVDDISINNHISQEDCAKPADLDKKNPEIKIDETGSTPGYEGEGEGDKNISRKPGNVLKTLDPILITIIAM'
111
+ 'SALGVLLGAVCGVVLYCACWHNGMSERNLSALENYNFELVDGVKLKKDKLNTQSTYSEA'
112
+ ),
113
+ }
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Deprotection rules (Fmoc SPPS side-chain protecting groups).
118
+ # Each rule is (label, reaction-SMARTS). Reactions are applied iteratively
119
+ # until no rule matches; on each match we re-sanitize the molecule.
120
+ # Patterns are intentionally specific so they don't accidentally rewrite
121
+ # atoms in the linker/payload (Aoa, SS, DSPA, Cpt, Dau, SN-38 do not contain
122
+ # tBu, trityl, or benzyl-phosphate groups).
123
+ # ---------------------------------------------------------------------------
124
+ DEPROTECTION_RULES = [
125
+ # 4-methoxytrityl on Cys: -S-C(Ph)(Ph)(p-anisyl) -> -SH
126
+ ("Mmt-S",
127
+ "[#16:1][C](-c1ccccc1)(-c2ccccc2)-c3ccc(O[CH3])cc3>>[#16:1]"),
128
+
129
+ # Trityl on Cys / His-N: -S-C(Ph)3 -> -SH ; -N-C(Ph)3 -> -NH
130
+ ("Trt-S/N",
131
+ "[#16,#7:1][C](-c1ccccc1)(-c2ccccc2)-c3ccccc3>>[*:1]"),
132
+
133
+ # Boc on amine: -N-C(=O)-O-C(CH3)3 -> -NH
134
+ ("Boc-N",
135
+ "[N:1][C](=O)O[C]([CH3])([CH3])[CH3]>>[N:1]"),
136
+
137
+ # OtBu ester on COOH: -C(=O)-O-C(CH3)3 -> -C(=O)OH
138
+ ("OtBu-ester",
139
+ "[C:1](=O)O[C]([CH3])([CH3])[CH3]>>[C:1](=O)O"),
140
+
141
+ # OtBu ether on Ser/Thr/Tyr: -O-C(CH3)3 -> -OH
142
+ ("OtBu-ether",
143
+ "[O:1][C]([CH3])([CH3])[CH3]>>[O:1]"),
144
+
145
+ # Bn on phosphate / phosphonate: -O-P(=O)(O)-O-CH2-Ph -> -O-P(=O)(O)-OH
146
+ ("Bn-phosphate",
147
+ "[#15:1](=O)([O])O[CH2]c1ccccc1>>[#15:1](=O)([O])O"),
148
+
149
+ # Bn ester: -C(=O)-O-CH2-Ph -> -C(=O)OH
150
+ ("Bn-ester",
151
+ "[C:1](=O)O[CH2]c1ccccc1>>[C:1](=O)O"),
152
+
153
+ # Bn ether (catch-all, after phosphate/ester): -O-CH2-Ph -> -OH
154
+ ("Bn-ether",
155
+ "[O:1][CH2]c1ccccc1>>[O:1]"),
156
+
157
+ # Pbf on Arg: -N-S(=O)(=O)-pentamethyldihydrobenzofuran -> -N
158
+ ("Pbf-N",
159
+ "[N:1][S](=O)(=O)c1c([CH3])c2c(c1)O[C]([CH3])([CH3])[C]2([CH3])[CH3]>>[N:1]"),
160
+
161
+ # 2,4-dimethoxybenzyl (Dmb) amide / amine cap: -N-CH2-Ar(2,4-OMe) -> -NH
162
+ ("Dmb-N",
163
+ "[N:1][CH2]c1cc(O[CH3])cc(O[CH3])c1>>[N:1]"),
164
+ ("Dmb-N-alt",
165
+ "[N:1][CH2]c1c(O[CH3])cc(O[CH3])cc1>>[N:1]"),
166
+ ]
167
+
168
+
169
+ def deprotect_mol(mol, rules, max_iters=30):
170
+ """Apply deprotection reactions until convergence.
171
+
172
+ Returns
173
+ -------
174
+ final_mol : Chem.Mol or None
175
+ counts : dict {label: int} -- how many times each rule fired.
176
+ """
177
+ counts = {label: 0 for label, _ in rules}
178
+ if mol is None:
179
+ return None, counts
180
+
181
+ # Pre-compile reactions
182
+ compiled = []
183
+ for label, rxn_sm in rules:
184
+ try:
185
+ rxn = AllChem.ReactionFromSmarts(rxn_sm)
186
+ if rxn.GetNumReactantTemplates() == 1:
187
+ compiled.append((label, rxn))
188
+ except Exception:
189
+ pass
190
+
191
+ for _ in range(max_iters):
192
+ changed = False
193
+ for label, rxn in compiled:
194
+ try:
195
+ products = rxn.RunReactants((mol,))
196
+ except Exception:
197
+ continue
198
+ if not products:
199
+ continue
200
+ new_mol = products[0][0]
201
+ try:
202
+ Chem.SanitizeMol(new_mol)
203
+ except Exception:
204
+ continue
205
+ try:
206
+ if Chem.MolToSmiles(new_mol) == Chem.MolToSmiles(mol):
207
+ continue
208
+ except Exception:
209
+ continue
210
+ mol = new_mol
211
+ counts[label] += 1
212
+ changed = True
213
+ break # restart rule list from the top
214
+ if not changed:
215
+ break
216
+ return mol, counts
217
+
218
+
219
+ def deprotect_smiles(smi, rules):
220
+ """Convenience wrapper: SMILES -> (deprot_smi, counts)."""
221
+ empty = {label: 0 for label, _ in rules}
222
+ if not isinstance(smi, str) or not smi:
223
+ return None, empty
224
+ mol = Chem.MolFromSmiles(smi)
225
+ if mol is None:
226
+ return None, empty
227
+ final_mol, counts = deprotect_mol(mol, rules)
228
+ if final_mol is None:
229
+ return None, counts
230
+ try:
231
+ return Chem.MolToSmiles(final_mol), counts
232
+ except Exception:
233
+ return None, counts
234
+
235
+
236
+ def compute_props(smi):
237
+ if not isinstance(smi, str) or not smi:
238
+ return None
239
+ mol = Chem.MolFromSmiles(smi)
240
+ if mol is None:
241
+ return None
242
+ return {
243
+ 'mw_dep': float(Descriptors.MolWt(mol)),
244
+ 'logp_dep': float(Descriptors.MolLogP(mol)),
245
+ 'tpsa_dep': float(Descriptors.TPSA(mol)),
246
+ 'hbd_dep': int(Descriptors.NumHDonors(mol)),
247
+ 'hba_dep': int(Descriptors.NumHAcceptors(mol)),
248
+ 'rotbonds_dep': int(Descriptors.NumRotatableBonds(mol)),
249
+ 'heavy_dep': int(mol.GetNumHeavyAtoms()),
250
+ }
251
+
252
+
253
+ def determine_target(input_path, target_arg):
254
+ if target_arg:
255
+ t = target_arg.upper()
256
+ if t in TARGET_SEQUENCES:
257
+ return t
258
+ fn = os.path.basename(input_path).upper()
259
+ for t in TARGET_SEQUENCES:
260
+ if t in fn:
261
+ return t
262
+ return None
263
+
264
+
265
+ def score_binding_smiles(model, tokenizer, smi_list, device, batch_size=32):
266
+ """Score a list of peptide SMILES with the BindingAffinity model.
267
+
268
+ Returns a numpy array of length len(smi_list); failed entries are NaN.
269
+ """
270
+ import torch
271
+ out = np.full(len(smi_list), np.nan, dtype=float)
272
+ if not smi_list:
273
+ return out
274
+
275
+ valid_idx = []
276
+ valid_tok = []
277
+ for i, s in enumerate(smi_list):
278
+ if not isinstance(s, str) or not s:
279
+ continue
280
+ try:
281
+ t = tokenizer(s)
282
+ except Exception:
283
+ continue
284
+ if hasattr(t, 'input_ids'):
285
+ t = t.input_ids
286
+ if isinstance(t, list):
287
+ t = torch.tensor(t)
288
+ if not torch.is_tensor(t):
289
+ continue
290
+ if t.dim() == 2:
291
+ t = t.squeeze(0)
292
+ if t.dim() != 1 or t.numel() == 0:
293
+ continue
294
+ valid_idx.append(i)
295
+ valid_tok.append(t.long())
296
+
297
+ if not valid_tok:
298
+ return out
299
+
300
+ for j in range(0, len(valid_tok), batch_size):
301
+ chunk = valid_tok[j:j + batch_size]
302
+ max_len = max(int(t.numel()) for t in chunk)
303
+ pad = torch.zeros((len(chunk), max_len), dtype=torch.long)
304
+ for k, t in enumerate(chunk):
305
+ pad[k, :t.numel()] = t
306
+ try:
307
+ with torch.no_grad():
308
+ scores = model(pad.to(device)).cpu().numpy().reshape(-1)
309
+ except Exception as e:
310
+ print(f" [score_binding] batch {j}: {e}")
311
+ continue
312
+ for k, idx in enumerate(valid_idx[j:j + batch_size]):
313
+ out[idx] = float(scores[k])
314
+ return out
315
+
316
+
317
+ def maybe_load_ncaa_sequences(csv_path, ncaa_dir):
318
+ """Pair rows of the CSV with NCAA sequence strings from the matching
319
+ `<base>.txt` two-column file (NCAA<TAB>SMILES). Returns a list of
320
+ NCAA strings (None for unmatched rows) aligned to the CSV rows.
321
+
322
+ Lookup strategy:
323
+ 1. Read all (NCAA, SMILES) pairs from the .txt.
324
+ 2. For each row in the CSV, match by `peptide_smiles`.
325
+ """
326
+ base = Path(csv_path).stem
327
+ if base.endswith('_conjugate_analysis'):
328
+ base = base[: -len('_conjugate_analysis')]
329
+ candidates = []
330
+ if ncaa_dir:
331
+ candidates.append(os.path.join(ncaa_dir, base + '.txt'))
332
+ csv_dir = os.path.dirname(os.path.abspath(csv_path))
333
+ candidates.extend([
334
+ os.path.join(csv_dir, base + '.txt'),
335
+ os.path.join(csv_dir, '..', base + '.txt'),
336
+ os.path.join(os.path.dirname(csv_dir), base + '.txt'),
337
+ ])
338
+ # also try stripping _smiles suffix used by some files
339
+ if base.endswith('_smiles'):
340
+ b2 = base[: -len('_smiles')]
341
+ for d in [ncaa_dir, csv_dir, os.path.join(csv_dir, '..'), os.path.dirname(csv_dir)]:
342
+ if d:
343
+ candidates.append(os.path.join(d, b2 + '.txt'))
344
+
345
+ for path in candidates:
346
+ if path and os.path.exists(path):
347
+ try:
348
+ pairs = {}
349
+ with open(path, 'r', encoding='utf-8', errors='replace') as f:
350
+ for line in f:
351
+ line = line.rstrip('\n').rstrip('\r')
352
+ if not line:
353
+ continue
354
+ if '\t' in line:
355
+ ncaa, smi = line.split('\t', 1)
356
+ else:
357
+ # one-column SMILES file, no NCAA available
358
+ return None
359
+ pairs[smi] = ncaa
360
+ return path, pairs
361
+ except Exception:
362
+ continue
363
+ return None
364
+
365
+
366
+ def parse_args():
367
+ p = argparse.ArgumentParser(
368
+ description="UAA peptide-drug conjugate deprotection + re-scoring",
369
+ formatter_class=argparse.RawDescriptionHelpFormatter,
370
+ )
371
+ p.add_argument('inputs', nargs='+',
372
+ help='UAA *_conjugate_analysis.csv file(s) produced by run_metrics.py')
373
+ p.add_argument('--output', '-o', default=None,
374
+ help='Output directory for *_deprot.csv files and summary '
375
+ '(default: same directory as each input)')
376
+ p.add_argument('--target', default=None, choices=list(TARGET_SEQUENCES),
377
+ help='Target protein (default: auto-detect from filename)')
378
+ p.add_argument('--device', default='auto',
379
+ help='cuda / cpu / auto (default: auto)')
380
+ p.add_argument('--no-rescore', action='store_true',
381
+ help='Skip binding rescoring (deprotect + recompute properties only)')
382
+ p.add_argument('--vocab', default='./smiles/smiles_tokenizer/new_vocab.txt')
383
+ p.add_argument('--splits', default='./smiles/smiles_tokenizer/new_splits.txt')
384
+ p.add_argument('--ncaa-txt-dir', default=None,
385
+ help='Directory holding the original NCAA .txt files '
386
+ '(NCAA<TAB>SMILES). Default: same dir as the CSV.')
387
+ return p.parse_args()
388
+
389
+
390
+ def main():
391
+ args = parse_args()
392
+
393
+ # Lazy heavy imports (only if needed)
394
+ if not args.no_rescore:
395
+ import torch
396
+ if args.device == 'auto':
397
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
398
+ else:
399
+ device = args.device
400
+ print(f"Device: {device}")
401
+
402
+ # Load tokenizer + binding model lazily
403
+ try:
404
+ from smiles.scoring.classifiers import BindingAffinity
405
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
406
+ except Exception as e:
407
+ print(f"Cannot import binding-affinity oracle modules: {e}")
408
+ print("Falling back to --no-rescore.")
409
+ args.no_rescore = True
410
+
411
+ if not args.no_rescore:
412
+ if not (os.path.exists(args.vocab) and os.path.exists(args.splits)):
413
+ print(f"Tokenizer vocab/splits not found "
414
+ f"(--vocab={args.vocab}, --splits={args.splits}).")
415
+ print("Run from MoCA root, or pass --vocab/--splits. Skipping rescoring.")
416
+ args.no_rescore = True
417
+ else:
418
+ tokenizer = SMILES_SPE_Tokenizer(args.vocab, args.splits)
419
+ else:
420
+ device = 'cpu'
421
+
422
+ # Print rule list
423
+ print("Deprotection rules (in order, applied iteratively):")
424
+ for label, _ in DEPROTECTION_RULES:
425
+ print(f" - {label}")
426
+
427
+ binding_models = {}
428
+ summary_rows = []
429
+ overall_strip_counts = {label: 0 for label, _ in DEPROTECTION_RULES}
430
+
431
+ for in_csv in args.inputs:
432
+ print(f"\n>>> {in_csv}")
433
+ if not os.path.exists(in_csv):
434
+ print(f" missing, skipping")
435
+ continue
436
+ df = pd.read_csv(in_csv)
437
+ if df.empty:
438
+ print(f" empty CSV, skipping")
439
+ continue
440
+ target = determine_target(in_csv, args.target)
441
+ if target is None:
442
+ print(f" cannot determine target, skipping")
443
+ continue
444
+ print(f" target = {target}, n = {len(df)}")
445
+
446
+ # Try to attach NCAA sequence strings from the matching .txt
447
+ ncaa_seq_col = [None] * len(df)
448
+ ncaa_lookup = maybe_load_ncaa_sequences(in_csv, args.ncaa_txt_dir)
449
+ if ncaa_lookup is not None:
450
+ ncaa_path, pairs = ncaa_lookup
451
+ n_matched = 0
452
+ for i, row in df.iterrows():
453
+ smi = row.get('peptide_smiles')
454
+ if isinstance(smi, str) and smi in pairs:
455
+ ncaa_seq_col[i] = pairs[smi]
456
+ n_matched += 1
457
+ print(f" paired {n_matched}/{len(df)} rows with NCAA sequences from {ncaa_path}")
458
+ df['ncaa_seq'] = ncaa_seq_col
459
+
460
+ # ---- Deprotection ----
461
+ prot_pept = df['peptide_smiles'].fillna('').astype(str).tolist()
462
+ prot_conj = df['conjugate_smiles'].fillna('').astype(str).tolist() \
463
+ if 'conjugate_smiles' in df.columns else [''] * len(df)
464
+
465
+ deprot_pept = []
466
+ deprot_conj = []
467
+ rule_counts_pept = {label: 0 for label, _ in DEPROTECTION_RULES}
468
+ rule_counts_conj = {label: 0 for label, _ in DEPROTECTION_RULES}
469
+ n_strips_pept = []
470
+ n_strips_conj = []
471
+ for sp, sc in zip(prot_pept, prot_conj):
472
+ dp, cp = deprotect_smiles(sp, DEPROTECTION_RULES)
473
+ dc, cc = deprotect_smiles(sc, DEPROTECTION_RULES)
474
+ deprot_pept.append(dp)
475
+ deprot_conj.append(dc)
476
+ for k, v in cp.items():
477
+ rule_counts_pept[k] += v
478
+ for k, v in cc.items():
479
+ rule_counts_conj[k] += v
480
+ overall_strip_counts[k] += v
481
+ n_strips_pept.append(sum(cp.values()))
482
+ n_strips_conj.append(sum(cc.values()))
483
+
484
+ df['peptide_smiles_deprot'] = deprot_pept
485
+ df['conjugate_smiles_deprot'] = deprot_conj
486
+ df['n_strip_pept'] = n_strips_pept
487
+ df['n_strip_conj'] = n_strips_conj
488
+
489
+ # ---- Properties on deprotected conjugate ----
490
+ props_list = [compute_props(s) for s in deprot_conj]
491
+ for k in ('mw_dep', 'logp_dep', 'tpsa_dep', 'hbd_dep',
492
+ 'hba_dep', 'rotbonds_dep', 'heavy_dep'):
493
+ df[k] = [p[k] if p else np.nan for p in props_list]
494
+
495
+ # ---- Binding rescoring ----
496
+ df['binding_protected'] = df.get('binding_affinity', np.nan)
497
+ if args.no_rescore:
498
+ df['binding_deprotected'] = np.nan
499
+ else:
500
+ if target not in binding_models:
501
+ print(f" loading binding-affinity model for {target}...")
502
+ seq = TARGET_SEQUENCES[target]
503
+ binding_models[target] = BindingAffinity(seq, device=device)
504
+ model = binding_models[target]
505
+ print(f" scoring {len(deprot_pept)} deprotected peptide SMILES...")
506
+ df['binding_deprotected'] = score_binding_smiles(
507
+ model, tokenizer, deprot_pept, device
508
+ )
509
+
510
+ # Convenience pKd columns (×10)
511
+ df['binding_protected_pKd'] = df['binding_protected'] * 10
512
+ df['binding_deprotected_pKd'] = df['binding_deprotected'] * 10
513
+
514
+ # ---- Save per-CSV output ----
515
+ out_dir = args.output or os.path.dirname(os.path.abspath(in_csv))
516
+ os.makedirs(out_dir, exist_ok=True)
517
+ out_csv = os.path.join(out_dir, Path(in_csv).stem + '_deprot.csv')
518
+ df.to_csv(out_csv, index=False)
519
+ print(f" -> {out_csv}")
520
+
521
+ # ---- Per-CSV summary ----
522
+ bp = pd.to_numeric(df['binding_protected'], errors='coerce').values
523
+ bd = pd.to_numeric(df['binding_deprotected'], errors='coerce').values
524
+ mask = np.isfinite(bp) & np.isfinite(bd)
525
+ n_pair = int(mask.sum())
526
+ if n_pair > 1:
527
+ try:
528
+ from scipy.stats import pearsonr, spearmanr
529
+ pr = float(pearsonr(bp[mask], bd[mask])[0])
530
+ sr = float(spearmanr(bp[mask], bd[mask]).correlation)
531
+ except Exception:
532
+ bp_z = bp[mask] - bp[mask].mean()
533
+ bd_z = bd[mask] - bd[mask].mean()
534
+ denom = (np.linalg.norm(bp_z) * np.linalg.norm(bd_z))
535
+ pr = float((bp_z * bd_z).sum() / denom) if denom else float('nan')
536
+ sr = float('nan')
537
+ else:
538
+ pr = float('nan')
539
+ sr = float('nan')
540
+
541
+ bp_v = bp[np.isfinite(bp)]
542
+ bd_v = bd[np.isfinite(bd)]
543
+ row = {
544
+ 'file': os.path.basename(in_csv),
545
+ 'target': target,
546
+ 'n': len(df),
547
+ 'n_with_both': n_pair,
548
+ 'protect_avg_strips_per_conj': float(np.mean(n_strips_conj)) if n_strips_conj else 0.0,
549
+ 'binding_prot_pKd_mean': float(bp_v.mean() * 10) if bp_v.size else float('nan'),
550
+ 'binding_prot_pKd_std': float(bp_v.std() * 10) if bp_v.size else float('nan'),
551
+ 'binding_dep_pKd_mean': float(bd_v.mean() * 10) if bd_v.size else float('nan'),
552
+ 'binding_dep_pKd_std': float(bd_v.std() * 10) if bd_v.size else float('nan'),
553
+ 'pearson_r': pr,
554
+ 'spearman_r': sr,
555
+ }
556
+ for label, _ in DEPROTECTION_RULES:
557
+ row[f'strips_conj__{label}'] = rule_counts_conj[label]
558
+ summary_rows.append(row)
559
+
560
+ # Print
561
+ print(f"\n --- {os.path.basename(in_csv)} ---")
562
+ print(f" binding (pKd) protected : {row['binding_prot_pKd_mean']:.3f} ± {row['binding_prot_pKd_std']:.3f} (n={int(np.isfinite(bp).sum())})")
563
+ print(f" binding (pKd) deprotected : {row['binding_dep_pKd_mean']:.3f} ± {row['binding_dep_pKd_std']:.3f} (n={int(np.isfinite(bd).sum())})")
564
+ print(f" Pearson r (prot vs deprot, paired n={n_pair}): {pr:.3f}")
565
+ print(f" Spearman r (prot vs deprot, paired n={n_pair}): {sr:.3f}")
566
+ avg_per = float(np.mean(n_strips_conj)) if n_strips_conj else 0.0
567
+ print(f" Total strip events on conjugates: {sum(n_strips_conj)} ({avg_per:.2f} per molecule)")
568
+ for label, _ in DEPROTECTION_RULES:
569
+ v = rule_counts_conj[label]
570
+ if v:
571
+ print(f" {label:<14s} {v}")
572
+
573
+ # ---- Aggregate summary ----
574
+ if summary_rows:
575
+ out_dir = args.output or os.path.dirname(os.path.abspath(args.inputs[0]))
576
+ os.makedirs(out_dir, exist_ok=True)
577
+ summary_csv = os.path.join(out_dir, 'uaa_deprotection_summary.csv')
578
+ pd.DataFrame(summary_rows).to_csv(summary_csv, index=False)
579
+ print(f"\nAggregate summary -> {summary_csv}")
580
+ print("\nOverall strip events on conjugates across all inputs:")
581
+ for label, _ in DEPROTECTION_RULES:
582
+ v = overall_strip_counts[label]
583
+ if v:
584
+ print(f" {label:<14s} {v}")
585
+
586
+ return 0
587
+
588
+
589
+ if __name__ == '__main__':
590
+ sys.exit(main() or 0)
smiles/generation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """PDC generation entry points."""
smiles/generation/cupofmoca_guided.py ADDED
@@ -0,0 +1,996 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CupOfMoCA-style Guided Generation with Alternative Guidance Methods
3
+
4
+ This implements Algorithm 2 (Sampling with CupOfMoCA) with different guidance strategies:
5
+ 1. Exponential Tilting (ExpTilt) - Direct implementation of Algorithm 2
6
+ 2. Classifier Guidance (CG) - Using external scores to adjust logits
7
+ 3. Best-of-K Selection (BoK) - Sample K candidates and select best
8
+
9
+ Key differences from the Barker-based method in guided.py:
10
+ - ExpTilt: Uses p'(z') ∝ p(z') · exp(β · S(z')) directly without Barker balancing
11
+ - CG: Adjusts base logits using external classifier/score signals: logits = base + w * score_logits
12
+ - BoK: Samples multiple candidates per step and selects the one with highest score
13
+
14
+ Note on Classifier Guidance vs Classifier-Free Guidance:
15
+ - Classifier Guidance (what we implement here): External scorers modify the generation distribution
16
+ - Classifier-Free Guidance (NOT implemented): Would require model trained with conditioning dropout,
17
+ and extrapolates between conditional and unconditional model outputs without external classifiers
18
+ """
19
+
20
+ import argparse
21
+ import re
22
+ import random
23
+ from collections import Counter
24
+ import csv
25
+ import json
26
+ import os
27
+ from datetime import datetime
28
+ from enum import Enum
29
+ from typing import List, Optional, Tuple, Dict, Any
30
+
31
+ import torch
32
+ import torch.nn as nn
33
+ import torch.nn.functional as F
34
+ from tqdm import tqdm
35
+ import numpy as np
36
+
37
+ # Import from the original codebase
38
+ from smiles.models.train import MDLMLightningModule
39
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
40
+ from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
41
+ from smiles.evaluation.pdc_analyzer import PDCAnalyzer
42
+ from smiles.scoring.classifiers import Conjugation, BindingAffinity
43
+
44
+
45
+ class GuidanceMethod(Enum):
46
+ """Available guidance methods"""
47
+ EXPONENTIAL_TILT = "exp_tilt" # Direct exponential tilting (Algorithm 2)
48
+ CLASSIFIER_GUIDANCE = "cg" # Classifier/score-based logit adjustment
49
+ BEST_OF_K = "bok" # Best-of-K selection
50
+ HYBRID = "hybrid" # Combination of methods
51
+
52
+
53
+ class GenerationTracker:
54
+ """
55
+ Tracks detailed validity information during generation
56
+ """
57
+ def __init__(self, output_dir='./generation_logs'):
58
+ self.output_dir = output_dir
59
+ os.makedirs(output_dir, exist_ok=True)
60
+
61
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
62
+ self.step_logs = []
63
+ self.final_results = []
64
+
65
+ def log_step(self, step: int, scores, detailed_results=None, improved_indices=None,
66
+ guidance_info: Optional[Dict] = None):
67
+ """Log information for a generation step"""
68
+ step_info = {
69
+ 'step': step,
70
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
71
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
72
+ 'improved_indices': improved_indices or [],
73
+ 'guidance_info': guidance_info or {}
74
+ }
75
+
76
+ if detailed_results:
77
+ step_info['detailed'] = []
78
+ for i, result in enumerate(detailed_results):
79
+ step_info['detailed'].append({
80
+ 'sample_idx': i,
81
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
82
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
83
+ 'conjugation_success': result.get('conjugation_success', False),
84
+ 'pdc_valid': result.get('pdc_valid', False),
85
+ 'num_conjugates': len(result.get('conjugates', [])),
86
+ 'error': result.get('error')
87
+ })
88
+
89
+ self.step_logs.append(step_info)
90
+
91
+ def log_final(self, sequences, detailed_results_with_props):
92
+ """Log final results with properties"""
93
+ for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
94
+ final_info = {
95
+ 'sample_idx': i,
96
+ 'peptide_smiles': seq,
97
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
98
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
99
+ 'num_conjugates': len(result.get('conjugates', [])),
100
+ 'num_valid_pdcs': len(result.get('valid_pdcs', [])),
101
+ 'best_pdc': None,
102
+ 'linker_used': result.get('linker_used', ''),
103
+ 'all_conjugates': result.get('conjugates', []),
104
+ 'properties': result.get('properties', {})
105
+ }
106
+
107
+ if result.get('best_pdc'):
108
+ final_info['best_pdc'] = {
109
+ 'smiles': result['best_pdc'].get('smiles', ''),
110
+ 'checks': result['best_pdc'].get('checks', {})
111
+ }
112
+
113
+ self.final_results.append(final_info)
114
+
115
+ def save(self, filename_prefix='generation'):
116
+ """Save all logs to files"""
117
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
118
+ with open(step_log_path, 'w') as f:
119
+ json.dump(self.step_logs, f, indent=2, default=str)
120
+
121
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
122
+ with open(final_path, 'w') as f:
123
+ json.dump(self.final_results, f, indent=2, default=str)
124
+
125
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
126
+ with open(csv_path, 'w', newline='') as f:
127
+ writer = csv.writer(f)
128
+ writer.writerow([
129
+ 'sample_idx', 'peptide_smiles', 'is_valid_peptide', 'has_conjugation_site',
130
+ 'linker_used', 'num_conjugates', 'num_valid_pdcs', 'best_pdc_smiles',
131
+ 'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
132
+ ])
133
+ for result in self.final_results:
134
+ props = result.get('properties', {})
135
+ writer.writerow([
136
+ result['sample_idx'],
137
+ result['peptide_smiles'],
138
+ result['is_valid_peptide'],
139
+ result['has_conjugation_site'],
140
+ result.get('linker_used', ''),
141
+ result['num_conjugates'],
142
+ result['num_valid_pdcs'],
143
+ result['best_pdc']['smiles'] if result.get('best_pdc') else '',
144
+ props.get('mf', ''),
145
+ props.get('polararea', ''),
146
+ props.get('xlogp', ''),
147
+ props.get('heavycnt', ''),
148
+ props.get('rotbonds', '')
149
+ ])
150
+
151
+ # Save dedicated PDC output: peptide → linker → all conjugate SMILES
152
+ pdc_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_pdcs.csv")
153
+ with open(pdc_path, 'w', newline='') as f:
154
+ writer = csv.writer(f)
155
+ writer.writerow(['sample_idx', 'peptide_smiles', 'linker_used', 'conjugate_idx', 'pdc_smiles'])
156
+ for result in self.final_results:
157
+ for j, conj in enumerate(result.get('all_conjugates', [])):
158
+ writer.writerow([
159
+ result['sample_idx'],
160
+ result['peptide_smiles'],
161
+ result.get('linker_used', ''),
162
+ j,
163
+ conj
164
+ ])
165
+
166
+ print(f"\n=== Generation logs saved ===")
167
+ print(f" Step logs: {step_log_path}")
168
+ print(f" Final results: {final_path}")
169
+ print(f" Summary CSV: {csv_path}")
170
+ print(f" PDC conjugates: {pdc_path}")
171
+
172
+ return step_log_path, final_path, csv_path, pdc_path
173
+
174
+ def print_step_summary(self, step, scores, detailed_results=None, guidance_method=None):
175
+ """Print a summary for the current step"""
176
+ if isinstance(scores, torch.Tensor):
177
+ scores_list = scores.cpu().numpy().tolist()
178
+ else:
179
+ scores_list = list(scores)
180
+
181
+ num_valid = sum(1 for s in scores_list if s > 0)
182
+
183
+ print(f"\n--- Step {step} Summary ({guidance_method or 'unknown'}) ---")
184
+ print(f" Valid: {num_valid}/{len(scores_list)}")
185
+
186
+ if detailed_results:
187
+ valid_peptide = sum(1 for r in detailed_results if r.get('is_valid_peptide'))
188
+ has_site = sum(1 for r in detailed_results if r.get('has_conjugation_site'))
189
+ conj_success = sum(1 for r in detailed_results if r.get('conjugation_success'))
190
+ pdc_valid = sum(1 for r in detailed_results if r.get('pdc_valid'))
191
+
192
+ print(f" Breakdown:")
193
+ print(f" Valid peptides: {valid_peptide}")
194
+ print(f" Has conjugation site: {has_site}")
195
+ print(f" Conjugation success: {conj_success}")
196
+ print(f" PDC valid: {pdc_valid}")
197
+
198
+ def print_final_summary(self):
199
+ """Print final summary"""
200
+ print("\n" + "=" * 60)
201
+ print("FINAL GENERATION SUMMARY")
202
+ print("=" * 60)
203
+
204
+ total = len(self.final_results)
205
+ valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
206
+
207
+ print(f"\nTotal sequences: {total}")
208
+ print(f"With valid PDCs: {valid_pdcs}")
209
+
210
+ print("\nDetailed results:")
211
+ for result in self.final_results:
212
+ status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
213
+ print(f"\n [{status}] Sample {result['sample_idx']}:")
214
+ print(f" Peptide: {result['peptide_smiles'][:60]}...")
215
+ print(f" Valid peptide: {result['is_valid_peptide']}")
216
+ print(f" Has conjugation site: {result['has_conjugation_site']}")
217
+ print(f" Conjugates: {result['num_conjugates']}")
218
+ print(f" Valid PDCs: {result['num_valid_pdcs']}")
219
+
220
+
221
+ class CupOfMoCAGenerator:
222
+ """
223
+ CupOfMoCA-style generator implementing Algorithm 2 with multiple guidance strategies.
224
+
225
+ Key differences from Barker-based approach:
226
+ 1. ExpTilt: Direct exponential tilting without balancing function
227
+ 2. CFG: Classifier-free guidance by mixing logits
228
+ 3. BoK: Best-of-K selection strategy
229
+ """
230
+
231
+ def __init__(self, model, device, objectives, args, tracker=None):
232
+ self.model = model
233
+ self.device = device
234
+ self.objectives = objectives
235
+ self.args = args
236
+ self.num_objectives = len(objectives)
237
+ self.peptide_analyzer = PeptideAnalyzer()
238
+ self.pdc_analyzer = PDCAnalyzer(device)
239
+ self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586] # Special tokens to avoid
240
+ self.tracker = tracker
241
+
242
+ # Parse guidance method
243
+ self.guidance_method = GuidanceMethod(args.guidance_method)
244
+
245
+ # Find the Conjugation objective for detailed tracking
246
+ self.conjugation_obj = None
247
+ for obj in objectives:
248
+ if isinstance(obj, Conjugation):
249
+ self.conjugation_obj = obj
250
+ break
251
+
252
+ # =========================================================================
253
+ # Initial Generation (x0 from prior μ₀)
254
+ # =========================================================================
255
+
256
+ def _generate_single_x0(self, n_steps: int = 16, temperature: float = 1.0) -> torch.Tensor:
257
+ """
258
+ Generate initial sequences using flow matching (Algorithm 2, Line 6: Sample Z_0 ~ μ_0)
259
+ This follows the flow from noise to data without guidance.
260
+ """
261
+ self.model.eval()
262
+
263
+ # Start with random tokens (noise at t=0)
264
+ x = torch.randint(
265
+ 0, self.model.tokenizer.vocab_size,
266
+ (self.args.num_samples, self.args.gen_len),
267
+ device=self.device
268
+ )
269
+
270
+ # Time schedule: t_0 = 0 to t_K = T (Line 5 of Algorithm 2)
271
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
272
+
273
+ with torch.no_grad():
274
+ for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
275
+ t_curr = time_steps[i]
276
+ t_next = time_steps[i + 1]
277
+
278
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
279
+
280
+ # Get predicted distribution (Line 8: p_{k+1} ← Φ_{θ*}^{t_k→t_{k+1}}(Z_k))
281
+ logits = self.model(x, t_tensor)
282
+ logits = logits / temperature
283
+ logits[:, :, 586] = -1000 # Mask invalid token
284
+
285
+ pred_x1 = torch.argmax(logits, dim=-1)
286
+
287
+ if i == n_steps - 1:
288
+ x = pred_x1
289
+ break
290
+
291
+ # Interpolate between noise and prediction
292
+ noise_prob = 1.0 - t_next
293
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
294
+ noise = torch.randint(0, self.model.tokenizer.vocab_size, x.shape, device=self.device)
295
+ x = torch.where(mask, noise, pred_x1)
296
+
297
+ return x
298
+
299
+ def _check_conjugation_validity(self, x: torch.Tensor) -> torch.Tensor:
300
+ """Check if sequences can be conjugated (validity indicator 1_valid in Algorithm 2)"""
301
+ if self.conjugation_obj is None:
302
+ sequences = self.model.tokenizer.batch_decode(x)
303
+ return torch.tensor([self.peptide_analyzer.is_peptide(seq) for seq in sequences],
304
+ device=self.device)
305
+ scores = self.conjugation_obj(x)
306
+ return scores > 0
307
+
308
+ def generate_x0(self, n_steps: int = 16, temperature: float = 1.0,
309
+ require_valid: bool = False, max_retries: int = 10) -> torch.Tensor:
310
+ """
311
+ Generate initial x0 sequences with optional validity enforcement.
312
+ Implements the constraint that x0 should satisfy validity before guidance.
313
+ """
314
+ print("Generating initial sequences (x0 from prior)...")
315
+
316
+ x = self._generate_single_x0(n_steps, temperature)
317
+
318
+ if require_valid:
319
+ print("Enforcing initial validity constraint...")
320
+
321
+ for retry in range(max_retries):
322
+ validity = self._check_conjugation_validity(x)
323
+ num_valid = validity.sum().item()
324
+ num_invalid = self.args.num_samples - num_valid
325
+
326
+ if num_invalid == 0:
327
+ print(f" All {self.args.num_samples} samples are valid!")
328
+ break
329
+
330
+ print(f" Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid")
331
+
332
+ invalid_indices = torch.where(~validity)[0]
333
+ original_num_samples = self.args.num_samples
334
+ self.args.num_samples = num_invalid
335
+ new_x = self._generate_single_x0(n_steps, temperature)
336
+ self.args.num_samples = original_num_samples
337
+ x[invalid_indices] = new_x
338
+
339
+ final_validity = self._check_conjugation_validity(x)
340
+ if final_validity.sum().item() < self.args.num_samples:
341
+ print(f"⚠ After {max_retries} retries, {final_validity.sum().item()}/{self.args.num_samples} valid")
342
+
343
+ generated_sequences = self.model.tokenizer.batch_decode(x)
344
+ for seq in generated_sequences:
345
+ print(f" {seq[:80]}...")
346
+
347
+ return x
348
+
349
+ # =========================================================================
350
+ # Score Functions
351
+ # =========================================================================
352
+
353
+ def _get_scores(self, x_batch: torch.Tensor) -> torch.Tensor:
354
+ """Calculate scores for all objectives: S(z) in Algorithm 2"""
355
+ scores = []
356
+ for obj_func in self.objectives:
357
+ scores.append(obj_func(x_batch.to(self.device)))
358
+ return torch.stack(scores, dim=0).to(self.device)
359
+
360
+ def _compute_aggregated_score(self, scores: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
361
+ """Compute weighted aggregated score S(z) for guidance"""
362
+ # Weighted sum or min depending on aggregation strategy
363
+ if self.args.score_aggregation == "weighted_sum":
364
+ return (weights * scores).sum(dim=0)
365
+ elif self.args.score_aggregation == "weighted_min":
366
+ return torch.min(weights * scores, dim=0).values
367
+ else: # default: weighted sum
368
+ return (weights * scores).sum(dim=0)
369
+
370
+ # =========================================================================
371
+ # Guidance Methods
372
+ # =========================================================================
373
+
374
+ def _exponential_tilt_guidance(
375
+ self,
376
+ x: torch.Tensor,
377
+ pos_probs: torch.Tensor,
378
+ candidates: torch.Tensor,
379
+ mut_idx: int,
380
+ sample_idx: int,
381
+ beta: float,
382
+ weights: torch.Tensor,
383
+ current_score: float = None
384
+ ) -> Tuple[int, Dict]:
385
+ """
386
+ Exponential Tilting Guidance (Algorithm 2, Lines 10-12) with acceptance criterion
387
+
388
+ p'(z') ∝ p_{k+1}(z') · exp(β · S(z')) · 1_valid(z')
389
+
390
+ Added: Only accept if new_score >= current_score (prevents regression)
391
+ """
392
+ num_candidates = len(candidates)
393
+ current_token = x[sample_idx, mut_idx].item()
394
+
395
+ if num_candidates == 0:
396
+ return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
397
+
398
+ # Limit candidates for efficiency
399
+ if num_candidates > self.args.top_k:
400
+ candidates = candidates[:self.args.top_k]
401
+ num_candidates = self.args.top_k
402
+
403
+ # Create batch of proposals
404
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
405
+ x_proposals[:, mut_idx] = candidates
406
+
407
+ # Compute scores S(z') for all candidates
408
+ proposal_scores = self._get_scores(x_proposals) # [num_objectives, num_candidates]
409
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights) # [num_candidates]
410
+
411
+ # Validity mask: 1_valid(z')
412
+ validity_mask = (proposal_scores[0] > 0).float() # Using first objective (conjugation) as validity
413
+
414
+ # Get base probabilities from the model
415
+ base_probs = pos_probs[sample_idx, candidates]
416
+
417
+ # Apply exponential tilting with numerical stability:
418
+ # p'(z') ∝ p(z') · exp(β · S(z')) · 1_valid(z')
419
+ #
420
+ # To avoid overflow, we use log-space computation:
421
+ # log(p') = log(p) + β·S + log(1_valid)
422
+ # Then normalize in log-space before exp
423
+
424
+ log_base_probs = torch.log(base_probs + 1e-10)
425
+ log_exp_scores = beta * aggregated_scores
426
+ log_validity = torch.log(validity_mask + 1e-10) # -inf for invalid
427
+
428
+ log_tilted = log_base_probs + log_exp_scores + log_validity
429
+
430
+ # Subtract max for numerical stability (log-sum-exp trick)
431
+ log_tilted_max = log_tilted.max()
432
+ if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
433
+ # All candidates are invalid or have zero probability
434
+ # Fallback to base distribution
435
+ fallback = True
436
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
437
+ else:
438
+ log_tilted_stable = log_tilted - log_tilted_max
439
+ tilted_probs = torch.exp(log_tilted_stable)
440
+
441
+ # Normalize
442
+ prob_sum = tilted_probs.sum()
443
+ if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
444
+ # Fallback to base distribution
445
+ fallback = True
446
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
447
+ else:
448
+ fallback = False
449
+ final_probs = tilted_probs / prob_sum
450
+
451
+ # Final safety check
452
+ if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
453
+ fallback = True
454
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
455
+
456
+ # Ensure no negative or zero probabilities for multinomial
457
+ final_probs = torch.clamp(final_probs, min=1e-10)
458
+ final_probs = final_probs / final_probs.sum()
459
+
460
+ selected_idx = torch.multinomial(final_probs, 1).item()
461
+ selected_token = candidates[selected_idx].item()
462
+ selected_score = aggregated_scores[selected_idx].item()
463
+
464
+ # ACCEPTANCE CRITERION: Only accept if score improves or stays same
465
+ accepted = True
466
+ if self.args.use_acceptance and current_score is not None:
467
+ if selected_score < current_score:
468
+ # Reject: keep current token
469
+ selected_token = current_token
470
+ accepted = False
471
+
472
+ info = {
473
+ 'method': 'exp_tilt',
474
+ 'beta': beta,
475
+ 'num_candidates': num_candidates,
476
+ 'proposal_score': aggregated_scores[selected_idx].item(),
477
+ 'selected_score': selected_score if accepted else current_score,
478
+ 'fallback': fallback,
479
+ 'validity_rate': validity_mask.mean().item(),
480
+ 'accepted': accepted
481
+ }
482
+
483
+ return selected_token, info
484
+
485
+ def _classifier_guidance(
486
+ self,
487
+ x: torch.Tensor,
488
+ logits: torch.Tensor,
489
+ mut_idx: int,
490
+ sample_idx: int,
491
+ guidance_weight: float,
492
+ weights: torch.Tensor
493
+ ) -> Tuple[int, Dict]:
494
+ """
495
+ Classifier Guidance (Score-based Logit Adjustment)
496
+
497
+ Uses external score functions to adjust the base model's logits:
498
+ logits_guided = logits_base + guidance_weight * score_logits
499
+
500
+ This differs from Classifier-Free Guidance (CFG) which requires:
501
+ - A model trained with conditioning dropout
502
+ - Extrapolation between conditional and unconditional model outputs
503
+
504
+ Here we use external classifiers/scorers to provide guidance signal.
505
+ """
506
+ base_logits = logits[sample_idx, mut_idx, :]
507
+
508
+ # Get top-k candidates from base distribution
509
+ sorted_logits, sorted_indices = torch.sort(base_logits, descending=True)
510
+ candidates = sorted_indices[:self.args.top_k]
511
+
512
+ # Filter invalid tokens
513
+ valid_mask = ~torch.isin(candidates, torch.tensor(self.invalid_tokens, device=self.device))
514
+ candidates = candidates[valid_mask]
515
+
516
+ if len(candidates) == 0:
517
+ return x[sample_idx, mut_idx].item(), {'method': 'cg', 'fallback': True}
518
+
519
+ # Create proposals and score them
520
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(len(candidates), 1)
521
+ x_proposals[:, mut_idx] = candidates
522
+
523
+ proposal_scores = self._get_scores(x_proposals)
524
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
525
+
526
+ # Create score-based logits adjustment
527
+ # Higher score -> higher probability
528
+ score_logits = aggregated_scores * self.args.score_temperature
529
+
530
+ # Base logits for candidates
531
+ base_candidate_logits = base_logits[candidates]
532
+
533
+ # Classifier guidance: guided = base + w * score_adjustment
534
+ guided_logits = base_candidate_logits + guidance_weight * score_logits
535
+
536
+ # Convert to probabilities
537
+ guided_probs = F.softmax(guided_logits, dim=-1)
538
+
539
+ # Sample
540
+ selected_idx = torch.multinomial(guided_probs, 1).item()
541
+ selected_token = candidates[selected_idx].item()
542
+
543
+ info = {
544
+ 'method': 'cg',
545
+ 'guidance_weight': guidance_weight,
546
+ 'num_candidates': len(candidates),
547
+ 'selected_score': aggregated_scores[selected_idx].item(),
548
+ 'fallback': False
549
+ }
550
+
551
+ return selected_token, info
552
+
553
+ def _best_of_k_guidance(
554
+ self,
555
+ x: torch.Tensor,
556
+ pos_probs: torch.Tensor,
557
+ candidates: torch.Tensor,
558
+ mut_idx: int,
559
+ sample_idx: int,
560
+ k: int,
561
+ weights: torch.Tensor
562
+ ) -> Tuple[int, Dict]:
563
+ """
564
+ Best-of-K Selection
565
+
566
+ 1. Sample K candidates from the base distribution
567
+ 2. Score all K candidates
568
+ 3. Select the one with the highest score
569
+
570
+ This is a simple but effective strategy that doesn't require
571
+ probability manipulation.
572
+ """
573
+ num_candidates = len(candidates)
574
+ if num_candidates == 0:
575
+ return x[sample_idx, mut_idx].item(), {'method': 'bok', 'fallback': True}
576
+
577
+ # Limit k to available candidates
578
+ k = min(k, num_candidates)
579
+
580
+ # Get probabilities for candidates
581
+ candidate_probs = pos_probs[sample_idx, candidates]
582
+ candidate_probs = candidate_probs / (candidate_probs.sum() + 1e-9)
583
+
584
+ # Sample K candidates
585
+ if k >= num_candidates:
586
+ sampled_indices = torch.arange(num_candidates, device=self.device)
587
+ else:
588
+ sampled_indices = torch.multinomial(candidate_probs, k, replacement=False)
589
+
590
+ sampled_tokens = candidates[sampled_indices]
591
+
592
+ # Create proposals for all K samples
593
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(len(sampled_tokens), 1)
594
+ x_proposals[:, mut_idx] = sampled_tokens
595
+
596
+ # Score all proposals
597
+ proposal_scores = self._get_scores(x_proposals)
598
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
599
+
600
+ # Select best
601
+ best_idx = torch.argmax(aggregated_scores).item()
602
+ selected_token = sampled_tokens[best_idx].item()
603
+
604
+ info = {
605
+ 'method': 'bok',
606
+ 'k': k,
607
+ 'num_candidates': num_candidates,
608
+ 'selected_score': aggregated_scores[best_idx].item(),
609
+ 'mean_score': aggregated_scores.mean().item(),
610
+ 'max_score': aggregated_scores.max().item(),
611
+ 'fallback': False
612
+ }
613
+
614
+ return selected_token, info
615
+
616
+ def _hybrid_guidance(
617
+ self,
618
+ x: torch.Tensor,
619
+ pos_probs: torch.Tensor,
620
+ logits: torch.Tensor,
621
+ candidates: torch.Tensor,
622
+ mut_idx: int,
623
+ sample_idx: int,
624
+ step: int,
625
+ beta: float,
626
+ weights: torch.Tensor
627
+ ) -> Tuple[int, Dict]:
628
+ """
629
+ Hybrid Guidance: Combines multiple methods adaptively
630
+
631
+ Strategy:
632
+ - Early steps: Use Best-of-K for exploration
633
+ - Middle steps: Use Exponential Tilting for balanced guidance
634
+ - Late steps: Use CFG for refinement
635
+ """
636
+ total_steps = self.args.optimization_steps
637
+ progress = step / max(total_steps - 1, 1)
638
+
639
+ if progress < 0.3:
640
+ # Early: Best-of-K for exploration
641
+ return self._best_of_k_guidance(
642
+ x, pos_probs, candidates, mut_idx, sample_idx,
643
+ k=self.args.bok_k, weights=weights
644
+ )
645
+ elif progress < 0.7:
646
+ # Middle: Exponential tilting
647
+ return self._exponential_tilt_guidance(
648
+ x, pos_probs, candidates, mut_idx, sample_idx,
649
+ beta=beta, weights=weights
650
+ )
651
+ else:
652
+ # Late: Classifier guidance for refinement
653
+ return self._classifier_guidance(
654
+ x, logits, mut_idx, sample_idx,
655
+ guidance_weight=self.args.cg_weight, weights=weights
656
+ )
657
+
658
+ # =========================================================================
659
+ # Main Generation Loop (Algorithm 2)
660
+ # =========================================================================
661
+
662
+ def generate(self) -> torch.Tensor:
663
+ """
664
+ Main generation loop implementing Algorithm 2 with the selected guidance method.
665
+
666
+ Lines 7-17 of Algorithm 2:
667
+ for k = 0 to K-1 do
668
+ p_{k+1} ← Φ_{θ*}^{t_k→t_{k+1}}(Z_k)
669
+ if guidance is active (β > 0) then
670
+ for each candidate z' ∈ TopK(p_{k+1}) do
671
+ p'(z') ∝ p_{k+1}(z') · exp(β · S(z')) · 1_valid(z')
672
+ Sample Z_{k+1} ~ p'
673
+ else
674
+ Sample Z_{k+1} ~ p_{k+1}
675
+ return Z_K
676
+ """
677
+ # Generate initial x0 with validity constraint
678
+ x = self.generate_x0(
679
+ require_valid=self.args.require_valid_x0,
680
+ max_retries=self.args.max_x0_retries
681
+ )
682
+
683
+ # Setup weights
684
+ if self.args.weights is None:
685
+ weights = torch.tensor(
686
+ [1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
687
+ device=self.device
688
+ ).view(-1, 1)
689
+ else:
690
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
691
+ if len(weights) != self.num_objectives:
692
+ raise ValueError("Number of weights must match number of objectives.")
693
+
694
+ print(f"\nGuidance Method: {self.guidance_method.value}")
695
+ print(f"Weights: {weights.squeeze().tolist()}")
696
+ print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
697
+ print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
698
+
699
+ # Main optimization loop (Line 7: for k = 0 to K-1)
700
+ with torch.no_grad():
701
+ for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
702
+ improved_indices = []
703
+
704
+ # Anneal guidance strength β (from beta_min to beta_max)
705
+ progress = step / max(self.args.optimization_steps - 1, 1)
706
+ beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * progress
707
+
708
+ # Choose position to modify (can be random or sequential)
709
+ if self.args.position_strategy == "random":
710
+ mut_idx = random.randint(1, self.args.gen_len - 2)
711
+ else: # sequential
712
+ mut_idx = (step % (self.args.gen_len - 2)) + 1
713
+
714
+ # Get model prediction (Line 8: p_{k+1} ← Φ_{θ*}^{t_k→t_{k+1}}(Z_k))
715
+ time_t = torch.full(
716
+ (self.args.num_samples,),
717
+ step / self.args.optimization_steps,
718
+ device=self.device
719
+ )
720
+
721
+ logits = self.model(x, time_t)
722
+ probs = F.softmax(logits, dim=-1)
723
+ pos_probs = probs[:, mut_idx, :]
724
+
725
+ # Zero out current tokens and invalid tokens
726
+ pos_probs[:, x[:, mut_idx]] = 0
727
+ pos_probs[:, self.invalid_tokens] = 0
728
+
729
+ # Get TopK candidates (Line 10: for each candidate z' ∈ TopK(p_{k+1}))
730
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
731
+
732
+ # Get current scores for comparison
733
+ current_scores = self._get_scores(x)
734
+ current_aggregated = self._compute_aggregated_score(current_scores, weights)
735
+
736
+ if step == 0:
737
+ print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
738
+ if self.tracker and self.conjugation_obj:
739
+ detailed = self.conjugation_obj.get_last_detailed_results()
740
+ self.tracker.log_step(step, current_scores[0], detailed)
741
+ self.tracker.print_step_summary(step, current_scores[0], detailed,
742
+ self.guidance_method.value)
743
+
744
+ # Process each sample
745
+ final_tokens = []
746
+ guidance_infos = []
747
+
748
+ for i in range(self.args.num_samples):
749
+ # Get candidates for this sample
750
+ sample_probs = sorted_probs[i]
751
+ sample_indices = sorted_indices[i]
752
+
753
+ # Top-p filtering
754
+ cumsum = torch.cumsum(sample_probs, dim=0)
755
+ mask = cumsum <= self.args.top_p
756
+ mask[0] = True # Always include at least one
757
+ candidates = sample_indices[mask]
758
+
759
+ # Current score for this sample (for acceptance criterion)
760
+ current_sample_score = current_aggregated[i].item()
761
+
762
+ # Apply selected guidance method (Lines 9-15)
763
+ if beta > 0: # Guidance active (Line 9)
764
+ if self.guidance_method == GuidanceMethod.EXPONENTIAL_TILT:
765
+ token, info = self._exponential_tilt_guidance(
766
+ x, pos_probs, candidates, mut_idx, i, beta, weights,
767
+ current_score=current_sample_score
768
+ )
769
+ elif self.guidance_method == GuidanceMethod.CLASSIFIER_GUIDANCE:
770
+ token, info = self._classifier_guidance(
771
+ x, logits, mut_idx, i, self.args.cg_weight, weights
772
+ )
773
+ elif self.guidance_method == GuidanceMethod.BEST_OF_K:
774
+ token, info = self._best_of_k_guidance(
775
+ x, pos_probs, candidates, mut_idx, i, self.args.bok_k, weights
776
+ )
777
+ elif self.guidance_method == GuidanceMethod.HYBRID:
778
+ token, info = self._hybrid_guidance(
779
+ x, pos_probs, logits, candidates, mut_idx, i, step, beta, weights
780
+ )
781
+ else:
782
+ raise ValueError(f"Unknown guidance method: {self.guidance_method}")
783
+ else:
784
+ # No guidance: sample from p_{k+1} directly (Line 15)
785
+ candidate_probs = pos_probs[i, candidates]
786
+ candidate_probs = candidate_probs / (candidate_probs.sum() + 1e-9)
787
+ idx = torch.multinomial(candidate_probs, 1).item()
788
+ token = candidates[idx].item()
789
+ info = {'method': 'unguided'}
790
+
791
+ final_tokens.append(token)
792
+ guidance_infos.append(info)
793
+
794
+ # Update sequences with new tokens
795
+ x[:, mut_idx] = torch.tensor(final_tokens, device=self.device)
796
+
797
+ # Check for improvements
798
+ new_scores = self._get_scores(x)
799
+ new_aggregated = self._compute_aggregated_score(new_scores, weights)
800
+
801
+ for i in range(self.args.num_samples):
802
+ if new_aggregated[i] > current_aggregated[i]:
803
+ improved_indices.append(i)
804
+
805
+ if improved_indices:
806
+ print(f"\nStep {step}: Improved samples: {improved_indices}")
807
+ for i in improved_indices:
808
+ print(f" Sample {i}:")
809
+ # print(f" Previous score: {current_aggregated[i].item():.4f} | Per-objective: {current_scores[:, i].cpu().numpy()}")
810
+ print(f" New score: {new_aggregated[i].item():.4f} | Per-objective: {new_scores[:, i].cpu().numpy()}")
811
+ print(f" Sequences: {self.model.tokenizer.batch_decode(x[improved_indices])}")
812
+
813
+ # Logging
814
+ if self.tracker and self.conjugation_obj and (step % 4 == 0 or step == self.args.optimization_steps - 1):
815
+ step_scores = self._get_scores(x)
816
+ detailed = self.conjugation_obj.get_last_detailed_results()
817
+ self.tracker.log_step(
818
+ step, step_scores[0], detailed, improved_indices,
819
+ guidance_info={'method': self.guidance_method.value, 'beta': beta}
820
+ )
821
+
822
+ if step == self.args.optimization_steps - 1:
823
+ self.tracker.print_step_summary(step, step_scores[0], detailed,
824
+ self.guidance_method.value)
825
+
826
+ return x
827
+
828
+
829
+ # =============================================================================
830
+ # Main Execution
831
+ # =============================================================================
832
+
833
+ def main(args):
834
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
835
+ print(f"Using device: {device}")
836
+ print(f"Guidance method: {args.guidance_method}")
837
+
838
+ tokenizer = SMILES_SPE_Tokenizer(
839
+ './smiles/smiles_tokenizer/new_vocab.txt',
840
+ './smiles/smiles_tokenizer/new_splits.txt'
841
+ )
842
+
843
+ # Initialize objectives
844
+ conjugation_model = Conjugation(tokenizer, device=device, builder_type=args.builder_type)
845
+ affinity_model = BindingAffinity(args.target, device)
846
+ OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
847
+
848
+ # Initialize tracker
849
+ tracker = GenerationTracker(output_dir=args.output_dir)
850
+
851
+ # Set random seeds
852
+ if args.seed is not None:
853
+ random.seed(args.seed)
854
+ np.random.seed(args.seed)
855
+ torch.manual_seed(args.seed)
856
+ if torch.cuda.is_available():
857
+ torch.cuda.manual_seed_all(args.seed)
858
+ print(f"Random seed: {args.seed}")
859
+
860
+ # Load model
861
+ print(f"Loading model from: {args.checkpoint}")
862
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
863
+ model_hparams = checkpoint["hyper_parameters"]["args"]
864
+
865
+ model = MDLMLightningModule.load_from_checkpoint(
866
+ args.checkpoint,
867
+ args=model_hparams,
868
+ tokenizer=tokenizer,
869
+ map_location=device,
870
+ strict=False
871
+ )
872
+ model.to(device)
873
+ print("Model loaded successfully.")
874
+
875
+ # Create generator
876
+ generator = CupOfMoCAGenerator(model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker)
877
+
878
+ all_sequences = []
879
+ all_final_results = []
880
+
881
+ for batch_idx in range(args.num_batches):
882
+ print(f"\n{'=' * 60}")
883
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
884
+ print(f"{'=' * 60}")
885
+
886
+ generated_tokens = generator.generate()
887
+ final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
888
+ sequence_str = tokenizer.batch_decode(generated_tokens)
889
+
890
+ print("\n--- Batch Results ---")
891
+ print(f"Sequences: {sequence_str}")
892
+ print(f"Scores: {final_scores}")
893
+
894
+ all_sequences.extend(sequence_str)
895
+
896
+ # Get detailed results
897
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
898
+ tracker.log_final(sequence_str, detailed_with_props)
899
+ all_final_results.extend(detailed_with_props)
900
+
901
+ # Save results
902
+ tracker.print_final_summary()
903
+ tracker.save(filename_prefix=f'cupofmoca_{args.guidance_method}')
904
+
905
+ with open(args.output_file, 'w') as f:
906
+ for seq in all_sequences:
907
+ f.write(seq + '\n')
908
+ print(f"\nSequences saved to: {args.output_file}")
909
+ print("\nGeneration complete.")
910
+
911
+
912
+ if __name__ == "__main__":
913
+ parser = argparse.ArgumentParser(
914
+ description="CupOfMoCA-style Guided Generation with Alternative Guidance Methods"
915
+ )
916
+
917
+ # Model and data
918
+ parser.add_argument("--checkpoint", type=str, required=True,
919
+ help="Path to trained model checkpoint")
920
+ parser.add_argument("--target", type=str, required=True,
921
+ help="Target protein sequence for binding affinity")
922
+
923
+ # Generation settings
924
+ parser.add_argument("--num_samples", type=int, default=10,
925
+ help="Number of samples per batch")
926
+ parser.add_argument("--num_batches", type=int, default=10,
927
+ help="Number of batches to generate")
928
+ parser.add_argument("--gen_len", type=int, default=50,
929
+ help="Length of sequences to generate")
930
+ parser.add_argument("--optimization_steps", type=int, default=16,
931
+ help="Number of guided optimization steps (K in Algorithm 2)")
932
+
933
+ # Guidance method selection
934
+ parser.add_argument("--guidance_method", type=str, default="exp_tilt",
935
+ choices=["exp_tilt", "cg", "bok", "hybrid"],
936
+ help="Guidance method: exp_tilt (exponential tilting), "
937
+ "cg (classifier/score guidance), bok (best-of-k), hybrid")
938
+
939
+ # Guidance parameters
940
+ parser.add_argument("--beta_min", type=float, default=1.0,
941
+ help="Minimum guidance strength β")
942
+ parser.add_argument("--beta_max", type=float, default=20.0,
943
+ help="Maximum guidance strength β")
944
+ parser.add_argument("--cg_weight", type=float, default=2.0,
945
+ help="Classifier guidance weight (for cg method)")
946
+ parser.add_argument("--use_acceptance", action="store_true",
947
+ help="Use acceptance criterion: only accept if new_score >= current_score (prevents regression)")
948
+ parser.add_argument("--bok_k", type=int, default=8,
949
+ help="Number of candidates for best-of-k selection")
950
+ parser.add_argument("--top_k", type=int, default=200,
951
+ help="Top-K candidates to consider for guidance")
952
+ parser.add_argument("--top_p", type=float, default=0.9,
953
+ help="Top-p threshold for candidate filtering")
954
+ parser.add_argument("--score_temperature", type=float, default=1.0,
955
+ help="Temperature for score-based logit adjustment")
956
+ parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
957
+ choices=["weighted_sum", "weighted_min"],
958
+ help="How to aggregate multi-objective scores")
959
+
960
+ # Weights
961
+ parser.add_argument("--weights", type=float, nargs='+', default=None,
962
+ help="Weights for objectives (e.g., 0.5 0.5)")
963
+
964
+ # Position selection
965
+ parser.add_argument("--position_strategy", type=str, default="random",
966
+ choices=["random", "sequential"],
967
+ help="Strategy for selecting mutation positions")
968
+
969
+ # Initial generation
970
+ parser.add_argument("--require_valid_x0", action="store_true",
971
+ help="Regenerate x0 until valid conjugatable peptides")
972
+ parser.add_argument("--max_x0_retries", type=int, default=10,
973
+ help="Max retries for valid x0 generation")
974
+
975
+ # PDC builder
976
+ parser.add_argument("--builder_type", type=str, default="AoaDau",
977
+ choices=["AoaDau", "ValAlaDau", "ValCitDau", "GlutarylDau", "Dau",
978
+ "SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt", "DSECCpt", "Cpt",
979
+ "DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"],
980
+ help="PDC builder type for conjugation. "
981
+ "'Dau' samples linker by GNRHR+Dau frequency. "
982
+ "'Cpt' samples linker by ERBB2+Cpt frequency. "
983
+ "'SN38' samples linker by NRP1+SN38 frequency.")
984
+
985
+ # Output
986
+ parser.add_argument("--output_file", type=str, default="./smiles.txt",
987
+ help="File to save generated sequences")
988
+ parser.add_argument("--output_dir", type=str, default="./generation_logs",
989
+ help="Directory for detailed logs")
990
+
991
+ # Reproducibility
992
+ parser.add_argument("--seed", type=int, default=None,
993
+ help="Random seed")
994
+
995
+ args = parser.parse_args()
996
+ main(args)
smiles/generation/cupofmoca_guided_aa.py ADDED
@@ -0,0 +1,1126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CupOfMoCA-style guided generation with the natural-AA foundation model."""
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ import os
7
+ import random
8
+ import sys
9
+ import time
10
+ from collections import defaultdict
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from contextlib import contextmanager
13
+ from datetime import datetime
14
+ from enum import Enum
15
+ from typing import Dict, Optional, Tuple
16
+
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+ from easydict import EasyDict as edict
22
+ from tqdm import tqdm
23
+
24
+ from smiles.models.train import MDLMLightningModule
25
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
26
+ from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
27
+ from smiles.evaluation.pdc_analyzer import PDCAnalyzer
28
+ from smiles.scoring.classifiers import Conjugation, BindingAffinity
29
+
30
+ # AA-mode shared utilities
31
+ from smiles.models.aa_helpers import (
32
+ ESM2AminoTokenizer as _AAEsmTokenizer,
33
+ MDLM_PepReDi as _AAMDLM,
34
+ make_affinity,
35
+ DEFAULT_AA_AFFINITY_CKPT,
36
+ INVALID_AA_TOKEN_IDS as _AA_INVALID_IDS,
37
+ )
38
+
39
+ from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
40
+ from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
41
+
42
+
43
+ # =============================================================================
44
+ # Amino Acid Foundation Model (DiT-based MDLM)
45
+ # =============================================================================
46
+
47
+ # --- AA-mode Model + Tokenizer (imported from aa_helpers) ---
48
+ MDLM = _AAMDLM
49
+ AminoTokenizer = _AAEsmTokenizer
50
+
51
+
52
+ # =============================================================================
53
+ # Profiler
54
+ # =============================================================================
55
+
56
+ class Profiler:
57
+ """Aggregates wall-clock time per named section. Optionally syncs CUDA.
58
+
59
+ `reset()` clears all state (use between batches for self-contained summaries).
60
+ `print_summary()` shows cumulative totals since the last reset, plus a
61
+ delta column showing time since the previous `print_summary()` call.
62
+ """
63
+
64
+ def __init__(self, enabled=False, cuda_sync=True):
65
+ self.enabled = enabled
66
+ self.cuda_sync = cuda_sync and torch.cuda.is_available()
67
+ self.totals = defaultdict(float)
68
+ self.counts = defaultdict(int)
69
+ self._last_totals = defaultdict(float)
70
+ self._last_counts = defaultdict(int)
71
+
72
+ @contextmanager
73
+ def section(self, name):
74
+ if not self.enabled:
75
+ yield
76
+ return
77
+ if self.cuda_sync:
78
+ torch.cuda.synchronize()
79
+ t0 = time.perf_counter()
80
+ try:
81
+ yield
82
+ finally:
83
+ if self.cuda_sync:
84
+ torch.cuda.synchronize()
85
+ self.totals[name] += time.perf_counter() - t0
86
+ self.counts[name] += 1
87
+
88
+ def reset(self):
89
+ self.totals.clear()
90
+ self.counts.clear()
91
+ self._last_totals.clear()
92
+ self._last_counts.clear()
93
+
94
+ def print_summary(self, header=""):
95
+ if not self.enabled or not self.totals:
96
+ return
97
+ cumulative_total = sum(self.totals.values())
98
+ delta_totals = {k: self.totals[k] - self._last_totals.get(k, 0.0)
99
+ for k in self.totals}
100
+ delta_counts = {k: self.counts[k] - self._last_counts.get(k, 0)
101
+ for k in self.counts}
102
+ interval_total = sum(delta_totals.values())
103
+
104
+ print(f"\n=== Profiler summary {header} ===")
105
+ print(f" (cumulative since reset | interval since previous summary)")
106
+ print(f" {'section':<34} "
107
+ f"{'calls':>6} {'cum(s)':>8} {'cum_avg':>8} "
108
+ f"{'Δcalls':>6} {'Δ(s)':>7} {'Δavg':>7} {'%Δ':>5}")
109
+ for name, tot in sorted(self.totals.items(), key=lambda kv: -kv[1]):
110
+ n = self.counts[name]
111
+ cum_avg = (tot / n) * 1000.0 if n else 0.0
112
+ dt = delta_totals[name]
113
+ dn = delta_counts[name]
114
+ d_avg = (dt / dn) * 1000.0 if dn else 0.0
115
+ d_pct = (dt / interval_total) * 100.0 if interval_total > 0 else 0.0
116
+ print(f" {name:<34} "
117
+ f"{n:>6d} {tot:>8.2f} {cum_avg:>7.1f}ms "
118
+ f"{dn:>6d} {dt:>7.2f} {d_avg:>6.1f}ms {d_pct:>4.1f}%")
119
+ print(f" {'-' * 34} {'-' * 6} {'-' * 8} {'-' * 8} "
120
+ f"{'-' * 6} {'-' * 7} {'-' * 7} {'-' * 5}")
121
+ print(f" {'TOTAL':<34} {'':>6} {cumulative_total:>8.2f} {'':>8} "
122
+ f"{'':>6} {interval_total:>7.2f}")
123
+
124
+ self._last_totals = dict(self.totals)
125
+ self._last_counts = dict(self.counts)
126
+
127
+
128
+ # =============================================================================
129
+ # Guidance Method Enum
130
+ # =============================================================================
131
+
132
+ class GuidanceMethod(Enum):
133
+ EXPONENTIAL_TILT = "exp_tilt"
134
+ CLASSIFIER_GUIDANCE = "cg"
135
+ BEST_OF_K = "bok"
136
+ HYBRID = "hybrid"
137
+
138
+
139
+ # =============================================================================
140
+ # Generation Tracker
141
+ # =============================================================================
142
+
143
+ class GenerationTracker:
144
+ def __init__(self, output_dir='./generation_logs'):
145
+ self.output_dir = output_dir
146
+ os.makedirs(output_dir, exist_ok=True)
147
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
148
+ self.step_logs = []
149
+ self.final_results = []
150
+
151
+ def log_step(self, step, scores, detailed_results=None, improved_indices=None,
152
+ guidance_info: Optional[Dict] = None):
153
+ step_info = {
154
+ 'step': step,
155
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
156
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
157
+ 'improved_indices': improved_indices or [],
158
+ 'guidance_info': guidance_info or {}
159
+ }
160
+ if detailed_results:
161
+ step_info['detailed'] = []
162
+ for i, result in enumerate(detailed_results):
163
+ step_info['detailed'].append({
164
+ 'sample_idx': i,
165
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
166
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
167
+ 'conjugation_success': result.get('conjugation_success', False),
168
+ 'pdc_valid': result.get('pdc_valid', False),
169
+ 'num_conjugates': len(result.get('conjugates', [])),
170
+ 'error': result.get('error')
171
+ })
172
+ self.step_logs.append(step_info)
173
+
174
+ def log_final(self, sequences, detailed_results_with_props, smiles_list=None):
175
+ for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
176
+ final_info = {
177
+ 'sample_idx': i,
178
+ 'aa_sequence': seq,
179
+ 'peptide_smiles': smiles_list[i] if smiles_list else seq,
180
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
181
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
182
+ 'num_conjugates': len(result.get('conjugates', [])),
183
+ 'num_valid_pdcs': len(result.get('valid_pdcs', [])),
184
+ 'best_pdc': None,
185
+ 'linker_used': result.get('linker_used', ''),
186
+ 'all_conjugates': result.get('conjugates', []),
187
+ 'properties': result.get('properties', {})
188
+ }
189
+ if result.get('best_pdc'):
190
+ final_info['best_pdc'] = {
191
+ 'smiles': result['best_pdc'].get('smiles', ''),
192
+ 'checks': result['best_pdc'].get('checks', {})
193
+ }
194
+ self.final_results.append(final_info)
195
+
196
+ def save(self, filename_prefix='generation'):
197
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
198
+ with open(step_log_path, 'w') as f:
199
+ json.dump(self.step_logs, f, indent=2, default=str)
200
+
201
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
202
+ with open(final_path, 'w') as f:
203
+ json.dump(self.final_results, f, indent=2, default=str)
204
+
205
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
206
+ with open(csv_path, 'w', newline='') as f:
207
+ writer = csv.writer(f)
208
+ writer.writerow([
209
+ 'sample_idx', 'aa_sequence', 'peptide_smiles', 'is_valid_peptide',
210
+ 'has_conjugation_site', 'linker_used', 'num_conjugates', 'num_valid_pdcs',
211
+ 'best_pdc_smiles', 'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
212
+ ])
213
+ for result in self.final_results:
214
+ props = result.get('properties', {})
215
+ writer.writerow([
216
+ result['sample_idx'],
217
+ result.get('aa_sequence', ''),
218
+ result['peptide_smiles'],
219
+ result['is_valid_peptide'],
220
+ result['has_conjugation_site'],
221
+ result.get('linker_used', ''),
222
+ result['num_conjugates'],
223
+ result['num_valid_pdcs'],
224
+ result['best_pdc']['smiles'] if result.get('best_pdc') else '',
225
+ props.get('mf', ''),
226
+ props.get('polararea', ''),
227
+ props.get('xlogp', ''),
228
+ props.get('heavycnt', ''),
229
+ props.get('rotbonds', '')
230
+ ])
231
+
232
+ pdc_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_pdcs.csv")
233
+ with open(pdc_path, 'w', newline='') as f:
234
+ writer = csv.writer(f)
235
+ writer.writerow(['sample_idx', 'aa_sequence', 'peptide_smiles', 'linker_used',
236
+ 'conjugate_idx', 'pdc_smiles'])
237
+ for result in self.final_results:
238
+ for j, conj in enumerate(result.get('all_conjugates', [])):
239
+ writer.writerow([
240
+ result['sample_idx'],
241
+ result.get('aa_sequence', ''),
242
+ result['peptide_smiles'],
243
+ result.get('linker_used', ''),
244
+ j,
245
+ conj
246
+ ])
247
+
248
+ print(f"\n=== Generation logs saved ===")
249
+ print(f" Step logs: {step_log_path}")
250
+ print(f" Final results: {final_path}")
251
+ print(f" Summary CSV: {csv_path}")
252
+ print(f" PDC conjugates: {pdc_path}")
253
+ return step_log_path, final_path, csv_path, pdc_path
254
+
255
+ def print_step_summary(self, step, scores, detailed_results=None, guidance_method=None):
256
+ scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores)
257
+ num_valid = sum(1 for s in scores_list if s > 0)
258
+ print(f"\n--- Step {step} Summary ({guidance_method or 'unknown'}) ---")
259
+ print(f" Valid: {num_valid}/{len(scores_list)}")
260
+ if detailed_results:
261
+ print(f" Breakdown:")
262
+ print(f" Valid peptides: {sum(1 for r in detailed_results if r.get('is_valid_peptide'))}")
263
+ print(f" Has conjugation site: {sum(1 for r in detailed_results if r.get('has_conjugation_site'))}")
264
+ print(f" Conjugation success: {sum(1 for r in detailed_results if r.get('conjugation_success'))}")
265
+ print(f" PDC valid: {sum(1 for r in detailed_results if r.get('pdc_valid'))}")
266
+
267
+ def print_final_summary(self):
268
+ print("\n" + "=" * 60)
269
+ print("FINAL GENERATION SUMMARY")
270
+ print("=" * 60)
271
+ total = len(self.final_results)
272
+ valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
273
+ print(f"\nTotal sequences: {total}")
274
+ print(f"With valid PDCs: {valid_pdcs}")
275
+ for result in self.final_results:
276
+ status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
277
+ print(f"\n [{status}] Sample {result['sample_idx']}:")
278
+ print(f" AA seq: {result.get('aa_sequence', '')}")
279
+ print(f" Peptide SMILES: {result['peptide_smiles'][:60]}...")
280
+ print(f" Valid peptide: {result['is_valid_peptide']}")
281
+ print(f" Has conjugation site: {result['has_conjugation_site']}")
282
+ print(f" Conjugates: {result['num_conjugates']}")
283
+ print(f" Valid PDCs: {result['num_valid_pdcs']}")
284
+
285
+
286
+ # =============================================================================
287
+ # CupOfMoCA Generator (UAA-aware)
288
+ # =============================================================================
289
+
290
+ class CupOfMoCAGeneratorUAA:
291
+ """CupOfMoCA Algorithm 2 generator that operates in AA token space.
292
+
293
+ AA tokens are converted to SMILES on the fly for scoring with the
294
+ Conjugation and BindingAffinity objectives.
295
+ """
296
+
297
+ def __init__(self, model, device, objectives, args, tokenizer, tokenizer_type='amino',
298
+ tracker=None):
299
+ self.model = model
300
+ self.device = device
301
+ self.objectives = objectives
302
+ self.args = args
303
+ self.num_objectives = len(objectives)
304
+ self.peptide_analyzer = PeptideAnalyzer()
305
+ self.pdc_analyzer = PDCAnalyzer(device)
306
+ self.tracker = tracker
307
+ self.tokenizer = tokenizer
308
+ self.tokenizer_type = tokenizer_type
309
+ self.guidance_method = GuidanceMethod(args.guidance_method)
310
+ self.profiler = Profiler(enabled=getattr(args, 'profile', False))
311
+
312
+ cascade_workers = max(1, getattr(args, 'cascade_workers', 1))
313
+ self._cascade_pool = (
314
+ ThreadPoolExecutor(max_workers=cascade_workers)
315
+ if cascade_workers > 1 else None
316
+ )
317
+
318
+ if tokenizer_type == 'amino':
319
+ # PepReDi natural-AA vocab is 24 tokens; 0-3 = cls/pad/eos/unk,
320
+ # 4-23 = canonical AAs. Block specials only. (UAA used id 38
321
+ # for D-AA 'g'; that's out of PepReDi vocab range.)
322
+ self.invalid_tokens = [0, 1, 2, 3]
323
+ self.smiles_tokenizer = SMILES_SPE_Tokenizer(
324
+ args.smiles_vocab_path, args.smiles_splits_path
325
+ )
326
+ else:
327
+ self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586]
328
+ self.smiles_tokenizer = None
329
+
330
+ self.conjugation_obj = None
331
+ for obj in objectives:
332
+ if isinstance(obj, Conjugation):
333
+ self.conjugation_obj = obj
334
+ break
335
+
336
+ # ------------------------------------------------------------------
337
+ # x0 generation (Algorithm 2, Line 6)
338
+ # ------------------------------------------------------------------
339
+ def _generate_single_x0(self, n_steps=16, temperature=1.0):
340
+ self.model.eval()
341
+
342
+ if self.tokenizer_type == 'amino':
343
+ vocab_size = self.model.vocab_size
344
+ valid_tokens = [i for i in range(vocab_size) if i not in self.invalid_tokens]
345
+ valid_tokens = torch.tensor(valid_tokens, device=self.device)
346
+ rand_indices = torch.randint(
347
+ 0, len(valid_tokens),
348
+ (self.args.num_samples, self.args.gen_len),
349
+ device=self.device
350
+ )
351
+ body = valid_tokens[rand_indices]
352
+ cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
353
+ eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
354
+ x = torch.cat([cls_col, body, eos_col], dim=1)
355
+ else:
356
+ vocab_size = self.model.tokenizer.vocab_size
357
+ x = torch.randint(
358
+ 0, vocab_size,
359
+ (self.args.num_samples, self.args.gen_len),
360
+ device=self.device
361
+ )
362
+
363
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
364
+
365
+ with torch.no_grad():
366
+ for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
367
+ t_curr = time_steps[i]
368
+ t_next = time_steps[i + 1]
369
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
370
+
371
+ logits = self.model(x, t_tensor) / temperature
372
+ for inv_tok in self.invalid_tokens:
373
+ if inv_tok < logits.shape[-1]:
374
+ logits[:, :, inv_tok] = -1000
375
+
376
+ pred_x1 = torch.argmax(logits, dim=-1)
377
+ if i == n_steps - 1:
378
+ x = pred_x1
379
+ break
380
+
381
+ noise_prob = 1.0 - t_next
382
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
383
+ noise = torch.randint(0, vocab_size, x.shape, device=self.device)
384
+ x = torch.where(mask, noise, pred_x1)
385
+
386
+ return x
387
+
388
+ def _aa_to_smiles_batch(self, x_batch):
389
+ """Decode AA tokens, convert to SMILES via three-letter codes."""
390
+ with self.profiler.section("aa2smiles.decode"):
391
+ seqs = self.tokenizer.batch_decode(x_batch)
392
+ with self.profiler.section("aa2smiles.one_to_three"):
393
+ three_letter_seqs = [one_letter_to_three_letter(s) for s in seqs]
394
+ with self.profiler.section("aa2smiles.aa_to_smiles"):
395
+ with ThreadPoolExecutor() as pool:
396
+ smiles_seqs = list(pool.map(
397
+ lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
398
+ three_letter_seqs
399
+ ))
400
+ return seqs, smiles_seqs
401
+
402
+ def _check_conjugation_validity(self, x):
403
+ if self.tokenizer_type == 'amino':
404
+ _, smiles_seqs = self._aa_to_smiles_batch(x)
405
+ validities = [
406
+ self.peptide_analyzer.is_peptide(s) if isinstance(s, str) else False
407
+ for s in smiles_seqs
408
+ ]
409
+ return torch.tensor(validities, device=self.device)
410
+
411
+ if self.conjugation_obj is None:
412
+ sequences = self.tokenizer.batch_decode(x)
413
+ return torch.tensor(
414
+ [self.peptide_analyzer.is_peptide(seq) for seq in sequences],
415
+ device=self.device
416
+ )
417
+ return self.conjugation_obj(x) > 0
418
+
419
+ def generate_x0(self, n_steps=16, temperature=1.0,
420
+ require_valid=False, max_retries=10):
421
+ print("Generating initial sequences (x0 from prior)...")
422
+ x = self._generate_single_x0(n_steps, temperature)
423
+
424
+ if require_valid:
425
+ print("Enforcing initial validity constraint...")
426
+ for retry in range(max_retries):
427
+ validity = self._check_conjugation_validity(x)
428
+ num_valid = validity.sum().item()
429
+ num_invalid = self.args.num_samples - num_valid
430
+ if num_invalid == 0:
431
+ print(f" All {self.args.num_samples} samples are valid!")
432
+ break
433
+ print(f" Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid")
434
+ invalid_indices = torch.where(~validity)[0]
435
+ original_num = self.args.num_samples
436
+ self.args.num_samples = num_invalid
437
+ new_x = self._generate_single_x0(n_steps, temperature)
438
+ self.args.num_samples = original_num
439
+ x[invalid_indices] = new_x
440
+
441
+ final_validity = self._check_conjugation_validity(x)
442
+ if final_validity.sum().item() < self.args.num_samples:
443
+ print(f"⚠ After {max_retries} retries, "
444
+ f"{final_validity.sum().item()}/{self.args.num_samples} valid")
445
+
446
+ generated_sequences = self.tokenizer.batch_decode(x)
447
+ for seq in generated_sequences:
448
+ print(f" {seq}")
449
+ return x
450
+
451
+ # ------------------------------------------------------------------
452
+ # Scoring
453
+ # ------------------------------------------------------------------
454
+ def _get_scores(self, x_batch):
455
+ with self.profiler.section("get_scores.total"):
456
+ scores = []
457
+
458
+ if self.tokenizer_type == 'amino':
459
+ _, smiles_seqs = self._aa_to_smiles_batch(x_batch)
460
+ with self.profiler.section("get_scores.valid_mask"):
461
+ valid_mask = torch.tensor(
462
+ [1 if isinstance(s, str) else 0 for s in smiles_seqs],
463
+ device=self.device
464
+ )
465
+ for i in torch.where((1 - valid_mask) == 1)[0].tolist():
466
+ smiles_seqs[i] = 'C'
467
+
468
+ with self.profiler.section("get_scores.smiles_tokenize"):
469
+ smiles_batch = self.smiles_tokenizer(
470
+ smiles_seqs, return_tensors='pt', padding=True
471
+ ).to(self.device)
472
+ attention_mask = smiles_batch.get("attention_mask", None)
473
+
474
+ for obj_func in self.objectives:
475
+ obj_name = type(obj_func).__name__
476
+ with self.profiler.section(f"get_scores.obj.{obj_name}"):
477
+ if getattr(obj_func, "input_type", None) == "aa":
478
+ # AA-input objective: score raw AA tokens (no
479
+ # SMILES conversion, no valid_mask multiply).
480
+ score = obj_func(x_batch.to(self.device)).to(self.device)
481
+ scores.append(score)
482
+ else:
483
+ try:
484
+ score = obj_func(
485
+ smiles_batch["input_ids"], attention_mask=attention_mask
486
+ ).to(self.device)
487
+ except TypeError:
488
+ score = obj_func(smiles_batch["input_ids"]).to(self.device)
489
+ scores.append(score * valid_mask)
490
+ else:
491
+ for obj_func in self.objectives:
492
+ obj_name = type(obj_func).__name__
493
+ with self.profiler.section(f"get_scores.obj.{obj_name}"):
494
+ scores.append(obj_func(x_batch.to(self.device)))
495
+
496
+ return torch.stack(scores, dim=0).to(self.device)
497
+
498
+ def _compute_aggregated_score(self, scores, weights):
499
+ if self.args.score_aggregation == "weighted_min":
500
+ return torch.min(weights * scores, dim=0).values
501
+ return (weights * scores).sum(dim=0)
502
+
503
+ # ------------------------------------------------------------------
504
+ # Cascade scoring (cheap filter + affinity on all → full PDC on top-K)
505
+ # ------------------------------------------------------------------
506
+ def _get_scores_cascaded(self, x_batch):
507
+ """Run cheap conjugation pre-filter + BindingAffinity on all candidates,
508
+ then run the expensive PDC validation only on the top `cascade_k` by
509
+ affinity (among those that pass the cheap filter).
510
+
511
+ Returns [num_objectives, batch] in the same layout as `_get_scores`.
512
+ Falls back to full scoring if Conjugation isn't an objective or
513
+ tokenizer_type != 'amino'.
514
+ """
515
+ if self.tokenizer_type != 'amino' or self.conjugation_obj is None:
516
+ return self._get_scores(x_batch)
517
+
518
+ with self.profiler.section("cascade.total"):
519
+ n = x_batch.shape[0]
520
+ cascade_k = self.args.cascade_k
521
+ conj_obj = self.conjugation_obj
522
+
523
+ _, smiles_seqs = self._aa_to_smiles_batch(x_batch)
524
+ valid_mask = torch.tensor(
525
+ [1.0 if isinstance(s, str) else 0.0 for s in smiles_seqs],
526
+ device=self.device
527
+ )
528
+ safe_smiles = [s if isinstance(s, str) else 'C' for s in smiles_seqs]
529
+
530
+ with self.profiler.section("cascade.cheap_filter"):
531
+ cheap_pass = []
532
+ for s, vm in zip(safe_smiles, valid_mask.tolist()):
533
+ if vm == 0.0:
534
+ cheap_pass.append(False)
535
+ continue
536
+ cheap_pass.append(
537
+ conj_obj.is_valid_peptide(s)
538
+ and conj_obj.has_conjugation_site(s)
539
+ )
540
+ cheap_pass_t = torch.tensor(
541
+ cheap_pass, device=self.device, dtype=torch.float
542
+ )
543
+
544
+ with self.profiler.section("cascade.smiles_tokenize"):
545
+ smiles_batch = self.smiles_tokenizer(
546
+ safe_smiles, return_tensors='pt', padding=True
547
+ ).to(self.device)
548
+ attention_mask = smiles_batch.get("attention_mask", None)
549
+
550
+ aff_obj = next(
551
+ (o for o in self.objectives if not isinstance(o, Conjugation)),
552
+ None
553
+ )
554
+ if aff_obj is None:
555
+ aff_scores = torch.zeros(n, device=self.device)
556
+ else:
557
+ with self.profiler.section(f"cascade.obj.{type(aff_obj).__name__}"):
558
+ if getattr(aff_obj, "input_type", None) == "aa":
559
+ # AA-input affinity: score raw AA tokens directly.
560
+ # Don't multiply by valid_mask (the AA->SMILES outcome
561
+ # is irrelevant for the AA-input scorer).
562
+ aff_scores = aff_obj(x_batch.to(self.device)).to(self.device)
563
+ else:
564
+ try:
565
+ aff_scores = aff_obj(
566
+ smiles_batch["input_ids"], attention_mask=attention_mask
567
+ ).to(self.device)
568
+ except TypeError:
569
+ aff_scores = aff_obj(smiles_batch["input_ids"]).to(self.device)
570
+ aff_scores = aff_scores * valid_mask
571
+
572
+ eligible_idx = torch.where(cheap_pass_t > 0)[0]
573
+ if cascade_k > 0 and len(eligible_idx) > cascade_k:
574
+ topk_local = torch.topk(aff_scores[eligible_idx], k=cascade_k).indices
575
+ validate_idx = eligible_idx[topk_local]
576
+ else:
577
+ validate_idx = eligible_idx
578
+
579
+ conj_scores = torch.zeros(n, device=self.device)
580
+ with self.profiler.section("cascade.full_validate"):
581
+ for j in validate_idx.tolist():
582
+ ok, conjs, _, _ = conj_obj.conjugate_peptide(
583
+ safe_smiles[j], exclude_n_terminus=True
584
+ )
585
+ if not ok or not conjs:
586
+ continue
587
+ has_valid, _ = conj_obj.validate_conjugates(conjs)
588
+ if has_valid:
589
+ conj_scores[j] = 1.0
590
+
591
+ out = []
592
+ for obj in self.objectives:
593
+ if isinstance(obj, Conjugation):
594
+ out.append(conj_scores)
595
+ else:
596
+ out.append(aff_scores)
597
+ return torch.stack(out, dim=0).to(self.device)
598
+
599
+ # ------------------------------------------------------------------
600
+ # Guidance methods
601
+ # ------------------------------------------------------------------
602
+ def _exponential_tilt_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
603
+ beta, weights, current_score=None):
604
+ num_candidates = len(candidates)
605
+ current_token = x[sample_idx, mut_idx].item()
606
+
607
+ if num_candidates == 0:
608
+ return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
609
+
610
+ if num_candidates > self.args.top_k:
611
+ candidates = candidates[:self.args.top_k]
612
+ num_candidates = self.args.top_k
613
+
614
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
615
+ x_proposals[:, mut_idx] = candidates
616
+
617
+ with self.profiler.section("exp_tilt.proposal_scores"):
618
+ if self.args.cascade_k > 0:
619
+ proposal_scores = self._get_scores_cascaded(x_proposals)
620
+ else:
621
+ proposal_scores = self._get_scores(x_proposals)
622
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
623
+ validity_mask = (proposal_scores[0] > 0).float()
624
+ base_probs = pos_probs[sample_idx, candidates]
625
+
626
+ log_base_probs = torch.log(base_probs + 1e-10)
627
+ log_exp_scores = beta * aggregated_scores
628
+ log_validity = torch.log(validity_mask + 1e-10)
629
+ log_tilted = log_base_probs + log_exp_scores + log_validity
630
+
631
+ log_tilted_max = log_tilted.max()
632
+ fallback = False
633
+ if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
634
+ fallback = True
635
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
636
+ else:
637
+ log_tilted_stable = log_tilted - log_tilted_max
638
+ tilted_probs = torch.exp(log_tilted_stable)
639
+ prob_sum = tilted_probs.sum()
640
+ if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
641
+ fallback = True
642
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
643
+ else:
644
+ final_probs = tilted_probs / prob_sum
645
+
646
+ if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
647
+ fallback = True
648
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
649
+
650
+ final_probs = torch.clamp(final_probs, min=1e-10)
651
+ final_probs = final_probs / final_probs.sum()
652
+
653
+ selected_idx = torch.multinomial(final_probs, 1).item()
654
+ selected_token = candidates[selected_idx].item()
655
+ selected_score = aggregated_scores[selected_idx].item()
656
+
657
+ accepted = True
658
+ if self.args.use_acceptance and current_score is not None:
659
+ if selected_score < current_score:
660
+ selected_token = current_token
661
+ accepted = False
662
+
663
+ return selected_token, {
664
+ 'method': 'exp_tilt', 'beta': beta, 'num_candidates': num_candidates,
665
+ 'proposal_score': aggregated_scores[selected_idx].item(),
666
+ 'selected_score': selected_score if accepted else current_score,
667
+ 'fallback': fallback, 'validity_rate': validity_mask.mean().item(),
668
+ 'accepted': accepted
669
+ }
670
+
671
+ def _classifier_guidance(self, x, logits, mut_idx, sample_idx,
672
+ guidance_weight, weights):
673
+ base_logits = logits[sample_idx, mut_idx, :]
674
+ sorted_logits, sorted_indices = torch.sort(base_logits, descending=True)
675
+ candidates = sorted_indices[:self.args.top_k]
676
+
677
+ valid_mask = ~torch.isin(
678
+ candidates, torch.tensor(self.invalid_tokens, device=self.device)
679
+ )
680
+ candidates = candidates[valid_mask]
681
+
682
+ if len(candidates) == 0:
683
+ return x[sample_idx, mut_idx].item(), {'method': 'cg', 'fallback': True}
684
+
685
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(len(candidates), 1)
686
+ x_proposals[:, mut_idx] = candidates
687
+
688
+ with self.profiler.section("cg.proposal_scores"):
689
+ if self.args.cascade_k > 0:
690
+ proposal_scores = self._get_scores_cascaded(x_proposals)
691
+ else:
692
+ proposal_scores = self._get_scores(x_proposals)
693
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
694
+
695
+ score_logits = aggregated_scores * self.args.score_temperature
696
+ base_candidate_logits = base_logits[candidates]
697
+ guided_logits = base_candidate_logits + guidance_weight * score_logits
698
+ guided_probs = F.softmax(guided_logits, dim=-1)
699
+
700
+ selected_idx = torch.multinomial(guided_probs, 1).item()
701
+ return candidates[selected_idx].item(), {
702
+ 'method': 'cg', 'guidance_weight': guidance_weight,
703
+ 'num_candidates': len(candidates),
704
+ 'selected_score': aggregated_scores[selected_idx].item(), 'fallback': False
705
+ }
706
+
707
+ def _best_of_k_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
708
+ k, weights):
709
+ num_candidates = len(candidates)
710
+ if num_candidates == 0:
711
+ return x[sample_idx, mut_idx].item(), {'method': 'bok', 'fallback': True}
712
+
713
+ k = min(k, num_candidates)
714
+ candidate_probs = pos_probs[sample_idx, candidates]
715
+ candidate_probs = candidate_probs / (candidate_probs.sum() + 1e-9)
716
+
717
+ if k >= num_candidates:
718
+ sampled_indices = torch.arange(num_candidates, device=self.device)
719
+ else:
720
+ sampled_indices = torch.multinomial(candidate_probs, k, replacement=False)
721
+
722
+ sampled_tokens = candidates[sampled_indices]
723
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(len(sampled_tokens), 1)
724
+ x_proposals[:, mut_idx] = sampled_tokens
725
+
726
+ with self.profiler.section("bok.proposal_scores"):
727
+ if self.args.cascade_k > 0:
728
+ proposal_scores = self._get_scores_cascaded(x_proposals)
729
+ else:
730
+ proposal_scores = self._get_scores(x_proposals)
731
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
732
+
733
+ best_idx = torch.argmax(aggregated_scores).item()
734
+ return sampled_tokens[best_idx].item(), {
735
+ 'method': 'bok', 'k': k, 'num_candidates': num_candidates,
736
+ 'selected_score': aggregated_scores[best_idx].item(),
737
+ 'mean_score': aggregated_scores.mean().item(),
738
+ 'max_score': aggregated_scores.max().item(), 'fallback': False
739
+ }
740
+
741
+ def _hybrid_guidance(self, x, pos_probs, logits, candidates, mut_idx, sample_idx,
742
+ step, beta, weights):
743
+ progress = step / max(self.args.optimization_steps - 1, 1)
744
+ if progress < 0.3:
745
+ return self._best_of_k_guidance(
746
+ x, pos_probs, candidates, mut_idx, sample_idx,
747
+ k=self.args.bok_k, weights=weights
748
+ )
749
+ elif progress < 0.7:
750
+ return self._exponential_tilt_guidance(
751
+ x, pos_probs, candidates, mut_idx, sample_idx,
752
+ beta=beta, weights=weights
753
+ )
754
+ else:
755
+ return self._classifier_guidance(
756
+ x, logits, mut_idx, sample_idx,
757
+ guidance_weight=self.args.cg_weight, weights=weights
758
+ )
759
+
760
+ # ------------------------------------------------------------------
761
+ # Main loop
762
+ # ------------------------------------------------------------------
763
+ def generate(self):
764
+ self.profiler.reset()
765
+ x = self.generate_x0(
766
+ require_valid=self.args.require_valid_x0,
767
+ max_retries=self.args.max_x0_retries
768
+ )
769
+
770
+ if self.args.weights is None:
771
+ weights = torch.tensor(
772
+ [1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
773
+ device=self.device
774
+ ).view(-1, 1)
775
+ else:
776
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
777
+ if len(weights) != self.num_objectives:
778
+ raise ValueError("Number of weights must match number of objectives.")
779
+
780
+ print(f"\nGuidance Method: {self.guidance_method.value}")
781
+ print(f"Weights: {weights.squeeze().tolist()}")
782
+ print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
783
+ print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
784
+
785
+ with torch.no_grad():
786
+ for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
787
+ improved_indices = []
788
+
789
+ progress = step / max(self.args.optimization_steps - 1, 1)
790
+ beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * progress
791
+
792
+ if self.args.position_strategy == "random":
793
+ mut_idx = random.randint(1, self.args.gen_len - 2)
794
+ else:
795
+ mut_idx = (step % (self.args.gen_len - 2)) + 1
796
+
797
+ time_t = torch.full(
798
+ (self.args.num_samples,),
799
+ step / self.args.optimization_steps,
800
+ device=self.device
801
+ )
802
+
803
+ with self.profiler.section("loop.model_forward"):
804
+ logits = self.model(x, time_t)
805
+ probs = F.softmax(logits, dim=-1)
806
+ pos_probs = probs[:, mut_idx, :]
807
+
808
+ pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
809
+ for inv_tok in self.invalid_tokens:
810
+ pos_probs[:, inv_tok] = 0
811
+
812
+ with self.profiler.section("loop.top_p_sort"):
813
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
814
+
815
+ with self.profiler.section("loop.current_scores"):
816
+ current_scores = self._get_scores(x)
817
+ current_aggregated = self._compute_aggregated_score(current_scores, weights)
818
+
819
+ if step == 0:
820
+ print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
821
+ if self.tracker and self.conjugation_obj:
822
+ detailed = self.conjugation_obj.get_last_detailed_results()
823
+ self.tracker.log_step(step, current_scores[0], detailed)
824
+ self.tracker.print_step_summary(
825
+ step, current_scores[0], detailed, self.guidance_method.value
826
+ )
827
+
828
+ final_tokens = []
829
+ for i in range(self.args.num_samples):
830
+ sample_probs = sorted_probs[i]
831
+ sample_indices = sorted_indices[i]
832
+
833
+ cumsum = torch.cumsum(sample_probs, dim=0)
834
+ mask = cumsum <= self.args.top_p
835
+ mask[0] = True
836
+ candidates = sample_indices[mask]
837
+
838
+ valid_candidate_mask = ~torch.isin(
839
+ candidates, torch.tensor(self.invalid_tokens, device=self.device)
840
+ )
841
+ candidates = candidates[valid_candidate_mask]
842
+
843
+ current_sample_score = current_aggregated[i].item()
844
+
845
+ if beta > 0 or self.guidance_method != GuidanceMethod.EXPONENTIAL_TILT:
846
+ with self.profiler.section(f"loop.guidance.{self.guidance_method.value}"):
847
+ if self.guidance_method == GuidanceMethod.EXPONENTIAL_TILT:
848
+ token, _ = self._exponential_tilt_guidance(
849
+ x, pos_probs, candidates, mut_idx, i, beta, weights,
850
+ current_score=current_sample_score
851
+ )
852
+ elif self.guidance_method == GuidanceMethod.CLASSIFIER_GUIDANCE:
853
+ token, _ = self._classifier_guidance(
854
+ x, logits, mut_idx, i, self.args.cg_weight, weights
855
+ )
856
+ elif self.guidance_method == GuidanceMethod.BEST_OF_K:
857
+ token, _ = self._best_of_k_guidance(
858
+ x, pos_probs, candidates, mut_idx, i, self.args.bok_k, weights
859
+ )
860
+ elif self.guidance_method == GuidanceMethod.HYBRID:
861
+ token, _ = self._hybrid_guidance(
862
+ x, pos_probs, logits, candidates, mut_idx, i, step, beta, weights
863
+ )
864
+ else:
865
+ raise ValueError(f"Unknown guidance: {self.guidance_method}")
866
+ else:
867
+ if len(candidates) == 0:
868
+ token = x[i, mut_idx].item()
869
+ else:
870
+ cprobs = pos_probs[i, candidates]
871
+ cprobs = cprobs / (cprobs.sum() + 1e-9)
872
+ idx = torch.multinomial(cprobs, 1).item()
873
+ token = candidates[idx].item()
874
+
875
+ final_tokens.append(token)
876
+
877
+ x[:, mut_idx] = torch.tensor(final_tokens, device=self.device)
878
+
879
+ with self.profiler.section("loop.new_scores"):
880
+ new_scores = self._get_scores(x)
881
+ new_aggregated = self._compute_aggregated_score(new_scores, weights)
882
+ for i in range(self.args.num_samples):
883
+ if new_aggregated[i] > current_aggregated[i]:
884
+ improved_indices.append(i)
885
+
886
+ if improved_indices:
887
+ for i in improved_indices:
888
+ prev_weighted = (weights.squeeze(1) * current_scores[:, i]).sum()
889
+ new_weighted = (weights.squeeze(1) * new_scores[:, i]).sum()
890
+ print(f"Previous Weighted Sum: {prev_weighted}")
891
+ print(f"Previous Scores: {current_scores[:, i]}")
892
+ print(f"New Weighted Sum: {new_weighted}")
893
+ print(f"New Scores: {new_scores[:, i]}")
894
+ print(self.tokenizer.batch_decode(x[improved_indices]))
895
+
896
+ if self.tracker and self.conjugation_obj and \
897
+ (step % 4 == 0 or step == self.args.optimization_steps - 1):
898
+ detailed = self.conjugation_obj.get_last_detailed_results()
899
+ self.tracker.log_step(
900
+ step, new_scores[0], detailed, improved_indices,
901
+ guidance_info={'method': self.guidance_method.value, 'beta': beta}
902
+ )
903
+ if step == self.args.optimization_steps - 1:
904
+ self.tracker.print_step_summary(
905
+ step, new_scores[0], detailed, self.guidance_method.value
906
+ )
907
+
908
+ if (self.profiler.enabled
909
+ and self.args.profile_every > 0
910
+ and (step + 1) % self.args.profile_every == 0):
911
+ self.profiler.print_summary(header=f"(through step {step + 1})")
912
+
913
+ self.profiler.print_summary(header="(end of generate)")
914
+ return x
915
+
916
+
917
+ # =============================================================================
918
+ # Main
919
+ # =============================================================================
920
+
921
+ def main(args):
922
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
923
+ print(f"Using device: {device}")
924
+ print(f"Tokenizer type: {args.tokenizer_type}")
925
+ print(f"Guidance method: {args.guidance_method}")
926
+
927
+ if args.tokenizer_type == 'amino':
928
+ print("Using Amino Acid tokenizer")
929
+ tokenizer = AminoTokenizer()
930
+ smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
931
+ else:
932
+ print("Using SMILES tokenizer")
933
+ tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
934
+ smiles_tokenizer = tokenizer
935
+
936
+ conjugation_model = Conjugation(smiles_tokenizer, device=device, builder_type=args.builder_type)
937
+ # Binding affinity: AA-input or SMILES-input depending on --affinity_type.
938
+ affinity_model = make_affinity(
939
+ args.affinity_type,
940
+ target_protein_seq=args.target,
941
+ device=device,
942
+ aa_ckpt_path=args.aa_affinity_ckpt,
943
+ )
944
+ print(f"Affinity scorer: {type(affinity_model).__name__} (input_type={getattr(affinity_model, 'input_type', '?')})")
945
+ OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
946
+
947
+ tracker = GenerationTracker(output_dir=args.output_dir)
948
+
949
+ if args.seed is not None:
950
+ random.seed(args.seed)
951
+ np.random.seed(args.seed)
952
+ torch.manual_seed(args.seed)
953
+ if torch.cuda.is_available():
954
+ torch.cuda.manual_seed_all(args.seed)
955
+ print(f"Random seed: {args.seed}")
956
+
957
+ print(f"Loading model from: {args.checkpoint}")
958
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
959
+
960
+ if args.tokenizer_type == 'amino':
961
+ raw_model_args = checkpoint['args']
962
+ if isinstance(raw_model_args, argparse.Namespace):
963
+ raw_model_args = vars(raw_model_args)
964
+ model_args = edict(raw_model_args)
965
+ model = MDLM(
966
+ vocab_size=model_args.vocab_size,
967
+ seq_len=model_args.seq_len,
968
+ model_dim=model_args.model_dim,
969
+ n_heads=model_args.n_heads,
970
+ n_layers=model_args.n_layers,
971
+ ).to(device)
972
+ model.load_state_dict(checkpoint['model_state_dict'])
973
+ print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
974
+ else:
975
+ model_hparams = checkpoint["hyper_parameters"]["args"]
976
+ model = MDLMLightningModule.load_from_checkpoint(
977
+ args.checkpoint, args=model_hparams, tokenizer=tokenizer,
978
+ map_location=device, strict=False
979
+ )
980
+ model.to(device)
981
+ model.eval()
982
+ print("Model loaded successfully.")
983
+
984
+ generator = CupOfMoCAGeneratorUAA(
985
+ model, device, OBJECTIVE_FUNCTIONS, args,
986
+ tokenizer=tokenizer, tokenizer_type=args.tokenizer_type, tracker=tracker
987
+ )
988
+
989
+ all_sequences = []
990
+ all_smiles = []
991
+ all_final_results = []
992
+
993
+ for batch_idx in range(args.num_batches):
994
+ print(f"\n{'=' * 60}")
995
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
996
+ print(f"{'=' * 60}")
997
+
998
+ generated_tokens = generator.generate()
999
+ final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
1000
+ sequence_str = tokenizer.batch_decode(generated_tokens)
1001
+
1002
+ print("\n--- Batch Results ---")
1003
+ print(f"Sequences: {sequence_str}")
1004
+ print(f"Scores: {final_scores}")
1005
+
1006
+ all_sequences.extend(sequence_str)
1007
+
1008
+ if args.tokenizer_type == 'amino':
1009
+ smiles_list = []
1010
+ for seq in sequence_str:
1011
+ three_letter = one_letter_to_three_letter(seq)
1012
+ smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
1013
+ smiles_list.append(smiles if isinstance(smiles, str) else 'C')
1014
+ smiles_tokens = smiles_tokenizer(
1015
+ smiles_list, return_tensors='pt', padding=True
1016
+ ).to(device)
1017
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(
1018
+ smiles_tokens["input_ids"]
1019
+ )
1020
+ all_smiles.extend(smiles_list)
1021
+ tracker.log_final(sequence_str, detailed_with_props, smiles_list=smiles_list)
1022
+ else:
1023
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
1024
+ tracker.log_final(sequence_str, detailed_with_props)
1025
+ all_final_results.extend(detailed_with_props)
1026
+
1027
+ tracker.print_final_summary()
1028
+ tracker.save(filename_prefix=f'cupofmoca_uaa_{args.guidance_method}')
1029
+
1030
+ with open(args.output_file, 'w') as f:
1031
+ for i, seq in enumerate(all_sequences):
1032
+ if args.tokenizer_type == 'amino':
1033
+ smiles = all_smiles[i] if i < len(all_smiles) else ''
1034
+ f.write(f"{seq}\t{smiles}\n")
1035
+ else:
1036
+ f.write(seq + '\n')
1037
+ print(f"\nSequences saved to: {args.output_file}")
1038
+ print("\nGeneration complete.")
1039
+
1040
+
1041
+ if __name__ == "__main__":
1042
+ parser = argparse.ArgumentParser(
1043
+ description="CupOfMoCA-style Guided Generation with UAA Foundation Model"
1044
+ )
1045
+
1046
+ parser.add_argument("--checkpoint", type=str, required=True)
1047
+ parser.add_argument("--target", type=str, required=True)
1048
+
1049
+ parser.add_argument("--num_samples", type=int, default=10)
1050
+ parser.add_argument("--num_batches", type=int, default=10)
1051
+ parser.add_argument("--gen_len", type=int, default=50)
1052
+ parser.add_argument("--optimization_steps", type=int, default=128)
1053
+
1054
+ parser.add_argument("--guidance_method", type=str, default="exp_tilt",
1055
+ choices=["exp_tilt", "cg", "bok", "hybrid"])
1056
+
1057
+ parser.add_argument("--beta_min", type=float, default=1.0)
1058
+ parser.add_argument("--beta_max", type=float, default=20.0)
1059
+ parser.add_argument("--cg_weight", type=float, default=2.0)
1060
+ parser.add_argument("--use_acceptance", action="store_true")
1061
+ parser.add_argument("--bok_k", type=int, default=8)
1062
+ parser.add_argument("--top_k", type=int, default=200)
1063
+ parser.add_argument("--top_p", type=float, default=0.9)
1064
+ parser.add_argument("--score_temperature", type=float, default=1.0)
1065
+ parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
1066
+ choices=["weighted_sum", "weighted_min"])
1067
+
1068
+ parser.add_argument("--weights", type=float, nargs='+', default=None)
1069
+
1070
+ parser.add_argument("--position_strategy", type=str, default="random",
1071
+ choices=["random", "sequential"])
1072
+
1073
+ parser.add_argument("--require_valid_x0", action="store_true")
1074
+ parser.add_argument("--max_x0_retries", type=int, default=10)
1075
+
1076
+ parser.add_argument("--builder_type", type=str, default="AoaDau",
1077
+ choices=["AoaDau", "ValAlaDau", "ValCitDau", "GlutarylDau", "Dau",
1078
+ "SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt", "DSECCpt", "Cpt",
1079
+ "DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"])
1080
+
1081
+ parser.add_argument("--tokenizer_type", type=str, default="amino",
1082
+ choices=["smiles", "amino"])
1083
+ parser.add_argument("--cyclic", action="store_true",
1084
+ help="Generate cyclic peptides (only for amino tokenizer).")
1085
+ parser.add_argument("--smiles_vocab_path", type=str,
1086
+ default="./smiles/smiles_tokenizer/new_vocab.txt")
1087
+ parser.add_argument("--smiles_splits_path", type=str,
1088
+ default="./smiles/smiles_tokenizer/new_splits.txt")
1089
+
1090
+ # AA-mode binding-affinity selection (this is the WT-AA / PepReDi script).
1091
+ parser.add_argument("--affinity_type", type=str, default="smiles",
1092
+ choices=["aa", "smiles"],
1093
+ help="Which binding-affinity oracle to guide on: "
1094
+ "'aa' = AA-input UnpooledBindingPredictor "
1095
+ "(matches moPPIt's setup); 'smiles' = "
1096
+ "SMILES-input BindingAffinity (matches the "
1097
+ "SMILES CupOFMoCA tables).")
1098
+ parser.add_argument("--aa_affinity_ckpt", type=str,
1099
+ default=DEFAULT_AA_AFFINITY_CKPT,
1100
+ help="Path to AA-input affinity predictor checkpoint.")
1101
+
1102
+ parser.add_argument("--output_file", type=str, default="./smiles.txt")
1103
+ parser.add_argument("--output_dir", type=str, default="./generation_logs")
1104
+
1105
+ parser.add_argument("--seed", type=int, default=None)
1106
+
1107
+ parser.add_argument("--profile", action="store_true",
1108
+ help="Enable per-section wall-clock profiling.")
1109
+ parser.add_argument("--profile_every", type=int, default=0,
1110
+ help="Print profiler summary every N optimization steps "
1111
+ "(0 = only at end of each generate()).")
1112
+
1113
+ parser.add_argument("--cascade_k", type=int, default=0,
1114
+ help="Cascade scoring: score all proposals with cheap "
1115
+ "Conjugation pre-filter + BindingAffinity, then run "
1116
+ "the expensive PDC validation only on the top-K by "
1117
+ "affinity (among cheap-filter survivors). 0 disables "
1118
+ "(full scoring on all proposals, original behavior).")
1119
+ parser.add_argument("--cascade_workers", type=int, default=1,
1120
+ help="Number of worker threads for parallelizing "
1121
+ "cascade.full_validate (CPU-bound RDKit work). "
1122
+ "1 = sequential (original behavior). Try 4-8 when "
1123
+ "--cascade_k is high. Ignored if --cascade_k=0.")
1124
+
1125
+ args = parser.parse_args()
1126
+ main(args)
smiles/generation/cupofmoca_guided_uaa.py ADDED
@@ -0,0 +1,1196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CupOfMoCA-style Guided Generation with UAA Foundation Model
3
+
4
+ Combines CupOfMoCA Algorithm 2 guidance strategies (exponential tilting,
5
+ classifier guidance, best-of-K, hybrid) with the amino-acid (UAA) foundation
6
+ model. Sequences are generated in AA token space, converted to SMILES for
7
+ scoring against Conjugation (peptide+PDC validity) and BindingAffinity.
8
+ """
9
+
10
+ import argparse
11
+ import csv
12
+ import json
13
+ import os
14
+ import random
15
+ import sys
16
+ import time
17
+ from collections import defaultdict
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from contextlib import contextmanager
20
+ from datetime import datetime
21
+ from enum import Enum
22
+ from typing import Dict, Optional, Tuple
23
+
24
+ import numpy as np
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+ from easydict import EasyDict as edict
29
+ from tqdm import tqdm
30
+
31
+ from smiles.models.train import MDLMLightningModule
32
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
33
+ from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
34
+ from smiles.evaluation.pdc_analyzer import PDCAnalyzer
35
+ from smiles.scoring.classifiers import Conjugation, BindingAffinity
36
+
37
+ from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
38
+ from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
39
+
40
+
41
+ # =============================================================================
42
+ # Amino Acid Foundation Model (DiT-based MDLM)
43
+ # =============================================================================
44
+
45
+ def modulate(x, shift, scale):
46
+ return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
47
+
48
+
49
+ class TimestepEmbedder(nn.Module):
50
+ def __init__(self, hidden_size):
51
+ super().__init__()
52
+ self.mlp = nn.Sequential(
53
+ nn.Linear(1, hidden_size, bias=True),
54
+ nn.SiLU(),
55
+ nn.Linear(hidden_size, hidden_size, bias=True),
56
+ )
57
+
58
+ def forward(self, t):
59
+ return self.mlp(t.unsqueeze(-1))
60
+
61
+
62
+ class DiTBlock(nn.Module):
63
+ def __init__(self, hidden_size, n_heads):
64
+ super().__init__()
65
+ self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
66
+ self.attn = nn.MultiheadAttention(hidden_size, n_heads, batch_first=True)
67
+ self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
68
+ self.mlp = nn.Sequential(
69
+ nn.Linear(hidden_size, 4 * hidden_size),
70
+ nn.GELU(),
71
+ nn.Linear(4 * hidden_size, hidden_size),
72
+ )
73
+ self.adaLN_modulation = nn.Sequential(
74
+ nn.SiLU(),
75
+ nn.Linear(hidden_size, 6 * hidden_size, bias=True),
76
+ )
77
+
78
+ def forward(self, x, c):
79
+ shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = \
80
+ self.adaLN_modulation(c).chunk(6, dim=1)
81
+ x_norm1 = modulate(self.norm1(x), shift_msa, scale_msa)
82
+ attn_output, _ = self.attn(x_norm1, x_norm1, x_norm1)
83
+ x = x + gate_msa.unsqueeze(1) * attn_output
84
+ x_norm2 = modulate(self.norm2(x), shift_mlp, scale_mlp)
85
+ mlp_output = self.mlp(x_norm2)
86
+ x = x + gate_mlp.unsqueeze(1) * mlp_output
87
+ return x
88
+
89
+
90
+ class MDLM(nn.Module):
91
+ def __init__(self, vocab_size, seq_len, model_dim, n_heads, n_layers):
92
+ super().__init__()
93
+ self.vocab_size = vocab_size
94
+ self.seq_len = seq_len
95
+ self.model_dim = model_dim
96
+
97
+ self.token_embedder = nn.Embedding(vocab_size, model_dim)
98
+ self.pos_embedder = nn.Parameter(torch.randn(1, seq_len, model_dim))
99
+ self.time_embedder = TimestepEmbedder(model_dim)
100
+
101
+ self.transformer_blocks = nn.ModuleList([
102
+ DiTBlock(model_dim, n_heads) for _ in range(n_layers)
103
+ ])
104
+ self.final_norm = nn.LayerNorm(model_dim)
105
+ self.lm_head = nn.Linear(model_dim, vocab_size)
106
+
107
+ def forward(self, x, t):
108
+ seq_len = x.shape[1]
109
+ x_embed = self.token_embedder(x) + self.pos_embedder[:, :seq_len, :]
110
+ t_embed = self.time_embedder(t)
111
+ for block in self.transformer_blocks:
112
+ x_embed = block(x_embed, t_embed)
113
+ x_embed = self.final_norm(x_embed)
114
+ return self.lm_head(x_embed)
115
+
116
+
117
+ class AminoTokenizer:
118
+ def __init__(self):
119
+ self.vocab = {
120
+ '<cls>': 0, '<pad>': 1, '<eos>': 2, '<unk>': 3,
121
+ 'L': 4, 'A': 5, 'G': 6, 'V': 7, 'S': 8, 'E': 9,
122
+ 'R': 10, 'T': 11, 'I': 12, 'D': 13, 'P': 14, 'K': 15,
123
+ 'Q': 16, 'N': 17, 'F': 18, 'Y': 19, 'M': 20, 'H': 21,
124
+ 'W': 22, 'C': 23,
125
+ 'Ŷ': 24, 'Ĉ': 25, 'Ĝ': 26, 'Ė': 27, 'Ƙ': 28,
126
+ 'Ṃ': 29, 'Ł': 30, 'Ċ': 31, 'Ṯ': 32,
127
+ 'a': 33, 'c': 34, 'd': 35, 'e': 36, 'f': 37,
128
+ 'g': 38, 'h': 39, 'i': 40, 'k': 41, 'l': 42,
129
+ 'm': 43, 'n': 44, 'p': 45, 'q': 46, 'r': 47,
130
+ 's': 48, 't': 49, 'v': 50, 'w': 51, 'y': 52,
131
+ }
132
+ self.id_to_token = {i: t for t, i in self.vocab.items()}
133
+ self.cls_token, self.pad_token = '<cls>', '<pad>'
134
+ self.eos_token, self.unk_token = '<eos>', '<unk>'
135
+ self.cls_id = self.vocab[self.cls_token]
136
+ self.pad_id = self.vocab[self.pad_token]
137
+ self.eos_id = self.vocab[self.eos_token]
138
+ self.unk_id = self.vocab[self.unk_token]
139
+ self.vocab_size = len(self.vocab)
140
+
141
+ def decode(self, ids, skip_special_tokens=True):
142
+ if isinstance(ids, torch.Tensor):
143
+ ids = ids.tolist()
144
+ tokens = []
145
+ for i in ids:
146
+ tok = self.id_to_token.get(int(i), self.unk_token)
147
+ if skip_special_tokens and tok in {self.cls_token, self.eos_token, self.pad_token}:
148
+ continue
149
+ tokens.append(tok)
150
+ return ''.join(tokens)
151
+
152
+ def batch_decode(self, batch_ids, skip_special_tokens=True):
153
+ return [self.decode(ids, skip_special_tokens=skip_special_tokens) for ids in batch_ids]
154
+
155
+
156
+ # =============================================================================
157
+ # Profiler
158
+ # =============================================================================
159
+
160
+ class Profiler:
161
+ """Aggregates wall-clock time per named section. Optionally syncs CUDA.
162
+
163
+ `reset()` clears all state (use between batches for self-contained summaries).
164
+ `print_summary()` shows cumulative totals since the last reset, plus a
165
+ delta column showing time since the previous `print_summary()` call.
166
+ """
167
+
168
+ def __init__(self, enabled=False, cuda_sync=True):
169
+ self.enabled = enabled
170
+ self.cuda_sync = cuda_sync and torch.cuda.is_available()
171
+ self.totals = defaultdict(float)
172
+ self.counts = defaultdict(int)
173
+ self._last_totals = defaultdict(float)
174
+ self._last_counts = defaultdict(int)
175
+
176
+ @contextmanager
177
+ def section(self, name):
178
+ if not self.enabled:
179
+ yield
180
+ return
181
+ if self.cuda_sync:
182
+ torch.cuda.synchronize()
183
+ t0 = time.perf_counter()
184
+ try:
185
+ yield
186
+ finally:
187
+ if self.cuda_sync:
188
+ torch.cuda.synchronize()
189
+ self.totals[name] += time.perf_counter() - t0
190
+ self.counts[name] += 1
191
+
192
+ def reset(self):
193
+ self.totals.clear()
194
+ self.counts.clear()
195
+ self._last_totals.clear()
196
+ self._last_counts.clear()
197
+
198
+ def print_summary(self, header=""):
199
+ if not self.enabled or not self.totals:
200
+ return
201
+ cumulative_total = sum(self.totals.values())
202
+ delta_totals = {k: self.totals[k] - self._last_totals.get(k, 0.0)
203
+ for k in self.totals}
204
+ delta_counts = {k: self.counts[k] - self._last_counts.get(k, 0)
205
+ for k in self.counts}
206
+ interval_total = sum(delta_totals.values())
207
+
208
+ print(f"\n=== Profiler summary {header} ===")
209
+ print(f" (cumulative since reset | interval since previous summary)")
210
+ print(f" {'section':<34} "
211
+ f"{'calls':>6} {'cum(s)':>8} {'cum_avg':>8} "
212
+ f"{'Δcalls':>6} {'Δ(s)':>7} {'Δavg':>7} {'%Δ':>5}")
213
+ for name, tot in sorted(self.totals.items(), key=lambda kv: -kv[1]):
214
+ n = self.counts[name]
215
+ cum_avg = (tot / n) * 1000.0 if n else 0.0
216
+ dt = delta_totals[name]
217
+ dn = delta_counts[name]
218
+ d_avg = (dt / dn) * 1000.0 if dn else 0.0
219
+ d_pct = (dt / interval_total) * 100.0 if interval_total > 0 else 0.0
220
+ print(f" {name:<34} "
221
+ f"{n:>6d} {tot:>8.2f} {cum_avg:>7.1f}ms "
222
+ f"{dn:>6d} {dt:>7.2f} {d_avg:>6.1f}ms {d_pct:>4.1f}%")
223
+ print(f" {'-' * 34} {'-' * 6} {'-' * 8} {'-' * 8} "
224
+ f"{'-' * 6} {'-' * 7} {'-' * 7} {'-' * 5}")
225
+ print(f" {'TOTAL':<34} {'':>6} {cumulative_total:>8.2f} {'':>8} "
226
+ f"{'':>6} {interval_total:>7.2f}")
227
+
228
+ self._last_totals = dict(self.totals)
229
+ self._last_counts = dict(self.counts)
230
+
231
+
232
+ # =============================================================================
233
+ # Guidance Method Enum
234
+ # =============================================================================
235
+
236
+ class GuidanceMethod(Enum):
237
+ EXPONENTIAL_TILT = "exp_tilt"
238
+ CLASSIFIER_GUIDANCE = "cg"
239
+ BEST_OF_K = "bok"
240
+ HYBRID = "hybrid"
241
+
242
+
243
+ # =============================================================================
244
+ # Generation Tracker
245
+ # =============================================================================
246
+
247
+ class GenerationTracker:
248
+ def __init__(self, output_dir='./generation_logs'):
249
+ self.output_dir = output_dir
250
+ os.makedirs(output_dir, exist_ok=True)
251
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
252
+ self.step_logs = []
253
+ self.final_results = []
254
+
255
+ def log_step(self, step, scores, detailed_results=None, improved_indices=None,
256
+ guidance_info: Optional[Dict] = None):
257
+ step_info = {
258
+ 'step': step,
259
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
260
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
261
+ 'improved_indices': improved_indices or [],
262
+ 'guidance_info': guidance_info or {}
263
+ }
264
+ if detailed_results:
265
+ step_info['detailed'] = []
266
+ for i, result in enumerate(detailed_results):
267
+ step_info['detailed'].append({
268
+ 'sample_idx': i,
269
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
270
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
271
+ 'conjugation_success': result.get('conjugation_success', False),
272
+ 'pdc_valid': result.get('pdc_valid', False),
273
+ 'num_conjugates': len(result.get('conjugates', [])),
274
+ 'error': result.get('error')
275
+ })
276
+ self.step_logs.append(step_info)
277
+
278
+ def log_final(self, sequences, detailed_results_with_props, smiles_list=None):
279
+ for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
280
+ final_info = {
281
+ 'sample_idx': i,
282
+ 'aa_sequence': seq,
283
+ 'peptide_smiles': smiles_list[i] if smiles_list else seq,
284
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
285
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
286
+ 'num_conjugates': len(result.get('conjugates', [])),
287
+ 'num_valid_pdcs': len(result.get('valid_pdcs', [])),
288
+ 'best_pdc': None,
289
+ 'linker_used': result.get('linker_used', ''),
290
+ 'all_conjugates': result.get('conjugates', []),
291
+ 'properties': result.get('properties', {})
292
+ }
293
+ if result.get('best_pdc'):
294
+ final_info['best_pdc'] = {
295
+ 'smiles': result['best_pdc'].get('smiles', ''),
296
+ 'checks': result['best_pdc'].get('checks', {})
297
+ }
298
+ self.final_results.append(final_info)
299
+
300
+ def save(self, filename_prefix='generation'):
301
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
302
+ with open(step_log_path, 'w') as f:
303
+ json.dump(self.step_logs, f, indent=2, default=str)
304
+
305
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
306
+ with open(final_path, 'w') as f:
307
+ json.dump(self.final_results, f, indent=2, default=str)
308
+
309
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
310
+ with open(csv_path, 'w', newline='') as f:
311
+ writer = csv.writer(f)
312
+ writer.writerow([
313
+ 'sample_idx', 'aa_sequence', 'peptide_smiles', 'is_valid_peptide',
314
+ 'has_conjugation_site', 'linker_used', 'num_conjugates', 'num_valid_pdcs',
315
+ 'best_pdc_smiles', 'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
316
+ ])
317
+ for result in self.final_results:
318
+ props = result.get('properties', {})
319
+ writer.writerow([
320
+ result['sample_idx'],
321
+ result.get('aa_sequence', ''),
322
+ result['peptide_smiles'],
323
+ result['is_valid_peptide'],
324
+ result['has_conjugation_site'],
325
+ result.get('linker_used', ''),
326
+ result['num_conjugates'],
327
+ result['num_valid_pdcs'],
328
+ result['best_pdc']['smiles'] if result.get('best_pdc') else '',
329
+ props.get('mf', ''),
330
+ props.get('polararea', ''),
331
+ props.get('xlogp', ''),
332
+ props.get('heavycnt', ''),
333
+ props.get('rotbonds', '')
334
+ ])
335
+
336
+ pdc_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_pdcs.csv")
337
+ with open(pdc_path, 'w', newline='') as f:
338
+ writer = csv.writer(f)
339
+ writer.writerow(['sample_idx', 'aa_sequence', 'peptide_smiles', 'linker_used',
340
+ 'conjugate_idx', 'pdc_smiles'])
341
+ for result in self.final_results:
342
+ for j, conj in enumerate(result.get('all_conjugates', [])):
343
+ writer.writerow([
344
+ result['sample_idx'],
345
+ result.get('aa_sequence', ''),
346
+ result['peptide_smiles'],
347
+ result.get('linker_used', ''),
348
+ j,
349
+ conj
350
+ ])
351
+
352
+ print(f"\n=== Generation logs saved ===")
353
+ print(f" Step logs: {step_log_path}")
354
+ print(f" Final results: {final_path}")
355
+ print(f" Summary CSV: {csv_path}")
356
+ print(f" PDC conjugates: {pdc_path}")
357
+ return step_log_path, final_path, csv_path, pdc_path
358
+
359
+ def print_step_summary(self, step, scores, detailed_results=None, guidance_method=None):
360
+ scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores)
361
+ num_valid = sum(1 for s in scores_list if s > 0)
362
+ print(f"\n--- Step {step} Summary ({guidance_method or 'unknown'}) ---")
363
+ print(f" Valid: {num_valid}/{len(scores_list)}")
364
+ if detailed_results:
365
+ print(f" Breakdown:")
366
+ print(f" Valid peptides: {sum(1 for r in detailed_results if r.get('is_valid_peptide'))}")
367
+ print(f" Has conjugation site: {sum(1 for r in detailed_results if r.get('has_conjugation_site'))}")
368
+ print(f" Conjugation success: {sum(1 for r in detailed_results if r.get('conjugation_success'))}")
369
+ print(f" PDC valid: {sum(1 for r in detailed_results if r.get('pdc_valid'))}")
370
+
371
+ def print_final_summary(self):
372
+ print("\n" + "=" * 60)
373
+ print("FINAL GENERATION SUMMARY")
374
+ print("=" * 60)
375
+ total = len(self.final_results)
376
+ valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
377
+ print(f"\nTotal sequences: {total}")
378
+ print(f"With valid PDCs: {valid_pdcs}")
379
+ for result in self.final_results:
380
+ status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
381
+ print(f"\n [{status}] Sample {result['sample_idx']}:")
382
+ print(f" AA seq: {result.get('aa_sequence', '')}")
383
+ print(f" Peptide SMILES: {result['peptide_smiles'][:60]}...")
384
+ print(f" Valid peptide: {result['is_valid_peptide']}")
385
+ print(f" Has conjugation site: {result['has_conjugation_site']}")
386
+ print(f" Conjugates: {result['num_conjugates']}")
387
+ print(f" Valid PDCs: {result['num_valid_pdcs']}")
388
+
389
+
390
+ # =============================================================================
391
+ # CupOfMoCA Generator (UAA-aware)
392
+ # =============================================================================
393
+
394
+ class CupOfMoCAGeneratorUAA:
395
+ """CupOfMoCA Algorithm 2 generator that operates in AA token space.
396
+
397
+ AA tokens are converted to SMILES on the fly for scoring with the
398
+ Conjugation and BindingAffinity objectives.
399
+ """
400
+
401
+ def __init__(self, model, device, objectives, args, tokenizer, tokenizer_type='amino',
402
+ tracker=None):
403
+ self.model = model
404
+ self.device = device
405
+ self.objectives = objectives
406
+ self.args = args
407
+ self.num_objectives = len(objectives)
408
+ self.peptide_analyzer = PeptideAnalyzer()
409
+ self.pdc_analyzer = PDCAnalyzer(device)
410
+ self.tracker = tracker
411
+ self.tokenizer = tokenizer
412
+ self.tokenizer_type = tokenizer_type
413
+ self.guidance_method = GuidanceMethod(args.guidance_method)
414
+ self.profiler = Profiler(enabled=getattr(args, 'profile', False))
415
+
416
+ cascade_workers = max(1, getattr(args, 'cascade_workers', 1))
417
+ self._cascade_pool = (
418
+ ThreadPoolExecutor(max_workers=cascade_workers)
419
+ if cascade_workers > 1 else None
420
+ )
421
+
422
+ if tokenizer_type == 'amino':
423
+ self.invalid_tokens = [0, 1, 2, 3, 38] # specials + 'g'
424
+ self.smiles_tokenizer = SMILES_SPE_Tokenizer(
425
+ args.smiles_vocab_path, args.smiles_splits_path
426
+ )
427
+ else:
428
+ self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586]
429
+ self.smiles_tokenizer = None
430
+
431
+ self.conjugation_obj = None
432
+ for obj in objectives:
433
+ if isinstance(obj, Conjugation):
434
+ self.conjugation_obj = obj
435
+ break
436
+
437
+ # ------------------------------------------------------------------
438
+ # x0 generation (Algorithm 2, Line 6)
439
+ # ------------------------------------------------------------------
440
+ def _generate_single_x0(self, n_steps=16, temperature=1.0):
441
+ self.model.eval()
442
+
443
+ if self.tokenizer_type == 'amino':
444
+ vocab_size = self.model.vocab_size
445
+ valid_tokens = [i for i in range(vocab_size) if i not in self.invalid_tokens]
446
+ valid_tokens = torch.tensor(valid_tokens, device=self.device)
447
+ rand_indices = torch.randint(
448
+ 0, len(valid_tokens),
449
+ (self.args.num_samples, self.args.gen_len),
450
+ device=self.device
451
+ )
452
+ body = valid_tokens[rand_indices]
453
+ cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
454
+ eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
455
+ x = torch.cat([cls_col, body, eos_col], dim=1)
456
+ else:
457
+ vocab_size = self.model.tokenizer.vocab_size
458
+ x = torch.randint(
459
+ 0, vocab_size,
460
+ (self.args.num_samples, self.args.gen_len),
461
+ device=self.device
462
+ )
463
+
464
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
465
+
466
+ with torch.no_grad():
467
+ for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
468
+ t_curr = time_steps[i]
469
+ t_next = time_steps[i + 1]
470
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
471
+
472
+ logits = self.model(x, t_tensor) / temperature
473
+ for inv_tok in self.invalid_tokens:
474
+ if inv_tok < logits.shape[-1]:
475
+ logits[:, :, inv_tok] = -1000
476
+
477
+ pred_x1 = torch.argmax(logits, dim=-1)
478
+ if i == n_steps - 1:
479
+ x = pred_x1
480
+ break
481
+
482
+ noise_prob = 1.0 - t_next
483
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
484
+ noise = torch.randint(0, vocab_size, x.shape, device=self.device)
485
+ x = torch.where(mask, noise, pred_x1)
486
+
487
+ return x
488
+
489
+ def _aa_to_smiles_batch(self, x_batch):
490
+ """Decode AA tokens, convert to SMILES via three-letter codes."""
491
+ with self.profiler.section("aa2smiles.decode"):
492
+ seqs = self.tokenizer.batch_decode(x_batch)
493
+ with self.profiler.section("aa2smiles.one_to_three"):
494
+ three_letter_seqs = [one_letter_to_three_letter(s) for s in seqs]
495
+ with self.profiler.section("aa2smiles.aa_to_smiles"):
496
+ with ThreadPoolExecutor() as pool:
497
+ smiles_seqs = list(pool.map(
498
+ lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
499
+ three_letter_seqs
500
+ ))
501
+ return seqs, smiles_seqs
502
+
503
+ def _check_conjugation_validity(self, x):
504
+ if self.tokenizer_type == 'amino':
505
+ _, smiles_seqs = self._aa_to_smiles_batch(x)
506
+ validities = [
507
+ self.peptide_analyzer.is_peptide(s) if isinstance(s, str) else False
508
+ for s in smiles_seqs
509
+ ]
510
+ return torch.tensor(validities, device=self.device)
511
+
512
+ if self.conjugation_obj is None:
513
+ sequences = self.tokenizer.batch_decode(x)
514
+ return torch.tensor(
515
+ [self.peptide_analyzer.is_peptide(seq) for seq in sequences],
516
+ device=self.device
517
+ )
518
+ return self.conjugation_obj(x) > 0
519
+
520
+ def generate_x0(self, n_steps=16, temperature=1.0,
521
+ require_valid=False, max_retries=10):
522
+ print("Generating initial sequences (x0 from prior)...")
523
+ x = self._generate_single_x0(n_steps, temperature)
524
+
525
+ if require_valid:
526
+ print("Enforcing initial validity constraint...")
527
+ for retry in range(max_retries):
528
+ validity = self._check_conjugation_validity(x)
529
+ num_valid = validity.sum().item()
530
+ num_invalid = self.args.num_samples - num_valid
531
+ if num_invalid == 0:
532
+ print(f" All {self.args.num_samples} samples are valid!")
533
+ break
534
+ print(f" Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid")
535
+ invalid_indices = torch.where(~validity)[0]
536
+ original_num = self.args.num_samples
537
+ self.args.num_samples = num_invalid
538
+ new_x = self._generate_single_x0(n_steps, temperature)
539
+ self.args.num_samples = original_num
540
+ x[invalid_indices] = new_x
541
+
542
+ final_validity = self._check_conjugation_validity(x)
543
+ if final_validity.sum().item() < self.args.num_samples:
544
+ print(f"⚠ After {max_retries} retries, "
545
+ f"{final_validity.sum().item()}/{self.args.num_samples} valid")
546
+
547
+ generated_sequences = self.tokenizer.batch_decode(x)
548
+ for seq in generated_sequences:
549
+ print(f" {seq}")
550
+ return x
551
+
552
+ # ------------------------------------------------------------------
553
+ # Scoring
554
+ # ------------------------------------------------------------------
555
+ def _get_scores(self, x_batch):
556
+ with self.profiler.section("get_scores.total"):
557
+ scores = []
558
+
559
+ if self.tokenizer_type == 'amino':
560
+ _, smiles_seqs = self._aa_to_smiles_batch(x_batch)
561
+ with self.profiler.section("get_scores.valid_mask"):
562
+ valid_mask = torch.tensor(
563
+ [1 if isinstance(s, str) else 0 for s in smiles_seqs],
564
+ device=self.device
565
+ )
566
+ for i in torch.where((1 - valid_mask) == 1)[0].tolist():
567
+ smiles_seqs[i] = 'C'
568
+
569
+ with self.profiler.section("get_scores.smiles_tokenize"):
570
+ smiles_batch = self.smiles_tokenizer(
571
+ smiles_seqs, return_tensors='pt', padding=True
572
+ ).to(self.device)
573
+ attention_mask = smiles_batch.get("attention_mask", None)
574
+
575
+ for obj_func in self.objectives:
576
+ obj_name = type(obj_func).__name__
577
+ with self.profiler.section(f"get_scores.obj.{obj_name}"):
578
+ try:
579
+ score = obj_func(
580
+ smiles_batch["input_ids"], attention_mask=attention_mask
581
+ ).to(self.device)
582
+ except TypeError:
583
+ score = obj_func(smiles_batch["input_ids"]).to(self.device)
584
+ scores.append(score * valid_mask)
585
+ else:
586
+ for obj_func in self.objectives:
587
+ obj_name = type(obj_func).__name__
588
+ with self.profiler.section(f"get_scores.obj.{obj_name}"):
589
+ scores.append(obj_func(x_batch.to(self.device)))
590
+
591
+ return torch.stack(scores, dim=0).to(self.device)
592
+
593
+ def _compute_aggregated_score(self, scores, weights):
594
+ if self.args.score_aggregation == "weighted_min":
595
+ return torch.min(weights * scores, dim=0).values
596
+ return (weights * scores).sum(dim=0)
597
+
598
+ # ------------------------------------------------------------------
599
+ # Cascade scoring (cheap filter + affinity on all → full PDC on top-K)
600
+ # ------------------------------------------------------------------
601
+ def _get_scores_cascaded(self, x_batch):
602
+ """Run cheap conjugation pre-filter + BindingAffinity on all candidates,
603
+ then run the expensive PDC validation only on the top `cascade_k` by
604
+ affinity (among those that pass the cheap filter).
605
+
606
+ Returns [num_objectives, batch] in the same layout as `_get_scores`.
607
+ Falls back to full scoring if Conjugation isn't an objective or
608
+ tokenizer_type != 'amino'.
609
+ """
610
+ if self.tokenizer_type != 'amino' or self.conjugation_obj is None:
611
+ return self._get_scores(x_batch)
612
+
613
+ with self.profiler.section("cascade.total"):
614
+ n = x_batch.shape[0]
615
+ cascade_k = self.args.cascade_k
616
+ conj_obj = self.conjugation_obj
617
+
618
+ _, smiles_seqs = self._aa_to_smiles_batch(x_batch)
619
+ valid_mask = torch.tensor(
620
+ [1.0 if isinstance(s, str) else 0.0 for s in smiles_seqs],
621
+ device=self.device
622
+ )
623
+ safe_smiles = [s if isinstance(s, str) else 'C' for s in smiles_seqs]
624
+
625
+ with self.profiler.section("cascade.cheap_filter"):
626
+ cheap_pass = []
627
+ for s, vm in zip(safe_smiles, valid_mask.tolist()):
628
+ if vm == 0.0:
629
+ cheap_pass.append(False)
630
+ continue
631
+ cheap_pass.append(
632
+ conj_obj.is_valid_peptide(s)
633
+ and conj_obj.has_conjugation_site(s)
634
+ )
635
+ cheap_pass_t = torch.tensor(
636
+ cheap_pass, device=self.device, dtype=torch.float
637
+ )
638
+
639
+ with self.profiler.section("cascade.smiles_tokenize"):
640
+ smiles_batch = self.smiles_tokenizer(
641
+ safe_smiles, return_tensors='pt', padding=True
642
+ ).to(self.device)
643
+ attention_mask = smiles_batch.get("attention_mask", None)
644
+
645
+ aff_obj = next(
646
+ (o for o in self.objectives if not isinstance(o, Conjugation)),
647
+ None
648
+ )
649
+ if aff_obj is None:
650
+ aff_scores = torch.zeros(n, device=self.device)
651
+ else:
652
+ with self.profiler.section(f"cascade.obj.{type(aff_obj).__name__}"):
653
+ try:
654
+ aff_scores = aff_obj(
655
+ smiles_batch["input_ids"], attention_mask=attention_mask
656
+ ).to(self.device)
657
+ except TypeError:
658
+ aff_scores = aff_obj(smiles_batch["input_ids"]).to(self.device)
659
+ aff_scores = aff_scores * valid_mask
660
+
661
+ eligible_idx = torch.where(cheap_pass_t > 0)[0]
662
+ if cascade_k > 0 and len(eligible_idx) > cascade_k:
663
+ topk_local = torch.topk(aff_scores[eligible_idx], k=cascade_k).indices
664
+ validate_idx = eligible_idx[topk_local]
665
+ else:
666
+ validate_idx = eligible_idx
667
+
668
+ conj_scores = torch.zeros(n, device=self.device)
669
+ with self.profiler.section("cascade.full_validate"):
670
+ for j in validate_idx.tolist():
671
+ ok, conjs, _, _ = conj_obj.conjugate_peptide(
672
+ safe_smiles[j], exclude_n_terminus=True
673
+ )
674
+ if not ok or not conjs:
675
+ continue
676
+ has_valid, _ = conj_obj.validate_conjugates(conjs)
677
+ if has_valid:
678
+ conj_scores[j] = 1.0
679
+
680
+ out = []
681
+ for obj in self.objectives:
682
+ if isinstance(obj, Conjugation):
683
+ out.append(conj_scores)
684
+ else:
685
+ out.append(aff_scores)
686
+ return torch.stack(out, dim=0).to(self.device)
687
+
688
+ # ------------------------------------------------------------------
689
+ # Guidance methods
690
+ # ------------------------------------------------------------------
691
+ def _exponential_tilt_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
692
+ beta, weights, current_score=None):
693
+ num_candidates = len(candidates)
694
+ current_token = x[sample_idx, mut_idx].item()
695
+
696
+ if num_candidates == 0:
697
+ return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
698
+
699
+ if num_candidates > self.args.top_k:
700
+ candidates = candidates[:self.args.top_k]
701
+ num_candidates = self.args.top_k
702
+
703
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
704
+ x_proposals[:, mut_idx] = candidates
705
+
706
+ with self.profiler.section("exp_tilt.proposal_scores"):
707
+ if self.args.cascade_k > 0:
708
+ proposal_scores = self._get_scores_cascaded(x_proposals)
709
+ else:
710
+ proposal_scores = self._get_scores(x_proposals)
711
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
712
+ validity_mask = (proposal_scores[0] > 0).float()
713
+ base_probs = pos_probs[sample_idx, candidates]
714
+
715
+ log_base_probs = torch.log(base_probs + 1e-10)
716
+ log_exp_scores = beta * aggregated_scores
717
+ log_validity = torch.log(validity_mask + 1e-10)
718
+ log_tilted = log_base_probs + log_exp_scores + log_validity
719
+
720
+ log_tilted_max = log_tilted.max()
721
+ fallback = False
722
+ if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
723
+ fallback = True
724
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
725
+ else:
726
+ log_tilted_stable = log_tilted - log_tilted_max
727
+ tilted_probs = torch.exp(log_tilted_stable)
728
+ prob_sum = tilted_probs.sum()
729
+ if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
730
+ fallback = True
731
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
732
+ else:
733
+ final_probs = tilted_probs / prob_sum
734
+
735
+ if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
736
+ fallback = True
737
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
738
+
739
+ final_probs = torch.clamp(final_probs, min=1e-10)
740
+ final_probs = final_probs / final_probs.sum()
741
+
742
+ selected_idx = torch.multinomial(final_probs, 1).item()
743
+ selected_token = candidates[selected_idx].item()
744
+ selected_score = aggregated_scores[selected_idx].item()
745
+
746
+ accepted = True
747
+ if self.args.use_acceptance and current_score is not None:
748
+ if selected_score < current_score:
749
+ selected_token = current_token
750
+ accepted = False
751
+
752
+ return selected_token, {
753
+ 'method': 'exp_tilt', 'beta': beta, 'num_candidates': num_candidates,
754
+ 'proposal_score': aggregated_scores[selected_idx].item(),
755
+ 'selected_score': selected_score if accepted else current_score,
756
+ 'fallback': fallback, 'validity_rate': validity_mask.mean().item(),
757
+ 'accepted': accepted
758
+ }
759
+
760
+ def _classifier_guidance(self, x, logits, mut_idx, sample_idx,
761
+ guidance_weight, weights):
762
+ base_logits = logits[sample_idx, mut_idx, :]
763
+ sorted_logits, sorted_indices = torch.sort(base_logits, descending=True)
764
+ candidates = sorted_indices[:self.args.top_k]
765
+
766
+ valid_mask = ~torch.isin(
767
+ candidates, torch.tensor(self.invalid_tokens, device=self.device)
768
+ )
769
+ candidates = candidates[valid_mask]
770
+
771
+ if len(candidates) == 0:
772
+ return x[sample_idx, mut_idx].item(), {'method': 'cg', 'fallback': True}
773
+
774
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(len(candidates), 1)
775
+ x_proposals[:, mut_idx] = candidates
776
+
777
+ with self.profiler.section("cg.proposal_scores"):
778
+ if self.args.cascade_k > 0:
779
+ proposal_scores = self._get_scores_cascaded(x_proposals)
780
+ else:
781
+ proposal_scores = self._get_scores(x_proposals)
782
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
783
+
784
+ score_logits = aggregated_scores * self.args.score_temperature
785
+ base_candidate_logits = base_logits[candidates]
786
+ guided_logits = base_candidate_logits + guidance_weight * score_logits
787
+ guided_probs = F.softmax(guided_logits, dim=-1)
788
+
789
+ selected_idx = torch.multinomial(guided_probs, 1).item()
790
+ return candidates[selected_idx].item(), {
791
+ 'method': 'cg', 'guidance_weight': guidance_weight,
792
+ 'num_candidates': len(candidates),
793
+ 'selected_score': aggregated_scores[selected_idx].item(), 'fallback': False
794
+ }
795
+
796
+ def _best_of_k_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
797
+ k, weights):
798
+ num_candidates = len(candidates)
799
+ if num_candidates == 0:
800
+ return x[sample_idx, mut_idx].item(), {'method': 'bok', 'fallback': True}
801
+
802
+ k = min(k, num_candidates)
803
+ candidate_probs = pos_probs[sample_idx, candidates]
804
+ candidate_probs = candidate_probs / (candidate_probs.sum() + 1e-9)
805
+
806
+ if k >= num_candidates:
807
+ sampled_indices = torch.arange(num_candidates, device=self.device)
808
+ else:
809
+ sampled_indices = torch.multinomial(candidate_probs, k, replacement=False)
810
+
811
+ sampled_tokens = candidates[sampled_indices]
812
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(len(sampled_tokens), 1)
813
+ x_proposals[:, mut_idx] = sampled_tokens
814
+
815
+ with self.profiler.section("bok.proposal_scores"):
816
+ if self.args.cascade_k > 0:
817
+ proposal_scores = self._get_scores_cascaded(x_proposals)
818
+ else:
819
+ proposal_scores = self._get_scores(x_proposals)
820
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
821
+
822
+ best_idx = torch.argmax(aggregated_scores).item()
823
+ return sampled_tokens[best_idx].item(), {
824
+ 'method': 'bok', 'k': k, 'num_candidates': num_candidates,
825
+ 'selected_score': aggregated_scores[best_idx].item(),
826
+ 'mean_score': aggregated_scores.mean().item(),
827
+ 'max_score': aggregated_scores.max().item(), 'fallback': False
828
+ }
829
+
830
+ def _hybrid_guidance(self, x, pos_probs, logits, candidates, mut_idx, sample_idx,
831
+ step, beta, weights):
832
+ progress = step / max(self.args.optimization_steps - 1, 1)
833
+ if progress < 0.3:
834
+ return self._best_of_k_guidance(
835
+ x, pos_probs, candidates, mut_idx, sample_idx,
836
+ k=self.args.bok_k, weights=weights
837
+ )
838
+ elif progress < 0.7:
839
+ return self._exponential_tilt_guidance(
840
+ x, pos_probs, candidates, mut_idx, sample_idx,
841
+ beta=beta, weights=weights
842
+ )
843
+ else:
844
+ return self._classifier_guidance(
845
+ x, logits, mut_idx, sample_idx,
846
+ guidance_weight=self.args.cg_weight, weights=weights
847
+ )
848
+
849
+ # ------------------------------------------------------------------
850
+ # Main loop
851
+ # ------------------------------------------------------------------
852
+ def generate(self):
853
+ self.profiler.reset()
854
+ x = self.generate_x0(
855
+ require_valid=self.args.require_valid_x0,
856
+ max_retries=self.args.max_x0_retries
857
+ )
858
+
859
+ if self.args.weights is None:
860
+ weights = torch.tensor(
861
+ [1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
862
+ device=self.device
863
+ ).view(-1, 1)
864
+ else:
865
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
866
+ if len(weights) != self.num_objectives:
867
+ raise ValueError("Number of weights must match number of objectives.")
868
+
869
+ print(f"\nGuidance Method: {self.guidance_method.value}")
870
+ print(f"Weights: {weights.squeeze().tolist()}")
871
+ print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
872
+ print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
873
+
874
+ with torch.no_grad():
875
+ for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
876
+ improved_indices = []
877
+
878
+ progress = step / max(self.args.optimization_steps - 1, 1)
879
+ beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * progress
880
+
881
+ if self.args.position_strategy == "random":
882
+ mut_idx = random.randint(1, self.args.gen_len - 2)
883
+ else:
884
+ mut_idx = (step % (self.args.gen_len - 2)) + 1
885
+
886
+ time_t = torch.full(
887
+ (self.args.num_samples,),
888
+ step / self.args.optimization_steps,
889
+ device=self.device
890
+ )
891
+
892
+ with self.profiler.section("loop.model_forward"):
893
+ logits = self.model(x, time_t)
894
+ probs = F.softmax(logits, dim=-1)
895
+ pos_probs = probs[:, mut_idx, :]
896
+
897
+ pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
898
+ for inv_tok in self.invalid_tokens:
899
+ pos_probs[:, inv_tok] = 0
900
+
901
+ with self.profiler.section("loop.top_p_sort"):
902
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
903
+
904
+ with self.profiler.section("loop.current_scores"):
905
+ current_scores = self._get_scores(x)
906
+ current_aggregated = self._compute_aggregated_score(current_scores, weights)
907
+
908
+ if step == 0:
909
+ print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
910
+ if self.tracker and self.conjugation_obj:
911
+ detailed = self.conjugation_obj.get_last_detailed_results()
912
+ self.tracker.log_step(step, current_scores[0], detailed)
913
+ self.tracker.print_step_summary(
914
+ step, current_scores[0], detailed, self.guidance_method.value
915
+ )
916
+
917
+ final_tokens = []
918
+ for i in range(self.args.num_samples):
919
+ sample_probs = sorted_probs[i]
920
+ sample_indices = sorted_indices[i]
921
+
922
+ cumsum = torch.cumsum(sample_probs, dim=0)
923
+ mask = cumsum <= self.args.top_p
924
+ mask[0] = True
925
+ candidates = sample_indices[mask]
926
+
927
+ valid_candidate_mask = ~torch.isin(
928
+ candidates, torch.tensor(self.invalid_tokens, device=self.device)
929
+ )
930
+ candidates = candidates[valid_candidate_mask]
931
+
932
+ current_sample_score = current_aggregated[i].item()
933
+
934
+ if beta > 0 or self.guidance_method != GuidanceMethod.EXPONENTIAL_TILT:
935
+ with self.profiler.section(f"loop.guidance.{self.guidance_method.value}"):
936
+ if self.guidance_method == GuidanceMethod.EXPONENTIAL_TILT:
937
+ token, _ = self._exponential_tilt_guidance(
938
+ x, pos_probs, candidates, mut_idx, i, beta, weights,
939
+ current_score=current_sample_score
940
+ )
941
+ elif self.guidance_method == GuidanceMethod.CLASSIFIER_GUIDANCE:
942
+ token, _ = self._classifier_guidance(
943
+ x, logits, mut_idx, i, self.args.cg_weight, weights
944
+ )
945
+ elif self.guidance_method == GuidanceMethod.BEST_OF_K:
946
+ token, _ = self._best_of_k_guidance(
947
+ x, pos_probs, candidates, mut_idx, i, self.args.bok_k, weights
948
+ )
949
+ elif self.guidance_method == GuidanceMethod.HYBRID:
950
+ token, _ = self._hybrid_guidance(
951
+ x, pos_probs, logits, candidates, mut_idx, i, step, beta, weights
952
+ )
953
+ else:
954
+ raise ValueError(f"Unknown guidance: {self.guidance_method}")
955
+ else:
956
+ if len(candidates) == 0:
957
+ token = x[i, mut_idx].item()
958
+ else:
959
+ cprobs = pos_probs[i, candidates]
960
+ cprobs = cprobs / (cprobs.sum() + 1e-9)
961
+ idx = torch.multinomial(cprobs, 1).item()
962
+ token = candidates[idx].item()
963
+
964
+ final_tokens.append(token)
965
+
966
+ x[:, mut_idx] = torch.tensor(final_tokens, device=self.device)
967
+
968
+ with self.profiler.section("loop.new_scores"):
969
+ new_scores = self._get_scores(x)
970
+ new_aggregated = self._compute_aggregated_score(new_scores, weights)
971
+ for i in range(self.args.num_samples):
972
+ if new_aggregated[i] > current_aggregated[i]:
973
+ improved_indices.append(i)
974
+
975
+ if improved_indices:
976
+ for i in improved_indices:
977
+ prev_weighted = (weights.squeeze(1) * current_scores[:, i]).sum()
978
+ new_weighted = (weights.squeeze(1) * new_scores[:, i]).sum()
979
+ print(f"Previous Weighted Sum: {prev_weighted}")
980
+ print(f"Previous Scores: {current_scores[:, i]}")
981
+ print(f"New Weighted Sum: {new_weighted}")
982
+ print(f"New Scores: {new_scores[:, i]}")
983
+ print(self.tokenizer.batch_decode(x[improved_indices]))
984
+
985
+ if self.tracker and self.conjugation_obj and \
986
+ (step % 4 == 0 or step == self.args.optimization_steps - 1):
987
+ detailed = self.conjugation_obj.get_last_detailed_results()
988
+ self.tracker.log_step(
989
+ step, new_scores[0], detailed, improved_indices,
990
+ guidance_info={'method': self.guidance_method.value, 'beta': beta}
991
+ )
992
+ if step == self.args.optimization_steps - 1:
993
+ self.tracker.print_step_summary(
994
+ step, new_scores[0], detailed, self.guidance_method.value
995
+ )
996
+
997
+ if (self.profiler.enabled
998
+ and self.args.profile_every > 0
999
+ and (step + 1) % self.args.profile_every == 0):
1000
+ self.profiler.print_summary(header=f"(through step {step + 1})")
1001
+
1002
+ self.profiler.print_summary(header="(end of generate)")
1003
+ return x
1004
+
1005
+
1006
+ # =============================================================================
1007
+ # Main
1008
+ # =============================================================================
1009
+
1010
+ def main(args):
1011
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
1012
+ print(f"Using device: {device}")
1013
+ print(f"Tokenizer type: {args.tokenizer_type}")
1014
+ print(f"Guidance method: {args.guidance_method}")
1015
+
1016
+ if args.tokenizer_type == 'amino':
1017
+ print("Using Amino Acid tokenizer")
1018
+ tokenizer = AminoTokenizer()
1019
+ smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
1020
+ else:
1021
+ print("Using SMILES tokenizer")
1022
+ tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
1023
+ smiles_tokenizer = tokenizer
1024
+
1025
+ conjugation_model = Conjugation(smiles_tokenizer, device=device, builder_type=args.builder_type)
1026
+ affinity_model = BindingAffinity(args.target, device)
1027
+ OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
1028
+
1029
+ tracker = GenerationTracker(output_dir=args.output_dir)
1030
+
1031
+ if args.seed is not None:
1032
+ random.seed(args.seed)
1033
+ np.random.seed(args.seed)
1034
+ torch.manual_seed(args.seed)
1035
+ if torch.cuda.is_available():
1036
+ torch.cuda.manual_seed_all(args.seed)
1037
+ print(f"Random seed: {args.seed}")
1038
+
1039
+ print(f"Loading model from: {args.checkpoint}")
1040
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
1041
+
1042
+ if args.tokenizer_type == 'amino':
1043
+ raw_model_args = checkpoint['args']
1044
+ if isinstance(raw_model_args, argparse.Namespace):
1045
+ raw_model_args = vars(raw_model_args)
1046
+ model_args = edict(raw_model_args)
1047
+ model = MDLM(
1048
+ vocab_size=model_args.vocab_size,
1049
+ seq_len=model_args.seq_len,
1050
+ model_dim=model_args.model_dim,
1051
+ n_heads=model_args.n_heads,
1052
+ n_layers=model_args.n_layers,
1053
+ ).to(device)
1054
+ model.load_state_dict(checkpoint['model_state_dict'])
1055
+ print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
1056
+ else:
1057
+ model_hparams = checkpoint["hyper_parameters"]["args"]
1058
+ model = MDLMLightningModule.load_from_checkpoint(
1059
+ args.checkpoint, args=model_hparams, tokenizer=tokenizer,
1060
+ map_location=device, strict=False
1061
+ )
1062
+ model.to(device)
1063
+ model.eval()
1064
+ print("Model loaded successfully.")
1065
+
1066
+ generator = CupOfMoCAGeneratorUAA(
1067
+ model, device, OBJECTIVE_FUNCTIONS, args,
1068
+ tokenizer=tokenizer, tokenizer_type=args.tokenizer_type, tracker=tracker
1069
+ )
1070
+
1071
+ all_sequences = []
1072
+ all_smiles = []
1073
+ all_final_results = []
1074
+
1075
+ for batch_idx in range(args.num_batches):
1076
+ print(f"\n{'=' * 60}")
1077
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
1078
+ print(f"{'=' * 60}")
1079
+
1080
+ generated_tokens = generator.generate()
1081
+ final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
1082
+ sequence_str = tokenizer.batch_decode(generated_tokens)
1083
+
1084
+ print("\n--- Batch Results ---")
1085
+ print(f"Sequences: {sequence_str}")
1086
+ print(f"Scores: {final_scores}")
1087
+
1088
+ all_sequences.extend(sequence_str)
1089
+
1090
+ if args.tokenizer_type == 'amino':
1091
+ smiles_list = []
1092
+ for seq in sequence_str:
1093
+ three_letter = one_letter_to_three_letter(seq)
1094
+ smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
1095
+ smiles_list.append(smiles if isinstance(smiles, str) else 'C')
1096
+ smiles_tokens = smiles_tokenizer(
1097
+ smiles_list, return_tensors='pt', padding=True
1098
+ ).to(device)
1099
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(
1100
+ smiles_tokens["input_ids"]
1101
+ )
1102
+ all_smiles.extend(smiles_list)
1103
+ tracker.log_final(sequence_str, detailed_with_props, smiles_list=smiles_list)
1104
+ else:
1105
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
1106
+ tracker.log_final(sequence_str, detailed_with_props)
1107
+ all_final_results.extend(detailed_with_props)
1108
+
1109
+ tracker.print_final_summary()
1110
+ tracker.save(filename_prefix=f'cupofmoca_uaa_{args.guidance_method}')
1111
+
1112
+ with open(args.output_file, 'w') as f:
1113
+ for i, seq in enumerate(all_sequences):
1114
+ if args.tokenizer_type == 'amino':
1115
+ smiles = all_smiles[i] if i < len(all_smiles) else ''
1116
+ f.write(f"{seq}\t{smiles}\n")
1117
+ else:
1118
+ f.write(seq + '\n')
1119
+ print(f"\nSequences saved to: {args.output_file}")
1120
+ print("\nGeneration complete.")
1121
+
1122
+
1123
+ if __name__ == "__main__":
1124
+ parser = argparse.ArgumentParser(
1125
+ description="CupOfMoCA-style Guided Generation with UAA Foundation Model"
1126
+ )
1127
+
1128
+ parser.add_argument("--checkpoint", type=str, required=True)
1129
+ parser.add_argument("--target", type=str, required=True)
1130
+
1131
+ parser.add_argument("--num_samples", type=int, default=10)
1132
+ parser.add_argument("--num_batches", type=int, default=10)
1133
+ parser.add_argument("--gen_len", type=int, default=50)
1134
+ parser.add_argument("--optimization_steps", type=int, default=128)
1135
+
1136
+ parser.add_argument("--guidance_method", type=str, default="exp_tilt",
1137
+ choices=["exp_tilt", "cg", "bok", "hybrid"])
1138
+
1139
+ parser.add_argument("--beta_min", type=float, default=1.0)
1140
+ parser.add_argument("--beta_max", type=float, default=20.0)
1141
+ parser.add_argument("--cg_weight", type=float, default=2.0)
1142
+ parser.add_argument("--use_acceptance", action="store_true")
1143
+ parser.add_argument("--bok_k", type=int, default=8)
1144
+ parser.add_argument("--top_k", type=int, default=200)
1145
+ parser.add_argument("--top_p", type=float, default=0.9)
1146
+ parser.add_argument("--score_temperature", type=float, default=1.0)
1147
+ parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
1148
+ choices=["weighted_sum", "weighted_min"])
1149
+
1150
+ parser.add_argument("--weights", type=float, nargs='+', default=None)
1151
+
1152
+ parser.add_argument("--position_strategy", type=str, default="random",
1153
+ choices=["random", "sequential"])
1154
+
1155
+ parser.add_argument("--require_valid_x0", action="store_true")
1156
+ parser.add_argument("--max_x0_retries", type=int, default=10)
1157
+
1158
+ parser.add_argument("--builder_type", type=str, default="AoaDau",
1159
+ choices=["AoaDau", "ValAlaDau", "ValCitDau", "GlutarylDau", "Dau",
1160
+ "SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt", "DSECCpt", "Cpt",
1161
+ "DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"])
1162
+
1163
+ parser.add_argument("--tokenizer_type", type=str, default="amino",
1164
+ choices=["smiles", "amino"])
1165
+ parser.add_argument("--cyclic", action="store_true",
1166
+ help="Generate cyclic peptides (only for amino tokenizer).")
1167
+ parser.add_argument("--smiles_vocab_path", type=str,
1168
+ default="./smiles/smiles_tokenizer/new_vocab.txt")
1169
+ parser.add_argument("--smiles_splits_path", type=str,
1170
+ default="./smiles/smiles_tokenizer/new_splits.txt")
1171
+
1172
+ parser.add_argument("--output_file", type=str, default="./smiles.txt")
1173
+ parser.add_argument("--output_dir", type=str, default="./generation_logs")
1174
+
1175
+ parser.add_argument("--seed", type=int, default=None)
1176
+
1177
+ parser.add_argument("--profile", action="store_true",
1178
+ help="Enable per-section wall-clock profiling.")
1179
+ parser.add_argument("--profile_every", type=int, default=0,
1180
+ help="Print profiler summary every N optimization steps "
1181
+ "(0 = only at end of each generate()).")
1182
+
1183
+ parser.add_argument("--cascade_k", type=int, default=0,
1184
+ help="Cascade scoring: score all proposals with cheap "
1185
+ "Conjugation pre-filter + BindingAffinity, then run "
1186
+ "the expensive PDC validation only on the top-K by "
1187
+ "affinity (among cheap-filter survivors). 0 disables "
1188
+ "(full scoring on all proposals, original behavior).")
1189
+ parser.add_argument("--cascade_workers", type=int, default=1,
1190
+ help="Number of worker threads for parallelizing "
1191
+ "cascade.full_validate (CPU-bound RDKit work). "
1192
+ "1 = sequential (original behavior). Try 4-8 when "
1193
+ "--cascade_k is high. Ignored if --cascade_k=0.")
1194
+
1195
+ args = parser.parse_args()
1196
+ main(args)
smiles/generation/cupofmoca_staged.py ADDED
@@ -0,0 +1,584 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CupOfMoCA-style Staged Generation with Exponential Tilting
3
+
4
+ This implements Algorithm 2 (Sampling with CupOfMoCA) with exponential tilting for staged generation.
5
+ Unlike the guided version, this uses:
6
+ - Peptide analyzer (not conjugation) as validity checker
7
+ - No PDC building during generation
8
+ - Simpler objectives focused on peptide validity and binding affinity
9
+
10
+ Exponential Tilting: p'(z') ∝ p(z') · exp(β · S(z')) · 1_valid(z')
11
+ """
12
+
13
+ import argparse
14
+ import os
15
+ import json
16
+ import csv
17
+ import random
18
+ from collections import Counter
19
+ from datetime import datetime
20
+ from typing import List, Optional, Tuple, Dict, Any
21
+
22
+ import numpy as np
23
+ import torch
24
+ import torch.nn as nn
25
+ import torch.nn.functional as F
26
+ from tqdm import tqdm
27
+
28
+ # Import from the original codebase
29
+ from smiles.models.train import MDLMLightningModule, PeptideAnalyzer
30
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
31
+ from smiles.scoring.classifiers import Analyzer, BindingAffinity
32
+
33
+
34
+ class GenerationTracker:
35
+ """Tracks detailed validity information during generation"""
36
+ def __init__(self, output_dir='./generation_logs'):
37
+ self.output_dir = output_dir
38
+ os.makedirs(output_dir, exist_ok=True)
39
+
40
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
41
+ self.step_logs = []
42
+ self.final_results = []
43
+
44
+ def log_step(self, step: int, scores, improved_indices=None, guidance_info: Optional[Dict] = None):
45
+ """Log information for a generation step"""
46
+ step_info = {
47
+ 'step': step,
48
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
49
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
50
+ 'improved_indices': improved_indices or [],
51
+ 'guidance_info': guidance_info or {}
52
+ }
53
+
54
+ self.step_logs.append(step_info)
55
+
56
+ def log_final(self, sequences, scores):
57
+ """Log final results"""
58
+ for i, (seq, score_vec) in enumerate(zip(sequences, scores.T)):
59
+ final_info = {
60
+ 'sample_idx': i,
61
+ 'peptide_smiles': seq,
62
+ 'is_valid_peptide': bool(score_vec[0] > 0),
63
+ 'binding_score': float(score_vec[1]) if len(score_vec) > 1 else None,
64
+ }
65
+ self.final_results.append(final_info)
66
+
67
+ def save(self, filename_prefix='generation'):
68
+ """Save all logs to files"""
69
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
70
+ with open(step_log_path, 'w') as f:
71
+ json.dump(self.step_logs, f, indent=2, default=str)
72
+
73
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
74
+ with open(final_path, 'w') as f:
75
+ json.dump(self.final_results, f, indent=2, default=str)
76
+
77
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
78
+ with open(csv_path, 'w', newline='') as f:
79
+ writer = csv.writer(f)
80
+ writer.writerow(['sample_idx', 'peptide_smiles', 'is_valid_peptide', 'binding_score'])
81
+ for result in self.final_results:
82
+ writer.writerow([
83
+ result['sample_idx'],
84
+ result['peptide_smiles'],
85
+ result['is_valid_peptide'],
86
+ result.get('binding_score', '')
87
+ ])
88
+
89
+ print(f"\n=== Generation logs saved ===")
90
+ print(f" Step logs: {step_log_path}")
91
+ print(f" Final results: {final_path}")
92
+ print(f" Summary CSV: {csv_path}")
93
+
94
+ return step_log_path, final_path, csv_path
95
+
96
+ def print_step_summary(self, step, scores):
97
+ """Print a summary for the current step"""
98
+ if isinstance(scores, torch.Tensor):
99
+ scores_list = scores.cpu().numpy().tolist()
100
+ else:
101
+ scores_list = list(scores)
102
+
103
+ num_valid = sum(1 for s in scores_list if s > 0)
104
+
105
+ print(f"\n--- Step {step} Summary ---")
106
+ print(f" Valid peptides: {num_valid}/{len(scores_list)}")
107
+
108
+ def print_final_summary(self):
109
+ """Print final summary"""
110
+ print("\n" + "=" * 60)
111
+ print("FINAL GENERATION SUMMARY")
112
+ print("=" * 60)
113
+
114
+ total = len(self.final_results)
115
+ valid_peptides = sum(1 for r in self.final_results if r.get('is_valid_peptide', False))
116
+
117
+ print(f"\nTotal sequences: {total}")
118
+ print(f"Valid peptides: {valid_peptides}")
119
+
120
+ if any(r.get('binding_score') is not None for r in self.final_results):
121
+ avg_binding = np.mean([r['binding_score'] for r in self.final_results if r.get('binding_score') is not None])
122
+ print(f"Average binding score: {avg_binding:.3f}")
123
+
124
+
125
+ class CupOfMoCAStaged:
126
+ """
127
+ CupOfMoCA-style staged generator with exponential tilting.
128
+
129
+ Uses peptide analyzer instead of conjugation model.
130
+ Focuses on generating valid peptides with good binding affinity.
131
+ """
132
+
133
+ def __init__(self, model, device, objectives, args, tracker=None):
134
+ self.model = model
135
+ self.device = device
136
+ self.objectives = objectives
137
+ self.args = args
138
+ self.num_objectives = len(objectives)
139
+ self.peptide_analyzer = PeptideAnalyzer()
140
+ self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586] # Special tokens to avoid
141
+ self.tracker = tracker
142
+
143
+ # Find the Analyzer objective for validity checking
144
+ self.analyzer_obj = None
145
+ for obj in objectives:
146
+ if isinstance(obj, Analyzer):
147
+ self.analyzer_obj = obj
148
+ break
149
+
150
+ # =========================================================================
151
+ # Initial Generation (x0 from prior)
152
+ # =========================================================================
153
+
154
+ def generate_x0(self, n_steps: int = 16, temperature: float = 1.0) -> torch.Tensor:
155
+ """
156
+ Generate initial sequences using flow matching.
157
+ This follows the flow from noise to data without guidance.
158
+ """
159
+ print("Generating initial sequences (x0 from prior)...")
160
+ self.model.eval()
161
+
162
+ # Start with random tokens (noise at t=0)
163
+ x = torch.randint(
164
+ 0, self.model.tokenizer.vocab_size,
165
+ (self.args.num_samples, self.args.gen_len),
166
+ device=self.device
167
+ )
168
+
169
+ # Time schedule
170
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
171
+
172
+ with torch.no_grad():
173
+ for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
174
+ t_curr = time_steps[i]
175
+ t_next = time_steps[i+1]
176
+
177
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
178
+
179
+ # Get model's prediction
180
+ logits = self.model(x, t_tensor)
181
+ logits = logits / temperature
182
+ logits[:, :, 586] = -1000 # Avoid padding token
183
+
184
+ pred_x1 = torch.argmax(logits, dim=-1)
185
+
186
+ if i == n_steps - 1:
187
+ x = pred_x1
188
+ break
189
+
190
+ # Construct next state with noise
191
+ noise_prob = 1.0 - t_next
192
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
193
+ noise = torch.randint(0, self.model.tokenizer.vocab_size, x.shape, device=self.device)
194
+ x = torch.where(mask, noise, pred_x1)
195
+
196
+ generated_sequences = self.model.tokenizer.batch_decode(x)
197
+
198
+ # Analyze the validity of the generated sequences
199
+ validities = []
200
+ for seq in generated_sequences:
201
+ validities.append(self.peptide_analyzer.is_peptide(seq))
202
+ print(seq)
203
+ print(f"Initial Sequence Validity: {validities}")
204
+
205
+ return x
206
+
207
+ # =========================================================================
208
+ # Score Functions
209
+ # =========================================================================
210
+
211
+ def _get_scores(self, x_batch: torch.Tensor) -> torch.Tensor:
212
+ """Calculate scores for all objectives"""
213
+ scores = []
214
+ for obj_func in self.objectives:
215
+ scores.append(obj_func(x_batch.to(self.device)))
216
+ return torch.stack(scores, dim=0).to(self.device)
217
+
218
+ def _compute_aggregated_score(self, scores: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
219
+ """Compute weighted aggregated score for guidance"""
220
+ if self.args.score_aggregation == "weighted_sum":
221
+ return (weights * scores).sum(dim=0)
222
+ elif self.args.score_aggregation == "weighted_min":
223
+ return torch.min(weights * scores, dim=0).values
224
+ else:
225
+ return (weights * scores).sum(dim=0)
226
+
227
+ # =========================================================================
228
+ # Exponential Tilting Guidance
229
+ # =========================================================================
230
+
231
+ def _exponential_tilt_guidance(
232
+ self,
233
+ x: torch.Tensor,
234
+ pos_probs: torch.Tensor,
235
+ candidates: torch.Tensor,
236
+ mut_idx: int,
237
+ sample_idx: int,
238
+ beta: float,
239
+ weights: torch.Tensor,
240
+ current_score: float = None
241
+ ) -> Tuple[int, Dict]:
242
+ """
243
+ Exponential Tilting Guidance with acceptance criterion
244
+
245
+ p'(z') ∝ p(z') · exp(β · S(z')) · 1_valid(z')
246
+
247
+ Only accept if new_score >= current_score (prevents regression)
248
+ """
249
+ num_candidates = len(candidates)
250
+ current_token = x[sample_idx, mut_idx].item()
251
+
252
+ if num_candidates == 0:
253
+ return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
254
+
255
+ # Limit candidates for efficiency
256
+ if num_candidates > self.args.top_k:
257
+ candidates = candidates[:self.args.top_k]
258
+ num_candidates = self.args.top_k
259
+
260
+ # Create batch of proposals
261
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
262
+ x_proposals[:, mut_idx] = candidates
263
+
264
+ # Compute scores for all candidates
265
+ proposal_scores = self._get_scores(x_proposals)
266
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
267
+
268
+ # Validity mask (using first objective - peptide analyzer)
269
+ validity_mask = (proposal_scores[0] > 0).float()
270
+
271
+ # Get base probabilities
272
+ base_probs = pos_probs[sample_idx, candidates]
273
+
274
+ # Apply exponential tilting in log-space for numerical stability
275
+ log_base_probs = torch.log(base_probs + 1e-10)
276
+ log_exp_scores = beta * aggregated_scores
277
+ log_validity = torch.log(validity_mask + 1e-10)
278
+
279
+ log_tilted = log_base_probs + log_exp_scores + log_validity
280
+
281
+ # Numerical stability
282
+ log_tilted_max = log_tilted.max()
283
+ fallback = False
284
+
285
+ if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
286
+ # Fallback to base distribution
287
+ fallback = True
288
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
289
+ else:
290
+ log_tilted_stable = log_tilted - log_tilted_max
291
+ tilted_probs = torch.exp(log_tilted_stable)
292
+
293
+ prob_sum = tilted_probs.sum()
294
+ if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
295
+ fallback = True
296
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
297
+ else:
298
+ final_probs = tilted_probs / prob_sum
299
+
300
+ # Safety check
301
+ if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
302
+ fallback = True
303
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
304
+
305
+ # Ensure valid probabilities
306
+ final_probs = torch.clamp(final_probs, min=1e-10)
307
+ final_probs = final_probs / final_probs.sum()
308
+
309
+ # Sample from guided distribution
310
+ selected_idx = torch.multinomial(final_probs, 1).item()
311
+ selected_token = candidates[selected_idx].item()
312
+ selected_score = aggregated_scores[selected_idx].item()
313
+
314
+ # ACCEPTANCE CRITERION: Only accept if score improves or stays same
315
+ accepted = True
316
+ if self.args.use_acceptance and current_score is not None:
317
+ if selected_score < current_score:
318
+ selected_token = current_token
319
+ selected_score = current_score
320
+ accepted = False
321
+
322
+ info = {
323
+ 'method': 'exp_tilt',
324
+ 'beta': beta,
325
+ 'num_candidates': num_candidates,
326
+ 'proposal_score': aggregated_scores[selected_idx].item(),
327
+ 'selected_score': selected_score,
328
+ 'fallback': fallback,
329
+ 'validity_rate': validity_mask.mean().item(),
330
+ 'accepted': accepted
331
+ }
332
+
333
+ return selected_token, info
334
+
335
+ # =========================================================================
336
+ # Main Generation Loop
337
+ # =========================================================================
338
+
339
+ def generate(self) -> torch.Tensor:
340
+ """Main generation loop with exponential tilting guidance"""
341
+ # Generate initial x0
342
+ x = self.generate_x0()
343
+
344
+ # Setup weights
345
+ if self.args.weights is None:
346
+ weights = torch.tensor(
347
+ [1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
348
+ device=self.device
349
+ ).view(-1, 1)
350
+ else:
351
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
352
+ if len(weights) != self.num_objectives:
353
+ raise ValueError("Number of weights must match number of objectives.")
354
+
355
+ print(f"\nGuidance Method: Exponential Tilting")
356
+ print(f"Weights: {weights.squeeze().tolist()}")
357
+ print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
358
+ print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
359
+
360
+ # Main optimization loop
361
+ with torch.no_grad():
362
+ for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
363
+ # Anneal guidance strength
364
+ beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * (
365
+ step / max(self.args.optimization_steps - 1, 1)
366
+ )
367
+
368
+ # Select mutation position
369
+ if self.args.position_strategy == "random":
370
+ mut_idx = random.randint(1, self.args.gen_len - 2)
371
+ else: # sequential
372
+ mut_idx = (step % (self.args.gen_len - 2)) + 1
373
+
374
+ # Determine generation timestep
375
+ generation_step = step % self.args.optimization_steps
376
+ time_t = torch.full(
377
+ (self.args.num_samples,),
378
+ (generation_step / self.args.optimization_steps),
379
+ device=self.device
380
+ )
381
+
382
+ # Get proposal distribution from model
383
+ logits = self.model(x, time_t)
384
+ probs = F.softmax(logits, dim=-1)
385
+ pos_probs = probs[:, mut_idx, :]
386
+
387
+ # Zero out current token and invalid tokens
388
+ pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
389
+ for invalid_tok in self.invalid_tokens:
390
+ pos_probs[:, invalid_tok] = 0
391
+
392
+ # Top-p filtering
393
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
394
+ cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
395
+ remove_mask = cumulative_probs > self.args.top_p
396
+ remove_mask[..., 1:] = remove_mask[..., :-1].clone()
397
+ remove_mask[..., 0] = 0
398
+
399
+ # Get current scores
400
+ current_scores = self._get_scores(x)
401
+ current_aggregated = self._compute_aggregated_score(current_scores, weights)
402
+
403
+ if step == 0:
404
+ print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
405
+
406
+ # Apply guidance for each sample
407
+ improved_count = 0
408
+ for i in range(self.args.num_samples):
409
+ sample_mask = remove_mask[i]
410
+ candidates = sorted_indices[i, ~sample_mask]
411
+
412
+ # Filter invalid tokens from candidates
413
+ valid_candidate_mask = ~torch.isin(
414
+ candidates,
415
+ torch.tensor(self.invalid_tokens, device=self.device)
416
+ )
417
+ candidates = candidates[valid_candidate_mask]
418
+
419
+ if len(candidates) == 0:
420
+ continue
421
+
422
+ # Apply exponential tilting
423
+ selected_token, guidance_info = self._exponential_tilt_guidance(
424
+ x, pos_probs, candidates, mut_idx, i, beta, weights,
425
+ current_score=current_aggregated[i].item()
426
+ )
427
+
428
+ if guidance_info['accepted']:
429
+ improved_count += 1
430
+
431
+ x[i, mut_idx] = selected_token
432
+
433
+ # Log progress
434
+ if step % 10 == 0 or improved_count > 0:
435
+ new_scores = self._get_scores(x)
436
+ if self.tracker:
437
+ self.tracker.log_step(step, new_scores[0])
438
+
439
+ if step % 20 == 0:
440
+ print(f"\nStep {step}: {improved_count}/{self.args.num_samples} improved")
441
+ print(f" β = {beta:.2f}")
442
+ print(f" Valid peptides: {(new_scores[0] > 0).sum().item()}/{self.args.num_samples}")
443
+
444
+ return x
445
+
446
+
447
+ # =============================================================================
448
+ # Main Execution
449
+ # =============================================================================
450
+
451
+ def main(args):
452
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
453
+ print(f"Using device: {device}")
454
+
455
+ tokenizer = SMILES_SPE_Tokenizer(
456
+ './smiles/smiles_tokenizer/new_vocab.txt',
457
+ './smiles/smiles_tokenizer/new_splits.txt'
458
+ )
459
+
460
+ # Initialize objectives
461
+ analyzer_model = Analyzer(tokenizer)
462
+ affinity_model = BindingAffinity(args.target, device)
463
+ OBJECTIVE_FUNCTIONS = [analyzer_model, affinity_model]
464
+
465
+ # Initialize tracker
466
+ tracker = GenerationTracker(output_dir=args.output_dir)
467
+
468
+ # Set random seeds
469
+ if args.seed is not None:
470
+ random.seed(args.seed)
471
+ np.random.seed(args.seed)
472
+ torch.manual_seed(args.seed)
473
+ if torch.cuda.is_available():
474
+ torch.cuda.manual_seed_all(args.seed)
475
+ print(f"Random seed: {args.seed}")
476
+
477
+ # Load model
478
+ print(f"Loading model from: {args.checkpoint}")
479
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
480
+ model_hparams = checkpoint["hyper_parameters"]["args"]
481
+
482
+ model = MDLMLightningModule.load_from_checkpoint(
483
+ args.checkpoint,
484
+ args=model_hparams,
485
+ tokenizer=tokenizer,
486
+ map_location=device,
487
+ strict=False
488
+ )
489
+ model.to(device)
490
+ print("Model loaded successfully.")
491
+
492
+ # Create generator
493
+ generator = CupOfMoCAStaged(model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker)
494
+
495
+ all_sequences = []
496
+ all_scores = []
497
+
498
+ for batch_idx in range(args.num_batches):
499
+ print(f"\n{'=' * 60}")
500
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
501
+ print(f"{'=' * 60}")
502
+
503
+ generated_tokens = generator.generate()
504
+ final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
505
+ sequence_str = tokenizer.batch_decode(generated_tokens)
506
+
507
+ print("\n--- Batch Results ---")
508
+ for i, seq in enumerate(sequence_str):
509
+ print(f" {i}: {seq}")
510
+ print(f"Scores: {final_scores}")
511
+
512
+ all_sequences.extend(sequence_str)
513
+ all_scores.append(final_scores)
514
+
515
+ tracker.log_final(sequence_str, final_scores)
516
+
517
+ # Save results
518
+ tracker.print_final_summary()
519
+ tracker.save(filename_prefix='cupofmoca_staged')
520
+
521
+ with open(args.output_file, 'w') as f:
522
+ for seq in all_sequences:
523
+ f.write(seq + '\n')
524
+ print(f"\nSequences saved to: {args.output_file}")
525
+ print("\nGeneration complete.")
526
+
527
+
528
+ if __name__ == "__main__":
529
+ parser = argparse.ArgumentParser(
530
+ description="CupOfMoCA-style Staged Generation with Exponential Tilting"
531
+ )
532
+
533
+ # Model and data
534
+ parser.add_argument("--checkpoint", type=str, required=True,
535
+ help="Path to trained model checkpoint")
536
+ parser.add_argument("--target", type=str, required=True,
537
+ help="Target protein sequence for binding affinity")
538
+
539
+ # Generation settings
540
+ parser.add_argument("--num_samples", type=int, default=10,
541
+ help="Number of samples per batch")
542
+ parser.add_argument("--num_batches", type=int, default=10,
543
+ help="Number of batches to generate")
544
+ parser.add_argument("--gen_len", type=int, default=50,
545
+ help="Length of sequences to generate")
546
+ parser.add_argument("--optimization_steps", type=int, default=128,
547
+ help="Number of guided optimization steps")
548
+
549
+ # Guidance parameters
550
+ parser.add_argument("--beta_min", type=float, default=1.0,
551
+ help="Minimum guidance strength β")
552
+ parser.add_argument("--beta_max", type=float, default=20.0,
553
+ help="Maximum guidance strength β")
554
+ parser.add_argument("--use_acceptance", action="store_true",
555
+ help="Use acceptance criterion: only accept if new_score >= current_score")
556
+ parser.add_argument("--top_k", type=int, default=200,
557
+ help="Top-K candidates to consider for guidance")
558
+ parser.add_argument("--top_p", type=float, default=0.9,
559
+ help="Top-p threshold for candidate filtering")
560
+ parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
561
+ choices=["weighted_sum", "weighted_min"],
562
+ help="How to aggregate multi-objective scores")
563
+
564
+ # Weights
565
+ parser.add_argument("--weights", type=float, nargs='+', default=None,
566
+ help="Weights for objectives (e.g., 1.0 1.0)")
567
+
568
+ # Position selection
569
+ parser.add_argument("--position_strategy", type=str, default="random",
570
+ choices=["random", "sequential"],
571
+ help="Strategy for selecting mutation positions")
572
+
573
+ # Output
574
+ parser.add_argument("--output_file", type=str, default="./smiles.txt",
575
+ help="File to save generated sequences")
576
+ parser.add_argument("--output_dir", type=str, default="./generation_logs",
577
+ help="Directory for detailed logs")
578
+
579
+ # Reproducibility
580
+ parser.add_argument("--seed", type=int, default=None,
581
+ help="Random seed")
582
+
583
+ args = parser.parse_args()
584
+ main(args)
smiles/generation/cupofmoca_staged_aa.py ADDED
@@ -0,0 +1,724 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CupOfMoCA-style staged generation with the natural-AA foundation model."""
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ import os
7
+ import random
8
+ import sys
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from datetime import datetime
11
+ from typing import Dict, Optional, Tuple
12
+
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+ from easydict import EasyDict as edict
18
+ from tqdm import tqdm
19
+
20
+ from smiles.models.train import MDLMLightningModule, PeptideAnalyzer
21
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
22
+ from smiles.scoring.classifiers import Analyzer, BindingAffinity
23
+
24
+ # AA-mode shared utilities
25
+ from smiles.models.aa_helpers import (
26
+ ESM2AminoTokenizer as _AAEsmTokenizer,
27
+ MDLM_PepReDi as _AAMDLM,
28
+ make_affinity,
29
+ DEFAULT_AA_AFFINITY_CKPT,
30
+ INVALID_AA_TOKEN_IDS as _AA_INVALID_IDS,
31
+ )
32
+
33
+ from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
34
+ from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
35
+
36
+
37
+ # =============================================================================
38
+ # Amino Acid Foundation Model (DiT-based MDLM)
39
+ # =============================================================================
40
+
41
+ # --- AA-mode Model + Tokenizer (imported from aa_helpers) ---
42
+ MDLM = _AAMDLM
43
+ AminoTokenizer = _AAEsmTokenizer
44
+
45
+
46
+ # =============================================================================
47
+ # Generation Tracker
48
+ # =============================================================================
49
+
50
+ class GenerationTracker:
51
+ def __init__(self, output_dir='./generation_logs'):
52
+ self.output_dir = output_dir
53
+ os.makedirs(output_dir, exist_ok=True)
54
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
55
+ self.step_logs = []
56
+ self.final_results = []
57
+
58
+ def log_step(self, step, scores, improved_indices=None, guidance_info: Optional[Dict] = None):
59
+ step_info = {
60
+ 'step': step,
61
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
62
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
63
+ 'improved_indices': improved_indices or [],
64
+ 'guidance_info': guidance_info or {}
65
+ }
66
+ self.step_logs.append(step_info)
67
+
68
+ def log_final(self, sequences, scores, smiles_list=None):
69
+ for i, (seq, score_vec) in enumerate(zip(sequences, scores.T)):
70
+ self.final_results.append({
71
+ 'sample_idx': i,
72
+ 'aa_sequence': seq,
73
+ 'peptide_smiles': smiles_list[i] if smiles_list else seq,
74
+ 'is_valid_peptide': bool(score_vec[0] > 0),
75
+ 'binding_score': float(score_vec[1]) if len(score_vec) > 1 else None,
76
+ })
77
+
78
+ def save(self, filename_prefix='generation'):
79
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
80
+ with open(step_log_path, 'w') as f:
81
+ json.dump(self.step_logs, f, indent=2, default=str)
82
+
83
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
84
+ with open(final_path, 'w') as f:
85
+ json.dump(self.final_results, f, indent=2, default=str)
86
+
87
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
88
+ with open(csv_path, 'w', newline='') as f:
89
+ writer = csv.writer(f)
90
+ writer.writerow(['sample_idx', 'aa_sequence', 'peptide_smiles',
91
+ 'is_valid_peptide', 'binding_score'])
92
+ for r in self.final_results:
93
+ writer.writerow([
94
+ r['sample_idx'], r.get('aa_sequence', ''), r['peptide_smiles'],
95
+ r['is_valid_peptide'], r.get('binding_score', '')
96
+ ])
97
+
98
+ print(f"\n=== Generation logs saved ===")
99
+ print(f" Step logs: {step_log_path}")
100
+ print(f" Final results: {final_path}")
101
+ print(f" Summary CSV: {csv_path}")
102
+ return step_log_path, final_path, csv_path
103
+
104
+ def print_step_summary(self, step, scores):
105
+ scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores)
106
+ num_valid = sum(1 for s in scores_list if s > 0)
107
+ print(f"\n--- Step {step} Summary ---")
108
+ print(f" Valid peptides: {num_valid}/{len(scores_list)}")
109
+
110
+ def print_final_summary(self):
111
+ print("\n" + "=" * 60)
112
+ print("FINAL GENERATION SUMMARY")
113
+ print("=" * 60)
114
+ total = len(self.final_results)
115
+ valid = sum(1 for r in self.final_results if r.get('is_valid_peptide', False))
116
+ print(f"\nTotal sequences: {total}")
117
+ print(f"Valid peptides: {valid}")
118
+ if any(r.get('binding_score') is not None for r in self.final_results):
119
+ avg = np.mean([r['binding_score'] for r in self.final_results
120
+ if r.get('binding_score') is not None])
121
+ print(f"Average binding score: {avg:.3f}")
122
+
123
+
124
+ # =============================================================================
125
+ # CupOfMoCA Staged Generator (UAA-aware)
126
+ # =============================================================================
127
+
128
+ class CupOfMoCAStagedUAA:
129
+ """Exponential-tilting staged generator that operates in AA token space."""
130
+
131
+ def __init__(self, model, device, objectives, args, tokenizer, tokenizer_type='amino',
132
+ tracker=None):
133
+ self.model = model
134
+ self.device = device
135
+ self.objectives = objectives
136
+ self.args = args
137
+ self.num_objectives = len(objectives)
138
+ self.peptide_analyzer = PeptideAnalyzer()
139
+ self.tracker = tracker
140
+ self.tokenizer = tokenizer
141
+ self.tokenizer_type = tokenizer_type
142
+
143
+ if tokenizer_type == 'amino':
144
+ # PepReDi natural-AA vocab is 24 tokens; 0-3 = cls/pad/eos/unk,
145
+ # 4-23 = canonical AAs. Block specials only. (UAA used id 38
146
+ # for D-AA 'g'; that's out of PepReDi vocab range.)
147
+ self.invalid_tokens = [0, 1, 2, 3]
148
+ self.smiles_tokenizer = SMILES_SPE_Tokenizer(
149
+ args.smiles_vocab_path, args.smiles_splits_path
150
+ )
151
+ else:
152
+ self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586]
153
+ self.smiles_tokenizer = None
154
+
155
+ self.analyzer_obj = None
156
+ for obj in objectives:
157
+ if isinstance(obj, Analyzer):
158
+ self.analyzer_obj = obj
159
+ break
160
+
161
+ # ------------------------------------------------------------------
162
+ # x0 generation
163
+ # ------------------------------------------------------------------
164
+ def generate_x0(self, n_steps=16, temperature=1.0):
165
+ print("Generating initial sequences (x0 from prior)...")
166
+ self.model.eval()
167
+
168
+ if self.tokenizer_type == 'amino':
169
+ vocab_size = self.model.vocab_size
170
+ valid_tokens = [i for i in range(vocab_size) if i not in self.invalid_tokens]
171
+ valid_tokens = torch.tensor(valid_tokens, device=self.device)
172
+ rand_indices = torch.randint(
173
+ 0, len(valid_tokens),
174
+ (self.args.num_samples, self.args.gen_len),
175
+ device=self.device
176
+ )
177
+ body = valid_tokens[rand_indices]
178
+ cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
179
+ eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
180
+ x = torch.cat([cls_col, body, eos_col], dim=1)
181
+ else:
182
+ vocab_size = self.model.tokenizer.vocab_size
183
+ x = torch.randint(0, vocab_size,
184
+ (self.args.num_samples, self.args.gen_len),
185
+ device=self.device)
186
+
187
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
188
+
189
+ with torch.no_grad():
190
+ for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
191
+ t_curr = time_steps[i]
192
+ t_next = time_steps[i + 1]
193
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
194
+
195
+ logits = self.model(x, t_tensor) / temperature
196
+ for inv_tok in self.invalid_tokens:
197
+ if inv_tok < logits.shape[-1]:
198
+ logits[:, :, inv_tok] = -1000
199
+
200
+ pred_x1 = torch.argmax(logits, dim=-1)
201
+ if i == n_steps - 1:
202
+ x = pred_x1
203
+ break
204
+
205
+ noise_prob = 1.0 - t_next
206
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
207
+ noise = torch.randint(0, vocab_size, x.shape, device=self.device)
208
+ x = torch.where(mask, noise, pred_x1)
209
+
210
+ generated_sequences = self.tokenizer.batch_decode(x)
211
+ for seq in generated_sequences:
212
+ print(f" {seq}")
213
+ return x
214
+
215
+ # ------------------------------------------------------------------
216
+ # Scoring
217
+ # ------------------------------------------------------------------
218
+ def _aa_to_smiles_batch(self, x_batch):
219
+ seqs = self.tokenizer.batch_decode(x_batch)
220
+ three_letter_seqs = [one_letter_to_three_letter(s) for s in seqs]
221
+ with ThreadPoolExecutor() as pool:
222
+ smiles_seqs = list(pool.map(
223
+ lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
224
+ three_letter_seqs
225
+ ))
226
+ return seqs, smiles_seqs
227
+
228
+ def _get_scores(self, x_batch):
229
+ scores = []
230
+
231
+ if self.tokenizer_type == 'amino':
232
+ _, smiles_seqs = self._aa_to_smiles_batch(x_batch)
233
+ valid_mask = torch.tensor(
234
+ [1 if isinstance(s, str) else 0 for s in smiles_seqs],
235
+ device=self.device
236
+ )
237
+ for i in torch.where((1 - valid_mask) == 1)[0].tolist():
238
+ smiles_seqs[i] = 'C'
239
+
240
+ smiles_batch = self.smiles_tokenizer(
241
+ smiles_seqs, return_tensors='pt', padding=True
242
+ ).to(self.device)
243
+ attention_mask = smiles_batch.get("attention_mask", None)
244
+
245
+ for obj_func in self.objectives:
246
+ if getattr(obj_func, "input_type", None) == "aa":
247
+ # AA-input objective: score raw AA tokens directly.
248
+ score = obj_func(x_batch.to(self.device)).to(self.device)
249
+ scores.append(score)
250
+ else:
251
+ try:
252
+ score = obj_func(
253
+ smiles_batch["input_ids"], attention_mask=attention_mask
254
+ ).to(self.device)
255
+ except TypeError:
256
+ score = obj_func(smiles_batch["input_ids"]).to(self.device)
257
+ scores.append(score * valid_mask)
258
+ else:
259
+ for obj_func in self.objectives:
260
+ scores.append(obj_func(x_batch.to(self.device)))
261
+
262
+ return torch.stack(scores, dim=0).to(self.device)
263
+
264
+ def _compute_aggregated_score(self, scores, weights):
265
+ if self.args.score_aggregation == "weighted_min":
266
+ return torch.min(weights * scores, dim=0).values
267
+ return (weights * scores).sum(dim=0)
268
+
269
+ # ------------------------------------------------------------------
270
+ # Exponential tilting
271
+ # ------------------------------------------------------------------
272
+ def _exponential_tilt_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
273
+ beta, weights, current_score=None, print_tilted=False,
274
+ step=None):
275
+ num_candidates = len(candidates)
276
+ current_token = x[sample_idx, mut_idx].item()
277
+
278
+ if num_candidates == 0:
279
+ return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
280
+
281
+ if num_candidates > self.args.top_k:
282
+ candidates = candidates[:self.args.top_k]
283
+ num_candidates = self.args.top_k
284
+
285
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
286
+ x_proposals[:, mut_idx] = candidates
287
+
288
+ proposal_scores = self._get_scores(x_proposals)
289
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
290
+ validity_mask = (proposal_scores[0] > 0).float()
291
+ base_probs = pos_probs[sample_idx, candidates]
292
+
293
+ log_base_probs = torch.log(base_probs + 1e-10)
294
+ log_exp_scores = beta * aggregated_scores
295
+ log_validity = torch.log(validity_mask + 1e-10)
296
+ log_tilted = log_base_probs + log_exp_scores + log_validity
297
+
298
+ log_tilted_max = log_tilted.max()
299
+ fallback = False
300
+ if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
301
+ fallback = True
302
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
303
+ else:
304
+ log_tilted_stable = log_tilted - log_tilted_max
305
+ tilted_probs = torch.exp(log_tilted_stable)
306
+ prob_sum = tilted_probs.sum()
307
+ if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
308
+ fallback = True
309
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
310
+ else:
311
+ final_probs = tilted_probs / prob_sum
312
+
313
+ if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
314
+ fallback = True
315
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
316
+
317
+ final_probs = torch.clamp(final_probs, min=1e-10)
318
+ final_probs = final_probs / final_probs.sum()
319
+
320
+ if print_tilted:
321
+ id_to_token = self.tokenizer.id_to_token
322
+ topk = min(self.args.print_logits_topk, num_candidates)
323
+ top_vals, top_local = torch.topk(final_probs, k=topk)
324
+ print(f"\n[tilted] step={step} sample={sample_idx} mut_idx={mut_idx} "
325
+ f"beta={beta:.2f} fallback={fallback}")
326
+ print(f" Top-{topk} candidates by TILTED prob "
327
+ f"(shows: base p, agg score S, β·S, valid, tilted p):")
328
+ for tv, li in zip(top_vals.tolist(), top_local.tolist()):
329
+ tid = candidates[li].item()
330
+ tok = id_to_token.get(int(tid), '?')
331
+ bp = base_probs[li].item()
332
+ sc = aggregated_scores[li].item()
333
+ bs = (beta * aggregated_scores[li]).item()
334
+ vd = validity_mask[li].item()
335
+ print(f" id={tid:>3} tok={tok!r:>6} base_p={bp:.3e} "
336
+ f"S={sc:+.3f} β·S={bs:+.3f} valid={vd:.0f} tilted_p={tv:.3e}")
337
+ d_local = [li for li in range(num_candidates)
338
+ if 33 <= candidates[li].item() <= 52
339
+ and candidates[li].item() not in self.invalid_tokens]
340
+ if d_local:
341
+ print(" D-AA lowercase candidates (if any survived top-p):")
342
+ for li in d_local:
343
+ tid = candidates[li].item()
344
+ tok = id_to_token.get(int(tid), '?')
345
+ print(f" id={tid:>3} tok={tok!r:>4} base_p={base_probs[li]:.3e} "
346
+ f"S={aggregated_scores[li]:+.3f} "
347
+ f"valid={validity_mask[li]:.0f} tilted_p={final_probs[li]:.3e}")
348
+ else:
349
+ print(" D-AA lowercase candidates: NONE made it into the top-p pool")
350
+
351
+ selected_idx = torch.multinomial(final_probs, 1).item()
352
+ selected_token = candidates[selected_idx].item()
353
+ selected_score = aggregated_scores[selected_idx].item()
354
+
355
+ accepted = True
356
+ if self.args.use_acceptance and current_score is not None:
357
+ if selected_score < current_score:
358
+ selected_token = current_token
359
+ selected_score = current_score
360
+ accepted = False
361
+
362
+ return selected_token, {
363
+ 'method': 'exp_tilt', 'beta': beta, 'num_candidates': num_candidates,
364
+ 'proposal_score': aggregated_scores[selected_idx].item(),
365
+ 'selected_score': selected_score, 'fallback': fallback,
366
+ 'validity_rate': validity_mask.mean().item(), 'accepted': accepted
367
+ }
368
+
369
+ # ------------------------------------------------------------------
370
+ # Logit inspection
371
+ # ------------------------------------------------------------------
372
+ def _print_logit_distribution(self, raw_probs, masked_probs, mut_idx, step, sample_idx):
373
+ """Print the model's per-position probability distribution for inspection.
374
+
375
+ raw_probs: [vocab_size] probabilities BEFORE masking current/invalid tokens
376
+ masked_probs: [vocab_size] probabilities AFTER masking
377
+ """
378
+ topk = self.args.print_logits_topk
379
+ id_to_token = self.tokenizer.id_to_token
380
+
381
+ # Token-category index ranges. Clip to model.vocab_size so we don't
382
+ # index out of bounds when the model uses a smaller vocab than the
383
+ # UAA tokenizer (e.g. PepReDi natural-AA vocab = 24).
384
+ V = int(raw_probs.shape[0])
385
+ cat_ranges = {
386
+ 'special' : [i for i in range(0, 4) if i < V],
387
+ 'L-AA upper': [i for i in range(4, 24) if i < V],
388
+ 'non-canon' : [i for i in range(24, 33) if i < V],
389
+ 'D-AA lower': [i for i in range(33, 53) if i < V],
390
+ }
391
+
392
+ print(f"\n[logits] step={step} sample={sample_idx} mut_idx={mut_idx}")
393
+
394
+ # Top-k from raw probs
395
+ top_vals, top_ids = torch.topk(raw_probs, k=min(topk, raw_probs.shape[0]))
396
+ print(f" Top-{topk} (raw model probs, pre-mask):")
397
+ for v, tid in zip(top_vals.tolist(), top_ids.tolist()):
398
+ tok = id_to_token.get(int(tid), '?')
399
+ print(f" id={tid:>3} tok={tok!r:>6} p={v:.4e}")
400
+
401
+ # Per-category mass
402
+ print(" Probability mass by category (raw / masked):")
403
+ for name, ids in cat_ranges.items():
404
+ ids_t = torch.tensor(ids, device=raw_probs.device)
405
+ raw_mass = raw_probs[ids_t].sum().item()
406
+ msk_mass = masked_probs[ids_t].sum().item()
407
+ print(f" {name:>10}: raw={raw_mass:.4e} masked={msk_mass:.4e}")
408
+
409
+ # Specifically: per-token probability for D-AA lowercase (excluding 'g'=38)
410
+ d_ids = [i for i in cat_ranges['D-AA lower'] if i not in self.invalid_tokens]
411
+ d_ids_t = torch.tensor(d_ids, device=raw_probs.device)
412
+ d_vals_raw = raw_probs[d_ids_t]
413
+ d_vals_msk = masked_probs[d_ids_t]
414
+ print(" D-AA lowercase per-token probs (raw / masked):")
415
+ for tid, vr, vm in zip(d_ids, d_vals_raw.tolist(), d_vals_msk.tolist()):
416
+ tok = id_to_token.get(int(tid), '?')
417
+ print(f" id={tid:>3} tok={tok!r:>4} raw={vr:.4e} masked={vm:.4e}")
418
+
419
+ # ------------------------------------------------------------------
420
+ # Main loop
421
+ # ------------------------------------------------------------------
422
+ def generate(self):
423
+ x = self.generate_x0()
424
+
425
+ if self.args.weights is None:
426
+ weights = torch.tensor(
427
+ [1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
428
+ device=self.device
429
+ ).view(-1, 1)
430
+ else:
431
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
432
+ if len(weights) != self.num_objectives:
433
+ raise ValueError("Number of weights must match number of objectives.")
434
+
435
+ print(f"\nGuidance Method: Exponential Tilting")
436
+ print(f"Weights: {weights.squeeze().tolist()}")
437
+ print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
438
+ print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
439
+
440
+ with torch.no_grad():
441
+ for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
442
+ beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * (
443
+ step / max(self.args.optimization_steps - 1, 1)
444
+ )
445
+
446
+ if self.args.position_strategy == "random":
447
+ mut_idx = random.randint(1, self.args.gen_len - 2)
448
+ else:
449
+ mut_idx = (step % (self.args.gen_len - 2)) + 1
450
+
451
+ generation_step = step % self.args.optimization_steps
452
+ time_t = torch.full(
453
+ (self.args.num_samples,),
454
+ generation_step / self.args.optimization_steps,
455
+ device=self.device
456
+ )
457
+
458
+ logits = self.model(x, time_t)
459
+ probs = F.softmax(logits, dim=-1)
460
+ pos_probs = probs[:, mut_idx, :]
461
+ raw_pos_probs = pos_probs.clone()
462
+
463
+ pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
464
+ for inv_tok in self.invalid_tokens:
465
+ pos_probs[:, inv_tok] = 0
466
+
467
+ should_print_logits = (
468
+ self.args.print_logits
469
+ and (step % max(self.args.print_logits_every, 1) == 0)
470
+ )
471
+ print_sample_idx = min(self.args.print_logits_sample,
472
+ self.args.num_samples - 1)
473
+ if should_print_logits:
474
+ self._print_logit_distribution(
475
+ raw_pos_probs[print_sample_idx], pos_probs[print_sample_idx],
476
+ mut_idx, step, print_sample_idx
477
+ )
478
+
479
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
480
+ cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
481
+ remove_mask = cumulative_probs > self.args.top_p
482
+ remove_mask[..., 1:] = remove_mask[..., :-1].clone()
483
+ remove_mask[..., 0] = 0
484
+
485
+ current_scores = self._get_scores(x)
486
+ current_aggregated = self._compute_aggregated_score(current_scores, weights)
487
+
488
+ if step == 0:
489
+ print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
490
+
491
+ improved = False
492
+ for i in range(self.args.num_samples):
493
+ sample_mask = remove_mask[i]
494
+ candidates = sorted_indices[i, ~sample_mask]
495
+
496
+ valid_candidate_mask = ~torch.isin(
497
+ candidates, torch.tensor(self.invalid_tokens, device=self.device)
498
+ )
499
+ candidates = candidates[valid_candidate_mask]
500
+
501
+ if len(candidates) == 0:
502
+ continue
503
+
504
+ prev_token = x[i, mut_idx].item()
505
+ prev_sample_scores = current_scores[:, i].clone()
506
+ prev_weighted_sum = (weights.squeeze(1) * prev_sample_scores).sum()
507
+
508
+ selected_token, info = self._exponential_tilt_guidance(
509
+ x, pos_probs, candidates, mut_idx, i, beta, weights,
510
+ current_score=current_aggregated[i].item(),
511
+ print_tilted=(should_print_logits and i == print_sample_idx),
512
+ step=step,
513
+ )
514
+
515
+ if selected_token == prev_token:
516
+ continue
517
+
518
+ x[i, mut_idx] = selected_token
519
+
520
+ new_sample_scores = self._get_scores(x[i].unsqueeze(0))[:, 0]
521
+ new_weighted_sum = (weights.squeeze(1) * new_sample_scores).sum()
522
+
523
+ if new_weighted_sum >= prev_weighted_sum:
524
+ improved = True
525
+ print(f"Previous Weighted Sum: {prev_weighted_sum}")
526
+ print(f"Previous Scores: {prev_sample_scores}")
527
+ print(f"New Weighted Sum: {new_weighted_sum}")
528
+ print(f"New Scores: {new_sample_scores}")
529
+ current_scores[:, i] = new_sample_scores
530
+ current_aggregated[i] = self._compute_aggregated_score(
531
+ new_sample_scores.unsqueeze(1), weights
532
+ )[0]
533
+ else:
534
+ x[i, mut_idx] = prev_token
535
+
536
+ if improved:
537
+ print(self.tokenizer.batch_decode(x))
538
+ if self.tracker:
539
+ self.tracker.log_step(
540
+ step, current_scores[0],
541
+ guidance_info={'method': 'exp_tilt', 'beta': beta}
542
+ )
543
+
544
+ return x
545
+
546
+
547
+ # =============================================================================
548
+ # Main
549
+ # =============================================================================
550
+
551
+ def main(args):
552
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
553
+ print(f"Using device: {device}")
554
+ print(f"Tokenizer type: {args.tokenizer_type}")
555
+
556
+ if args.seed is not None:
557
+ random.seed(args.seed)
558
+ np.random.seed(args.seed)
559
+ torch.manual_seed(args.seed)
560
+ if torch.cuda.is_available():
561
+ torch.cuda.manual_seed_all(args.seed)
562
+ print(f"Random seed: {args.seed}")
563
+
564
+ if args.tokenizer_type == 'amino':
565
+ print("Using Amino Acid tokenizer")
566
+ tokenizer = AminoTokenizer()
567
+ smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
568
+ else:
569
+ print("Using SMILES tokenizer")
570
+ tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
571
+ smiles_tokenizer = tokenizer
572
+
573
+ analyzer_model = Analyzer(smiles_tokenizer)
574
+ # Binding affinity: AA-input or SMILES-input depending on --affinity_type.
575
+ affinity_model = make_affinity(
576
+ args.affinity_type,
577
+ target_protein_seq=args.target,
578
+ device=device,
579
+ aa_ckpt_path=args.aa_affinity_ckpt,
580
+ )
581
+ print(f"Affinity scorer: {type(affinity_model).__name__} (input_type={getattr(affinity_model, 'input_type', '?')})")
582
+ OBJECTIVE_FUNCTIONS = [analyzer_model, affinity_model]
583
+
584
+ tracker = GenerationTracker(output_dir=args.output_dir)
585
+
586
+ print(f"Loading model from: {args.checkpoint}")
587
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
588
+
589
+ if args.tokenizer_type == 'amino':
590
+ raw_model_args = checkpoint['args']
591
+ if isinstance(raw_model_args, argparse.Namespace):
592
+ raw_model_args = vars(raw_model_args)
593
+ model_args = edict(raw_model_args)
594
+ model = MDLM(
595
+ vocab_size=model_args.vocab_size,
596
+ seq_len=model_args.seq_len,
597
+ model_dim=model_args.model_dim,
598
+ n_heads=model_args.n_heads,
599
+ n_layers=model_args.n_layers,
600
+ ).to(device)
601
+ model.load_state_dict(checkpoint['model_state_dict'])
602
+ print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
603
+ else:
604
+ model_hparams = checkpoint["hyper_parameters"]["args"]
605
+ model = MDLMLightningModule.load_from_checkpoint(
606
+ args.checkpoint, args=model_hparams, tokenizer=tokenizer,
607
+ map_location=device, strict=False
608
+ )
609
+ model.to(device)
610
+ model.eval()
611
+ print("Model loaded successfully.")
612
+
613
+ generator = CupOfMoCAStagedUAA(
614
+ model, device, OBJECTIVE_FUNCTIONS, args,
615
+ tokenizer=tokenizer, tokenizer_type=args.tokenizer_type, tracker=tracker
616
+ )
617
+
618
+ all_sequences = []
619
+ all_smiles = []
620
+ all_scores = []
621
+
622
+ for batch_idx in range(args.num_batches):
623
+ print(f"\n{'=' * 60}")
624
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
625
+ print(f"{'=' * 60}")
626
+
627
+ generated_tokens = generator.generate()
628
+ final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
629
+ sequence_str = tokenizer.batch_decode(generated_tokens)
630
+
631
+ print("\n--- Batch Results ---")
632
+ for i, seq in enumerate(sequence_str):
633
+ print(f" {i}: {seq}")
634
+ print(f"Scores: {final_scores}")
635
+
636
+ all_sequences.extend(sequence_str)
637
+ all_scores.append(final_scores)
638
+
639
+ if args.tokenizer_type == 'amino':
640
+ smiles_list = []
641
+ for seq in sequence_str:
642
+ three_letter = one_letter_to_three_letter(seq)
643
+ smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
644
+ smiles_list.append(smiles if isinstance(smiles, str) else 'INVALID_SMILES')
645
+ all_smiles.extend(smiles_list)
646
+ tracker.log_final(sequence_str, final_scores, smiles_list=smiles_list)
647
+ else:
648
+ tracker.log_final(sequence_str, final_scores)
649
+
650
+ tracker.print_final_summary()
651
+ tracker.save(filename_prefix='cupofmoca_staged_uaa')
652
+
653
+ with open(args.output_file, 'w') as f:
654
+ for i, seq in enumerate(all_sequences):
655
+ if args.tokenizer_type == 'amino':
656
+ smiles = all_smiles[i] if i < len(all_smiles) else ''
657
+ f.write(f"{seq}\t{smiles}\n")
658
+ else:
659
+ f.write(seq + '\n')
660
+ print(f"\nSequences saved to: {args.output_file}")
661
+ print("\nGeneration complete.")
662
+
663
+
664
+ if __name__ == "__main__":
665
+ parser = argparse.ArgumentParser(
666
+ description="CupOfMoCA-style Staged Generation with UAA Foundation Model"
667
+ )
668
+
669
+ parser.add_argument("--checkpoint", type=str, required=True)
670
+ parser.add_argument("--target", type=str, required=True)
671
+
672
+ parser.add_argument("--num_samples", type=int, default=10)
673
+ parser.add_argument("--num_batches", type=int, default=10)
674
+ parser.add_argument("--gen_len", type=int, default=50)
675
+ parser.add_argument("--optimization_steps", type=int, default=128)
676
+
677
+ parser.add_argument("--beta_min", type=float, default=1.0)
678
+ parser.add_argument("--beta_max", type=float, default=20.0)
679
+ parser.add_argument("--use_acceptance", action="store_true")
680
+ parser.add_argument("--top_k", type=int, default=200)
681
+ parser.add_argument("--top_p", type=float, default=0.9)
682
+ parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
683
+ choices=["weighted_sum", "weighted_min"])
684
+
685
+ parser.add_argument("--weights", type=float, nargs='+', default=None)
686
+
687
+ parser.add_argument("--position_strategy", type=str, default="random",
688
+ choices=["random", "sequential"])
689
+
690
+ parser.add_argument("--tokenizer_type", type=str, default="amino",
691
+ choices=["smiles", "amino"])
692
+ parser.add_argument("--cyclic", action="store_true",
693
+ help="Generate cyclic peptides (only for amino tokenizer).")
694
+ parser.add_argument("--smiles_vocab_path", type=str,
695
+ default="./smiles/smiles_tokenizer/new_vocab.txt")
696
+ parser.add_argument("--smiles_splits_path", type=str,
697
+ default="./smiles/smiles_tokenizer/new_splits.txt")
698
+
699
+ # AA-mode binding-affinity selection (this is the WT-AA / PepReDi script).
700
+ parser.add_argument("--affinity_type", type=str, default="smiles",
701
+ choices=["aa", "smiles"],
702
+ help="Which binding-affinity oracle to use: "
703
+ "'aa' = AA-input UnpooledBindingPredictor, "
704
+ "'smiles' = SMILES-input BindingAffinity.")
705
+ parser.add_argument("--aa_affinity_ckpt", type=str,
706
+ default=DEFAULT_AA_AFFINITY_CKPT,
707
+ help="Path to AA-input affinity predictor checkpoint.")
708
+
709
+ parser.add_argument("--output_file", type=str, default="./smiles.txt")
710
+ parser.add_argument("--output_dir", type=str, default="./generation_logs")
711
+
712
+ parser.add_argument("--seed", type=int, default=None)
713
+
714
+ parser.add_argument("--print_logits", action="store_true",
715
+ help="Print per-step probability distribution at the mutation position.")
716
+ parser.add_argument("--print_logits_every", type=int, default=1,
717
+ help="Print logits every N optimization steps (requires --print_logits).")
718
+ parser.add_argument("--print_logits_topk", type=int, default=20,
719
+ help="Number of top tokens to show per logit printout.")
720
+ parser.add_argument("--print_logits_sample", type=int, default=0,
721
+ help="Which sample index to print logits for (default: 0).")
722
+
723
+ args = parser.parse_args()
724
+ main(args)
smiles/generation/cupofmoca_staged_uaa.py ADDED
@@ -0,0 +1,797 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CupOfMoCA-style staged generation with the UAA foundation model."""
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ import os
7
+ import random
8
+ import sys
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from datetime import datetime
11
+ from typing import Dict, Optional, Tuple
12
+
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+ from easydict import EasyDict as edict
18
+ from tqdm import tqdm
19
+
20
+ from smiles.models.train import MDLMLightningModule, PeptideAnalyzer
21
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
22
+ from smiles.scoring.classifiers import Analyzer, BindingAffinity
23
+
24
+ from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
25
+ from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
26
+
27
+
28
+ # =============================================================================
29
+ # Amino Acid Foundation Model (DiT-based MDLM)
30
+ # =============================================================================
31
+
32
+ def modulate(x, shift, scale):
33
+ return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
34
+
35
+
36
+ class TimestepEmbedder(nn.Module):
37
+ def __init__(self, hidden_size):
38
+ super().__init__()
39
+ self.mlp = nn.Sequential(
40
+ nn.Linear(1, hidden_size, bias=True),
41
+ nn.SiLU(),
42
+ nn.Linear(hidden_size, hidden_size, bias=True),
43
+ )
44
+
45
+ def forward(self, t):
46
+ return self.mlp(t.unsqueeze(-1))
47
+
48
+
49
+ class DiTBlock(nn.Module):
50
+ def __init__(self, hidden_size, n_heads):
51
+ super().__init__()
52
+ self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
53
+ self.attn = nn.MultiheadAttention(hidden_size, n_heads, batch_first=True)
54
+ self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
55
+ self.mlp = nn.Sequential(
56
+ nn.Linear(hidden_size, 4 * hidden_size),
57
+ nn.GELU(),
58
+ nn.Linear(4 * hidden_size, hidden_size),
59
+ )
60
+ self.adaLN_modulation = nn.Sequential(
61
+ nn.SiLU(),
62
+ nn.Linear(hidden_size, 6 * hidden_size, bias=True),
63
+ )
64
+
65
+ def forward(self, x, c):
66
+ shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = \
67
+ self.adaLN_modulation(c).chunk(6, dim=1)
68
+ x_norm1 = modulate(self.norm1(x), shift_msa, scale_msa)
69
+ attn_output, _ = self.attn(x_norm1, x_norm1, x_norm1)
70
+ x = x + gate_msa.unsqueeze(1) * attn_output
71
+ x_norm2 = modulate(self.norm2(x), shift_mlp, scale_mlp)
72
+ mlp_output = self.mlp(x_norm2)
73
+ x = x + gate_mlp.unsqueeze(1) * mlp_output
74
+ return x
75
+
76
+
77
+ class MDLM(nn.Module):
78
+ def __init__(self, vocab_size, seq_len, model_dim, n_heads, n_layers):
79
+ super().__init__()
80
+ self.vocab_size = vocab_size
81
+ self.seq_len = seq_len
82
+ self.model_dim = model_dim
83
+
84
+ self.token_embedder = nn.Embedding(vocab_size, model_dim)
85
+ self.pos_embedder = nn.Parameter(torch.randn(1, seq_len, model_dim))
86
+ self.time_embedder = TimestepEmbedder(model_dim)
87
+
88
+ self.transformer_blocks = nn.ModuleList([
89
+ DiTBlock(model_dim, n_heads) for _ in range(n_layers)
90
+ ])
91
+ self.final_norm = nn.LayerNorm(model_dim)
92
+ self.lm_head = nn.Linear(model_dim, vocab_size)
93
+
94
+ def forward(self, x, t):
95
+ seq_len = x.shape[1]
96
+ x_embed = self.token_embedder(x) + self.pos_embedder[:, :seq_len, :]
97
+ t_embed = self.time_embedder(t)
98
+ for block in self.transformer_blocks:
99
+ x_embed = block(x_embed, t_embed)
100
+ x_embed = self.final_norm(x_embed)
101
+ return self.lm_head(x_embed)
102
+
103
+
104
+ class AminoTokenizer:
105
+ def __init__(self):
106
+ self.vocab = {
107
+ '<cls>': 0, '<pad>': 1, '<eos>': 2, '<unk>': 3,
108
+ 'L': 4, 'A': 5, 'G': 6, 'V': 7, 'S': 8, 'E': 9,
109
+ 'R': 10, 'T': 11, 'I': 12, 'D': 13, 'P': 14, 'K': 15,
110
+ 'Q': 16, 'N': 17, 'F': 18, 'Y': 19, 'M': 20, 'H': 21,
111
+ 'W': 22, 'C': 23,
112
+ 'Ŷ': 24, 'Ĉ': 25, 'Ĝ': 26, 'Ė': 27, 'Ƙ': 28,
113
+ 'Ṃ': 29, 'Ł': 30, 'Ċ': 31, 'Ṯ': 32,
114
+ 'a': 33, 'c': 34, 'd': 35, 'e': 36, 'f': 37,
115
+ 'g': 38, 'h': 39, 'i': 40, 'k': 41, 'l': 42,
116
+ 'm': 43, 'n': 44, 'p': 45, 'q': 46, 'r': 47,
117
+ 's': 48, 't': 49, 'v': 50, 'w': 51, 'y': 52,
118
+ }
119
+ self.id_to_token = {i: t for t, i in self.vocab.items()}
120
+ self.cls_token, self.pad_token = '<cls>', '<pad>'
121
+ self.eos_token, self.unk_token = '<eos>', '<unk>'
122
+ self.cls_id = self.vocab[self.cls_token]
123
+ self.pad_id = self.vocab[self.pad_token]
124
+ self.eos_id = self.vocab[self.eos_token]
125
+ self.unk_id = self.vocab[self.unk_token]
126
+ self.vocab_size = len(self.vocab)
127
+
128
+ def decode(self, ids, skip_special_tokens=True):
129
+ if isinstance(ids, torch.Tensor):
130
+ ids = ids.tolist()
131
+ tokens = []
132
+ for i in ids:
133
+ tok = self.id_to_token.get(int(i), self.unk_token)
134
+ if skip_special_tokens and tok in {self.cls_token, self.eos_token, self.pad_token}:
135
+ continue
136
+ tokens.append(tok)
137
+ return ''.join(tokens)
138
+
139
+ def batch_decode(self, batch_ids, skip_special_tokens=True):
140
+ return [self.decode(ids, skip_special_tokens=skip_special_tokens) for ids in batch_ids]
141
+
142
+
143
+ # =============================================================================
144
+ # Generation Tracker
145
+ # =============================================================================
146
+
147
+ class GenerationTracker:
148
+ def __init__(self, output_dir='./generation_logs'):
149
+ self.output_dir = output_dir
150
+ os.makedirs(output_dir, exist_ok=True)
151
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
152
+ self.step_logs = []
153
+ self.final_results = []
154
+
155
+ def log_step(self, step, scores, improved_indices=None, guidance_info: Optional[Dict] = None):
156
+ step_info = {
157
+ 'step': step,
158
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
159
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
160
+ 'improved_indices': improved_indices or [],
161
+ 'guidance_info': guidance_info or {}
162
+ }
163
+ self.step_logs.append(step_info)
164
+
165
+ def log_final(self, sequences, scores, smiles_list=None):
166
+ for i, (seq, score_vec) in enumerate(zip(sequences, scores.T)):
167
+ self.final_results.append({
168
+ 'sample_idx': i,
169
+ 'aa_sequence': seq,
170
+ 'peptide_smiles': smiles_list[i] if smiles_list else seq,
171
+ 'is_valid_peptide': bool(score_vec[0] > 0),
172
+ 'binding_score': float(score_vec[1]) if len(score_vec) > 1 else None,
173
+ })
174
+
175
+ def save(self, filename_prefix='generation'):
176
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
177
+ with open(step_log_path, 'w') as f:
178
+ json.dump(self.step_logs, f, indent=2, default=str)
179
+
180
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
181
+ with open(final_path, 'w') as f:
182
+ json.dump(self.final_results, f, indent=2, default=str)
183
+
184
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
185
+ with open(csv_path, 'w', newline='') as f:
186
+ writer = csv.writer(f)
187
+ writer.writerow(['sample_idx', 'aa_sequence', 'peptide_smiles',
188
+ 'is_valid_peptide', 'binding_score'])
189
+ for r in self.final_results:
190
+ writer.writerow([
191
+ r['sample_idx'], r.get('aa_sequence', ''), r['peptide_smiles'],
192
+ r['is_valid_peptide'], r.get('binding_score', '')
193
+ ])
194
+
195
+ print(f"\n=== Generation logs saved ===")
196
+ print(f" Step logs: {step_log_path}")
197
+ print(f" Final results: {final_path}")
198
+ print(f" Summary CSV: {csv_path}")
199
+ return step_log_path, final_path, csv_path
200
+
201
+ def print_step_summary(self, step, scores):
202
+ scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores)
203
+ num_valid = sum(1 for s in scores_list if s > 0)
204
+ print(f"\n--- Step {step} Summary ---")
205
+ print(f" Valid peptides: {num_valid}/{len(scores_list)}")
206
+
207
+ def print_final_summary(self):
208
+ print("\n" + "=" * 60)
209
+ print("FINAL GENERATION SUMMARY")
210
+ print("=" * 60)
211
+ total = len(self.final_results)
212
+ valid = sum(1 for r in self.final_results if r.get('is_valid_peptide', False))
213
+ print(f"\nTotal sequences: {total}")
214
+ print(f"Valid peptides: {valid}")
215
+ if any(r.get('binding_score') is not None for r in self.final_results):
216
+ avg = np.mean([r['binding_score'] for r in self.final_results
217
+ if r.get('binding_score') is not None])
218
+ print(f"Average binding score: {avg:.3f}")
219
+
220
+
221
+ # =============================================================================
222
+ # CupOfMoCA Staged Generator (UAA-aware)
223
+ # =============================================================================
224
+
225
+ class CupOfMoCAStagedUAA:
226
+ """Exponential-tilting staged generator that operates in AA token space."""
227
+
228
+ def __init__(self, model, device, objectives, args, tokenizer, tokenizer_type='amino',
229
+ tracker=None):
230
+ self.model = model
231
+ self.device = device
232
+ self.objectives = objectives
233
+ self.args = args
234
+ self.num_objectives = len(objectives)
235
+ self.peptide_analyzer = PeptideAnalyzer()
236
+ self.tracker = tracker
237
+ self.tokenizer = tokenizer
238
+ self.tokenizer_type = tokenizer_type
239
+
240
+ if tokenizer_type == 'amino':
241
+ self.invalid_tokens = [0, 1, 2, 3, 38] # specials + 'g'
242
+ self.smiles_tokenizer = SMILES_SPE_Tokenizer(
243
+ args.smiles_vocab_path, args.smiles_splits_path
244
+ )
245
+ else:
246
+ self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586]
247
+ self.smiles_tokenizer = None
248
+
249
+ self.analyzer_obj = None
250
+ for obj in objectives:
251
+ if isinstance(obj, Analyzer):
252
+ self.analyzer_obj = obj
253
+ break
254
+
255
+ # ------------------------------------------------------------------
256
+ # x0 generation
257
+ # ------------------------------------------------------------------
258
+ def generate_x0(self, n_steps=16, temperature=1.0):
259
+ print("Generating initial sequences (x0 from prior)...")
260
+ self.model.eval()
261
+
262
+ if self.tokenizer_type == 'amino':
263
+ vocab_size = self.model.vocab_size
264
+ valid_tokens = [i for i in range(vocab_size) if i not in self.invalid_tokens]
265
+ valid_tokens = torch.tensor(valid_tokens, device=self.device)
266
+ rand_indices = torch.randint(
267
+ 0, len(valid_tokens),
268
+ (self.args.num_samples, self.args.gen_len),
269
+ device=self.device
270
+ )
271
+ body = valid_tokens[rand_indices]
272
+ cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
273
+ eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
274
+ x = torch.cat([cls_col, body, eos_col], dim=1)
275
+ else:
276
+ vocab_size = self.model.tokenizer.vocab_size
277
+ x = torch.randint(0, vocab_size,
278
+ (self.args.num_samples, self.args.gen_len),
279
+ device=self.device)
280
+
281
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
282
+
283
+ with torch.no_grad():
284
+ for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
285
+ t_curr = time_steps[i]
286
+ t_next = time_steps[i + 1]
287
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
288
+
289
+ logits = self.model(x, t_tensor) / temperature
290
+ for inv_tok in self.invalid_tokens:
291
+ if inv_tok < logits.shape[-1]:
292
+ logits[:, :, inv_tok] = -1000
293
+
294
+ pred_x1 = torch.argmax(logits, dim=-1)
295
+ if i == n_steps - 1:
296
+ x = pred_x1
297
+ break
298
+
299
+ noise_prob = 1.0 - t_next
300
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
301
+ noise = torch.randint(0, vocab_size, x.shape, device=self.device)
302
+ x = torch.where(mask, noise, pred_x1)
303
+
304
+ generated_sequences = self.tokenizer.batch_decode(x)
305
+ for seq in generated_sequences:
306
+ print(f" {seq}")
307
+ return x
308
+
309
+ # ------------------------------------------------------------------
310
+ # Scoring
311
+ # ------------------------------------------------------------------
312
+ def _aa_to_smiles_batch(self, x_batch):
313
+ seqs = self.tokenizer.batch_decode(x_batch)
314
+ three_letter_seqs = [one_letter_to_three_letter(s) for s in seqs]
315
+ with ThreadPoolExecutor() as pool:
316
+ smiles_seqs = list(pool.map(
317
+ lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
318
+ three_letter_seqs
319
+ ))
320
+ return seqs, smiles_seqs
321
+
322
+ def _get_scores(self, x_batch):
323
+ scores = []
324
+
325
+ if self.tokenizer_type == 'amino':
326
+ _, smiles_seqs = self._aa_to_smiles_batch(x_batch)
327
+ valid_mask = torch.tensor(
328
+ [1 if isinstance(s, str) else 0 for s in smiles_seqs],
329
+ device=self.device
330
+ )
331
+ for i in torch.where((1 - valid_mask) == 1)[0].tolist():
332
+ smiles_seqs[i] = 'C'
333
+
334
+ smiles_batch = self.smiles_tokenizer(
335
+ smiles_seqs, return_tensors='pt', padding=True
336
+ ).to(self.device)
337
+ attention_mask = smiles_batch.get("attention_mask", None)
338
+
339
+ for obj_func in self.objectives:
340
+ try:
341
+ score = obj_func(
342
+ smiles_batch["input_ids"], attention_mask=attention_mask
343
+ ).to(self.device)
344
+ except TypeError:
345
+ score = obj_func(smiles_batch["input_ids"]).to(self.device)
346
+ scores.append(score * valid_mask)
347
+ else:
348
+ for obj_func in self.objectives:
349
+ scores.append(obj_func(x_batch.to(self.device)))
350
+
351
+ return torch.stack(scores, dim=0).to(self.device)
352
+
353
+ def _compute_aggregated_score(self, scores, weights):
354
+ if self.args.score_aggregation == "weighted_min":
355
+ return torch.min(weights * scores, dim=0).values
356
+ return (weights * scores).sum(dim=0)
357
+
358
+ # ------------------------------------------------------------------
359
+ # Exponential tilting
360
+ # ------------------------------------------------------------------
361
+ def _exponential_tilt_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
362
+ beta, weights, current_score=None, print_tilted=False,
363
+ step=None):
364
+ num_candidates = len(candidates)
365
+ current_token = x[sample_idx, mut_idx].item()
366
+
367
+ if num_candidates == 0:
368
+ return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
369
+
370
+ if num_candidates > self.args.top_k:
371
+ candidates = candidates[:self.args.top_k]
372
+ num_candidates = self.args.top_k
373
+
374
+ x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
375
+ x_proposals[:, mut_idx] = candidates
376
+
377
+ proposal_scores = self._get_scores(x_proposals)
378
+ aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
379
+ validity_mask = (proposal_scores[0] > 0).float()
380
+ base_probs = pos_probs[sample_idx, candidates]
381
+
382
+ log_base_probs = torch.log(base_probs + 1e-10)
383
+ log_exp_scores = beta * aggregated_scores
384
+ log_validity = torch.log(validity_mask + 1e-10)
385
+ log_tilted = log_base_probs + log_exp_scores + log_validity
386
+
387
+ log_tilted_max = log_tilted.max()
388
+ fallback = False
389
+ if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
390
+ fallback = True
391
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
392
+ else:
393
+ log_tilted_stable = log_tilted - log_tilted_max
394
+ tilted_probs = torch.exp(log_tilted_stable)
395
+ prob_sum = tilted_probs.sum()
396
+ if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
397
+ fallback = True
398
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
399
+ else:
400
+ final_probs = tilted_probs / prob_sum
401
+
402
+ if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
403
+ fallback = True
404
+ final_probs = base_probs / (base_probs.sum() + 1e-9)
405
+
406
+ final_probs = torch.clamp(final_probs, min=1e-10)
407
+ final_probs = final_probs / final_probs.sum()
408
+
409
+ if print_tilted:
410
+ id_to_token = self.tokenizer.id_to_token
411
+ topk = min(self.args.print_logits_topk, num_candidates)
412
+ top_vals, top_local = torch.topk(final_probs, k=topk)
413
+ print(f"\n[tilted] step={step} sample={sample_idx} mut_idx={mut_idx} "
414
+ f"beta={beta:.2f} fallback={fallback}")
415
+ print(f" Top-{topk} candidates by TILTED prob "
416
+ f"(shows: base p, agg score S, β·S, valid, tilted p):")
417
+ for tv, li in zip(top_vals.tolist(), top_local.tolist()):
418
+ tid = candidates[li].item()
419
+ tok = id_to_token.get(int(tid), '?')
420
+ bp = base_probs[li].item()
421
+ sc = aggregated_scores[li].item()
422
+ bs = (beta * aggregated_scores[li]).item()
423
+ vd = validity_mask[li].item()
424
+ print(f" id={tid:>3} tok={tok!r:>6} base_p={bp:.3e} "
425
+ f"S={sc:+.3f} β·S={bs:+.3f} valid={vd:.0f} tilted_p={tv:.3e}")
426
+ d_local = [li for li in range(num_candidates)
427
+ if 33 <= candidates[li].item() <= 52
428
+ and candidates[li].item() not in self.invalid_tokens]
429
+ if d_local:
430
+ print(" D-AA lowercase candidates (if any survived top-p):")
431
+ for li in d_local:
432
+ tid = candidates[li].item()
433
+ tok = id_to_token.get(int(tid), '?')
434
+ print(f" id={tid:>3} tok={tok!r:>4} base_p={base_probs[li]:.3e} "
435
+ f"S={aggregated_scores[li]:+.3f} "
436
+ f"valid={validity_mask[li]:.0f} tilted_p={final_probs[li]:.3e}")
437
+ else:
438
+ print(" D-AA lowercase candidates: NONE made it into the top-p pool")
439
+
440
+ selected_idx = torch.multinomial(final_probs, 1).item()
441
+ selected_token = candidates[selected_idx].item()
442
+ selected_score = aggregated_scores[selected_idx].item()
443
+
444
+ accepted = True
445
+ if self.args.use_acceptance and current_score is not None:
446
+ if selected_score < current_score:
447
+ selected_token = current_token
448
+ selected_score = current_score
449
+ accepted = False
450
+
451
+ return selected_token, {
452
+ 'method': 'exp_tilt', 'beta': beta, 'num_candidates': num_candidates,
453
+ 'proposal_score': aggregated_scores[selected_idx].item(),
454
+ 'selected_score': selected_score, 'fallback': fallback,
455
+ 'validity_rate': validity_mask.mean().item(), 'accepted': accepted
456
+ }
457
+
458
+ # ------------------------------------------------------------------
459
+ # Logit inspection
460
+ # ------------------------------------------------------------------
461
+ def _print_logit_distribution(self, raw_probs, masked_probs, mut_idx, step, sample_idx):
462
+ """Print the model's per-position probability distribution for inspection.
463
+
464
+ raw_probs: [vocab_size] probabilities BEFORE masking current/invalid tokens
465
+ masked_probs: [vocab_size] probabilities AFTER masking
466
+ """
467
+ topk = self.args.print_logits_topk
468
+ id_to_token = self.tokenizer.id_to_token
469
+
470
+ # Token-category index ranges (AminoTokenizer):
471
+ # specials : 0-3
472
+ # L-AA upper: 4-23
473
+ # non-canon : 24-32
474
+ # D-AA lower: 33-52 (38='g' is invalid)
475
+ cat_ranges = {
476
+ 'special' : list(range(0, 4)),
477
+ 'L-AA upper': list(range(4, 24)),
478
+ 'non-canon' : list(range(24, 33)),
479
+ 'D-AA lower': list(range(33, 53)),
480
+ }
481
+
482
+ print(f"\n[logits] step={step} sample={sample_idx} mut_idx={mut_idx}")
483
+
484
+ # Top-k from raw probs
485
+ top_vals, top_ids = torch.topk(raw_probs, k=min(topk, raw_probs.shape[0]))
486
+ print(f" Top-{topk} (raw model probs, pre-mask):")
487
+ for v, tid in zip(top_vals.tolist(), top_ids.tolist()):
488
+ tok = id_to_token.get(int(tid), '?')
489
+ print(f" id={tid:>3} tok={tok!r:>6} p={v:.4e}")
490
+
491
+ # Per-category mass
492
+ print(" Probability mass by category (raw / masked):")
493
+ for name, ids in cat_ranges.items():
494
+ ids_t = torch.tensor(ids, device=raw_probs.device)
495
+ raw_mass = raw_probs[ids_t].sum().item()
496
+ msk_mass = masked_probs[ids_t].sum().item()
497
+ print(f" {name:>10}: raw={raw_mass:.4e} masked={msk_mass:.4e}")
498
+
499
+ # Specifically: per-token probability for D-AA lowercase (excluding 'g'=38)
500
+ d_ids = [i for i in cat_ranges['D-AA lower'] if i not in self.invalid_tokens]
501
+ d_ids_t = torch.tensor(d_ids, device=raw_probs.device)
502
+ d_vals_raw = raw_probs[d_ids_t]
503
+ d_vals_msk = masked_probs[d_ids_t]
504
+ print(" D-AA lowercase per-token probs (raw / masked):")
505
+ for tid, vr, vm in zip(d_ids, d_vals_raw.tolist(), d_vals_msk.tolist()):
506
+ tok = id_to_token.get(int(tid), '?')
507
+ print(f" id={tid:>3} tok={tok!r:>4} raw={vr:.4e} masked={vm:.4e}")
508
+
509
+ # ------------------------------------------------------------------
510
+ # Main loop
511
+ # ------------------------------------------------------------------
512
+ def generate(self):
513
+ x = self.generate_x0()
514
+
515
+ if self.args.weights is None:
516
+ weights = torch.tensor(
517
+ [1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
518
+ device=self.device
519
+ ).view(-1, 1)
520
+ else:
521
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
522
+ if len(weights) != self.num_objectives:
523
+ raise ValueError("Number of weights must match number of objectives.")
524
+
525
+ print(f"\nGuidance Method: Exponential Tilting")
526
+ print(f"Weights: {weights.squeeze().tolist()}")
527
+ print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
528
+ print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
529
+
530
+ with torch.no_grad():
531
+ for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
532
+ beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * (
533
+ step / max(self.args.optimization_steps - 1, 1)
534
+ )
535
+
536
+ if self.args.position_strategy == "random":
537
+ mut_idx = random.randint(1, self.args.gen_len - 2)
538
+ else:
539
+ mut_idx = (step % (self.args.gen_len - 2)) + 1
540
+
541
+ generation_step = step % self.args.optimization_steps
542
+ time_t = torch.full(
543
+ (self.args.num_samples,),
544
+ generation_step / self.args.optimization_steps,
545
+ device=self.device
546
+ )
547
+
548
+ logits = self.model(x, time_t)
549
+ probs = F.softmax(logits, dim=-1)
550
+ pos_probs = probs[:, mut_idx, :]
551
+ raw_pos_probs = pos_probs.clone()
552
+
553
+ pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
554
+ for inv_tok in self.invalid_tokens:
555
+ pos_probs[:, inv_tok] = 0
556
+
557
+ should_print_logits = (
558
+ self.args.print_logits
559
+ and (step % max(self.args.print_logits_every, 1) == 0)
560
+ )
561
+ print_sample_idx = min(self.args.print_logits_sample,
562
+ self.args.num_samples - 1)
563
+ if should_print_logits:
564
+ self._print_logit_distribution(
565
+ raw_pos_probs[print_sample_idx], pos_probs[print_sample_idx],
566
+ mut_idx, step, print_sample_idx
567
+ )
568
+
569
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
570
+ cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
571
+ remove_mask = cumulative_probs > self.args.top_p
572
+ remove_mask[..., 1:] = remove_mask[..., :-1].clone()
573
+ remove_mask[..., 0] = 0
574
+
575
+ current_scores = self._get_scores(x)
576
+ current_aggregated = self._compute_aggregated_score(current_scores, weights)
577
+
578
+ if step == 0:
579
+ print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
580
+
581
+ improved = False
582
+ for i in range(self.args.num_samples):
583
+ sample_mask = remove_mask[i]
584
+ candidates = sorted_indices[i, ~sample_mask]
585
+
586
+ valid_candidate_mask = ~torch.isin(
587
+ candidates, torch.tensor(self.invalid_tokens, device=self.device)
588
+ )
589
+ candidates = candidates[valid_candidate_mask]
590
+
591
+ if len(candidates) == 0:
592
+ continue
593
+
594
+ prev_token = x[i, mut_idx].item()
595
+ prev_sample_scores = current_scores[:, i].clone()
596
+ prev_weighted_sum = (weights.squeeze(1) * prev_sample_scores).sum()
597
+
598
+ selected_token, info = self._exponential_tilt_guidance(
599
+ x, pos_probs, candidates, mut_idx, i, beta, weights,
600
+ current_score=current_aggregated[i].item(),
601
+ print_tilted=(should_print_logits and i == print_sample_idx),
602
+ step=step,
603
+ )
604
+
605
+ if selected_token == prev_token:
606
+ continue
607
+
608
+ x[i, mut_idx] = selected_token
609
+
610
+ new_sample_scores = self._get_scores(x[i].unsqueeze(0))[:, 0]
611
+ new_weighted_sum = (weights.squeeze(1) * new_sample_scores).sum()
612
+
613
+ if new_weighted_sum >= prev_weighted_sum:
614
+ improved = True
615
+ print(f"Previous Weighted Sum: {prev_weighted_sum}")
616
+ print(f"Previous Scores: {prev_sample_scores}")
617
+ print(f"New Weighted Sum: {new_weighted_sum}")
618
+ print(f"New Scores: {new_sample_scores}")
619
+ current_scores[:, i] = new_sample_scores
620
+ current_aggregated[i] = self._compute_aggregated_score(
621
+ new_sample_scores.unsqueeze(1), weights
622
+ )[0]
623
+ else:
624
+ x[i, mut_idx] = prev_token
625
+
626
+ if improved:
627
+ print(self.tokenizer.batch_decode(x))
628
+ if self.tracker:
629
+ self.tracker.log_step(
630
+ step, current_scores[0],
631
+ guidance_info={'method': 'exp_tilt', 'beta': beta}
632
+ )
633
+
634
+ return x
635
+
636
+
637
+ # =============================================================================
638
+ # Main
639
+ # =============================================================================
640
+
641
+ def main(args):
642
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
643
+ print(f"Using device: {device}")
644
+ print(f"Tokenizer type: {args.tokenizer_type}")
645
+
646
+ if args.seed is not None:
647
+ random.seed(args.seed)
648
+ np.random.seed(args.seed)
649
+ torch.manual_seed(args.seed)
650
+ if torch.cuda.is_available():
651
+ torch.cuda.manual_seed_all(args.seed)
652
+ print(f"Random seed: {args.seed}")
653
+
654
+ if args.tokenizer_type == 'amino':
655
+ print("Using Amino Acid tokenizer")
656
+ tokenizer = AminoTokenizer()
657
+ smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
658
+ else:
659
+ print("Using SMILES tokenizer")
660
+ tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
661
+ smiles_tokenizer = tokenizer
662
+
663
+ analyzer_model = Analyzer(smiles_tokenizer)
664
+ affinity_model = BindingAffinity(args.target, device)
665
+ OBJECTIVE_FUNCTIONS = [analyzer_model, affinity_model]
666
+
667
+ tracker = GenerationTracker(output_dir=args.output_dir)
668
+
669
+ print(f"Loading model from: {args.checkpoint}")
670
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
671
+
672
+ if args.tokenizer_type == 'amino':
673
+ raw_model_args = checkpoint['args']
674
+ if isinstance(raw_model_args, argparse.Namespace):
675
+ raw_model_args = vars(raw_model_args)
676
+ model_args = edict(raw_model_args)
677
+ model = MDLM(
678
+ vocab_size=model_args.vocab_size,
679
+ seq_len=model_args.seq_len,
680
+ model_dim=model_args.model_dim,
681
+ n_heads=model_args.n_heads,
682
+ n_layers=model_args.n_layers,
683
+ ).to(device)
684
+ model.load_state_dict(checkpoint['model_state_dict'])
685
+ print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
686
+ else:
687
+ model_hparams = checkpoint["hyper_parameters"]["args"]
688
+ model = MDLMLightningModule.load_from_checkpoint(
689
+ args.checkpoint, args=model_hparams, tokenizer=tokenizer,
690
+ map_location=device, strict=False
691
+ )
692
+ model.to(device)
693
+ model.eval()
694
+ print("Model loaded successfully.")
695
+
696
+ generator = CupOfMoCAStagedUAA(
697
+ model, device, OBJECTIVE_FUNCTIONS, args,
698
+ tokenizer=tokenizer, tokenizer_type=args.tokenizer_type, tracker=tracker
699
+ )
700
+
701
+ all_sequences = []
702
+ all_smiles = []
703
+ all_scores = []
704
+
705
+ for batch_idx in range(args.num_batches):
706
+ print(f"\n{'=' * 60}")
707
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
708
+ print(f"{'=' * 60}")
709
+
710
+ generated_tokens = generator.generate()
711
+ final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
712
+ sequence_str = tokenizer.batch_decode(generated_tokens)
713
+
714
+ print("\n--- Batch Results ---")
715
+ for i, seq in enumerate(sequence_str):
716
+ print(f" {i}: {seq}")
717
+ print(f"Scores: {final_scores}")
718
+
719
+ all_sequences.extend(sequence_str)
720
+ all_scores.append(final_scores)
721
+
722
+ if args.tokenizer_type == 'amino':
723
+ smiles_list = []
724
+ for seq in sequence_str:
725
+ three_letter = one_letter_to_three_letter(seq)
726
+ smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
727
+ smiles_list.append(smiles if isinstance(smiles, str) else 'INVALID_SMILES')
728
+ all_smiles.extend(smiles_list)
729
+ tracker.log_final(sequence_str, final_scores, smiles_list=smiles_list)
730
+ else:
731
+ tracker.log_final(sequence_str, final_scores)
732
+
733
+ tracker.print_final_summary()
734
+ tracker.save(filename_prefix='cupofmoca_staged_uaa')
735
+
736
+ with open(args.output_file, 'w') as f:
737
+ for i, seq in enumerate(all_sequences):
738
+ if args.tokenizer_type == 'amino':
739
+ smiles = all_smiles[i] if i < len(all_smiles) else ''
740
+ f.write(f"{seq}\t{smiles}\n")
741
+ else:
742
+ f.write(seq + '\n')
743
+ print(f"\nSequences saved to: {args.output_file}")
744
+ print("\nGeneration complete.")
745
+
746
+
747
+ if __name__ == "__main__":
748
+ parser = argparse.ArgumentParser(
749
+ description="CupOfMoCA-style Staged Generation with UAA Foundation Model"
750
+ )
751
+
752
+ parser.add_argument("--checkpoint", type=str, required=True)
753
+ parser.add_argument("--target", type=str, required=True)
754
+
755
+ parser.add_argument("--num_samples", type=int, default=10)
756
+ parser.add_argument("--num_batches", type=int, default=10)
757
+ parser.add_argument("--gen_len", type=int, default=50)
758
+ parser.add_argument("--optimization_steps", type=int, default=128)
759
+
760
+ parser.add_argument("--beta_min", type=float, default=1.0)
761
+ parser.add_argument("--beta_max", type=float, default=20.0)
762
+ parser.add_argument("--use_acceptance", action="store_true")
763
+ parser.add_argument("--top_k", type=int, default=200)
764
+ parser.add_argument("--top_p", type=float, default=0.9)
765
+ parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
766
+ choices=["weighted_sum", "weighted_min"])
767
+
768
+ parser.add_argument("--weights", type=float, nargs='+', default=None)
769
+
770
+ parser.add_argument("--position_strategy", type=str, default="random",
771
+ choices=["random", "sequential"])
772
+
773
+ parser.add_argument("--tokenizer_type", type=str, default="amino",
774
+ choices=["smiles", "amino"])
775
+ parser.add_argument("--cyclic", action="store_true",
776
+ help="Generate cyclic peptides (only for amino tokenizer).")
777
+ parser.add_argument("--smiles_vocab_path", type=str,
778
+ default="./smiles/smiles_tokenizer/new_vocab.txt")
779
+ parser.add_argument("--smiles_splits_path", type=str,
780
+ default="./smiles/smiles_tokenizer/new_splits.txt")
781
+
782
+ parser.add_argument("--output_file", type=str, default="./smiles.txt")
783
+ parser.add_argument("--output_dir", type=str, default="./generation_logs")
784
+
785
+ parser.add_argument("--seed", type=int, default=None)
786
+
787
+ parser.add_argument("--print_logits", action="store_true",
788
+ help="Print per-step probability distribution at the mutation position.")
789
+ parser.add_argument("--print_logits_every", type=int, default=1,
790
+ help="Print logits every N optimization steps (requires --print_logits).")
791
+ parser.add_argument("--print_logits_topk", type=int, default=20,
792
+ help="Number of top tokens to show per logit printout.")
793
+ parser.add_argument("--print_logits_sample", type=int, default=0,
794
+ help="Which sample index to print logits for (default: 0).")
795
+
796
+ args = parser.parse_args()
797
+ main(args)
smiles/generation/guided.py ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import re
3
+ import random
4
+ from collections import Counter
5
+ import csv
6
+ import json
7
+ import os
8
+ from datetime import datetime
9
+ import torch
10
+ import torch.nn as nn
11
+ import torch.nn.functional as F
12
+ from tqdm import tqdm
13
+ from smiles.models.train import MDLMLightningModule
14
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
15
+ import numpy as np
16
+
17
+ from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
18
+ from smiles.evaluation.pdc_analyzer import PDCAnalyzer
19
+ from smiles.scoring.classifiers import Conjugation, BindingAffinity
20
+
21
+
22
+ class GenerationTracker:
23
+ """
24
+ Tracks detailed validity information during generation
25
+ """
26
+ def __init__(self, output_dir='./generation_logs'):
27
+ self.output_dir = output_dir
28
+ os.makedirs(output_dir, exist_ok=True)
29
+
30
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
31
+ self.step_logs = []
32
+ self.final_results = []
33
+
34
+ def log_step(self, step, scores, detailed_results=None, improved_indices=None):
35
+ """Log information for a generation step"""
36
+ step_info = {
37
+ 'step': step,
38
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
39
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
40
+ 'improved_indices': improved_indices or []
41
+ }
42
+
43
+ if detailed_results:
44
+ step_info['detailed'] = []
45
+ for i, result in enumerate(detailed_results):
46
+ step_info['detailed'].append({
47
+ 'sample_idx': i,
48
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
49
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
50
+ 'conjugation_success': result.get('conjugation_success', False),
51
+ 'pdc_valid': result.get('pdc_valid', False),
52
+ 'num_conjugates': len(result.get('conjugates', [])),
53
+ 'error': result.get('error')
54
+ })
55
+
56
+ self.step_logs.append(step_info)
57
+
58
+ def log_final(self, sequences, detailed_results_with_props):
59
+ """Log final results with properties"""
60
+ for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
61
+ final_info = {
62
+ 'sample_idx': i,
63
+ 'peptide_smiles': seq,
64
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
65
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
66
+ 'num_conjugates': len(result.get('conjugates', [])),
67
+ 'num_valid_pdcs': len(result.get('valid_pdcs', [])),
68
+ 'best_pdc': None,
69
+ 'properties': result.get('properties', {})
70
+ }
71
+
72
+ if result.get('best_pdc'):
73
+ final_info['best_pdc'] = {
74
+ 'smiles': result['best_pdc'].get('smiles', ''),
75
+ 'checks': result['best_pdc'].get('checks', {})
76
+ }
77
+
78
+ self.final_results.append(final_info)
79
+
80
+ def save(self, filename_prefix='generation'):
81
+ """Save all logs to files"""
82
+ # Save step logs
83
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
84
+ with open(step_log_path, 'w') as f:
85
+ json.dump(self.step_logs, f, indent=2, default=str)
86
+
87
+ # Save final results
88
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
89
+ with open(final_path, 'w') as f:
90
+ json.dump(self.final_results, f, indent=2, default=str)
91
+
92
+ # Save summary CSV
93
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
94
+ with open(csv_path, 'w', newline='') as f:
95
+ writer = csv.writer(f)
96
+ writer.writerow([
97
+ 'sample_idx', 'peptide_smiles', 'is_valid_peptide', 'has_conjugation_site',
98
+ 'num_conjugates', 'num_valid_pdcs', 'best_pdc_smiles',
99
+ 'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
100
+ ])
101
+ for result in self.final_results:
102
+ props = result.get('properties', {})
103
+ writer.writerow([
104
+ result['sample_idx'],
105
+ result['peptide_smiles'],
106
+ result['is_valid_peptide'],
107
+ result['has_conjugation_site'],
108
+ result['num_conjugates'],
109
+ result['num_valid_pdcs'],
110
+ result['best_pdc']['smiles'] if result.get('best_pdc') else '',
111
+ props.get('mf', ''),
112
+ props.get('polararea', ''),
113
+ props.get('xlogp', ''),
114
+ props.get('heavycnt', ''),
115
+ props.get('rotbonds', '')
116
+ ])
117
+
118
+ print(f"\n=== Generation logs saved ===")
119
+ print(f" Step logs: {step_log_path}")
120
+ print(f" Final results: {final_path}")
121
+ print(f" Summary CSV: {csv_path}")
122
+
123
+ return step_log_path, final_path, csv_path
124
+
125
+ def print_step_summary(self, step, scores, detailed_results=None):
126
+ """Print a summary for the current step"""
127
+ if isinstance(scores, torch.Tensor):
128
+ scores_list = scores.cpu().numpy().tolist()
129
+ else:
130
+ scores_list = list(scores)
131
+
132
+ num_valid = sum(1 for s in scores_list if s > 0)
133
+
134
+ print(f"\n--- Step {step} Summary ---")
135
+ print(f" Valid: {num_valid}/{len(scores_list)}")
136
+
137
+ if detailed_results:
138
+ valid_peptide = sum(1 for r in detailed_results if r.get('is_valid_peptide'))
139
+ has_site = sum(1 for r in detailed_results if r.get('has_conjugation_site'))
140
+ conj_success = sum(1 for r in detailed_results if r.get('conjugation_success'))
141
+ pdc_valid = sum(1 for r in detailed_results if r.get('pdc_valid'))
142
+
143
+ print(f" Breakdown:")
144
+ print(f" Valid peptides: {valid_peptide}")
145
+ print(f" Has conjugation site: {has_site}")
146
+ print(f" Conjugation success: {conj_success}")
147
+ print(f" PDC valid: {pdc_valid}")
148
+
149
+ def print_final_summary(self):
150
+ """Print final summary"""
151
+ print("\n" + "=" * 60)
152
+ print("FINAL GENERATION SUMMARY")
153
+ print("=" * 60)
154
+
155
+ total = len(self.final_results)
156
+ valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
157
+
158
+ print(f"\nTotal sequences: {total}")
159
+ print(f"With valid PDCs: {valid_pdcs}")
160
+
161
+ print("\nDetailed results:")
162
+ for result in self.final_results:
163
+ status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
164
+ print(f"\n [{status}] Sample {result['sample_idx']}:")
165
+ print(f" Peptide: {result['peptide_smiles'][:60]}...")
166
+ print(f" Valid peptide: {result['is_valid_peptide']}")
167
+ print(f" Has conjugation site: {result['has_conjugation_site']}")
168
+ print(f" Conjugates: {result['num_conjugates']}")
169
+ print(f" Valid PDCs: {result['num_valid_pdcs']}")
170
+
171
+ if result.get('properties'):
172
+ props = result['properties']
173
+ print(f" Properties:")
174
+ for k, v in props.items():
175
+ print(f" {k}: {v}")
176
+
177
+
178
+ def peptide_bond_mask(smiles_list):
179
+ # Initialize the batch mask
180
+ batch_size = len(smiles_list)
181
+ max_seq_length = 1035 #max(len(smiles) for smiles in smiles_list) # Find the longest SMILES
182
+ mask = torch.zeros((batch_size, max_seq_length), dtype=torch.int) # Mask filled with zeros
183
+
184
+ bond_patterns = [
185
+ (r'OC\(=O\)', 'ester'),
186
+ (r'N\(C\)C\(=O\)', 'n_methyl'),
187
+ (r'N[12]C\(=O\)', 'peptide'), # Pro peptide bonds
188
+ (r'NC\(=O\)', 'peptide'), # Regular peptide bonds
189
+ (r'C\(=O\)N\(C\)', 'n_methyl'),
190
+ (r'C\(=O\)N[12]?', 'peptide')
191
+ ]
192
+
193
+ for batch_idx, smiles in enumerate(smiles_list):
194
+ positions = []
195
+ used = set()
196
+
197
+ # Identify bonds
198
+ for pattern, bond_type in bond_patterns:
199
+ for match in re.finditer(pattern, smiles):
200
+ if not any(p in range(match.start(), match.end()) for p in used):
201
+ positions.append({
202
+ 'start': match.start(),
203
+ 'end': match.end(),
204
+ 'type': bond_type,
205
+ 'pattern': match.group()
206
+ })
207
+ used.update(range(match.start(), match.end()))
208
+
209
+ # Update the mask for the current SMILES
210
+ for pos in positions:
211
+ mask[batch_idx, pos['start']:pos['end']] = 1
212
+
213
+ return mask
214
+
215
+ def peptide_token_mask(smiles_list, token_lists):
216
+ # Initialize the batch mask
217
+ batch_size = len(smiles_list)
218
+ token_seq_length = max(len(tokens) for tokens in token_lists) # Find the longest tokenized sequence
219
+ tokenized_masks = torch.zeros((batch_size, token_seq_length), dtype=torch.int) # Mask filled with zeros
220
+ atomwise_masks = peptide_bond_mask(smiles_list)
221
+
222
+
223
+ for batch_idx, atomwise_mask in enumerate(atomwise_masks):
224
+ token_seq = token_lists[batch_idx]
225
+ atom_idx = 0
226
+
227
+ for token_idx, token in enumerate(token_seq):
228
+ if token_idx != 0 and token_idx != len(token_seq) - 1:
229
+ if torch.sum(atomwise_mask[atom_idx:atom_idx+len(token)]) >= 1:
230
+ tokenized_masks[batch_idx][token_idx] = 1
231
+ atom_idx += len(token)
232
+
233
+ return tokenized_masks
234
+
235
+ class MOGGenerator:
236
+ def __init__(self, model, device, objectives, args, tracker=None):
237
+ self.model = model
238
+ self.device = device
239
+ self.objectives = objectives
240
+ self.args = args
241
+ self.num_objectives = len(objectives)
242
+ self.peptide_analyzer = PeptideAnalyzer()
243
+ self.pdc_analyzer = PDCAnalyzer(device)
244
+ self.invalid = [0, 1, 2, 3, 4, 585, 586]
245
+ self.tracker = tracker
246
+
247
+ # Find the Conjugation objective for detailed tracking
248
+ self.conjugation_obj = None
249
+ for obj in objectives:
250
+ if isinstance(obj, Conjugation):
251
+ self.conjugation_obj = obj
252
+ break
253
+
254
+ def _generate_single_x0(self, n_steps=16, temperature=1.0):
255
+ """Generate a single batch of x0 sequences."""
256
+ self.model.eval()
257
+
258
+ # 1. Start with a tensor of random tokens (pure noise at t=0)
259
+ x = torch.randint(
260
+ 0,
261
+ self.model.tokenizer.vocab_size,
262
+ (self.args.num_samples, self.args.gen_len),
263
+ device=self.device
264
+ )
265
+
266
+ # 2. Define the time schedule for the forward process (0.0 to 1.0)
267
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
268
+
269
+ # 3. Iteratively follow the flow from noise to data
270
+ with torch.no_grad():
271
+ for i in tqdm(range(n_steps), desc="Flow Matching Steps", leave=False):
272
+ t_curr = time_steps[i]
273
+ t_next = time_steps[i+1]
274
+
275
+ # Prepare the current timestep tensor for the model
276
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
277
+
278
+ # Get the model's prediction for the final clean sequence (at t=1)
279
+ logits = self.model(x, t_tensor)
280
+ logits = logits / temperature
281
+ logits[:, :, 586] = -1000
282
+
283
+ pred_x1 = torch.argmax(logits, dim=-1)
284
+
285
+ # On the last step, the result is the final prediction
286
+ if i == n_steps - 1:
287
+ x = pred_x1
288
+ break
289
+
290
+ # --- Construct the next state x_{t_next} ---
291
+ # The probability of a token being noise at time t_next is (1 - t_next).
292
+ noise_prob = 1.0 - t_next
293
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
294
+
295
+ # Generate new random tokens for the noise positions
296
+ noise = torch.randint(
297
+ 0,
298
+ self.model.tokenizer.vocab_size,
299
+ x.shape,
300
+ device=self.device
301
+ )
302
+
303
+ # Combine the final prediction with noise to form the next intermediate state
304
+ x = torch.where(mask, noise, pred_x1)
305
+
306
+ return x
307
+
308
+ def _check_conjugation_validity(self, x):
309
+ """
310
+ Check if sequences can be conjugated.
311
+ Returns a boolean tensor indicating validity for each sample.
312
+ """
313
+ if self.conjugation_obj is None:
314
+ # Fall back to peptide validity check
315
+ sequences = self.model.tokenizer.batch_decode(x)
316
+ return torch.tensor([self.peptide_analyzer.is_peptide(seq) for seq in sequences], device=self.device)
317
+
318
+ # Use conjugation objective - score > 0 means valid
319
+ scores = self.conjugation_obj(x)
320
+ return scores > 0
321
+
322
+ def generate_x0(self, n_steps=16, temperature=1.0, require_valid=False, max_retries=10):
323
+ """
324
+ Generate initial x0 sequences.
325
+
326
+ Args:
327
+ n_steps: Number of flow matching steps
328
+ temperature: Sampling temperature
329
+ require_valid: If True, regenerate invalid samples until all are valid conjugatable peptides
330
+ max_retries: Maximum number of regeneration attempts per sample
331
+ """
332
+ print("Starting initial SMILES generation...")
333
+
334
+ x = self._generate_single_x0(n_steps, temperature)
335
+
336
+ if require_valid:
337
+ print("Checking conjugation validity and regenerating invalid samples...")
338
+
339
+ for retry in range(max_retries):
340
+ validity = self._check_conjugation_validity(x)
341
+ num_valid = validity.sum().item()
342
+ num_invalid = self.args.num_samples - num_valid
343
+
344
+ if num_invalid == 0:
345
+ print(f"All {self.args.num_samples} samples are valid conjugatable peptides!")
346
+ break
347
+
348
+ print(f"Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid, regenerating {num_invalid} invalid samples...")
349
+
350
+ # Get indices of invalid samples
351
+ invalid_indices = torch.where(~validity)[0]
352
+
353
+ # Generate new candidates for invalid samples
354
+ # Temporarily adjust num_samples to generate replacements
355
+ original_num_samples = self.args.num_samples
356
+ self.args.num_samples = num_invalid
357
+
358
+ new_x = self._generate_single_x0(n_steps, temperature)
359
+
360
+ self.args.num_samples = original_num_samples
361
+
362
+ # Replace invalid samples with new ones
363
+ x[invalid_indices] = new_x
364
+
365
+ # Final validity check
366
+ final_validity = self._check_conjugation_validity(x)
367
+ num_final_valid = final_validity.sum().item()
368
+ if num_final_valid < self.args.num_samples:
369
+ print(f"Warning: After {max_retries} retries, only {num_final_valid}/{self.args.num_samples} samples are valid.")
370
+ else:
371
+ print(f"Success: All {self.args.num_samples} samples are valid conjugatable peptides.")
372
+
373
+ # 4. Decode the final token IDs into SMILES strings
374
+ generated_sequences = self.model.tokenizer.batch_decode(x)
375
+
376
+ # 5. Analyze the validity of the generated sequences
377
+ validities = []
378
+ for seq in generated_sequences:
379
+ validities.append(self.peptide_analyzer.is_peptide(seq))
380
+ print(seq)
381
+ print(f"Initial Sequence Validity: {validities}")
382
+ return x
383
+
384
+ def _get_scores(self, x_batch):
385
+ """Calculates the normalized scores for a batch of sequences."""
386
+ scores = []
387
+ for obj_func in self.objectives:
388
+ scores.append(obj_func(x_batch.to(self.device)))
389
+ return torch.stack(scores, dim=0).to(self.device)
390
+
391
+ def _barker_g(self, u):
392
+ """Barker balancing function."""
393
+ return u / (1 + u)
394
+
395
+ def validity(self, x):
396
+ sampled_sequences = self.model.tokenizer.batch_decode(x)
397
+
398
+ return [1.0 if self.peptide_analyzer.is_peptide(seq) else 0.0 for seq in sampled_sequences]
399
+
400
+ def generate(self):
401
+ """Main generation loop with tracking."""
402
+ shape = (self.args.num_samples, self.args.gen_len)
403
+ x = self.generate_x0(
404
+ require_valid=self.args.require_valid_x0,
405
+ max_retries=self.args.max_x0_retries
406
+ )
407
+ print(x)
408
+
409
+ if args.weights is None:
410
+ # The first weight is for conjugation (peptide + PDC validity)
411
+ # We need to ensure the SMILES sequences are valid peptides with valid PDCs
412
+ weights = torch.tensor([1] + [1/(self.num_objectives-1)] * (self.num_objectives-1), device=self.device).view(-1,1)
413
+ else:
414
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
415
+ if len(weights) != self.num_objectives:
416
+ raise ValueError("Number of weights must match number of objectives.")
417
+ print(f"Weights: {weights}")
418
+
419
+ with torch.no_grad():
420
+ for t in tqdm(range(self.args.optimization_steps), desc="MOG Generation"):
421
+ improved = False
422
+ improved_indices = []
423
+
424
+ # Anneal guidance strength
425
+ eta_t = self.args.eta_min + (self.args.eta_max - self.args.eta_min) * (t / (self.args.optimization_steps - 1))
426
+ # Choose a random position to mutate
427
+ mut_idx = random.randint(1, self.args.gen_len-2)
428
+
429
+ # Determine the generation timestep
430
+ # We cycle through the timesteps to ensure all are visited
431
+ generation_step = t % self.args.optimization_steps
432
+ time_t = torch.full((self.args.num_samples,), (generation_step / self.args.optimization_steps), device=self.device)
433
+
434
+ # Get proposal distribution from ReDi model for the chosen position
435
+ logits = self.model(x, time_t)
436
+ probs = F.softmax(logits, dim=-1)
437
+ pos_probs = probs[:, mut_idx, :]
438
+ pos_probs[:, x[:, mut_idx]] = 0 # We don't evalute the same token
439
+ pos_probs[:, self.invalid] = 0
440
+
441
+ # Prune candidate vocabulary using top-p sampling
442
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
443
+ cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
444
+ remove_mask = cumulative_probs > self.args.top_p
445
+ remove_mask[..., 1:] = remove_mask[..., :-1].clone()
446
+ remove_mask[..., 0] = 0
447
+
448
+ # Get the set of candidate tokens for each sample in the batch
449
+ candidate_tokens_list = []
450
+ for i in range(self.args.num_samples):
451
+ sample_mask = remove_mask[i]
452
+ candidates = sorted_indices[i, ~sample_mask]
453
+ candidate_tokens_list.append(candidates)
454
+
455
+ # Get current scores
456
+ current_scores = self._get_scores(x)
457
+ w_current = torch.exp(eta_t * torch.min(weights * current_scores, dim=0).values)
458
+
459
+ if t == 0:
460
+ print(f"Initial Scores: {current_scores}")
461
+ # Log initial step with detailed results
462
+ if self.tracker and self.conjugation_obj:
463
+ detailed = self.conjugation_obj.get_last_detailed_results()
464
+ self.tracker.log_step(t, current_scores[0], detailed)
465
+ self.tracker.print_step_summary(t, current_scores[0], detailed)
466
+
467
+ # Evaluate all candidate tokens for each sample
468
+ final_proposal_tokens = []
469
+ for i in range(self.args.num_samples):
470
+ candidates = candidate_tokens_list[i]
471
+ candidates = torch.tensor([token for token in candidates if token not in self.invalid], device=candidates.device)
472
+ if len(candidates) >= 200:
473
+ candidates = candidates[:200]
474
+ num_candidates = len(candidates)
475
+
476
+ # Create a batch of proposed sequences for the current sample
477
+ x_prop_batch = x[i].repeat(num_candidates, 1)
478
+ x_prop_batch[:, mut_idx] = candidates
479
+
480
+ # Evaluate all proposals
481
+ proposal_scores = self._get_scores(x_prop_batch)
482
+ proposal_s_omega = torch.min(weights * proposal_scores, dim=0).values
483
+ w_proposal = torch.exp(eta_t * proposal_s_omega)
484
+
485
+ # Get ReDi probabilities for the candidates
486
+ redi_probs = pos_probs[i, candidates]
487
+
488
+ # Calculate unnormalized guided probabilities
489
+ tilde_q = redi_probs * self._barker_g(w_proposal / w_current[i])
490
+
491
+ # Normalize and sample the final token
492
+ final_probs = tilde_q / (torch.sum(tilde_q) + 1e-9)
493
+
494
+ index = torch.multinomial(final_probs, 1).item()
495
+
496
+ if (random.uniform(0,1) > 1 and proposal_scores[:,index][0] == 1) or torch.sum(weights.squeeze(1) * proposal_scores[:, index]) >= torch.sum(weights.squeeze(1) * current_scores[:,i]):
497
+ final_token = candidates[index]
498
+ print(f"Previous Weighted Sum: {torch.sum(weights.squeeze(1) * current_scores[:,i])}")
499
+ print(f"Previous Scores: {current_scores[:,i]}")
500
+
501
+ print(f"New Weighted Sum: {torch.sum(weights.squeeze(1) * proposal_scores[:, index])}")
502
+ print(f"New Scores: {proposal_scores[:,index]}")
503
+ improved = True
504
+ improved_indices.append(i)
505
+
506
+ else:
507
+ final_token = x[i][mut_idx]
508
+
509
+ final_proposal_tokens.append(final_token)
510
+
511
+ # Update the sequences with the chosen tokens
512
+ x[torch.arange(self.args.num_samples), mut_idx] = torch.stack(final_proposal_tokens)
513
+ if improved:
514
+ print(self.model.tokenizer.batch_decode(x))
515
+
516
+ # Log step with tracking (every 4 steps to reduce overhead)
517
+ if self.tracker and self.conjugation_obj and (t % 4 == 0 or t == self.args.optimization_steps - 1):
518
+ # Re-evaluate for detailed logging
519
+ step_scores = self._get_scores(x)
520
+ detailed = self.conjugation_obj.get_last_detailed_results()
521
+ self.tracker.log_step(t, step_scores[0], detailed, improved_indices)
522
+
523
+ # Print summary for final step
524
+ if t == self.args.optimization_steps - 1:
525
+ self.tracker.print_step_summary(t, step_scores[0], detailed)
526
+
527
+ return x
528
+
529
+
530
+ # --- Main Execution ---
531
+ def main(args):
532
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
533
+ print(f"Using device: {device}")
534
+
535
+ target = args.target
536
+ tokenizer = SMILES_SPE_Tokenizer('./smiles/smiles_tokenizer/new_vocab.txt', './smiles/smiles_tokenizer/new_splits.txt')
537
+
538
+ # Initialize Conjugation model with specified builder type
539
+ conjugation_model = Conjugation(tokenizer, device=device, builder_type=args.builder_type)
540
+ # hemolysis_model = Hemolysis(device)
541
+ # nonfouling_model = Nonfouling(device)
542
+ # solubility_model = Solubility(device)
543
+ # permeability_model = Permeability(device)
544
+ affinity_model = BindingAffinity(target, device)
545
+
546
+ # List of all objective functions
547
+ OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
548
+
549
+ # Initialize tracker for logging
550
+ tracker = GenerationTracker(output_dir=args.output_dir)
551
+
552
+ # --- Set random seeds for reproducibility ---
553
+ if args.seed is not None:
554
+ import random
555
+ import numpy as np
556
+ random.seed(args.seed)
557
+ np.random.seed(args.seed)
558
+ torch.manual_seed(args.seed)
559
+ if torch.cuda.is_available():
560
+ torch.cuda.manual_seed(args.seed)
561
+ torch.cuda.manual_seed_all(args.seed)
562
+ print(f"Random seed set to: {args.seed}")
563
+
564
+ # --- Load Model ---
565
+ print(f"Loading model from checkpoint: {args.checkpoint}")
566
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
567
+ model_hparams = checkpoint["hyper_parameters"]["args"]
568
+
569
+ model = MDLMLightningModule.load_from_checkpoint(
570
+ args.checkpoint,
571
+ args=model_hparams,
572
+ tokenizer=tokenizer,
573
+ map_location=device,
574
+ strict=False
575
+ )
576
+ model.to(device)
577
+ print("Model loaded successfully.")
578
+
579
+ mog_generator = MOGGenerator(model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker)
580
+
581
+ all_sequences = []
582
+ all_final_results = []
583
+
584
+ for batch_idx in range(args.num_batches):
585
+ print(f"\n{'='*60}")
586
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
587
+ print(f"{'='*60}")
588
+
589
+ generated_tokens = mog_generator.generate()
590
+ final_scores = mog_generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
591
+ sequence_str = tokenizer.batch_decode(generated_tokens)
592
+
593
+ print("\n--- Batch Results ---")
594
+ print(f"Sequences: {sequence_str}")
595
+ print(f"Scores: {final_scores}")
596
+
597
+ all_sequences.extend(sequence_str)
598
+
599
+ # Get detailed results with properties for final sequences
600
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
601
+ tracker.log_final(sequence_str, detailed_with_props)
602
+ all_final_results.extend(detailed_with_props)
603
+
604
+ # Print and save final summary
605
+ tracker.print_final_summary()
606
+ tracker.save(filename_prefix='pdc_generation')
607
+
608
+ # Also save sequences to simple text file
609
+ with open(args.output_file, 'w') as f:
610
+ for seq in all_sequences:
611
+ f.write(seq + '\n')
612
+ print(f"\nSequences saved to: {args.output_file}")
613
+
614
+ print("\nGeneration complete.")
615
+
616
+
617
+
618
+ if __name__ == "__main__":
619
+ parser = argparse.ArgumentParser(description="Multi-Objective Generation with LBP-MOG-ReDi (Single Mutation).")
620
+
621
+ parser.add_argument("--checkpoint", type=str, required=True, help="Path to the trained ReDi model checkpoint.")
622
+ parser.add_argument("--num_samples", type=int, default=10, help="Number of samples to generate.")
623
+ parser.add_argument("--num_batches", type=int, default=10, help="Number of batches to generate.")
624
+ parser.add_argument("--output_file", type=str, default="./smiles.txt", help="File to save the generated sequences.")
625
+ parser.add_argument("--output_dir", type=str, default="./generation_logs", help="Directory to save detailed generation logs.")
626
+ parser.add_argument("--gen_len", type=int, default=50, help="Length of the sequences to generate.")
627
+ parser.add_argument("--optimization_steps", type=int, default=16, help="Number of optimization steps per generation.")
628
+ parser.add_argument("--weights", type=float, nargs='+', required=False, help="Weights for the objectives (e.g., 0.5 0.5).")
629
+ parser.add_argument("--eta_min", type=float, default=1.0, help="Minimum guidance strength for annealing.")
630
+ parser.add_argument("--eta_max", type=float, default=20.0, help="Maximum guidance strength for annealing.")
631
+ parser.add_argument("--top_p", type=float, default=0.9, help="Top-p for pruning candidate tokens.")
632
+
633
+ # PDC builder
634
+ parser.add_argument("--builder_type", type=str, default="AoaDau",
635
+ choices=["AoaDau", "ValAlaDau", "ValCitDau", "GlutarylDau", "Dau",
636
+ "SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt", "DSECCpt", "Cpt",
637
+ "DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"],
638
+ help="PDC builder type for conjugation. "
639
+ "'Dau' samples linker by GNRHR+Dau frequency. "
640
+ "'Cpt' samples linker by ERBB2+Cpt frequency. "
641
+ "'SN38' samples linker by NRP1+SN38 frequency.")
642
+
643
+ parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility.")
644
+ parser.add_argument("--require_valid_x0", action="store_true",
645
+ help="If set, regenerate x0 samples until they are valid conjugatable peptides.")
646
+ parser.add_argument("--max_x0_retries", type=int, default=10,
647
+ help="Maximum retries when require_valid_x0 is enabled.")
648
+
649
+ parser.add_argument("--target", type=str, required=True, help="Target protein sequence for binding affinity.")
650
+ args = parser.parse_args()
651
+ main(args)
smiles/generation/guided_aa.py ADDED
@@ -0,0 +1,872 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Natural-AA variant of guided_uaa.py."""
2
+ import argparse
3
+ import re
4
+ import random
5
+ import sys
6
+ from collections import Counter
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ import csv
9
+ import json
10
+ import os
11
+ from datetime import datetime
12
+ import torch
13
+ import torch.nn as nn
14
+ import torch.nn.functional as F
15
+ from tqdm import tqdm
16
+ from smiles.models.train import MDLMLightningModule
17
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
18
+ import numpy as np
19
+ from easydict import EasyDict as edict
20
+
21
+ from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
22
+ from smiles.evaluation.pdc_analyzer import PDCAnalyzer
23
+ from smiles.scoring.classifiers import Conjugation, BindingAffinity
24
+
25
+ from smiles.models.aa_helpers import (
26
+ ESM2AminoTokenizer as _AAEsmTokenizer,
27
+ MDLM_PepReDi as _AAMDLM,
28
+ make_affinity,
29
+ DEFAULT_AA_AFFINITY_CKPT,
30
+ INVALID_AA_TOKEN_IDS as _AA_INVALID_IDS,
31
+ )
32
+
33
+ from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
34
+ from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
35
+
36
+
37
+ MDLM = _AAMDLM
38
+ AminoTokenizer = _AAEsmTokenizer
39
+
40
+
41
+ class GenerationTracker:
42
+ """
43
+ Tracks detailed validity information during generation
44
+ """
45
+ def __init__(self, output_dir='./generation_logs'):
46
+ self.output_dir = output_dir
47
+ os.makedirs(output_dir, exist_ok=True)
48
+
49
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
50
+ self.step_logs = []
51
+ self.final_results = []
52
+
53
+ def log_step(self, step, scores, detailed_results=None, improved_indices=None):
54
+ """Log information for a generation step"""
55
+ step_info = {
56
+ 'step': step,
57
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
58
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
59
+ 'improved_indices': improved_indices or []
60
+ }
61
+
62
+ if detailed_results:
63
+ step_info['detailed'] = []
64
+ for i, result in enumerate(detailed_results):
65
+ step_info['detailed'].append({
66
+ 'sample_idx': i,
67
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
68
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
69
+ 'conjugation_success': result.get('conjugation_success', False),
70
+ 'pdc_valid': result.get('pdc_valid', False),
71
+ 'num_conjugates': len(result.get('conjugates', [])),
72
+ 'error': result.get('error')
73
+ })
74
+
75
+ self.step_logs.append(step_info)
76
+
77
+ def log_final(self, sequences, detailed_results_with_props):
78
+ """Log final results with properties"""
79
+ for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
80
+ final_info = {
81
+ 'sample_idx': i,
82
+ 'peptide_smiles': seq,
83
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
84
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
85
+ 'num_conjugates': len(result.get('conjugates', [])),
86
+ 'num_valid_pdcs': len(result.get('valid_pdcs', [])),
87
+ 'best_pdc': None,
88
+ 'properties': result.get('properties', {})
89
+ }
90
+
91
+ if result.get('best_pdc'):
92
+ final_info['best_pdc'] = {
93
+ 'smiles': result['best_pdc'].get('smiles', ''),
94
+ 'checks': result['best_pdc'].get('checks', {})
95
+ }
96
+
97
+ self.final_results.append(final_info)
98
+
99
+ def save(self, filename_prefix='generation'):
100
+ """Save all logs to files"""
101
+ # Save step logs
102
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
103
+ with open(step_log_path, 'w') as f:
104
+ json.dump(self.step_logs, f, indent=2, default=str)
105
+
106
+ # Save final results
107
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
108
+ with open(final_path, 'w') as f:
109
+ json.dump(self.final_results, f, indent=2, default=str)
110
+
111
+ # Save summary CSV
112
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
113
+ with open(csv_path, 'w', newline='') as f:
114
+ writer = csv.writer(f)
115
+ writer.writerow([
116
+ 'sample_idx', 'peptide_smiles', 'is_valid_peptide', 'has_conjugation_site',
117
+ 'num_conjugates', 'num_valid_pdcs', 'best_pdc_smiles',
118
+ 'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
119
+ ])
120
+ for result in self.final_results:
121
+ props = result.get('properties', {})
122
+ writer.writerow([
123
+ result['sample_idx'],
124
+ result['peptide_smiles'],
125
+ result['is_valid_peptide'],
126
+ result['has_conjugation_site'],
127
+ result['num_conjugates'],
128
+ result['num_valid_pdcs'],
129
+ result['best_pdc']['smiles'] if result.get('best_pdc') else '',
130
+ props.get('mf', ''),
131
+ props.get('polararea', ''),
132
+ props.get('xlogp', ''),
133
+ props.get('heavycnt', ''),
134
+ props.get('rotbonds', '')
135
+ ])
136
+
137
+ print(f"\n=== Generation logs saved ===")
138
+ print(f" Step logs: {step_log_path}")
139
+ print(f" Final results: {final_path}")
140
+ print(f" Summary CSV: {csv_path}")
141
+
142
+ return step_log_path, final_path, csv_path
143
+
144
+ def print_step_summary(self, step, scores, detailed_results=None):
145
+ """Print a summary for the current step"""
146
+ if isinstance(scores, torch.Tensor):
147
+ scores_list = scores.cpu().numpy().tolist()
148
+ else:
149
+ scores_list = list(scores)
150
+
151
+ num_valid = sum(1 for s in scores_list if s > 0)
152
+
153
+ print(f"\n--- Step {step} Summary ---")
154
+ print(f" Valid: {num_valid}/{len(scores_list)}")
155
+
156
+ if detailed_results:
157
+ valid_peptide = sum(1 for r in detailed_results if r.get('is_valid_peptide'))
158
+ has_site = sum(1 for r in detailed_results if r.get('has_conjugation_site'))
159
+ conj_success = sum(1 for r in detailed_results if r.get('conjugation_success'))
160
+ pdc_valid = sum(1 for r in detailed_results if r.get('pdc_valid'))
161
+
162
+ print(f" Breakdown:")
163
+ print(f" Valid peptides: {valid_peptide}")
164
+ print(f" Has conjugation site: {has_site}")
165
+ print(f" Conjugation success: {conj_success}")
166
+ print(f" PDC valid: {pdc_valid}")
167
+
168
+ def print_final_summary(self):
169
+ """Print final summary"""
170
+ print("\n" + "=" * 60)
171
+ print("FINAL GENERATION SUMMARY")
172
+ print("=" * 60)
173
+
174
+ total = len(self.final_results)
175
+ valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
176
+
177
+ print(f"\nTotal sequences: {total}")
178
+ print(f"With valid PDCs: {valid_pdcs}")
179
+
180
+ print("\nDetailed results:")
181
+ for result in self.final_results:
182
+ status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
183
+ print(f"\n [{status}] Sample {result['sample_idx']}:")
184
+ print(f" Peptide: {result['peptide_smiles'][:60]}...")
185
+ print(f" Valid peptide: {result['is_valid_peptide']}")
186
+ print(f" Has conjugation site: {result['has_conjugation_site']}")
187
+ print(f" Conjugates: {result['num_conjugates']}")
188
+ print(f" Valid PDCs: {result['num_valid_pdcs']}")
189
+
190
+ if result.get('properties'):
191
+ props = result['properties']
192
+ print(f" Properties:")
193
+ for k, v in props.items():
194
+ print(f" {k}: {v}")
195
+
196
+
197
+ def peptide_bond_mask(smiles_list):
198
+ # Initialize the batch mask
199
+ batch_size = len(smiles_list)
200
+ max_seq_length = 1035 #max(len(smiles) for smiles in smiles_list) # Find the longest SMILES
201
+ mask = torch.zeros((batch_size, max_seq_length), dtype=torch.int) # Mask filled with zeros
202
+
203
+ bond_patterns = [
204
+ (r'OC\(=O\)', 'ester'),
205
+ (r'N\(C\)C\(=O\)', 'n_methyl'),
206
+ (r'N[12]C\(=O\)', 'peptide'), # Pro peptide bonds
207
+ (r'NC\(=O\)', 'peptide'), # Regular peptide bonds
208
+ (r'C\(=O\)N\(C\)', 'n_methyl'),
209
+ (r'C\(=O\)N[12]?', 'peptide')
210
+ ]
211
+
212
+ for batch_idx, smiles in enumerate(smiles_list):
213
+ positions = []
214
+ used = set()
215
+
216
+ # Identify bonds
217
+ for pattern, bond_type in bond_patterns:
218
+ for match in re.finditer(pattern, smiles):
219
+ if not any(p in range(match.start(), match.end()) for p in used):
220
+ positions.append({
221
+ 'start': match.start(),
222
+ 'end': match.end(),
223
+ 'type': bond_type,
224
+ 'pattern': match.group()
225
+ })
226
+ used.update(range(match.start(), match.end()))
227
+
228
+ # Update the mask for the current SMILES
229
+ for pos in positions:
230
+ mask[batch_idx, pos['start']:pos['end']] = 1
231
+
232
+ return mask
233
+
234
+ def peptide_token_mask(smiles_list, token_lists):
235
+ # Initialize the batch mask
236
+ batch_size = len(smiles_list)
237
+ token_seq_length = max(len(tokens) for tokens in token_lists) # Find the longest tokenized sequence
238
+ tokenized_masks = torch.zeros((batch_size, token_seq_length), dtype=torch.int) # Mask filled with zeros
239
+ atomwise_masks = peptide_bond_mask(smiles_list)
240
+
241
+
242
+ for batch_idx, atomwise_mask in enumerate(atomwise_masks):
243
+ token_seq = token_lists[batch_idx]
244
+ atom_idx = 0
245
+
246
+ for token_idx, token in enumerate(token_seq):
247
+ if token_idx != 0 and token_idx != len(token_seq) - 1:
248
+ if torch.sum(atomwise_mask[atom_idx:atom_idx+len(token)]) >= 1:
249
+ tokenized_masks[batch_idx][token_idx] = 1
250
+ atom_idx += len(token)
251
+
252
+ return tokenized_masks
253
+
254
+ class MOGGenerator:
255
+ def __init__(self, model, device, objectives, args, tracker=None, tokenizer=None, tokenizer_type='smiles'):
256
+ self.model = model
257
+ self.device = device
258
+ self.objectives = objectives
259
+ self.args = args
260
+ self.num_objectives = len(objectives)
261
+ self.peptide_analyzer = PeptideAnalyzer()
262
+ self.pdc_analyzer = PDCAnalyzer(device)
263
+ self.tracker = tracker
264
+ self.tokenizer = tokenizer
265
+ self.tokenizer_type = tokenizer_type
266
+
267
+ # Set invalid tokens based on tokenizer type
268
+ if tokenizer_type == 'amino':
269
+ # PepReDi (natural-AA) vocab is 24 tokens: 0-3 are specials
270
+ # (cls/pad/eos/unk) and 4-23 are the 20 canonical AAs. The UAA
271
+ # script also blocked id 38 ('g' D-AA), but that's out of range
272
+ # for PepReDi and would crash the `pos_probs[:, self.invalid] = 0`
273
+ # masking. Block special tokens only.
274
+ self.invalid = [0, 1, 2, 3]
275
+ # Initialize SMILES tokenizer for AA-to-SMILES conversion
276
+ self.smiles_tokenizer = SMILES_SPE_Tokenizer(
277
+ args.smiles_vocab_path,
278
+ args.smiles_splits_path
279
+ )
280
+ else:
281
+ self.invalid = [0, 1, 2, 3, 4, 585, 586] # SMILES special tokens
282
+ self.smiles_tokenizer = None
283
+
284
+ # Find the Conjugation objective for detailed tracking
285
+ self.conjugation_obj = None
286
+ for obj in objectives:
287
+ if isinstance(obj, Conjugation):
288
+ self.conjugation_obj = obj
289
+ break
290
+
291
+ def _generate_single_x0(self, n_steps=16, temperature=1.0):
292
+ """Generate a single batch of x0 sequences."""
293
+ self.model.eval()
294
+
295
+ # Get vocab size based on tokenizer type
296
+ if self.tokenizer_type == 'amino':
297
+ vocab_size = self.model.vocab_size
298
+ else:
299
+ vocab_size = self.model.tokenizer.vocab_size
300
+
301
+ # 1. Start with a tensor of random tokens (pure noise at t=0)
302
+ # For amino tokenizer, exclude invalid tokens from initialization
303
+ if self.tokenizer_type == 'amino':
304
+ valid_tokens = [i for i in range(vocab_size) if i not in self.invalid]
305
+ valid_tokens = torch.tensor(valid_tokens, device=self.device)
306
+ rand_indices = torch.randint(0, len(valid_tokens), (self.args.num_samples, self.args.gen_len), device=self.device)
307
+ body = valid_tokens[rand_indices]
308
+ # Add CLS and EOS tokens
309
+ cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
310
+ eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
311
+ x = torch.cat([cls_col, body, eos_col], dim=1)
312
+ else:
313
+ x = torch.randint(
314
+ 0,
315
+ vocab_size,
316
+ (self.args.num_samples, self.args.gen_len),
317
+ device=self.device
318
+ )
319
+
320
+ # 2. Define the time schedule for the forward process (0.0 to 1.0)
321
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
322
+
323
+ # 3. Iteratively follow the flow from noise to data
324
+ with torch.no_grad():
325
+ for i in tqdm(range(n_steps), desc="Flow Matching Steps", leave=False):
326
+ t_curr = time_steps[i]
327
+ t_next = time_steps[i+1]
328
+
329
+ # Prepare the current timestep tensor for the model
330
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
331
+
332
+ # Get the model's prediction for the final clean sequence (at t=1)
333
+ logits = self.model(x, t_tensor)
334
+ logits = logits / temperature
335
+
336
+ # Mask out invalid tokens
337
+ for inv_tok in self.invalid:
338
+ if inv_tok < logits.shape[-1]:
339
+ logits[:, :, inv_tok] = -1000
340
+
341
+ pred_x1 = torch.argmax(logits, dim=-1)
342
+
343
+ # On the last step, the result is the final prediction
344
+ if i == n_steps - 1:
345
+ x = pred_x1
346
+ break
347
+
348
+ # --- Construct the next state x_{t_next} ---
349
+ # The probability of a token being noise at time t_next is (1 - t_next).
350
+ noise_prob = 1.0 - t_next
351
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
352
+
353
+ # Generate new random tokens for the noise positions
354
+ noise = torch.randint(
355
+ 0,
356
+ vocab_size,
357
+ x.shape,
358
+ device=self.device
359
+ )
360
+
361
+ # Combine the final prediction with noise to form the next intermediate state
362
+ x = torch.where(mask, noise, pred_x1)
363
+
364
+ return x
365
+
366
+ def _check_conjugation_validity(self, x):
367
+ """
368
+ Check if sequences can be conjugated.
369
+ Returns a boolean tensor indicating validity for each sample.
370
+ """
371
+ if self.tokenizer_type == 'amino':
372
+ # For amino tokenizer, convert to SMILES first
373
+ seqs = self.tokenizer.batch_decode(x)
374
+ three_letter_seqs = [one_letter_to_three_letter(seq) for seq in seqs]
375
+ smiles_seqs = [aa_to_smiles(three_letter_seq, cyclic=self.args.cyclic)
376
+ for three_letter_seq in three_letter_seqs]
377
+
378
+ # Check if SMILES conversion succeeded and result is valid peptide
379
+ validities = []
380
+ for smiles in smiles_seqs:
381
+ if isinstance(smiles, str):
382
+ validities.append(self.peptide_analyzer.is_peptide(smiles))
383
+ else:
384
+ validities.append(False)
385
+ return torch.tensor(validities, device=self.device)
386
+
387
+ if self.conjugation_obj is None:
388
+ # Fall back to peptide validity check
389
+ sequences = self.tokenizer.batch_decode(x)
390
+ return torch.tensor([self.peptide_analyzer.is_peptide(seq) for seq in sequences], device=self.device)
391
+
392
+ # Use conjugation objective - score > 0 means valid
393
+ scores = self.conjugation_obj(x)
394
+ return scores > 0
395
+
396
+ def generate_x0(self, n_steps=16, temperature=1.0, require_valid=False, max_retries=10):
397
+ """
398
+ Generate initial x0 sequences.
399
+
400
+ Args:
401
+ n_steps: Number of flow matching steps
402
+ temperature: Sampling temperature
403
+ require_valid: If True, regenerate invalid samples until all are valid conjugatable peptides
404
+ max_retries: Maximum number of regeneration attempts per sample
405
+ """
406
+ print("Starting initial SMILES generation...")
407
+
408
+ x = self._generate_single_x0(n_steps, temperature)
409
+
410
+ if require_valid:
411
+ print("Checking conjugation validity and regenerating invalid samples...")
412
+
413
+ for retry in range(max_retries):
414
+ validity = self._check_conjugation_validity(x)
415
+ num_valid = validity.sum().item()
416
+ num_invalid = self.args.num_samples - num_valid
417
+
418
+ if num_invalid == 0:
419
+ print(f"All {self.args.num_samples} samples are valid conjugatable peptides!")
420
+ break
421
+
422
+ print(f"Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid, regenerating {num_invalid} invalid samples...")
423
+
424
+ # Get indices of invalid samples
425
+ invalid_indices = torch.where(~validity)[0]
426
+
427
+ # Generate new candidates for invalid samples
428
+ # Temporarily adjust num_samples to generate replacements
429
+ original_num_samples = self.args.num_samples
430
+ self.args.num_samples = num_invalid
431
+
432
+ new_x = self._generate_single_x0(n_steps, temperature)
433
+
434
+ self.args.num_samples = original_num_samples
435
+
436
+ # Replace invalid samples with new ones
437
+ x[invalid_indices] = new_x
438
+
439
+ # Final validity check
440
+ final_validity = self._check_conjugation_validity(x)
441
+ num_final_valid = final_validity.sum().item()
442
+ if num_final_valid < self.args.num_samples:
443
+ print(f"Warning: After {max_retries} retries, only {num_final_valid}/{self.args.num_samples} samples are valid.")
444
+ else:
445
+ print(f"Success: All {self.args.num_samples} samples are valid conjugatable peptides.")
446
+
447
+ # 4. Decode the final token IDs into strings
448
+ generated_sequences = self.tokenizer.batch_decode(x)
449
+
450
+ # 5. Analyze the validity of the generated sequences
451
+ validities = []
452
+ if self.tokenizer_type == 'amino':
453
+ # For amino mode, report conjugation-relevant validity after AA->SMILES conversion.
454
+ validity_tensor = self._check_conjugation_validity(x)
455
+ validities = [bool(v.item()) for v in validity_tensor]
456
+ for seq in generated_sequences:
457
+ print(seq)
458
+ else:
459
+ for seq in generated_sequences:
460
+ validities.append(self.peptide_analyzer.is_peptide(seq))
461
+ print(seq)
462
+ if self.tokenizer_type == 'amino':
463
+ print(f"Initial Peptide Validity (AA->SMILES): {validities}")
464
+ else:
465
+ print(f"Initial Sequence Validity: {validities}")
466
+ return x
467
+
468
+ def _get_scores(self, x_batch):
469
+ """Calculates the normalized scores for a batch of sequences."""
470
+ scores = []
471
+
472
+ if self.tokenizer_type == 'amino':
473
+ # Convert AA tokens to SMILES for scoring
474
+ seqs = self.tokenizer.batch_decode(x_batch)
475
+
476
+ # Convert one-letter AA codes to three-letter, then to SMILES
477
+ three_letter_seqs = [one_letter_to_three_letter(seq) for seq in seqs]
478
+ with ThreadPoolExecutor() as pool:
479
+ smiles_seqs = list(pool.map(
480
+ lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
481
+ three_letter_seqs
482
+ ))
483
+
484
+ # Track valid conversions
485
+ valid_mask = torch.tensor(
486
+ [1 if isinstance(seq, str) else 0 for seq in smiles_seqs],
487
+ device=self.device
488
+ )
489
+
490
+ # Replace invalid SMILES with dummy 'C' for tokenization
491
+ for i in torch.where((1 - valid_mask) == 1)[0].tolist():
492
+ smiles_seqs[i] = 'C'
493
+
494
+ # Tokenize SMILES for objective functions
495
+ smiles_batch = self.smiles_tokenizer(
496
+ smiles_seqs, return_tensors='pt', padding=True
497
+ ).to(self.device)
498
+ attention_mask = smiles_batch.get("attention_mask", None)
499
+
500
+ # Evaluate each objective. Objectives may declare `.input_type`:
501
+ # 'aa' -> feed raw AA tokens (no SMILES conversion)
502
+ # 'smiles' -> feed SMILES tokens (default for the existing
503
+ # Conjugation / SMILES BindingAffinity)
504
+ for obj_func in self.objectives:
505
+ if getattr(obj_func, "input_type", None) == "aa":
506
+ # AA-input objective (e.g. UnpooledBindingPredictor):
507
+ # score directly on the original AA tokens; do not multiply
508
+ # by valid_mask (the AA->SMILES conversion outcome doesn't
509
+ # affect this scorer).
510
+ score = obj_func(x_batch.to(self.device)).to(self.device)
511
+ scores.append(score)
512
+ else:
513
+ try:
514
+ score = obj_func(
515
+ smiles_batch["input_ids"],
516
+ attention_mask=attention_mask
517
+ ).to(self.device)
518
+ except TypeError:
519
+ score = obj_func(smiles_batch["input_ids"]).to(self.device)
520
+ scores.append(score * valid_mask) # Zero out invalid conversions
521
+ else:
522
+ # Original SMILES-based scoring
523
+ for obj_func in self.objectives:
524
+ scores.append(obj_func(x_batch.to(self.device)))
525
+
526
+ return torch.stack(scores, dim=0).to(self.device)
527
+
528
+ def _barker_g(self, u):
529
+ """Barker balancing function."""
530
+ return u / (1 + u)
531
+
532
+ def validity(self, x):
533
+ sampled_sequences = self.tokenizer.batch_decode(x)
534
+
535
+ if self.tokenizer_type == 'amino':
536
+ # For amino tokenizer, convert to SMILES first and check validity
537
+ three_letter_seqs = [one_letter_to_three_letter(seq) for seq in sampled_sequences]
538
+ smiles_seqs = [aa_to_smiles(three_letter_seq, cyclic=self.args.cyclic)
539
+ for three_letter_seq in three_letter_seqs]
540
+ return [1.0 if isinstance(seq, str) and self.peptide_analyzer.is_peptide(seq) else 0.0
541
+ for seq in smiles_seqs]
542
+ else:
543
+ return [1.0 if self.peptide_analyzer.is_peptide(seq) else 0.0 for seq in sampled_sequences]
544
+
545
+ def generate(self):
546
+ """Main generation loop with tracking."""
547
+ shape = (self.args.num_samples, self.args.gen_len)
548
+ x = self.generate_x0(
549
+ require_valid=self.args.require_valid_x0,
550
+ max_retries=self.args.max_x0_retries
551
+ )
552
+ print(x)
553
+
554
+ if self.args.weights is None:
555
+ # The first weight is for conjugation (peptide + PDC validity)
556
+ # We need to ensure the SMILES sequences are valid peptides with valid PDCs
557
+ weights = torch.tensor([1] + [1/(self.num_objectives-1)] * (self.num_objectives-1), device=self.device).view(-1,1)
558
+ else:
559
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
560
+ if len(weights) != self.num_objectives:
561
+ raise ValueError("Number of weights must match number of objectives.")
562
+ print(f"Weights: {weights}")
563
+
564
+ with torch.no_grad():
565
+ current_scores = self._get_scores(x)
566
+ for t in tqdm(range(self.args.optimization_steps), desc="MOG Generation"):
567
+ improved = False
568
+ improved_indices = []
569
+
570
+ # Anneal guidance strength
571
+ eta_t = self.args.eta_min + (self.args.eta_max - self.args.eta_min) * (t / (self.args.optimization_steps - 1))
572
+ # Choose a random position to mutate
573
+ mut_idx = random.randint(1, self.args.gen_len-2)
574
+
575
+ # Determine the generation timestep
576
+ # We cycle through the timesteps to ensure all are visited
577
+ generation_step = t % self.args.optimization_steps
578
+ time_t = torch.full((self.args.num_samples,), (generation_step / self.args.optimization_steps), device=self.device)
579
+
580
+ # Get proposal distribution from ReDi model for the chosen position
581
+ logits = self.model(x, time_t)
582
+ probs = F.softmax(logits, dim=-1)
583
+ pos_probs = probs[:, mut_idx, :]
584
+ pos_probs[:, x[:, mut_idx]] = 0 # We don't evalute the same token
585
+ pos_probs[:, self.invalid] = 0
586
+
587
+ # Prune candidate vocabulary using top-p sampling
588
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
589
+ cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
590
+ remove_mask = cumulative_probs > self.args.top_p
591
+ remove_mask[..., 1:] = remove_mask[..., :-1].clone()
592
+ remove_mask[..., 0] = 0
593
+
594
+ # Get the set of candidate tokens for each sample in the batch
595
+ candidate_tokens_list = []
596
+ for i in range(self.args.num_samples):
597
+ sample_mask = remove_mask[i]
598
+ candidates = sorted_indices[i, ~sample_mask]
599
+ candidate_tokens_list.append(candidates)
600
+
601
+ w_current = torch.exp(eta_t * torch.min(weights * current_scores, dim=0).values)
602
+
603
+ if t == 0:
604
+ print(f"Initial Scores: {current_scores}")
605
+ # Log initial step with detailed results
606
+ if self.tracker and self.conjugation_obj:
607
+ detailed = self.conjugation_obj.get_last_detailed_results()
608
+ self.tracker.log_step(t, current_scores[0], detailed)
609
+ self.tracker.print_step_summary(t, current_scores[0], detailed)
610
+
611
+ # Evaluate all candidate tokens for each sample
612
+ final_proposal_tokens = []
613
+ for i in range(self.args.num_samples):
614
+ candidates = candidate_tokens_list[i]
615
+ candidates = torch.tensor([token for token in candidates if token not in self.invalid], device=candidates.device)
616
+ if len(candidates) >= 200:
617
+ candidates = candidates[:200]
618
+ num_candidates = len(candidates)
619
+
620
+ # Create a batch of proposed sequences for the current sample
621
+ x_prop_batch = x[i].repeat(num_candidates, 1)
622
+ x_prop_batch[:, mut_idx] = candidates
623
+
624
+ # Evaluate all proposals
625
+ proposal_scores = self._get_scores(x_prop_batch)
626
+ proposal_s_omega = torch.min(weights * proposal_scores, dim=0).values
627
+ w_proposal = torch.exp(eta_t * proposal_s_omega)
628
+
629
+ # Get ReDi probabilities for the candidates
630
+ redi_probs = pos_probs[i, candidates]
631
+
632
+ # Calculate unnormalized guided probabilities
633
+ tilde_q = redi_probs * self._barker_g(w_proposal / w_current[i])
634
+
635
+ # Normalize and sample the final token
636
+ final_probs = tilde_q / (torch.sum(tilde_q) + 1e-9)
637
+
638
+ index = torch.multinomial(final_probs, 1).item()
639
+
640
+ if (random.uniform(0,1) > 1 and proposal_scores[:,index][0] == 1) or torch.sum(weights.squeeze(1) * proposal_scores[:, index]) >= torch.sum(weights.squeeze(1) * current_scores[:,i]):
641
+ final_token = candidates[index]
642
+ print(f"Previous Weighted Sum: {torch.sum(weights.squeeze(1) * current_scores[:,i])}")
643
+ print(f"Previous Scores: {current_scores[:,i]}")
644
+
645
+ print(f"New Weighted Sum: {torch.sum(weights.squeeze(1) * proposal_scores[:, index])}")
646
+ print(f"New Scores: {proposal_scores[:,index]}")
647
+ improved = True
648
+ improved_indices.append(i)
649
+
650
+ else:
651
+ final_token = x[i][mut_idx]
652
+
653
+ final_proposal_tokens.append(final_token)
654
+
655
+ # Update the sequences with the chosen tokens
656
+ x[torch.arange(self.args.num_samples), mut_idx] = torch.stack(final_proposal_tokens)
657
+ if improved:
658
+ current_scores = self._get_scores(x)
659
+ print(self.tokenizer.batch_decode(x))
660
+
661
+ # Log step with tracking (every 4 steps to reduce overhead)
662
+ if self.tracker and self.conjugation_obj and (t % 4 == 0 or t == self.args.optimization_steps - 1):
663
+ detailed = self.conjugation_obj.get_last_detailed_results()
664
+ self.tracker.log_step(t, current_scores[0], detailed, improved_indices)
665
+
666
+ # Print summary for final step
667
+ if t == self.args.optimization_steps - 1:
668
+ self.tracker.print_step_summary(t, current_scores[0], detailed)
669
+
670
+ return x
671
+
672
+
673
+ # --- Main Execution ---
674
+ def main(args):
675
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
676
+ print(f"Using device: {device}")
677
+
678
+ target = args.target
679
+
680
+ # --- Initialize tokenizer based on type ---
681
+ if args.tokenizer_type == 'amino':
682
+ print("Using Amino Acid tokenizer")
683
+ tokenizer = AminoTokenizer()
684
+ smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
685
+ else:
686
+ print("Using SMILES tokenizer")
687
+ tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
688
+ smiles_tokenizer = tokenizer
689
+
690
+ # --- Initialize objective functions ---
691
+ # Conjugation always uses SMILES tokens (AA->SMILES->builder).
692
+ conjugation_model = Conjugation(smiles_tokenizer, device=device, builder_type=args.builder_type)
693
+ # Binding affinity: AA-input or SMILES-input, depending on --affinity_type.
694
+ # The returned object carries `.input_type` ('aa' or 'smiles') so the
695
+ # generator routes inputs correctly in _get_scores.
696
+ affinity_model = make_affinity(
697
+ args.affinity_type,
698
+ target_protein_seq=target,
699
+ device=device,
700
+ aa_ckpt_path=args.aa_affinity_ckpt,
701
+ )
702
+ print(f"Affinity scorer: {type(affinity_model).__name__} (input_type={getattr(affinity_model, 'input_type', '?')})")
703
+
704
+ # List of all objective functions
705
+ OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
706
+
707
+ # Initialize tracker for logging
708
+ tracker = GenerationTracker(output_dir=args.output_dir)
709
+
710
+ # --- Set random seeds for reproducibility ---
711
+ if args.seed is not None:
712
+ import random
713
+ import numpy as np
714
+ random.seed(args.seed)
715
+ np.random.seed(args.seed)
716
+ torch.manual_seed(args.seed)
717
+ if torch.cuda.is_available():
718
+ torch.cuda.manual_seed(args.seed)
719
+ torch.cuda.manual_seed_all(args.seed)
720
+ print(f"Random seed set to: {args.seed}")
721
+
722
+ # --- Load Model ---
723
+ print(f"Loading model from checkpoint: {args.checkpoint}")
724
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
725
+
726
+ if args.tokenizer_type == 'amino':
727
+ # Vanilla PyTorch checkpoint for amino model
728
+ raw_model_args = checkpoint['args']
729
+ if isinstance(raw_model_args, argparse.Namespace):
730
+ raw_model_args = vars(raw_model_args)
731
+ model_args = edict(raw_model_args)
732
+ model = MDLM(
733
+ vocab_size=model_args.vocab_size,
734
+ seq_len=model_args.seq_len,
735
+ model_dim=model_args.model_dim,
736
+ n_heads=model_args.n_heads,
737
+ n_layers=model_args.n_layers
738
+ ).to(device)
739
+ model.load_state_dict(checkpoint['model_state_dict'])
740
+ print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
741
+ else:
742
+ # PyTorch Lightning checkpoint for SMILES model
743
+ model_hparams = checkpoint["hyper_parameters"]["args"]
744
+ model = MDLMLightningModule.load_from_checkpoint(
745
+ args.checkpoint,
746
+ args=model_hparams,
747
+ tokenizer=tokenizer,
748
+ map_location=device,
749
+ strict=False
750
+ )
751
+ model.to(device)
752
+ model.eval()
753
+ print("Model loaded successfully.")
754
+
755
+ mog_generator = MOGGenerator(
756
+ model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker,
757
+ tokenizer=tokenizer, tokenizer_type=args.tokenizer_type
758
+ )
759
+
760
+ all_sequences = []
761
+ all_final_results = []
762
+
763
+ for batch_idx in range(args.num_batches):
764
+ print(f"\n{'='*60}")
765
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
766
+ print(f"{'='*60}")
767
+
768
+ generated_tokens = mog_generator.generate()
769
+ final_scores = mog_generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
770
+ sequence_str = tokenizer.batch_decode(generated_tokens)
771
+
772
+ print("\n--- Batch Results ---")
773
+ print(f"Sequences: {sequence_str}")
774
+ print(f"Scores: {final_scores}")
775
+
776
+ all_sequences.extend(sequence_str)
777
+
778
+ # Get detailed results with properties for final sequences
779
+ # For amino tokenizer, we need to convert to SMILES first for detailed scoring
780
+ if args.tokenizer_type == 'amino':
781
+ # Convert AA to SMILES for detailed conjugation analysis
782
+ smiles_list = []
783
+ for seq in sequence_str:
784
+ three_letter = one_letter_to_three_letter(seq)
785
+ smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
786
+ smiles_list.append(smiles if isinstance(smiles, str) else 'C')
787
+ # Tokenize SMILES for conjugation model
788
+ smiles_tokens = smiles_tokenizer(smiles_list, return_tensors='pt', padding=True).to(device)
789
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(smiles_tokens["input_ids"])
790
+ else:
791
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
792
+
793
+ tracker.log_final(sequence_str, detailed_with_props)
794
+ all_final_results.extend(detailed_with_props)
795
+
796
+ # Print and save final summary
797
+ tracker.print_final_summary()
798
+ tracker.save(filename_prefix='pdc_generation')
799
+
800
+ # Save sequences to text file
801
+ with open(args.output_file, 'w') as f:
802
+ for i, seq in enumerate(all_sequences):
803
+ if args.tokenizer_type == 'amino':
804
+ # Also save converted SMILES for amino sequences
805
+ three_letter = one_letter_to_three_letter(seq)
806
+ smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
807
+ if isinstance(smiles, str):
808
+ f.write(f"{seq}\t{smiles}\n")
809
+ else:
810
+ f.write(f"{seq}\tINVALID_SMILES\n")
811
+ else:
812
+ f.write(seq + '\n')
813
+ print(f"\nSequences saved to: {args.output_file}")
814
+
815
+ print("\nGeneration complete.")
816
+
817
+
818
+
819
+ if __name__ == "__main__":
820
+ parser = argparse.ArgumentParser(description="Multi-Objective Generation with LBP-MOG-ReDi (Single Mutation).")
821
+
822
+ parser.add_argument("--checkpoint", type=str, required=True, help="Path to the trained ReDi model checkpoint.")
823
+ parser.add_argument("--num_samples", type=int, default=10, help="Number of samples to generate.")
824
+ parser.add_argument("--num_batches", type=int, default=10, help="Number of batches to generate.")
825
+ parser.add_argument("--output_file", type=str, default="./smiles.txt", help="File to save the generated sequences.")
826
+ parser.add_argument("--output_dir", type=str, default="./generation_logs", help="Directory to save detailed generation logs.")
827
+ parser.add_argument("--gen_len", type=int, default=50, help="Length of the sequences to generate.")
828
+ parser.add_argument("--optimization_steps", type=int, default=16, help="Number of optimization steps per generation.")
829
+ parser.add_argument("--weights", type=float, nargs='+', required=False, help="Weights for the objectives.")
830
+ parser.add_argument("--eta_min", type=float, default=1.0, help="Minimum guidance strength.")
831
+ parser.add_argument("--eta_max", type=float, default=20.0, help="Maximum guidance strength.")
832
+ parser.add_argument("--top_p", type=float, default=0.9, help="Top-p for pruning candidate tokens.")
833
+ parser.add_argument("--builder_type", type=str, default="AoaDau",
834
+ choices=["AoaDau", "SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt",
835
+ "DSECCpt", "Cpt", "DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"],
836
+ help="PDC builder type for conjugation.")
837
+ parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility.")
838
+ parser.add_argument("--require_valid_x0", action="store_true",
839
+ help="If set, regenerate x0 samples until they are valid conjugatable peptides.")
840
+ parser.add_argument("--max_x0_retries", type=int, default=10,
841
+ help="Maximum retries when require_valid_x0 is enabled.")
842
+
843
+ parser.add_argument("--target", type=str, required=True, help="Target protein sequence for binding affinity.")
844
+
845
+ # Tokenizer and model type arguments
846
+ parser.add_argument("--tokenizer_type", type=str, default="smiles", choices=["smiles", "amino"],
847
+ help="Type of tokenizer: 'smiles' for SMILES SPE tokenizer, 'amino' for amino acid tokenizer.")
848
+ parser.add_argument("--cyclic", action="store_true",
849
+ help="Generate cyclic peptides (only for amino tokenizer).")
850
+ parser.add_argument("--smiles_vocab_path", type=str,
851
+ default="./smiles/smiles_tokenizer/new_vocab.txt",
852
+ help="Path to SMILES tokenizer vocabulary file.")
853
+ parser.add_argument("--smiles_splits_path", type=str,
854
+ default="./smiles/smiles_tokenizer/new_splits.txt",
855
+ help="Path to SMILES tokenizer splits file.")
856
+
857
+ # AA-mode binding-affinity selection (this is the WT-AA / PepReDi script).
858
+ parser.add_argument("--affinity_type", type=str, default="smiles",
859
+ choices=["aa", "smiles"],
860
+ help="Which binding-affinity oracle to use during "
861
+ "guidance: 'aa' = AA-input UnpooledBindingPredictor "
862
+ "(matches moPPIt's setup), 'smiles' = SMILES-input "
863
+ "BindingAffinity (matches the SMILES CupOFMoCA "
864
+ "tables). Defaults to 'aa' since this script is "
865
+ "the WT-AA pipeline.")
866
+ parser.add_argument("--aa_affinity_ckpt", type=str,
867
+ default=DEFAULT_AA_AFFINITY_CKPT,
868
+ help="Path to the AA-input affinity predictor "
869
+ "checkpoint (binding_affinity_unpooled.pt).")
870
+
871
+ args = parser.parse_args()
872
+ main(args)
smiles/generation/guided_uaa.py ADDED
@@ -0,0 +1,981 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import re
3
+ import random
4
+ import sys
5
+ from collections import Counter
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ import csv
8
+ import json
9
+ import os
10
+ from datetime import datetime
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ from tqdm import tqdm
15
+ from smiles.models.train import MDLMLightningModule
16
+ from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
17
+ import numpy as np
18
+ from easydict import EasyDict as edict
19
+
20
+ from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
21
+ from smiles.evaluation.pdc_analyzer import PDCAnalyzer
22
+ from smiles.scoring.classifiers import Conjugation, BindingAffinity
23
+
24
+ # Import AA-to-SMILES conversion utilities
25
+ from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
26
+ from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
27
+
28
+
29
+ # --- Model Architecture for Amino Acid Model ---
30
+ def modulate(x, shift, scale):
31
+ """Modulates the input tensor x with a shift and scale."""
32
+ return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
33
+
34
+
35
+ class TimestepEmbedder(nn.Module):
36
+ """Embeds a continuous scalar timestep t in [0, 1] into a vector representation."""
37
+ def __init__(self, hidden_size):
38
+ super().__init__()
39
+ self.mlp = nn.Sequential(
40
+ nn.Linear(1, hidden_size, bias=True),
41
+ nn.SiLU(),
42
+ nn.Linear(hidden_size, hidden_size, bias=True),
43
+ )
44
+
45
+ def forward(self, t):
46
+ return self.mlp(t.unsqueeze(-1))
47
+
48
+
49
+ class DiTBlock(nn.Module):
50
+ """A single block of the Diffusion Transformer."""
51
+ def __init__(self, hidden_size, n_heads):
52
+ super().__init__()
53
+ self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
54
+ self.attn = nn.MultiheadAttention(hidden_size, n_heads, batch_first=True)
55
+ self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
56
+ self.mlp = nn.Sequential(
57
+ nn.Linear(hidden_size, 4 * hidden_size),
58
+ nn.GELU(),
59
+ nn.Linear(4 * hidden_size, hidden_size)
60
+ )
61
+ self.adaLN_modulation = nn.Sequential(
62
+ nn.SiLU(),
63
+ nn.Linear(hidden_size, 6 * hidden_size, bias=True)
64
+ )
65
+
66
+ def forward(self, x, c):
67
+ shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.adaLN_modulation(c).chunk(6, dim=1)
68
+ x_norm1 = modulate(self.norm1(x), shift_msa, scale_msa)
69
+ attn_output, _ = self.attn(x_norm1, x_norm1, x_norm1)
70
+ x = x + gate_msa.unsqueeze(1) * attn_output
71
+ x_norm2 = modulate(self.norm2(x), shift_mlp, scale_mlp)
72
+ mlp_output = self.mlp(x_norm2)
73
+ x = x + gate_mlp.unsqueeze(1) * mlp_output
74
+ return x
75
+
76
+
77
+ class MDLM(nn.Module):
78
+ """Masked Diffusion Language Model (MDLM) using a DiT backbone."""
79
+ def __init__(self, vocab_size, seq_len, model_dim, n_heads, n_layers):
80
+ super().__init__()
81
+ self.vocab_size = vocab_size
82
+ self.seq_len = seq_len
83
+ self.model_dim = model_dim
84
+
85
+ self.token_embedder = nn.Embedding(vocab_size, model_dim)
86
+ self.pos_embedder = nn.Parameter(torch.randn(1, seq_len, model_dim))
87
+ self.time_embedder = TimestepEmbedder(model_dim)
88
+
89
+ self.transformer_blocks = nn.ModuleList([
90
+ DiTBlock(model_dim, n_heads) for _ in range(n_layers)
91
+ ])
92
+
93
+ self.final_norm = nn.LayerNorm(model_dim)
94
+ self.lm_head = nn.Linear(model_dim, vocab_size)
95
+
96
+ def forward(self, x, t):
97
+ seq_len = x.shape[1]
98
+ x_embed = self.token_embedder(x) + self.pos_embedder[:, :seq_len, :]
99
+ t_embed = self.time_embedder(t)
100
+ for block in self.transformer_blocks:
101
+ x_embed = block(x_embed, t_embed)
102
+ x_embed = self.final_norm(x_embed)
103
+ logits = self.lm_head(x_embed)
104
+ return logits
105
+
106
+
107
+ class AminoTokenizer:
108
+ """Tokenizer for amino acid sequences (natural and unnatural AAs)."""
109
+ def __init__(self):
110
+ self.vocab = {
111
+ '<cls>': 0, '<pad>': 1, '<eos>': 2, '<unk>': 3,
112
+ 'L': 4, 'A': 5, 'G': 6, 'V': 7, 'S': 8, 'E': 9,
113
+ 'R': 10, 'T': 11, 'I': 12, 'D': 13, 'P': 14, 'K': 15,
114
+ 'Q': 16, 'N': 17, 'F': 18, 'Y': 19, 'M': 20, 'H': 21,
115
+ 'W': 22, 'C': 23,
116
+ # Non-canonical amino acids
117
+ 'Ŷ': 24, 'Ĉ': 25, 'Ĝ': 26, 'Ė': 27, 'Ƙ': 28,
118
+ 'Ṃ': 29, 'Ł': 30, 'Ċ': 31, 'Ṯ': 32,
119
+ # Lowercase tokens (D-amino acids)
120
+ 'a': 33, 'c': 34, 'd': 35, 'e': 36, 'f': 37,
121
+ 'g': 38, 'h': 39, 'i': 40, 'k': 41, 'l': 42,
122
+ 'm': 43, 'n': 44, 'p': 45, 'q': 46, 'r': 47,
123
+ 's': 48, 't': 49, 'v': 50, 'w': 51, 'y': 52,
124
+ }
125
+ self.id_to_token = {i: t for t, i in self.vocab.items()}
126
+
127
+ self.cls_token = '<cls>'
128
+ self.pad_token = '<pad>'
129
+ self.eos_token = '<eos>'
130
+ self.unk_token = '<unk>'
131
+
132
+ self.cls_id = self.vocab[self.cls_token]
133
+ self.pad_id = self.vocab[self.pad_token]
134
+ self.eos_id = self.vocab[self.eos_token]
135
+ self.unk_id = self.vocab[self.unk_token]
136
+
137
+ self.vocab_size = len(self.vocab.keys())
138
+
139
+ self._ncaa_to_nat_token = {
140
+ 'Ŷ': 'Y', 'Ĉ': 'C', 'Ċ': 'C', 'Ĝ': 'G', 'Ė': 'E',
141
+ 'Ƙ': 'K', 'Ṃ': 'M', 'Ł': 'L', 'Ṯ': 'T',
142
+ }
143
+
144
+ id_map = torch.arange(self.vocab_size, dtype=torch.long)
145
+ for ncaa_tok, nat_tok in self._ncaa_to_nat_token.items():
146
+ if ncaa_tok in self.vocab and nat_tok in self.vocab:
147
+ id_map[self.vocab[ncaa_tok]] = self.vocab[nat_tok]
148
+ self._ncaa_to_nat_id_map = id_map
149
+
150
+ def encode(self, seq: str, add_special_tokens: bool = True):
151
+ token_ids = []
152
+ if add_special_tokens:
153
+ token_ids.append(self.cls_id)
154
+ for ch in seq:
155
+ token_ids.append(self.vocab.get(ch, self.unk_id))
156
+ if add_special_tokens:
157
+ token_ids.append(self.eos_id)
158
+ return token_ids
159
+
160
+ def batch_encode(self, seqs, add_special_tokens: bool = True):
161
+ return torch.tensor([self.encode(s, add_special_tokens=add_special_tokens) for s in seqs])
162
+
163
+ def decode(self, ids, skip_special_tokens: bool = True):
164
+ if isinstance(ids, torch.Tensor):
165
+ ids = ids.tolist()
166
+ tokens = []
167
+ for i in ids:
168
+ tok = self.id_to_token.get(int(i), self.unk_token)
169
+ if skip_special_tokens and tok in {self.cls_token, self.eos_token, self.pad_token}:
170
+ continue
171
+ tokens.append(tok)
172
+ return ''.join(tokens)
173
+
174
+ def batch_decode(self, batch_ids, skip_special_tokens: bool = True):
175
+ return [self.decode(ids, skip_special_tokens=skip_special_tokens) for ids in batch_ids]
176
+
177
+ def to_natural_ids(self, batch_ids: torch.Tensor) -> torch.Tensor:
178
+ """Map non-canonical AA token IDs to natural AA token IDs."""
179
+ if not torch.is_tensor(batch_ids):
180
+ batch_ids = torch.tensor(batch_ids, dtype=torch.long)
181
+ id_map = self._ncaa_to_nat_id_map.to(device=batch_ids.device)
182
+ out = batch_ids.clone()
183
+ valid = (out >= 0) & (out < id_map.numel())
184
+ out[valid] = id_map[out[valid]]
185
+ out[~valid] = self.unk_id
186
+ return out
187
+
188
+
189
+ class GenerationTracker:
190
+ """
191
+ Tracks detailed validity information during generation
192
+ """
193
+ def __init__(self, output_dir='./generation_logs'):
194
+ self.output_dir = output_dir
195
+ os.makedirs(output_dir, exist_ok=True)
196
+
197
+ self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
198
+ self.step_logs = []
199
+ self.final_results = []
200
+
201
+ def log_step(self, step, scores, detailed_results=None, improved_indices=None):
202
+ """Log information for a generation step"""
203
+ step_info = {
204
+ 'step': step,
205
+ 'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
206
+ 'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
207
+ 'improved_indices': improved_indices or []
208
+ }
209
+
210
+ if detailed_results:
211
+ step_info['detailed'] = []
212
+ for i, result in enumerate(detailed_results):
213
+ step_info['detailed'].append({
214
+ 'sample_idx': i,
215
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
216
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
217
+ 'conjugation_success': result.get('conjugation_success', False),
218
+ 'pdc_valid': result.get('pdc_valid', False),
219
+ 'num_conjugates': len(result.get('conjugates', [])),
220
+ 'error': result.get('error')
221
+ })
222
+
223
+ self.step_logs.append(step_info)
224
+
225
+ def log_final(self, sequences, detailed_results_with_props):
226
+ """Log final results with properties"""
227
+ for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
228
+ final_info = {
229
+ 'sample_idx': i,
230
+ 'peptide_smiles': seq,
231
+ 'is_valid_peptide': result.get('is_valid_peptide', False),
232
+ 'has_conjugation_site': result.get('has_conjugation_site', False),
233
+ 'num_conjugates': len(result.get('conjugates', [])),
234
+ 'num_valid_pdcs': len(result.get('valid_pdcs', [])),
235
+ 'best_pdc': None,
236
+ 'properties': result.get('properties', {})
237
+ }
238
+
239
+ if result.get('best_pdc'):
240
+ final_info['best_pdc'] = {
241
+ 'smiles': result['best_pdc'].get('smiles', ''),
242
+ 'checks': result['best_pdc'].get('checks', {})
243
+ }
244
+
245
+ self.final_results.append(final_info)
246
+
247
+ def save(self, filename_prefix='generation'):
248
+ """Save all logs to files"""
249
+ # Save step logs
250
+ step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
251
+ with open(step_log_path, 'w') as f:
252
+ json.dump(self.step_logs, f, indent=2, default=str)
253
+
254
+ # Save final results
255
+ final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
256
+ with open(final_path, 'w') as f:
257
+ json.dump(self.final_results, f, indent=2, default=str)
258
+
259
+ # Save summary CSV
260
+ csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
261
+ with open(csv_path, 'w', newline='') as f:
262
+ writer = csv.writer(f)
263
+ writer.writerow([
264
+ 'sample_idx', 'peptide_smiles', 'is_valid_peptide', 'has_conjugation_site',
265
+ 'num_conjugates', 'num_valid_pdcs', 'best_pdc_smiles',
266
+ 'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
267
+ ])
268
+ for result in self.final_results:
269
+ props = result.get('properties', {})
270
+ writer.writerow([
271
+ result['sample_idx'],
272
+ result['peptide_smiles'],
273
+ result['is_valid_peptide'],
274
+ result['has_conjugation_site'],
275
+ result['num_conjugates'],
276
+ result['num_valid_pdcs'],
277
+ result['best_pdc']['smiles'] if result.get('best_pdc') else '',
278
+ props.get('mf', ''),
279
+ props.get('polararea', ''),
280
+ props.get('xlogp', ''),
281
+ props.get('heavycnt', ''),
282
+ props.get('rotbonds', '')
283
+ ])
284
+
285
+ print(f"\n=== Generation logs saved ===")
286
+ print(f" Step logs: {step_log_path}")
287
+ print(f" Final results: {final_path}")
288
+ print(f" Summary CSV: {csv_path}")
289
+
290
+ return step_log_path, final_path, csv_path
291
+
292
+ def print_step_summary(self, step, scores, detailed_results=None):
293
+ """Print a summary for the current step"""
294
+ if isinstance(scores, torch.Tensor):
295
+ scores_list = scores.cpu().numpy().tolist()
296
+ else:
297
+ scores_list = list(scores)
298
+
299
+ num_valid = sum(1 for s in scores_list if s > 0)
300
+
301
+ print(f"\n--- Step {step} Summary ---")
302
+ print(f" Valid: {num_valid}/{len(scores_list)}")
303
+
304
+ if detailed_results:
305
+ valid_peptide = sum(1 for r in detailed_results if r.get('is_valid_peptide'))
306
+ has_site = sum(1 for r in detailed_results if r.get('has_conjugation_site'))
307
+ conj_success = sum(1 for r in detailed_results if r.get('conjugation_success'))
308
+ pdc_valid = sum(1 for r in detailed_results if r.get('pdc_valid'))
309
+
310
+ print(f" Breakdown:")
311
+ print(f" Valid peptides: {valid_peptide}")
312
+ print(f" Has conjugation site: {has_site}")
313
+ print(f" Conjugation success: {conj_success}")
314
+ print(f" PDC valid: {pdc_valid}")
315
+
316
+ def print_final_summary(self):
317
+ """Print final summary"""
318
+ print("\n" + "=" * 60)
319
+ print("FINAL GENERATION SUMMARY")
320
+ print("=" * 60)
321
+
322
+ total = len(self.final_results)
323
+ valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
324
+
325
+ print(f"\nTotal sequences: {total}")
326
+ print(f"With valid PDCs: {valid_pdcs}")
327
+
328
+ print("\nDetailed results:")
329
+ for result in self.final_results:
330
+ status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
331
+ print(f"\n [{status}] Sample {result['sample_idx']}:")
332
+ print(f" Peptide: {result['peptide_smiles'][:60]}...")
333
+ print(f" Valid peptide: {result['is_valid_peptide']}")
334
+ print(f" Has conjugation site: {result['has_conjugation_site']}")
335
+ print(f" Conjugates: {result['num_conjugates']}")
336
+ print(f" Valid PDCs: {result['num_valid_pdcs']}")
337
+
338
+ if result.get('properties'):
339
+ props = result['properties']
340
+ print(f" Properties:")
341
+ for k, v in props.items():
342
+ print(f" {k}: {v}")
343
+
344
+
345
+ def peptide_bond_mask(smiles_list):
346
+ # Initialize the batch mask
347
+ batch_size = len(smiles_list)
348
+ max_seq_length = 1035 #max(len(smiles) for smiles in smiles_list) # Find the longest SMILES
349
+ mask = torch.zeros((batch_size, max_seq_length), dtype=torch.int) # Mask filled with zeros
350
+
351
+ bond_patterns = [
352
+ (r'OC\(=O\)', 'ester'),
353
+ (r'N\(C\)C\(=O\)', 'n_methyl'),
354
+ (r'N[12]C\(=O\)', 'peptide'), # Pro peptide bonds
355
+ (r'NC\(=O\)', 'peptide'), # Regular peptide bonds
356
+ (r'C\(=O\)N\(C\)', 'n_methyl'),
357
+ (r'C\(=O\)N[12]?', 'peptide')
358
+ ]
359
+
360
+ for batch_idx, smiles in enumerate(smiles_list):
361
+ positions = []
362
+ used = set()
363
+
364
+ # Identify bonds
365
+ for pattern, bond_type in bond_patterns:
366
+ for match in re.finditer(pattern, smiles):
367
+ if not any(p in range(match.start(), match.end()) for p in used):
368
+ positions.append({
369
+ 'start': match.start(),
370
+ 'end': match.end(),
371
+ 'type': bond_type,
372
+ 'pattern': match.group()
373
+ })
374
+ used.update(range(match.start(), match.end()))
375
+
376
+ # Update the mask for the current SMILES
377
+ for pos in positions:
378
+ mask[batch_idx, pos['start']:pos['end']] = 1
379
+
380
+ return mask
381
+
382
+ def peptide_token_mask(smiles_list, token_lists):
383
+ # Initialize the batch mask
384
+ batch_size = len(smiles_list)
385
+ token_seq_length = max(len(tokens) for tokens in token_lists) # Find the longest tokenized sequence
386
+ tokenized_masks = torch.zeros((batch_size, token_seq_length), dtype=torch.int) # Mask filled with zeros
387
+ atomwise_masks = peptide_bond_mask(smiles_list)
388
+
389
+
390
+ for batch_idx, atomwise_mask in enumerate(atomwise_masks):
391
+ token_seq = token_lists[batch_idx]
392
+ atom_idx = 0
393
+
394
+ for token_idx, token in enumerate(token_seq):
395
+ if token_idx != 0 and token_idx != len(token_seq) - 1:
396
+ if torch.sum(atomwise_mask[atom_idx:atom_idx+len(token)]) >= 1:
397
+ tokenized_masks[batch_idx][token_idx] = 1
398
+ atom_idx += len(token)
399
+
400
+ return tokenized_masks
401
+
402
+ class MOGGenerator:
403
+ def __init__(self, model, device, objectives, args, tracker=None, tokenizer=None, tokenizer_type='smiles'):
404
+ self.model = model
405
+ self.device = device
406
+ self.objectives = objectives
407
+ self.args = args
408
+ self.num_objectives = len(objectives)
409
+ self.peptide_analyzer = PeptideAnalyzer()
410
+ self.pdc_analyzer = PDCAnalyzer(device)
411
+ self.tracker = tracker
412
+ self.tokenizer = tokenizer
413
+ self.tokenizer_type = tokenizer_type
414
+
415
+ # Set invalid tokens based on tokenizer type
416
+ if tokenizer_type == 'amino':
417
+ self.invalid = [0, 1, 2, 3, 38] # special tokens + 'g' for amino
418
+ # Initialize SMILES tokenizer for AA-to-SMILES conversion
419
+ self.smiles_tokenizer = SMILES_SPE_Tokenizer(
420
+ args.smiles_vocab_path,
421
+ args.smiles_splits_path
422
+ )
423
+ else:
424
+ self.invalid = [0, 1, 2, 3, 4, 585, 586] # SMILES special tokens
425
+ self.smiles_tokenizer = None
426
+
427
+ # Find the Conjugation objective for detailed tracking
428
+ self.conjugation_obj = None
429
+ for obj in objectives:
430
+ if isinstance(obj, Conjugation):
431
+ self.conjugation_obj = obj
432
+ break
433
+
434
+ def _generate_single_x0(self, n_steps=16, temperature=1.0):
435
+ """Generate a single batch of x0 sequences."""
436
+ self.model.eval()
437
+
438
+ # Get vocab size based on tokenizer type
439
+ if self.tokenizer_type == 'amino':
440
+ vocab_size = self.model.vocab_size
441
+ else:
442
+ vocab_size = self.model.tokenizer.vocab_size
443
+
444
+ # 1. Start with a tensor of random tokens (pure noise at t=0)
445
+ # For amino tokenizer, exclude invalid tokens from initialization
446
+ if self.tokenizer_type == 'amino':
447
+ valid_tokens = [i for i in range(vocab_size) if i not in self.invalid]
448
+ valid_tokens = torch.tensor(valid_tokens, device=self.device)
449
+ rand_indices = torch.randint(0, len(valid_tokens), (self.args.num_samples, self.args.gen_len), device=self.device)
450
+ body = valid_tokens[rand_indices]
451
+ # Add CLS and EOS tokens
452
+ cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
453
+ eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
454
+ x = torch.cat([cls_col, body, eos_col], dim=1)
455
+ else:
456
+ x = torch.randint(
457
+ 0,
458
+ vocab_size,
459
+ (self.args.num_samples, self.args.gen_len),
460
+ device=self.device
461
+ )
462
+
463
+ # 2. Define the time schedule for the forward process (0.0 to 1.0)
464
+ time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
465
+
466
+ # 3. Iteratively follow the flow from noise to data
467
+ with torch.no_grad():
468
+ for i in tqdm(range(n_steps), desc="Flow Matching Steps", leave=False):
469
+ t_curr = time_steps[i]
470
+ t_next = time_steps[i+1]
471
+
472
+ # Prepare the current timestep tensor for the model
473
+ t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
474
+
475
+ # Get the model's prediction for the final clean sequence (at t=1)
476
+ logits = self.model(x, t_tensor)
477
+ logits = logits / temperature
478
+
479
+ # Mask out invalid tokens
480
+ for inv_tok in self.invalid:
481
+ if inv_tok < logits.shape[-1]:
482
+ logits[:, :, inv_tok] = -1000
483
+
484
+ pred_x1 = torch.argmax(logits, dim=-1)
485
+
486
+ # On the last step, the result is the final prediction
487
+ if i == n_steps - 1:
488
+ x = pred_x1
489
+ break
490
+
491
+ # --- Construct the next state x_{t_next} ---
492
+ # The probability of a token being noise at time t_next is (1 - t_next).
493
+ noise_prob = 1.0 - t_next
494
+ mask = torch.rand(x.shape, device=self.device) < noise_prob
495
+
496
+ # Generate new random tokens for the noise positions
497
+ noise = torch.randint(
498
+ 0,
499
+ vocab_size,
500
+ x.shape,
501
+ device=self.device
502
+ )
503
+
504
+ # Combine the final prediction with noise to form the next intermediate state
505
+ x = torch.where(mask, noise, pred_x1)
506
+
507
+ return x
508
+
509
+ def _check_conjugation_validity(self, x):
510
+ """
511
+ Check if sequences can be conjugated.
512
+ Returns a boolean tensor indicating validity for each sample.
513
+ """
514
+ if self.tokenizer_type == 'amino':
515
+ # For amino tokenizer, convert to SMILES first
516
+ seqs = self.tokenizer.batch_decode(x)
517
+ three_letter_seqs = [one_letter_to_three_letter(seq) for seq in seqs]
518
+ smiles_seqs = [aa_to_smiles(three_letter_seq, cyclic=self.args.cyclic)
519
+ for three_letter_seq in three_letter_seqs]
520
+
521
+ # Check if SMILES conversion succeeded and result is valid peptide
522
+ validities = []
523
+ for smiles in smiles_seqs:
524
+ if isinstance(smiles, str):
525
+ validities.append(self.peptide_analyzer.is_peptide(smiles))
526
+ else:
527
+ validities.append(False)
528
+ return torch.tensor(validities, device=self.device)
529
+
530
+ if self.conjugation_obj is None:
531
+ # Fall back to peptide validity check
532
+ sequences = self.tokenizer.batch_decode(x)
533
+ return torch.tensor([self.peptide_analyzer.is_peptide(seq) for seq in sequences], device=self.device)
534
+
535
+ # Use conjugation objective - score > 0 means valid
536
+ scores = self.conjugation_obj(x)
537
+ return scores > 0
538
+
539
+ def generate_x0(self, n_steps=16, temperature=1.0, require_valid=False, max_retries=10):
540
+ """
541
+ Generate initial x0 sequences.
542
+
543
+ Args:
544
+ n_steps: Number of flow matching steps
545
+ temperature: Sampling temperature
546
+ require_valid: If True, regenerate invalid samples until all are valid conjugatable peptides
547
+ max_retries: Maximum number of regeneration attempts per sample
548
+ """
549
+ print("Starting initial SMILES generation...")
550
+
551
+ x = self._generate_single_x0(n_steps, temperature)
552
+
553
+ if require_valid:
554
+ print("Checking conjugation validity and regenerating invalid samples...")
555
+
556
+ for retry in range(max_retries):
557
+ validity = self._check_conjugation_validity(x)
558
+ num_valid = validity.sum().item()
559
+ num_invalid = self.args.num_samples - num_valid
560
+
561
+ if num_invalid == 0:
562
+ print(f"All {self.args.num_samples} samples are valid conjugatable peptides!")
563
+ break
564
+
565
+ print(f"Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid, regenerating {num_invalid} invalid samples...")
566
+
567
+ # Get indices of invalid samples
568
+ invalid_indices = torch.where(~validity)[0]
569
+
570
+ # Generate new candidates for invalid samples
571
+ # Temporarily adjust num_samples to generate replacements
572
+ original_num_samples = self.args.num_samples
573
+ self.args.num_samples = num_invalid
574
+
575
+ new_x = self._generate_single_x0(n_steps, temperature)
576
+
577
+ self.args.num_samples = original_num_samples
578
+
579
+ # Replace invalid samples with new ones
580
+ x[invalid_indices] = new_x
581
+
582
+ # Final validity check
583
+ final_validity = self._check_conjugation_validity(x)
584
+ num_final_valid = final_validity.sum().item()
585
+ if num_final_valid < self.args.num_samples:
586
+ print(f"Warning: After {max_retries} retries, only {num_final_valid}/{self.args.num_samples} samples are valid.")
587
+ else:
588
+ print(f"Success: All {self.args.num_samples} samples are valid conjugatable peptides.")
589
+
590
+ # 4. Decode the final token IDs into strings
591
+ generated_sequences = self.tokenizer.batch_decode(x)
592
+
593
+ # 5. Analyze the validity of the generated sequences
594
+ validities = []
595
+ if self.tokenizer_type == 'amino':
596
+ # For amino mode, report conjugation-relevant validity after AA->SMILES conversion.
597
+ validity_tensor = self._check_conjugation_validity(x)
598
+ validities = [bool(v.item()) for v in validity_tensor]
599
+ for seq in generated_sequences:
600
+ print(seq)
601
+ else:
602
+ for seq in generated_sequences:
603
+ validities.append(self.peptide_analyzer.is_peptide(seq))
604
+ print(seq)
605
+ if self.tokenizer_type == 'amino':
606
+ print(f"Initial Peptide Validity (AA->SMILES): {validities}")
607
+ else:
608
+ print(f"Initial Sequence Validity: {validities}")
609
+ return x
610
+
611
+ def _get_scores(self, x_batch):
612
+ """Calculates the normalized scores for a batch of sequences."""
613
+ scores = []
614
+
615
+ if self.tokenizer_type == 'amino':
616
+ # Convert AA tokens to SMILES for scoring
617
+ seqs = self.tokenizer.batch_decode(x_batch)
618
+
619
+ # Convert one-letter AA codes to three-letter, then to SMILES
620
+ three_letter_seqs = [one_letter_to_three_letter(seq) for seq in seqs]
621
+ with ThreadPoolExecutor() as pool:
622
+ smiles_seqs = list(pool.map(
623
+ lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
624
+ three_letter_seqs
625
+ ))
626
+
627
+ # Track valid conversions
628
+ valid_mask = torch.tensor(
629
+ [1 if isinstance(seq, str) else 0 for seq in smiles_seqs],
630
+ device=self.device
631
+ )
632
+
633
+ # Replace invalid SMILES with dummy 'C' for tokenization
634
+ for i in torch.where((1 - valid_mask) == 1)[0].tolist():
635
+ smiles_seqs[i] = 'C'
636
+
637
+ # Tokenize SMILES for objective functions
638
+ smiles_batch = self.smiles_tokenizer(
639
+ smiles_seqs, return_tensors='pt', padding=True
640
+ ).to(self.device)
641
+ attention_mask = smiles_batch.get("attention_mask", None)
642
+
643
+ # Evaluate each objective function on SMILES tokens
644
+ for obj_func in self.objectives:
645
+ try:
646
+ score = obj_func(
647
+ smiles_batch["input_ids"],
648
+ attention_mask=attention_mask
649
+ ).to(self.device)
650
+ except TypeError:
651
+ score = obj_func(smiles_batch["input_ids"]).to(self.device)
652
+ scores.append(score * valid_mask) # Zero out invalid conversions
653
+ else:
654
+ # Original SMILES-based scoring
655
+ for obj_func in self.objectives:
656
+ scores.append(obj_func(x_batch.to(self.device)))
657
+
658
+ return torch.stack(scores, dim=0).to(self.device)
659
+
660
+ def _barker_g(self, u):
661
+ """Barker balancing function."""
662
+ return u / (1 + u)
663
+
664
+ def validity(self, x):
665
+ sampled_sequences = self.tokenizer.batch_decode(x)
666
+
667
+ if self.tokenizer_type == 'amino':
668
+ # For amino tokenizer, convert to SMILES first and check validity
669
+ three_letter_seqs = [one_letter_to_three_letter(seq) for seq in sampled_sequences]
670
+ smiles_seqs = [aa_to_smiles(three_letter_seq, cyclic=self.args.cyclic)
671
+ for three_letter_seq in three_letter_seqs]
672
+ return [1.0 if isinstance(seq, str) and self.peptide_analyzer.is_peptide(seq) else 0.0
673
+ for seq in smiles_seqs]
674
+ else:
675
+ return [1.0 if self.peptide_analyzer.is_peptide(seq) else 0.0 for seq in sampled_sequences]
676
+
677
+ def generate(self):
678
+ """Main generation loop with tracking."""
679
+ shape = (self.args.num_samples, self.args.gen_len)
680
+ x = self.generate_x0(
681
+ require_valid=self.args.require_valid_x0,
682
+ max_retries=self.args.max_x0_retries
683
+ )
684
+ print(x)
685
+
686
+ if self.args.weights is None:
687
+ # The first weight is for conjugation (peptide + PDC validity)
688
+ # We need to ensure the SMILES sequences are valid peptides with valid PDCs
689
+ weights = torch.tensor([1] + [1/(self.num_objectives-1)] * (self.num_objectives-1), device=self.device).view(-1,1)
690
+ else:
691
+ weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
692
+ if len(weights) != self.num_objectives:
693
+ raise ValueError("Number of weights must match number of objectives.")
694
+ print(f"Weights: {weights}")
695
+
696
+ with torch.no_grad():
697
+ current_scores = self._get_scores(x)
698
+ for t in tqdm(range(self.args.optimization_steps), desc="MOG Generation"):
699
+ improved = False
700
+ improved_indices = []
701
+
702
+ # Anneal guidance strength
703
+ eta_t = self.args.eta_min + (self.args.eta_max - self.args.eta_min) * (t / (self.args.optimization_steps - 1))
704
+ # Choose a random position to mutate
705
+ mut_idx = random.randint(1, self.args.gen_len-2)
706
+
707
+ # Determine the generation timestep
708
+ # We cycle through the timesteps to ensure all are visited
709
+ generation_step = t % self.args.optimization_steps
710
+ time_t = torch.full((self.args.num_samples,), (generation_step / self.args.optimization_steps), device=self.device)
711
+
712
+ # Get proposal distribution from ReDi model for the chosen position
713
+ logits = self.model(x, time_t)
714
+ probs = F.softmax(logits, dim=-1)
715
+ pos_probs = probs[:, mut_idx, :]
716
+ pos_probs[:, x[:, mut_idx]] = 0 # We don't evalute the same token
717
+ pos_probs[:, self.invalid] = 0
718
+
719
+ # Prune candidate vocabulary using top-p sampling
720
+ sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
721
+ cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
722
+ remove_mask = cumulative_probs > self.args.top_p
723
+ remove_mask[..., 1:] = remove_mask[..., :-1].clone()
724
+ remove_mask[..., 0] = 0
725
+
726
+ # Get the set of candidate tokens for each sample in the batch
727
+ candidate_tokens_list = []
728
+ for i in range(self.args.num_samples):
729
+ sample_mask = remove_mask[i]
730
+ candidates = sorted_indices[i, ~sample_mask]
731
+ candidate_tokens_list.append(candidates)
732
+
733
+ w_current = torch.exp(eta_t * torch.min(weights * current_scores, dim=0).values)
734
+
735
+ if t == 0:
736
+ print(f"Initial Scores: {current_scores}")
737
+ # Log initial step with detailed results
738
+ if self.tracker and self.conjugation_obj:
739
+ detailed = self.conjugation_obj.get_last_detailed_results()
740
+ self.tracker.log_step(t, current_scores[0], detailed)
741
+ self.tracker.print_step_summary(t, current_scores[0], detailed)
742
+
743
+ # Evaluate all candidate tokens for each sample
744
+ final_proposal_tokens = []
745
+ for i in range(self.args.num_samples):
746
+ candidates = candidate_tokens_list[i]
747
+ candidates = torch.tensor([token for token in candidates if token not in self.invalid], device=candidates.device)
748
+ if len(candidates) >= 200:
749
+ candidates = candidates[:200]
750
+ num_candidates = len(candidates)
751
+
752
+ # Create a batch of proposed sequences for the current sample
753
+ x_prop_batch = x[i].repeat(num_candidates, 1)
754
+ x_prop_batch[:, mut_idx] = candidates
755
+
756
+ # Evaluate all proposals
757
+ proposal_scores = self._get_scores(x_prop_batch)
758
+ proposal_s_omega = torch.min(weights * proposal_scores, dim=0).values
759
+ w_proposal = torch.exp(eta_t * proposal_s_omega)
760
+
761
+ # Get ReDi probabilities for the candidates
762
+ redi_probs = pos_probs[i, candidates]
763
+
764
+ # Calculate unnormalized guided probabilities
765
+ tilde_q = redi_probs * self._barker_g(w_proposal / w_current[i])
766
+
767
+ # Normalize and sample the final token
768
+ final_probs = tilde_q / (torch.sum(tilde_q) + 1e-9)
769
+
770
+ index = torch.multinomial(final_probs, 1).item()
771
+
772
+ if (random.uniform(0,1) > 1 and proposal_scores[:,index][0] == 1) or torch.sum(weights.squeeze(1) * proposal_scores[:, index]) >= torch.sum(weights.squeeze(1) * current_scores[:,i]):
773
+ final_token = candidates[index]
774
+ print(f"Previous Weighted Sum: {torch.sum(weights.squeeze(1) * current_scores[:,i])}")
775
+ print(f"Previous Scores: {current_scores[:,i]}")
776
+
777
+ print(f"New Weighted Sum: {torch.sum(weights.squeeze(1) * proposal_scores[:, index])}")
778
+ print(f"New Scores: {proposal_scores[:,index]}")
779
+ improved = True
780
+ improved_indices.append(i)
781
+
782
+ else:
783
+ final_token = x[i][mut_idx]
784
+
785
+ final_proposal_tokens.append(final_token)
786
+
787
+ # Update the sequences with the chosen tokens
788
+ x[torch.arange(self.args.num_samples), mut_idx] = torch.stack(final_proposal_tokens)
789
+ if improved:
790
+ current_scores = self._get_scores(x)
791
+ print(self.tokenizer.batch_decode(x))
792
+
793
+ # Log step with tracking (every 4 steps to reduce overhead)
794
+ if self.tracker and self.conjugation_obj and (t % 4 == 0 or t == self.args.optimization_steps - 1):
795
+ detailed = self.conjugation_obj.get_last_detailed_results()
796
+ self.tracker.log_step(t, current_scores[0], detailed, improved_indices)
797
+
798
+ # Print summary for final step
799
+ if t == self.args.optimization_steps - 1:
800
+ self.tracker.print_step_summary(t, current_scores[0], detailed)
801
+
802
+ return x
803
+
804
+
805
+ # --- Main Execution ---
806
+ def main(args):
807
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
808
+ print(f"Using device: {device}")
809
+
810
+ target = args.target
811
+
812
+ # --- Initialize tokenizer based on type ---
813
+ if args.tokenizer_type == 'amino':
814
+ print("Using Amino Acid tokenizer")
815
+ tokenizer = AminoTokenizer()
816
+ smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
817
+ else:
818
+ print("Using SMILES tokenizer")
819
+ tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
820
+ smiles_tokenizer = tokenizer
821
+
822
+ # --- Initialize objective functions ---
823
+ # Use SMILES tokenizer for objectives (they expect SMILES tokens)
824
+ conjugation_model = Conjugation(smiles_tokenizer, device=device, builder_type=args.builder_type)
825
+ affinity_model = BindingAffinity(target, device)
826
+
827
+ # List of all objective functions
828
+ OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
829
+
830
+ # Initialize tracker for logging
831
+ tracker = GenerationTracker(output_dir=args.output_dir)
832
+
833
+ # --- Set random seeds for reproducibility ---
834
+ if args.seed is not None:
835
+ import random
836
+ import numpy as np
837
+ random.seed(args.seed)
838
+ np.random.seed(args.seed)
839
+ torch.manual_seed(args.seed)
840
+ if torch.cuda.is_available():
841
+ torch.cuda.manual_seed(args.seed)
842
+ torch.cuda.manual_seed_all(args.seed)
843
+ print(f"Random seed set to: {args.seed}")
844
+
845
+ # --- Load Model ---
846
+ print(f"Loading model from checkpoint: {args.checkpoint}")
847
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
848
+
849
+ if args.tokenizer_type == 'amino':
850
+ # Vanilla PyTorch checkpoint for amino model
851
+ raw_model_args = checkpoint['args']
852
+ if isinstance(raw_model_args, argparse.Namespace):
853
+ raw_model_args = vars(raw_model_args)
854
+ model_args = edict(raw_model_args)
855
+ model = MDLM(
856
+ vocab_size=model_args.vocab_size,
857
+ seq_len=model_args.seq_len,
858
+ model_dim=model_args.model_dim,
859
+ n_heads=model_args.n_heads,
860
+ n_layers=model_args.n_layers
861
+ ).to(device)
862
+ model.load_state_dict(checkpoint['model_state_dict'])
863
+ print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
864
+ else:
865
+ # PyTorch Lightning checkpoint for SMILES model
866
+ model_hparams = checkpoint["hyper_parameters"]["args"]
867
+ model = MDLMLightningModule.load_from_checkpoint(
868
+ args.checkpoint,
869
+ args=model_hparams,
870
+ tokenizer=tokenizer,
871
+ map_location=device,
872
+ strict=False
873
+ )
874
+ model.to(device)
875
+ model.eval()
876
+ print("Model loaded successfully.")
877
+
878
+ mog_generator = MOGGenerator(
879
+ model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker,
880
+ tokenizer=tokenizer, tokenizer_type=args.tokenizer_type
881
+ )
882
+
883
+ all_sequences = []
884
+ all_final_results = []
885
+
886
+ for batch_idx in range(args.num_batches):
887
+ print(f"\n{'='*60}")
888
+ print(f"BATCH {batch_idx + 1}/{args.num_batches}")
889
+ print(f"{'='*60}")
890
+
891
+ generated_tokens = mog_generator.generate()
892
+ final_scores = mog_generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
893
+ sequence_str = tokenizer.batch_decode(generated_tokens)
894
+
895
+ print("\n--- Batch Results ---")
896
+ print(f"Sequences: {sequence_str}")
897
+ print(f"Scores: {final_scores}")
898
+
899
+ all_sequences.extend(sequence_str)
900
+
901
+ # Get detailed results with properties for final sequences
902
+ # For amino tokenizer, we need to convert to SMILES first for detailed scoring
903
+ if args.tokenizer_type == 'amino':
904
+ # Convert AA to SMILES for detailed conjugation analysis
905
+ smiles_list = []
906
+ for seq in sequence_str:
907
+ three_letter = one_letter_to_three_letter(seq)
908
+ smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
909
+ smiles_list.append(smiles if isinstance(smiles, str) else 'C')
910
+ # Tokenize SMILES for conjugation model
911
+ smiles_tokens = smiles_tokenizer(smiles_list, return_tensors='pt', padding=True).to(device)
912
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(smiles_tokens["input_ids"])
913
+ else:
914
+ detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
915
+
916
+ tracker.log_final(sequence_str, detailed_with_props)
917
+ all_final_results.extend(detailed_with_props)
918
+
919
+ # Print and save final summary
920
+ tracker.print_final_summary()
921
+ tracker.save(filename_prefix='pdc_generation')
922
+
923
+ # Save sequences to text file
924
+ with open(args.output_file, 'w') as f:
925
+ for i, seq in enumerate(all_sequences):
926
+ if args.tokenizer_type == 'amino':
927
+ # Also save converted SMILES for amino sequences
928
+ three_letter = one_letter_to_three_letter(seq)
929
+ smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
930
+ if isinstance(smiles, str):
931
+ f.write(f"{seq}\t{smiles}\n")
932
+ else:
933
+ f.write(f"{seq}\tINVALID_SMILES\n")
934
+ else:
935
+ f.write(seq + '\n')
936
+ print(f"\nSequences saved to: {args.output_file}")
937
+
938
+ print("\nGeneration complete.")
939
+
940
+
941
+
942
+ if __name__ == "__main__":
943
+ parser = argparse.ArgumentParser(description="Multi-Objective Generation with LBP-MOG-ReDi (Single Mutation).")
944
+
945
+ parser.add_argument("--checkpoint", type=str, required=True, help="Path to the trained ReDi model checkpoint.")
946
+ parser.add_argument("--num_samples", type=int, default=10, help="Number of samples to generate.")
947
+ parser.add_argument("--num_batches", type=int, default=10, help="Number of batches to generate.")
948
+ parser.add_argument("--output_file", type=str, default="./smiles.txt", help="File to save the generated sequences.")
949
+ parser.add_argument("--output_dir", type=str, default="./generation_logs", help="Directory to save detailed generation logs.")
950
+ parser.add_argument("--gen_len", type=int, default=50, help="Length of the sequences to generate.")
951
+ parser.add_argument("--optimization_steps", type=int, default=16, help="Number of optimization steps per generation.")
952
+ parser.add_argument("--weights", type=float, nargs='+', required=False, help="Weights for the objectives.")
953
+ parser.add_argument("--eta_min", type=float, default=1.0, help="Minimum guidance strength.")
954
+ parser.add_argument("--eta_max", type=float, default=20.0, help="Maximum guidance strength.")
955
+ parser.add_argument("--top_p", type=float, default=0.9, help="Top-p for pruning candidate tokens.")
956
+ parser.add_argument("--builder_type", type=str, default="AoaDau",
957
+ choices=["AoaDau", "SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt",
958
+ "DSECCpt", "Cpt", "DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"],
959
+ help="PDC builder type for conjugation.")
960
+ parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility.")
961
+ parser.add_argument("--require_valid_x0", action="store_true",
962
+ help="If set, regenerate x0 samples until they are valid conjugatable peptides.")
963
+ parser.add_argument("--max_x0_retries", type=int, default=10,
964
+ help="Maximum retries when require_valid_x0 is enabled.")
965
+
966
+ parser.add_argument("--target", type=str, required=True, help="Target protein sequence for binding affinity.")
967
+
968
+ # Tokenizer and model type arguments
969
+ parser.add_argument("--tokenizer_type", type=str, default="smiles", choices=["smiles", "amino"],
970
+ help="Type of tokenizer: 'smiles' for SMILES SPE tokenizer, 'amino' for amino acid tokenizer.")
971
+ parser.add_argument("--cyclic", action="store_true",
972
+ help="Generate cyclic peptides (only for amino tokenizer).")
973
+ parser.add_argument("--smiles_vocab_path", type=str,
974
+ default="./smiles/smiles_tokenizer/new_vocab.txt",
975
+ help="Path to SMILES tokenizer vocabulary file.")
976
+ parser.add_argument("--smiles_splits_path", type=str,
977
+ default="./smiles/smiles_tokenizer/new_splits.txt",
978
+ help="Path to SMILES tokenizer splits file.")
979
+
980
+ args = parser.parse_args()
981
+ main(args)