From 710abfa2dfb0e455ec479d9f3db5a582aa3cf071 Mon Sep 17 00:00:00 2001 From: jwilber Date: Tue, 14 Jul 2026 16:31:03 -0700 Subject: [PATCH 1/4] add workflows grouping with binder tasks Signed-off-by: jwilber --- skills.sh.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/skills.sh.json b/skills.sh.json index 84c0cc3..328f852 100644 --- a/skills.sh.json +++ b/skills.sh.json @@ -46,6 +46,13 @@ "kermt-pretrain-scratch", "kermt-setup" ] + }, + { + "title": "Workflows", + "skills": [ + "complexa-binder-design", + "protein-binder-design" + ] } ] } From e2ebfd61afb49b8cb89fafdf5a32b1b6f26e9f86 Mon Sep 17 00:00:00 2001 From: jwilber Date: Tue, 14 Jul 2026 16:32:43 -0700 Subject: [PATCH 2/4] add script to ensure plugings in sync with repo state Signed-off-by: jwilber --- scripts/plugin_sync.py | 215 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 scripts/plugin_sync.py diff --git a/scripts/plugin_sync.py b/scripts/plugin_sync.py new file mode 100644 index 0000000..2d2a8b5 --- /dev/null +++ b/scripts/plugin_sync.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Keep the generated plugin payload in sync with the source skills. + +The installable plugin under ``plugins/bionemo-agent-toolkit/`` is a *generated* +copy of the source skills, and the marketplace catalogs ship whatever is in that +payload. Nothing in the repo regenerates it automatically, so it silently drifts +(e.g. a skill added to source but never added to the plugin). + +This script enforces two invariants: + + 1. COVERAGE — every distributable source skill is listed in ``skills.sh.json``. + 2. FRESHNESS — for every listed skill, the plugin payload folder is an exact + copy of the source skill folder minus ``evals/``. + +Modes: + --check (CI + local) exit non-zero and report if anything is out of sync. + --write (contributor) rebuild the payload to match ``skills.sh.json``. + (Coverage gaps are NOT auto-fixed — adding a skill to a grouping in + skills.sh.json is a human decision; --check will tell you.) + +Usage: + python scripts/plugin_sync.py --check + python scripts/plugin_sync.py --write +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +PLUGIN_SKILLS = REPO / "plugins" / "bionemo-agent-toolkit" / "skills" +CONFIG = REPO / "skills.sh.json" + +# Source roots that hold distributable skills. +SOURCE_ROOTS = ["nim-skills", "library-skills", "open-models-skills", "workflows"] +# Path segments that never contain a distributable skill. +EXCLUDE_SEGMENTS = {"plugins", "vendor", "evals", "node_modules", ".git"} +# Files/dirs ignored when copying/comparing (never part of the payload). +# `.skillsource.json` is skills.sh metadata that the generator strips from the payload. +JUNK = {".DS_Store", "__pycache__", ".skillsource.json"} +# Subdirectories of a skill that are stripped from the plugin payload. +STRIP_FROM_PAYLOAD = {"evals"} + + +def _excluded(path: Path) -> bool: + return any(seg in EXCLUDE_SEGMENTS for seg in path.parts) + + +def discover_source_skills() -> dict[str, Path]: + """Map skill name -> source dir. A skill is a SKILL.md dir with no + descendant SKILL.md dir (containers/index skills are excluded).""" + candidates: list[Path] = [] + for root in SOURCE_ROOTS: + root_path = REPO / root + if not root_path.exists(): + continue + for skill_md in root_path.rglob("SKILL.md"): + rel = skill_md.relative_to(REPO) + if _excluded(rel.parent) or _excluded(rel): + continue + candidates.append(skill_md.parent) + + # Drop containers: a candidate that is an ancestor of another candidate. + skills: dict[str, Path] = {} + for c in candidates: + if any(other != c and c in other.parents for other in candidates): + continue # container / index skill, not distributable + if c.name in skills: + raise SystemExit(f"ERROR: duplicate skill name '{c.name}': " + f"{skills[c.name]} vs {c}") + skills[c.name] = c + return skills + + +def config_skill_names() -> list[str]: + data = json.loads(CONFIG.read_text()) + names: list[str] = [] + for group in data.get("groupings", []): + names.extend(group.get("skills", [])) + names.extend(data.get("notGrouped", []) if isinstance(data.get("notGrouped"), list) else []) + return names + + +def _file_map(root: Path, strip_top: set[str]) -> dict[str, str]: + """relpath -> sha256 for files under root, skipping junk and stripped top dirs.""" + out: dict[str, str] = {} + for p in root.rglob("*"): + if p.is_dir(): + continue + rel = p.relative_to(root) + if rel.parts and rel.parts[0] in strip_top: + continue + if any(part in JUNK for part in rel.parts): + continue + out[str(rel)] = hashlib.sha256(p.read_bytes()).hexdigest() + return out + + +def compare(source_dir: Path, payload_dir: Path) -> list[str]: + """Return list of human-readable differences (empty == in sync).""" + if not payload_dir.exists(): + return [f"missing from plugin payload entirely"] + src = _file_map(source_dir, STRIP_FROM_PAYLOAD) + dst = _file_map(payload_dir, set()) + diffs = [] + for rel in sorted(set(src) - set(dst)): + diffs.append(f"missing in payload: {rel}") + for rel in sorted(set(dst) - set(src)): + diffs.append(f"extra in payload: {rel}") + for rel in sorted(set(src) & set(dst)): + if src[rel] != dst[rel]: + diffs.append(f"content differs: {rel}") + return diffs + + +def check() -> int: + source = discover_source_skills() + listed = config_skill_names() + listed_set = set(listed) + payload_dirs = {p.name for p in PLUGIN_SKILLS.iterdir() if p.is_dir()} if PLUGIN_SKILLS.exists() else set() + + problems: list[str] = [] + + # 1. Coverage: source skills missing from skills.sh.json + missing_cfg = sorted(set(source) - listed_set) + for name in missing_cfg: + problems.append(f"[coverage] source skill '{name}' ({source[name].relative_to(REPO)}) " + f"is NOT listed in skills.sh.json") + + # 2. Stale config: listed names with no source skill + for name in sorted(listed_set - set(source)): + problems.append(f"[stale-config] skills.sh.json lists '{name}' but no source skill exists") + + # 3. Orphan payload: payload folders not listed in config + for name in sorted(payload_dirs - listed_set): + problems.append(f"[orphan] plugin payload has '{name}' but it is not in skills.sh.json") + + # 4. Freshness: each listed+existing skill must match source minus evals + for name in listed: + if name not in source: + continue # already reported as stale-config + diffs = compare(source[name], PLUGIN_SKILLS / name) + for d in diffs: + problems.append(f"[freshness] {name}: {d}") + + if problems: + print("Plugin sync check FAILED:\n") + for p in problems: + print(f" - {p}") + print("\nFix:") + if missing_cfg: + print(" * Add the [coverage] skills to a grouping in skills.sh.json (pick the right group).") + print(" * Then run: python scripts/plugin_sync.py --write (and commit the result)") + return 1 + + print(f"Plugin sync OK — {len(listed)} skills, payload matches source (minus evals/).") + return 0 + + +def write() -> int: + source = discover_source_skills() + listed = config_skill_names() + listed_set = set(listed) + PLUGIN_SKILLS.mkdir(parents=True, exist_ok=True) + + def _ignore(_dir, names): + return {n for n in names if n in STRIP_FROM_PAYLOAD or n in JUNK} + + rebuilt = 0 + for name in listed: + src = source.get(name) + if src is None: + print(f" ! skip '{name}': listed in skills.sh.json but no source skill found") + continue + dst = PLUGIN_SKILLS / name + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst, ignore=_ignore) + rebuilt += 1 + + # Remove payload folders no longer listed. + removed = 0 + for p in list(PLUGIN_SKILLS.iterdir()): + if p.is_dir() and p.name not in listed_set: + shutil.rmtree(p) + removed += 1 + print(f" - removed orphan payload: {p.name}") + + print(f"Rebuilt {rebuilt} skill(s) in the payload; removed {removed} orphan(s).") + + uncovered = sorted(set(source) - listed_set) + if uncovered: + print("\nNOTE: these source skills are NOT in skills.sh.json and were " + "therefore NOT added to the plugin:") + for name in uncovered: + print(f" - {name} ({source[name].relative_to(REPO)})") + print("Add them to a grouping in skills.sh.json (a human choice), then re-run --write.") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--check", action="store_true", help="verify sync; non-zero exit if out of sync") + g.add_argument("--write", action="store_true", help="rebuild the plugin payload from source + skills.sh.json") + args = ap.parse_args() + return check() if args.check else write() + + +if __name__ == "__main__": + sys.exit(main()) From 01a9b62669ffeaaad05501a83797f99065b525d1 Mon Sep 17 00:00:00 2001 From: jwilber Date: Tue, 14 Jul 2026 16:33:17 -0700 Subject: [PATCH 3/4] create plugins for binder tasks Signed-off-by: jwilber --- .../skills/complexa-binder-design/LICENSE | 201 +++ .../skills/complexa-binder-design/NOTICE | 29 + .../skills/complexa-binder-design/README.md | 88 ++ .../skills/complexa-binder-design/SKILL.md | 199 +++ .../assets/targets.json | 7 + .../prompts/hotspot_paperclip.md | 119 ++ .../references/complexa-cli.md | 113 ++ .../references/pipeline.md | 123 ++ .../references/setup.md | 167 +++ .../references/target-and-hotspots.md | 96 ++ .../references/validation.md | 80 ++ .../scripts/boltz2_refold.py | 150 ++ .../scripts/check_setup.sh | 68 + .../scripts/complexa_design.py | 159 +++ .../scripts/fetch_ipsae.sh | 29 + .../scripts/fetch_target_msa_colabfold.py | 153 ++ .../scripts/hotspot_strategy.py | 179 +++ .../scripts/pdb_interface.py | 172 +++ .../scripts/pdb_to_boltz_template_cif.py | 82 ++ .../scripts/pipeline.py | 1271 +++++++++++++++++ .../scripts/preflight_design.py | 243 ++++ .../scripts/setup_af2_params.sh | 46 + .../scripts/validate_binders.py | 482 +++++++ .../vendor/ipsae/README.md | 22 + .../vendor/ipsae/VENDOR.md | 36 + .../vendor/science-skills/LICENSE | 202 +++ .../vendor/science-skills/VENDOR.md | 50 + .../SKILL.md | 115 ++ .../scripts/analyze_pae.py | 212 +++ .../scripts/analyze_plddt.py | 125 ++ .../scripts/fetch_structure.py | 200 +++ .../science-skills/uniprot_database/SKILL.md | 292 ++++ .../uniprot_database/scripts/uniprot_tools.py | 509 +++++++ .../skills/protein-binder-design/LICENSE | 201 +++ .../skills/protein-binder-design/README.md | 55 + .../skills/protein-binder-design/SKILL.md | 116 ++ .../protein-binder-design/assets/targets.json | 16 + .../references/local-nim-setup.md | 80 ++ .../references/manifest.md | 71 + .../references/pipeline.md | 151 ++ .../references/validation.md | 69 + .../protein-binder-design/scripts/controls.py | 26 + .../protein-binder-design/scripts/manifest.py | 179 +++ .../protein-binder-design/scripts/metrics.py | 39 + .../scripts/pdb_utils.py | 91 ++ .../protein-binder-design/scripts/registry.py | 24 + 46 files changed, 7137 insertions(+) create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/LICENSE create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/NOTICE create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/README.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/SKILL.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/assets/targets.json create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/prompts/hotspot_paperclip.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/complexa-cli.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/pipeline.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/setup.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/target-and-hotspots.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/validation.md create mode 100755 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/boltz2_refold.py create mode 100755 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/check_setup.sh create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/complexa_design.py create mode 100755 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/fetch_ipsae.sh create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/fetch_target_msa_colabfold.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/hotspot_strategy.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pdb_interface.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pdb_to_boltz_template_cif.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pipeline.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/preflight_design.py create mode 100755 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/setup_af2_params.sh create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/validate_binders.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/ipsae/README.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/ipsae/VENDOR.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/LICENSE create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/VENDOR.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/SKILL.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/analyze_pae.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/analyze_plddt.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/fetch_structure.py create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/uniprot_database/SKILL.md create mode 100644 plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/uniprot_database/scripts/uniprot_tools.py create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/LICENSE create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/README.md create mode 100755 plugins/bionemo-agent-toolkit/skills/protein-binder-design/SKILL.md create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/assets/targets.json create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/local-nim-setup.md create mode 100755 plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/manifest.md create mode 100755 plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/pipeline.md create mode 100755 plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/validation.md create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/controls.py create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/manifest.py create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/metrics.py create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/pdb_utils.py create mode 100644 plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/registry.py diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/LICENSE b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/LICENSE new file mode 100644 index 0000000..834d9d9 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/NOTICE b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/NOTICE new file mode 100644 index 0000000..ddb08d5 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/NOTICE @@ -0,0 +1,29 @@ +complexa-binder-design (Agent Skill) +Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES + +This product is licensed under the Apache License, Version 2.0 (see LICENSE). + +This skill orchestrates, but does not redistribute, the following third-party +components. Each is obtained by the user from its own source under its own license: + +- Proteina-Complexa (model + code) + https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa + https://research.nvidia.com/labs/genair/proteina-complexa/ + Weights via NGC: nvidia/clara/proteina_complexa + See the upstream repository for its license and model terms. + +- ipSAE (interaction prediction Score from Aligned Errors) + https://github.com/dunbracklab/IPSAE (ipsae.py) + Author: Roland L. Dunbrack Jr., Fox Chase Cancer Center + License: MIT. + Fetched on demand by scripts/fetch_ipsae.sh into vendor/ipsae/ (not bundled). + +- science-skills (UniProt + AlphaFold-DB Stage-1 tooling), vendored under + vendor/science-skills/ with a small stdlib-urllib shim (changes noted in-file + per Apache-2.0 §4). See vendor/science-skills/VENDOR.md. + https://github.com/google-deepmind/science-skills + License: Apache-2.0 (see vendor/science-skills/LICENSE). + +- BioNeMo NIMs (Boltz2, OpenFold3, MSA-Search) used for independent validation + are accessed as services (build.nvidia.com or self-hosted NGC containers) under + their respective terms. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/README.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/README.md new file mode 100644 index 0000000..86b6080 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/README.md @@ -0,0 +1,88 @@ +# complexa-binder-design (Agent Skill) + +De novo protein binder design as an **Agent Skill**, powered by NVIDIA +**Proteina-Complexa** — a generative model that **co-designs the binder sequence and +full-atom structure together** (no inverse-folding step) with reward-guided +test-time search. The skill drives the open `complexa` CLI to generate, then +**independently validates** each binder with a different model (Boltz2 / OpenFold3), +gates on interface confidence, ranks, and writes a reproducible manifest. + +## Layout + +``` +complexa-binder-design/ +├── SKILL.md # entry point (agent reads this first) +├── references/ +│ ├── target-and-hotspots.md # Stage 1: structure resolution + evidence-based hotspots + crop +│ ├── complexa-cli.md # how to drive the open `complexa` CLI (overrides, search, outputs) +│ ├── pipeline.md # stage-by-stage orchestration + run-until-N-validated loop +│ └── validation.md # independent holo/apo refold, metrics, gate +├── scripts/ +│ ├── pipeline.py # Stage-1 resolution + Stage-2 generation (open CLI) + scoring +│ ├── preflight_design.py # no-GPU target/hotspot/size planner (READY verdict) +│ ├── hotspot_strategy.py # evidence-based, accessibility-aware hotspot resolver +│ ├── pdb_interface.py # PDB co-complex interface hotspots (gold standard) +│ ├── complexa_design.py # thin `complexa design` driver (submit → discover → extract) +│ ├── validate_binders.py # independent Boltz2/OF3 scoring, ipSAE, apo↔holo RMSD, gating +│ ├── fetch_ipsae.sh # fetch the MIT ipSAE script into vendor/ipsae/ +│ ├── fetch_target_msa_colabfold.py +│ └── pdb_to_boltz_template_cif.py +├── prompts/hotspot_paperclip.md # literature-mining fallback prompt +├── assets/targets.json # EXAMPLE upstream targets; use `complexa target add` for your own +├── vendor/ +│ ├── science-skills/ # vendored UniProt + AFDB tooling (Apache-2.0) +│ └── ipsae/ # ipSAE lands here after fetch_ipsae.sh (not bundled) +├── NOTICE # third-party attribution +└── LICENSE # Apache-2.0 +``` + +## Setup + +Full standalone (no-NIM) setup — install Proteina-Complexa + weights, Python deps, +AF2 configure-vs-bypass, optional analyze tools, validation endpoint, and all env +vars — is in **[`references/setup.md`](references/setup.md)**. After setup, run +`bash scripts/check_setup.sh` for a readiness checklist. Quick prerequisites: + +## Prerequisites + +1. **Proteina-Complexa** — clone, build, and download weights: + ```bash + git clone https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa + cd Proteina-Complexa && ./env/build_uv_env.sh && source .venv/bin/activate + complexa init && complexa download --complexa-all + export COMPLEXA_REPO=$PWD + ``` + Project page: · + Weights (NGC): `nvidia/clara/proteina_complexa`. +2. **A validator NIM** — Boltz2 (default) or OpenFold3, hosted at + [build.nvidia.com](https://build.nvidia.com) (`export NVIDIA_API_KEY=nvapi-...`) + or self-hosted (`--endpoint local`). +3. **ipSAE** — `bash scripts/fetch_ipsae.sh` (one-time; MIT, fetched not bundled). +4. **Python** ≥ 3.10 with `numpy`, `gemmi`, `pyyaml` (Stage-1 structure handling + + target registration); `gemmi` also enables Boltz2 templates. The optional + **Paperclip** CLI enables the literature-mining hotspot fallback. + +Then plan a target with **no GPU**: + +```bash +python scripts/preflight_design.py # name, UniProt accession, or PDB; READY / NEEDS-ATTENTION verdict +``` + +## Run (from an agent) + +Open this folder in your agent and prompt, e.g.: + +> Design 10 binders for `` with Proteina-Complexa using best-of-N search, +> then validate them independently with Boltz2 and rank by interface confidence. + +The agent loads `SKILL.md`, generates via the `complexa` CLI (`references/complexa-cli.md`), +re-folds each binder independently, gates, and writes `runs/_/` +(manifest + `ranked_binders.json/.csv` + report). + +> **Why two models?** Proteina-Complexa's own evaluate stage uses AF2/RF3/ESMFold — +> the family its search optimizes against. Validating with Boltz2/OpenFold3 gives an +> *independent* interface-confidence check. + +## License + +Apache-2.0 (see `LICENSE`); third-party components keep their own licenses (`NOTICE`). diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/SKILL.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/SKILL.md new file mode 100644 index 0000000..dedfa23 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/SKILL.md @@ -0,0 +1,199 @@ +--- +name: complexa-binder-design +description: > + Run a complete protein binder design campaign with NVIDIA Proteina-Complexa: resolve a target structure and hotspots from a name/sequence/PDB, co-design binder sequence+structure with reward-guided test-time search (best-of-n, beam search, FK steering, MCTS), select with the internal AF2 reward gate, then INDEPENDENTLY validate each binder by refolding the complex with Boltz2 (default) or OpenFold3 and rank on interface confidence, pLDDT, ipSAE, apo/holo stability, and hotspot contact. Use whenever the user wants de novo binders against a named target, sequence, or PDB, hotspot/epitope-targeted design, Proteina-Complexa / Complexa, or ranked validated binders from one request. Sibling of protein-binder-design (RFdiffusion + ProteinMPNN); this skill uses Proteina-Complexa. +license: Apache-2.0 +compatibility: "python>=3.10; numpy>=1.24; gemmi (target prep + Boltz2 templates); pyyaml (target registration)" +allowed-tools: Bash, Read, Write, AskUserQuestion +--- + +# Complexa Binder Design (workflow) + +From one request — "design binders for ``" — to ranked, **independently +validated** binders. Each returned binder is a **co-designed sequence + predicted +binder–target complex**, gated by interface confidence, by whether the binder +actually contacts the target hotspots, and by **apo/holo stability**. + +Generation uses **Proteina-Complexa** (co-designs binder sequence + full-atom +structure together — no inverse-folding step — with reward-guided test-time search). +Validation uses a **different** model family (Boltz2 / OpenFold3), so the headline +confidence is an independent check, not the generator grading its own homework. + +> **Upstream model + code (you provide these):** +> - Project page: +> - Code: (the `complexa` CLI) +> - Weights (NGC): `nvidia/clara/proteina_complexa` +> - Paper: Didi et al., *Scaling Atomistic Protein Binder Design…*, ICLR 2026. + +> **First time on a host? → `references/setup.md`** — full standalone setup with **no +> NIM**: install Proteina-Complexa + download weights, Python deps (`numpy gemmi +> pyyaml`), AF2 **configure-vs-bypass**, optional analyze tools (`foldseek`/`sc`/`dssp`), +> the Boltz2/OF3 validation endpoint, and every env var. Then run +> `bash scripts/check_setup.sh` for a one-shot readiness checklist. + +``` +Stage 1: Resolve target + hotspots → target.pdb + hotspots.json (no GPU) + ┌──────────────────────────────────────────────────────────────────────┐ + │ repeat until ≥ N validated passers (or a stop cap): │ +Stage 2 │ Generate (complexa design) → complex .pdb + AF2-reward-gated designs │ +Stage 3 │ Validate (Boltz2 default; OF3 optional) → holo+apo + ipTM/ipSAE/ │ + │ pLDDT + apo↔holo RMSD + hotspot contact → passers │ + └──────────────────────────────────────────────────────────────────────┘ +Stage 4: Report → REPORT__.md (GO/NO-GO) +``` + +## Composed pieces (read on demand — do not inline) + +| Step | Tool | Owns | +|---|---|---| +| Target + hotspots | vendored `science-skills` (UniProt, AFDB) + `scripts/` | structure resolution, evidence-based hotspots, ≤500 crop, preflight | +| Generation | **Proteina-Complexa** `complexa` CLI | co-designed binder seq+structure, AF2-reward gate → `references/complexa-cli.md` | +| Validate / score | `boltz2-nim` (default) or `openfold3-nim` | independent holo+apo refold, ipTM / pLDDT / PAE | +| MSA (target) | `msa-search-nim` or `scripts/fetch_target_msa_colabfold.py` | target A3M for higher-confidence refolds | + +If you run inside the Proteina-Complexa repo, its bundled `.claude/skills` +(`complexa-setup`, `complexa-target`, `complexa-design`) can drive the generation +half; this skill adds the automated Stage 1, the independent validation, GO/NO-GO, +and the manifest. + +## Stage 1 — resolve target and hotspots (no GPU) + +The user gives a target as a **name**, **sequence**, and/or **structure file**. +Resolve exactly **one design-ready structure**, in priority order: (1) experimental +**PDB** (RCSB), (2) **AFDB** model (UniProt → `vendor/science-skills/.../fetch_structure.py`), +(3) **user-provided** file, (4) **fold de novo** (MSA-Search + OpenFold3/Boltz2). +`scripts/pipeline.py:resolve_target_spec`/`resolve_target` automate (1)–(2) from +free text. + +**Hotspots** = the target residues the binder should contact — a compact, +surface-exposed, binder-accessible epitope. Resolve in evidence order +(`scripts/hotspot_strategy.py`, `scripts/pdb_interface.py`): + +1. **PDB co-complex interface** (gold standard) — interface residues from a structure + where the target contacts a protein partner. +2. **UniProt functional residues** — `Mutagenesis` + accessible `Active/Binding/Site`, + **filtered to the extracellular/accessible range** (catalytic/cytoplasmic pockets + are the wrong surface for a binder and are dropped). +3. **Literature (Paperclip)** — full-text mining when 1–2 are empty + (`prompts/hotspot_paperclip.md`); structure-confirmed to auto-correct numbering. +4. **Unconditioned** (`[]`) only as a documented last resort. + +Then enforce, deterministically: +- **Structure alignment** (`align_hotspots_to_structure`) — drop residues absent from + the coordinate file; read back the real 3-letter identity (catches UniProt↔PDB + numbering offsets — never assume equal indices or chain `A`). +- **Epitope sanity** (`_prune_hotspots`) — one compact patch: drop outliers > 30 Å + from the cluster centroid, cap at 15 residues, prefer ≥ 2. +- **Size budget ≤ 500 residues** (`_crop_target_to_epitope`) — Complexa builds an + O(n²) pair-feature map over the whole complex, so crop large targets to an epitope + window (original numbering preserved). + +**Preflight (no GPU):** `python3 scripts/preflight_design.py …` +reports the conditioned length, re-aligned hotspots + source, compactness, the ≤500 +budget, and a READY / NEEDS-ATTENTION verdict. Review before spending GPU. + +## Stage 2 — generate (Proteina-Complexa, open CLI) + +Register the target (hotspots + binder length are target-dict-driven), then **use +`complexa generate` (NOT the full `complexa design`)** for the lean, fast path: + +```bash +python scripts/complexa_design.py run --task-name --run-name \ + --algorithm best-of-n --num-samples --seed 0 --out +``` + +`complexa_design.py run` defaults to the **`generate`** verb. With **`best-of-n` + the +AF2 reward** (AF2 params configured via `setup_af2_params.sh` + `AF2_DIR`), the search +**AF2-selects the best candidates during generation** and writes co-designed +**sequence + structure** PDBs to `inference/` — **use the sequence directly, do not +MPNN-redesign it**. Search algorithms: `best-of-n` (default) · `beam-search` · +`fk-steering` · `mcts`. Overrides + outputs: `references/complexa-cli.md`. + +> **Do NOT run the full `complexa design` for this workflow.** Its `evaluate` stage +> **re-folds every design with AF2/RF3/ESMFold (redundant** — best-of-n already +> AF2-selected during search**)** and its `analyze` stage needs `foldseek`/`sc` +> (usually not installed). It is much slower and adds a failure mode. The lean +> `generate` → independent **Boltz2** validation (Stage 3) is the intended path. +> +> No AF2 params? use `--af2-bypass` (`single-pass` + drop the AF2 reward); selection +> then falls entirely to the independent Boltz2 gate (Stage 3). Low-complexity +> (poly-X) sequences are dropped before spending Boltz2. + +## Stage 3 — validate (independent refold) + gate + +**Validate a capped shortlist, not the whole pool.** Best-of-n produces many +candidates; fold only ~**2× the requested N** (the top ones by the generation/AF2 +reward) — validating the entire pool wastes GPU/time and (on hosted Boltz2) trips rate +limits. Point at a local Boltz2 NIM via `$BOLTZ2_URL` (`--endpoint local`) when available. + +Per binder run **two** predictions with one refolder (Boltz2 default): **holo** +(binder + target; target MSA, binder single-sequence, `write_full_pae`) and **apo** +(binder alone). One command does it: **`scripts/boltz2_refold.py`** makes the holo +calls (with retry/backoff for rate limits) and chains **`scripts/validate_binders.py`**, +which runs apo + computes the metrics + applies the gate + ranks. Per-chain +conditioning + metric definitions: `references/validation.md`. + +**Gate (defaults — every gate must hold):** ipTM ≥ 0.65, complex pLDDT ≥ 0.70, binder +pLDDT ≥ 0.70, apo binder pLDDT ≥ 0.70, **ipSAE_min ≥ 0.45**, apo↔holo binder RMSD +≤ 2.5 Å, ≥ 20% of conditioned hotspots contacted (CB–CB < 13 Å). Record **every** +design (pass *and* fail) with a `failure_reason`. Rank protein binders by interface +confidence (ipTM/ipSAE) + pLDDT + stability — **not** Boltz2 `affinity_pic50` +(ligand-only). + +## Bounded-budget loop + report + +The deliverable is **the top-N binders ranked by interface confidence** (default 10). +Aim for N that pass the full gate, but **bound the cost**: run **at most 2 generation +rounds**, then **deliver the top-N by score (ipTM, then ipSAE_min) even if fewer than N +clear the strict gate** — keep each design's `pass`/`failure_reason` flag so quality is +still visible. Do **not** keep generating just to chase N strict passes (that is the +single biggest time sink). Stop on N-passed / 2 rounds / budget / a zero-passer round. +One run dir per campaign; `manifest.json` records target, Complexa run config + seeds, +per-design lineage/scores/artifacts, gate status. The report states GO/NO-GO, +requested-vs-achieved N (passed and delivered), ranked binders, and which stop +condition fired. Layout, loop, and report sections: `references/pipeline.md`. + +## Configuration + +- `COMPLEXA_REPO` — path to your local Proteina-Complexa checkout (the `complexa` + CLI runs there). Checkpoints via the pipeline YAML or `++ckpt_path=…`. Reward + weights (`AF2_DIR`, `RF3_CKPT_PATH`/`RF3_EXEC_PATH`) via the repo's `.env`. +- Boltz2 / OpenFold3 endpoints + auth: hosted (`https://health.api.nvidia.com/v1/…` + + `NVIDIA_API_KEY`) or local (`http://localhost:8000/…`, no auth). The validator + takes `--endpoint hosted|local`; the key is read from `NVIDIA_API_KEY`/`NGC_API_KEY` + (or `--env-file`). Never hardcode hosts/keys. +- `COMPLEXA_OUTPUTS` — run-output root (default `./outputs`). + +## Scripts & assets + +- `references/setup.md` + `scripts/check_setup.sh` — standalone (no-NIM) install guide + and a one-shot environment readiness check. +- `scripts/pipeline.py` — orchestrator (Stage-1 resolution + open-CLI generation + + AF2 gate + scoring); `score_existing` and `full` modes. +- `scripts/preflight_design.py` — no-GPU target/hotspot/size planner. +- `scripts/hotspot_strategy.py`, `scripts/pdb_interface.py` — evidence-based hotspots. +- `scripts/complexa_design.py` — thin `complexa design` driver + output extraction. +- `scripts/setup_af2_params.sh` — download AF2-Multimer params (public, no auth) + + create the `params/` layout, for reward-guided search (`best-of-n`, etc.). +- `scripts/boltz2_refold.py` — Stage-3 **holo** Boltz2 refolds (retry/backoff + throttle) + → `validation/raw/`, then chains `validate_binders.py`. +- `scripts/validate_binders.py` — apo Boltz2 + scoring, ipSAE, apo↔holo RMSD, + hotspot contact, gating, ranking → `ranked_binders.json`. Needs the Dunbrack **ipSAE** + script: `bash scripts/fetch_ipsae.sh` (MIT; fetched, not bundled — see `vendor/ipsae/`). +- `scripts/fetch_target_msa_colabfold.py`, `scripts/pdb_to_boltz_template_cif.py` — + target MSA / structural-template helpers for validation. +- `vendor/science-skills/` — DeepMind UniProt + AFDB tooling (Apache-2.0) for Stage 1. +- `prompts/hotspot_paperclip.md` — literature-mining hotspot fallback. +- `assets/targets.json` — example registered targets (use `complexa target add` for your own). + +## Responsible use + +De novo binder design is dual-use. Decline requests aimed at enhancing pathogen +fitness, toxin potency, or bioweapon function; keep designs to legitimate research +and therapeutic intent. + +## See also + +- `protein-binder-design` — same goal via RFdiffusion + ProteinMPNN (BioNeMo NIMs). +- Proteina-Complexa docs: `README.md`, `docs/INFERENCE.md`, `docs/CONFIGURATION_GUIDE.md`, + `docs/EVALUATION_METRICS.md`, and its bundled `.claude/skills/` in the repo above. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/assets/targets.json b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/assets/targets.json new file mode 100644 index 0000000..8744470 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/assets/targets.json @@ -0,0 +1,7 @@ +{ + "_comment": "TEMPLATE only. The authoritative target registry ships with the Proteina-Complexa repo (configs/targets/targets_dict.yaml) — run `complexa target list` in your checkout for the real, full set, and select one with ++generation.task_name=. To add your own: `complexa target add --pdb target.pdb --chain --span --hotspots --binder-length ` (see references/complexa-cli.md and references/target-and-hotspots.md).", + "source": "https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa", + "registry": [ + {"task_name": "", "name": "", "note": "register with `complexa target add`, or pick one from `complexa target list`"} + ] +} diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/prompts/hotspot_paperclip.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/prompts/hotspot_paperclip.md new file mode 100644 index 0000000..9540a67 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/prompts/hotspot_paperclip.md @@ -0,0 +1,119 @@ +# Hotspot fallback — Paperclip full-text literature search + +Use this prompt **only when UniProt has no usable hotspot features** for the +target (no `Active site` / `Binding site` / `Site` / `Mutagenesis` / disease +`Natural variant`). This is common for receptors, cytokines, and other +non-enzymes whose functional surface is a **protein–protein interaction +epitope**, not a catalytic pocket — exactly the kind of site a binder should +grip, and exactly what UniProt rarely annotates. + +This is a **single, narrow job** — not bindclaw's 7-set hypothesis sweep. Produce +**one** evidence-grounded hotspot set, residue-level, and make every residue +**coordinate-valid in the structure the designer will actually consume**. + +## Tool — the `paperclip` CLI (drive it exactly as bindclaw does) + +Paperclip searches 8M+ full-text papers (PMC, bioRxiv, medRxiv). Its advantage +over abstract search: it can read **alanine-scanning tables, mutagenesis Results +sections, ΔΔG values, and co-crystal contact lists** where residue numbers live. +It is a **CLI** (`gxl_paperclip`); shell out to it (the `/paperclip` Claude Code +skill wraps the same commands). + +**Install (one-time, Python 3.8+):** +```bash +curl -fsSL https://paperclip.gxl.ai/install.sh | bash # → wrapper at ~/.local/bin/paperclip +# or: pip install https://paperclip.gxl.ai/paperclip.whl && paperclip setup +paperclip login # sign in (also happens automatically on first use) +paperclip config # verify: Server https://paperclip.gxl.ai, Auth ✓ +``` + +Core commands: + +```bash +paperclip search "<3-6 word query>" -n 5 # → result set id s_xxxxxxxx +paperclip map --from s_xxxxxxxx "Extract ALL specific residue numbers involved \ + in binding, mutagenesis, or hot spots; include ΔΔG values if present." +paperclip grep -i "" /papers//content.lines +paperclip cat /papers//meta.json # title, authors, doi for citation +``` + +`search` returns paper IDs (`PMC*` / `bio_*` / `med_*` / `arx_*`) and a result-set +id; `map --from ` extracts structured answers across the whole set; `grep`/`cat` +read individual papers' full text. If the `paperclip` CLI is unavailable or not +signed in (`paperclip login`), fall back to `WebSearch` over the same query shapes +and cite URLs instead. + +## Inputs you are given + +- Target name + UniProt accession. +- The resolved design structure: `target.pdb` / `target.cif`, **its chain ID**, + and **its observed residue range** (e.g. AFDB full-length `A1-350`, or a + cropped construct `X282-382`). Read these from the file — do not assume. + +## Procedure (keep it short — ≤ ~6 searches) + +1. Identify the target's known binding partner / drug / epitope from 2–3 + short `paperclip search` queries (2–4 keywords each; long queries return + nothing): + - `paperclip search " binding site residues mutagenesis" -n 5` + - `paperclip search " alanine scanning hot spot" -n 5` + - `paperclip search " crystal structure interface contact" -n 5` +2. Extract **specific residue numbers** verbatim with `paperclip map --from + s_xxx "..."`, then `paperclip grep`/`cat` the most promising papers to read + the Results/Methods tables. Capture ΔΔG when given. +3. **Align every residue to the resolved structure** (this is mandatory — see + below). Drop or remap anything outside the structure's range/chain. +4. Write the two output files. Do not narrate first; just write. + +## Alignment to the structure (the "ordering" rule — do not skip) + +Literature residue numbers are almost always in **UniProt canonical +numbering**. The structure the designer consumes may be renumbered or cropped: + +- **AFDB model** → numbering == UniProt, full length. A literature position `N` + maps to `chain{N}` only if `N` is within the model's range. +- **Experimental PDB / cropped construct** → author numbering (`auth_seq_id`) + is often **offset**, with gaps. A UniProt position is **not** the same number + in the PDB. Map by aligning the UniProt sequence to the structure's observed + sequence (SIFTS or a pairwise alignment) — never by equal indices. + +For **every** proposed residue: +- confirm `(chain, position)` exists in the coordinate file, and +- confirm the **residue identity** reads back as expected (a literature + "Tyr123" must be `TYR` at the mapped residue, not assumed). + +A low-confidence but **coordinate-valid** epitope residue beats a +high-confidence residue that is **absent** from the design structure. Mark any +off-structure evidence as such and choose a valid alternative. + +> The downstream pipeline re-checks this deterministically +> (`pipeline.align_hotspots_to_structure`): residues not present in the +> structure are dropped with a warning, so off-structure positions are wasted +> work — align them here. + +## Outputs (write both) + +`hotspots.json` — the format the pipeline + Stage 2 consume (same as the +UniProt path), in the **structure's** numbering and chain: + +```json +[ + { "chain": "A", "residue": "TYR", "position": 123, + "source": "paperclip", "evidence": "PMC9064197: Ala scan ΔΔG 5.2 kcal/mol" }, + { "chain": "A", "residue": "LYS", "position": 124, + "source": "paperclip", "evidence": " co-crystal contact" } +] +``` + +`hotspots.txt` — human-readable: each residue with its paper/PDB citation, the +mechanism in 1–2 sentences, and an explicit note on how numbering was mapped to +the structure. State residue count and overall confidence. + +## Rules + +- ≥ 3 residues with real, cited position numbers; they should form a spatial + cluster (≤ ~15 Å span) so conditioning is geometrically meaningful. +- **Never fabricate** residue numbers, PMIDs/PMC IDs, or PDB entries. If you + cannot find specific numbers, say so, set confidence `low`, and hand back to + the caller (options: structural surface-patch heuristic, ask the user, or + proceed **unconditioned** with `[]` and document it) — do not invent hotspots. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/complexa-cli.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/complexa-cli.md new file mode 100644 index 0000000..7fd0f4a --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/complexa-cli.md @@ -0,0 +1,113 @@ +# Driving Proteina-Complexa (the `complexa` CLI) + +Generation runs through the open Proteina-Complexa release and its `complexa` Hydra +CLI. This page is the operational summary; the repo's own docs are authoritative: +`README.md`, `docs/INFERENCE.md`, `docs/CONFIGURATION_GUIDE.md`, +`docs/EVALUATION_METRICS.md` in . + +## One-time setup + +```bash +git clone https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa +cd Proteina-Complexa +./env/build_uv_env.sh && source .venv/bin/activate # or: docker build -f env/docker/Dockerfile . +complexa init # writes .env +complexa download --complexa-all # model + autoencoder checkpoints from NGC +``` + +Set `COMPLEXA_REPO=/path/to/Proteina-Complexa` so the helper script and the agent +know where to run. Checkpoints are configured in the pipeline YAML (`ckpt_path`, +`ckpt_name`, `autoencoder_ckpt_path`) or overridden on the CLI (below). + +## Pipelines (config + model) + +| Pipeline | Config | NGC model | +|---|---|---| +| **Protein binder** (this skill) | `configs/search_binder_local_pipeline.yaml` | `proteina_complexa` | +| Ligand binder | `configs/search_ligand_binder_local_pipeline.yaml` | `proteina_complexa_ligand` | +| AME (motif + ligand) | `configs/search_ame_local_pipeline.yaml` | `proteina_complexa_ame` | + +Each pipeline runs four stages: **generate → filter → evaluate → analyze** +(`complexa design` runs all four; `complexa generate|filter|evaluate|analyze` run them +individually). + +## CLI verbs + +| Command | Use | +|---|---| +| `complexa validate design ` | resolve the config (catches missing ckpt/env vars before GPU time) | +| `complexa design ++…` | full pipeline: generate → filter → evaluate → analyze | +| `complexa generate ++…` | generation only (skip evaluate/analyze) | +| `complexa target add/list/show` | register / inspect design targets | +| `complexa status ` | check outputs of a run | + +## Key Hydra overrides (`++key=value`) + +| Override | Meaning | +|---|---| +| `++run_name=` | run label (appears in output paths) | +| `++generation.task_name=` | which registered target to design against | +| `++generation.search.algorithm=` | `single-pass` · `best-of-n` · `beam-search` · `fk-steering` · `mcts` | +| `++generation.dataloader.dataset.nres.nsamples=` | number of candidates to sample | +| `++seed=` | reproducibility | +| `++gen_njobs=` / `++eval_njobs=` | GPU parallelism (one GPU per job) | +| `++ckpt_path=` `++ckpt_name=complexa.ckpt` `++autoencoder_ckpt_path=` | checkpoint locations | + +> Hotspots and binder length are **target-dict-driven** (set per target via +> `complexa target add` / `configs/targets/targets_dict.yaml`), not scalar CLI +> overrides. Register the target first, then select it with `generation.task_name`. + +## Reward-guided search & rewards + +`best-of-n`/`beam-search`/`fk-steering`/`mcts` steer denoising by a reward built from +structure-prediction confidence and interface H-bond energies. Reward weights live in +`configs/pipeline/binder/binder_generate.yaml` and resolve weights from `.env`: + +``` +AF2_DIR=/path/to/AF2 # AlphaFold2 params (af2folding reward + AF2 evaluate) +RF3_CKPT_PATH=/path/to/rf3.ckpt # RoseTTAFold3 reward / evaluate +RF3_EXEC_PATH=/path/to/bin/rf3 +``` + +If a reward model's weights are absent, drop it (e.g. disable `af2folding`) and use +`single-pass`, or comment the reward out — then rely on this skill's independent +Boltz2 gate for selection. + +## Example: design binders for a registered target + +```bash +cd "$COMPLEXA_REPO" +complexa design configs/search_binder_local_pipeline.yaml \ + ++run_name= \ + ++generation.task_name= \ + ++generation.search.algorithm=best-of-n \ + ++generation.dataloader.dataset.nres.nsamples=8 \ + ++seed=0 ++gen_njobs=1 ++eval_njobs=1 +complexa status configs/search_binder_local_pipeline.yaml +``` + +`` is a registered target. The repo ships example targets (run +`complexa target list` in your checkout to see them); for your own target, register it +first (`complexa target add --pdb target.pdb --chain A --span +--hotspots --binder-length `) and pass +`++generation.task_name=`. See `scripts/complexa_design.py` for a thin +driver that assembles this command and discovers outputs. + +## Outputs + +- `./inference/…` — generated **complex PDBs** (target chain + binder chain; the + binder chain carries the co-designed sequence — read it directly). +- `./evaluation_results/…` — Complexa's own per-sample CSVs (AF2/RF3/ESMFold metrics). +- `./logs/…` — Hydra run logs. + +This skill consumes the `./inference` complex PDBs and re-validates them +independently (Boltz2/OpenFold3) — see `validation.md`. + +## GPU / memory notes + +- **Keep binder + target ≤ ~500 residues.** Complexa builds an O(n²) pair-feature + map; crop large targets to a window around the epitope (preserve numbering). +- The AF2-Multimer reward (JAX) preallocates a large share of an 80 GB GPU. If you + hit OOM with rewards enabled, set `XLA_PYTHON_CLIENT_PREALLOCATE=false` (and a + `MEM_FRACTION`) so PyTorch and the AF2 reward coexist, or reduce the pool size. +- Increase `gen_njobs`/`eval_njobs` to your GPU count to parallelize. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/pipeline.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/pipeline.md new file mode 100644 index 0000000..b0f8810 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/pipeline.md @@ -0,0 +1,123 @@ +# Pipeline — stages, handoffs, loop, layout + +## Stage 1 — target + hotspots (automated, no GPU) + +Resolve one design-ready structure (PDB → AFDB → provided file → fold), define a +**compact, surface-exposed epitope** with the evidence-based resolver, align + prune, +build a target MSA, and crop to the **binder + target ≤ ~500-residue** budget. Run the +**preflight** and review before GPU. Full detail (resolver order, numbering caveats, +crop): `target-and-hotspots.md`. Driven by `scripts/pipeline.py` + +`scripts/preflight_design.py`. + +## Stage 2 — register + generate (open `complexa` CLI) + +Register the target in Complexa's target dict (hotspots + binder-length range are +target-dict-driven), then **generate** with the lean path — `complexa generate` with +reward-guided `best-of-n` (NOT the full `complexa design`): + +```bash +export COMPLEXA_REPO=/path/to/Proteina-Complexa +complexa target add my_target --pdb target.pdb --chain A --span 1-115 \ + --hotspots A54,A56 --binder-length 60-90 # or reuse an example (assets/targets.json) +python scripts/complexa_design.py run --task-name my_target --run-name run1 \ + --algorithm best-of-n --num-samples 8 --seed 0 --out outputs/run1 +``` + +`complexa_design.py run` shells `complexa generate` (best-of-n), discovers the +`inference/` complex PDBs, and extracts the co-designed sequences. Overrides + outputs: +`complexa-cli.md`. + +> **Avoid the full `complexa design`** for this workflow: best-of-n already AF2-selects +> during generation, so the full pipeline's `evaluate` (re-folds every design with +> AF2/RF3/ESMFold) is redundant and its `analyze` needs `foldseek`/`sc`. Generation +> alone emits the co-designed seq+structure; validate independently in Stage 3. + +**AF2 quality gate.** With the AF2 reward configured, `best-of-n` keeps the +AF2-confident designs during search (i_pTM/pLDDT-guided); a persistent empty result is +a scientific signal (bad hotspots/length/algorithm), not a reason to loop harder. No +AF2 weights → `--af2-bypass` (`single-pass`) and let the Boltz2 gate (Stage 3) select. + +## Stage 3 — extract + validate (independent refold) + +The binder chain carries the **co-designed sequence** (read directly — no MPNN). Per +surviving binder, run **two** predictions with one refolder (`boltz2-nim` default): +holo (binder+target) and apo (binder alone) — a **different** model family than +Complexa's AF2/RF3 reward+evaluate, so the check is independent. Turnkey: + +```bash +python scripts/boltz2_refold.py --run-dir --pdbs /*.pdb \ + --endpoint hosted --validate scripts/validate_binders.py [--hotspots /hotspots.json] +``` + +`boltz2_refold.py` makes the **holo** Boltz2 calls (retry/backoff + `--throttle` to +avoid HTTP 429), writes `validation/raw/*.json`, then chains `validate_binders.py` +(apo + ipSAE + apo↔holo RMSD + gate + rank). Policy + metrics: `validation.md`. + +## Stage 4 — gate + rank + report + +`scripts/validate_binders.py` applies the gate (`validation.md`), ranks survivors, and +writes `ranked_binders.json`/`.csv`; then write the report. + +## Run-until-N-validated loop + +The deliverable is **N designs that pass the full gate**, not N raw designs. Only a +fraction pass, so loop Stages 3–5 and accumulate passers: + +``` +N = user-requested count (default 10) +validated = [] # deduped by binder sequence +round = 0 +while len(validated) < N and not stop_cap(): + round += 1 + batch = complexa_generate(target, nsamples=k, seed=base+round) + scored = validate(batch) # holo+apo, full gate + passers = [d for d in scored if d.pass and d.seq not in seqs(validated)] + validated += passers +return rank(validated)[:N] +``` + +Size each round from the measured pass rate `p = passers/generated`: +`k = ceil((N - len(validated)) / max(p, p_floor)) * safety`. + +**Stop caps** (state which fired): reached N; `round ≥ max_rounds` (default 8); +sample/GPU budget exhausted; or ≥3 consecutive zero-passer rounds. A persistent 0% +pass rate is a scientific signal (bad hotspots/length/algorithm) — surface it and +propose changes instead of burning GPU. + +## Output layout + +``` +outputs/_/ # run_id = UTC %Y-%m-%d_%H%M%S +├── target.pdb / target.cif # from target-preparation +├── hotspots.json # [{chain,residue,position}, ...] +├── design/ # Stage 3: Complexa complex PDBs + the exact command + run config +├── sequences/ # binder sequences extracted from the complexes +├── validation/holo/ , validation/apo/ , validation/validation_scores.json(.csv) +├── ranked_binders.json / .csv # every design (pass+fail), all metrics, pass, failure_reason +├── REPORT__.md +└── manifest.json # target, Complexa run config + seeds, params, versions, paths +``` + +Use one `_` for the whole loop; never scatter or overwrite. + +## Report sections + +1. **Executive summary** — target, what was designed, requested-vs-achieved N, + headline in 2–3 sentences. +2. **Loop provenance** — rounds, generated/round, per-round + overall pass rate, + cumulative validated, which stop condition fired. +3. **Decision: GO / NO-GO** — did any binder clear the full gate? +4. **Target & hotspots** — `Target: NAME (UniProtID)`, structure source, hotspot + identities + citations. +5. **Ranked binders** — table: rank, round, sequence/length, holo `.cif`, apo `.cif`, + ipTM, ipSAE_min, complex/binder/apo pLDDT, apo↔holo RMSD, hotspot-contact, pass. +6. **Independent validation** — refolder vs Complexa's own evaluate; apo/holo + stability. +7. **Concerns & limitations** — de-novo MSA caveats, reward-model availability, + numbering risks, missing endpoints. +8. **Reproducibility** — Complexa run config + per-round seed/sample settings, model + + checkpoint versions, full artifact paths. + +Report only measured values — never fabricate; write `null`/`N/A` when missing. If a +stage failed, say so plainly (stage + verbatim error) and emit no scores for stages +that did not run. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/setup.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/setup.md new file mode 100644 index 0000000..55074ec --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/setup.md @@ -0,0 +1,167 @@ +# Setup — standalone (open `complexa` CLI, no NIM) + +This skill drives the **open Proteina-Complexa release** directly via its `complexa` +CLI on a GPU host. There is **no NIM / HTTP service** involved. Do this once per host. + +## 0. Hardware & OS + +- An NVIDIA GPU with working CUDA (validated on A100 80GB; A6000/H100/etc. fine). + Keep **binder + target ≤ ~500 residues** (the AF2-reward path preallocates a large + GPU slice). Ubuntu 22.04+ (the upstream UV env needs a recent glibc; use Docker on + older systems). +- Python ≥ 3.10 for the skill's own scripts. + +## 1. Install Proteina-Complexa + download weights + +```bash +git clone https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa +cd Proteina-Complexa + +# (a) UV env (recommended, no Docker): +./env/build_uv_env.sh # FULL install — required (see note) +source .venv/bin/activate +# (b) OR the upstream image: docker run --gpus all -it proteina-complexa + +complexa init # writes .env (Phase 1) — re-run `complexa init uv|docker` to emit env.sh +complexa download --complexa-all # model + autoencoder checkpoints from NGC +export COMPLEXA_REPO=$PWD # the skill reads this +``` + +Weights land in `ckpts/` (`complexa.ckpt`, `complexa_ae.ckpt`). The Complexa weights +download from **public NGC URLs — no NGC key required** (`complexa download --complexa` +for just the protein binder model, `--complexa-all` for all three). Verify: +`complexa validate design configs/search_binder_local_pipeline.yaml` shows both +checkpoints **Found**. + +> **Use the FULL build — not `--minimal`.** The generation code path imports the +> colabdesign / JAX / dm-haiku stack **unconditionally** (via `proteinfoundation.search`), +> so generation fails on a `--minimal` env with `ModuleNotFoundError: jax` / `haiku` +> even when you use `single-pass` + the AF2 bypass. `./env/build_uv_env.sh` (full, +> default) installs these. On Python 3.12 the upstream `tmol` install may warn/fail — +> that's non-fatal for generation. (Validated clean on a fresh 2×H100 Linux host.) + +## 2. Skill Python dependencies + +The skill's Stage-1 tooling needs a few packages **in the same environment** that +runs the scripts (the Proteina-Complexa `.venv` is convenient): + +```bash +# in the Proteina-Complexa venv (uv) or any py>=3.10 env: +uv pip install numpy gemmi pyyaml # or: pip install numpy gemmi pyyaml +bash scripts/fetch_ipsae.sh # vendors the MIT ipSAE script into vendor/ipsae/ +``` + +> `gemmi` + `pyyaml` are required for Stage 1 (structure parsing, crop, target +> registration). `numpy` for validation scoring. ipSAE is fetched, not bundled. + +## 3. AF2 reward — configure it OR bypass it (pick one) + +Complexa's reward-guided search + its `full`-pipeline pre-gate use an **AF2-Multimer** +reward. It is **optional**: + +- **Configure AF2** (enables reward-guided search + the AF2 pre-gate) — one command: + ```bash + bash scripts/setup_af2_params.sh # downloads AF2 (public, no auth) + creates the params/ layout + export AF2_DIR=$COMPLEXA_REPO/community_models/ckpts/AF2 + ``` + This handles the **`params/` symlink quirk**: colabdesign enumerates + `$AF2_DIR/params/`, but the public AF2 tar extracts `params_model_*.npz` flat — the + script creates the `params/` symlinks so model loading works. Requires **GPU JAX** + (the full build installs `jax==0.4.x` with CUDA — verify `python -c "import jax; + print(jax.devices())"` shows `cuda`). Then `best-of-n` / `beam-search` / `fk-steering` + / `mcts` and the i_pTM>0.70 & pLDDT>0.70 pre-gate work. +- **Bypass AF2** (no AF2 params needed): use `single-pass` generation **and** drop the + reward with the Hydra override `~generation.reward_model.reward_models.af2folding`. + Selection then falls entirely to the **independent Boltz2 gate** (Stage 3). The + helper does this for you: `complexa_design.py --af2-bypass`. + +> If a run fails with `AssertionError: No model parameters found` / +> `model_*_multimer_v3 not found`, AF2 params aren't configured — run +> `setup_af2_params.sh` (configure) or use `--af2-bypass`. (Validated live: bypass → +> co-designed binder in ~30 s; best-of-n + AF2 → a binder passing the full gate.) + +## 4. Optional analysis tools (full `complexa design` only) + +The **analyze/diversity** stage of `complexa design` uses external binaries; they are +**not needed** for `complexa generate` + this skill's independent Boltz2 validation: + +- `foldseek`, `mmseqs` (diversity), `dssp`, `sc` (shape complementarity). +- Pre-built `dssp`/`sc` are available from FreeBindCraft; set `FOLDSEEK_EXEC`, + `MMSEQS_EXEC`, `DSSP_EXEC`, `SC_EXEC` in `.env`. If you only run generation + + independent validation, you can ignore these (a config warning is expected). + +## 5. Validation endpoint (Stage 3) + +Independent refold uses a **Boltz2** (default) or **OpenFold3** NIM — a *different* +model family than Complexa's reward/evaluate: + +- **Hosted:** `https://health.api.nvidia.com/v1/biology/mit/boltz2/predict` + + `export NVIDIA_API_KEY=nvapi-...` → `--endpoint hosted`. +- **Local NIM:** `http://localhost:8000/...` (no auth) → `--endpoint local`. To stand one + up yourself: + + ```bash + docker login nvcr.io -u '$oauthtoken' -p "$NGC_API_KEY" # once + mkdir -p ~/nimcache_boltz2 && chmod 777 ~/nimcache_boltz2 + docker run -d --name boltz2 --gpus device=0 --shm-size=8g \ + -e NGC_API_KEY -v ~/nimcache_boltz2:/opt/nim/.cache -p 8000:8000 \ + nvcr.io/nim/mit/boltz2:latest # OpenFold3 NIM analogously + curl -fsS http://localhost:8000/v1/health/ready && echo READY + export BOLTZ2_URL=http://localhost:8000/biology/mit/boltz2/predict # validator reads this + ``` + + **Profile note:** if the NIM exits with `NIMProfileIDNotFound` / "0 profiles" (some GPUs + have no bundled profile), run `docker run --rm --gpus device=0 -e NGC_API_KEY + nvcr.io/nim/mit/boltz2:latest list-model-profiles` and pin the profile matching **your** + GPU's compute capability via `-e NIM_MODEL_PROFILE=` — pick it for the + hardware you're on, don't reuse an id from another machine. (Fuller multi-NIM launch + guide: the sibling `protein-binder-design/references/local-nim-setup.md`.) + +`scripts/boltz2_refold.py` does the **holo** refolds (and chains `validate_binders.py` +for apo + gate). Both have **retry/backoff** for the hosted endpoint's rate limit +(HTTP 429); for large batches keep `--throttle` (default 5 s between holo calls) or use +a local Boltz2 NIM. + +## 6. Environment variables (summary) + +| Var | Purpose | +|---|---| +| `COMPLEXA_REPO` | path to the Proteina-Complexa checkout (required for generation) | +| `COMPLEXA_BIN` | `complexa` binary (default `complexa`; e.g. `/.venv/bin/complexa`) | +| `COMPLEXA_CONFIG` | pipeline YAML (default `configs/search_binder_local_pipeline.yaml`) | +| `COMPLEXA_OUTPUTS` | skill run-dir root (default `outputs`) | +| `COMPLEXA_TIMEOUT_S` | per-`complexa design` subprocess timeout (default 21600) | +| `NVIDIA_API_KEY` / `NGC_API_KEY` | hosted Boltz2/OF3 auth + NGC weight download | +| `AF2_DIR` | AF2-Multimer params (only if NOT bypassing AF2) | +| `RF3_CKPT_PATH`, `RF3_EXEC_PATH` | RoseTTAFold3 reward/eval (optional) | +| `FOLDSEEK_EXEC`, `MMSEQS_EXEC`, `DSSP_EXEC`, `SC_EXEC` | analyze-stage tools (optional) | + +## 7. Verify the environment + +```bash +bash scripts/check_setup.sh # one-shot readiness checklist +python scripts/preflight_design.py # Stage 1 end-to-end (no GPU) +``` + +## 8. End-to-end quickstart (no NIM) + +```bash +export COMPLEXA_REPO=/path/to/Proteina-Complexa +export COMPLEXA_BIN=$COMPLEXA_REPO/.venv/bin/complexa # if not on PATH + +# Stage 1 — plan target + hotspots (no GPU) +python scripts/preflight_design.py # name, UniProt accession, or PDB + +# Stage 2 — generate (lean: complexa generate + best-of-n; NOT full `complexa design`). +# Reward-guided (best, needs AF2 set up in step 3): +python scripts/complexa_design.py run --task-name --run-name \ + --algorithm best-of-n --num-samples 8 --seed 0 --out outputs/ +# ...or AF2-free quick path: add --af2-bypass --algorithm single-pass + +# Stage 3 — independent Boltz2 HOLO refold (+ apo + ipSAE + gate + rank) in one step. +# Point --pdbs at the generated complexes (under $COMPLEXA_REPO/inference/...): +python scripts/boltz2_refold.py --run-dir outputs/ \ + --pdbs outputs//inference/*.pdb \ + --endpoint hosted --validate scripts/validate_binders.py +# -> outputs//ranked_binders.json (+ .csv): every design with pass/fail + metrics +``` diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/target-and-hotspots.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/target-and-hotspots.md new file mode 100644 index 0000000..dafab2e --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/target-and-hotspots.md @@ -0,0 +1,96 @@ +# Stage 1 — target structure + hotspots (detailed) + +Automated, no-GPU. Implemented in `scripts/pipeline.py` (resolution + alignment + +prune + crop), `scripts/hotspot_strategy.py` / `scripts/pdb_interface.py` (hotspot +evidence), and surfaced by `scripts/preflight_design.py`. Always run the preflight and +review before spending GPU. + +## 1. Resolve exactly one design-ready structure + +Try sources **in priority order**; use the first that yields a usable structure: + +| # | Source | When | +|---|---|---| +| 1 | **Experimental PDB** | a design-ready RCSB entry exists (`https://files.rcsb.org/download/XXXX.pdb`) | +| 2 | **AFDB** | no usable PDB → resolve UniProt accession → fetch the AlphaFold model | +| 3 | **User file** | the user hands you a `.pdb`/`.cif` | +| 4 | **Fold de novo** | none of the above → MSA-Search + OpenFold3/Boltz2 | + +Free-text names resolve to a UniProt accession with the vendored `uniprot_database` +skill (`vendor/science-skills/uniprot_database`); AFDB fetch uses +`vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/fetch_structure.py`. +Resolution prefers reviewed (Swiss-Prot) entries (which have AFDB models), human first, +but works across organisms (allergens, viral, …). A typed UniProt accession or 4-char +PDB ID is accepted directly. + +## 2. Define hotspots (evidence-based, accessibility-aware) + +Hotspots are the **target residues the binder should contact** — a compact, +surface-exposed, binder-accessible epitope. `hotspot_strategy.resolve_hotspots()` +resolves them in this order, every candidate restricted to the accessible surface: + +1. **UniProt functional (trusted default)** — `Mutagenesis` residues with a + binding/interaction effect, plus `Active/Binding/Site` **only when accessible**. + A binder can only reach the **extracellular topological domain** of a membrane + protein, so candidates are filtered to it and the target is cropped to that region. + Catalytic/intracellular pockets (e.g. HER2 kinase ATP site, IL1R1 cytoplasmic TIR) + are dropped — they are the wrong surface for a binder. +2. **PDB co-complex interface (gold standard, fallback + review)** — + `pdb_interface.interface_hotspots`: from the target's PDB cross-references, find a + structure where the target chain contacts a protein partner, compute interface + residues (≤ 5 Å heavy-atom), map PDB→UniProt by alignment. Review for crystal/ + non-biological contacts. +3. **Paperclip literature** — full-text mining (alanine scans, ΔΔG, co-crystal + contacts) when 1–2 are empty; see `prompts/hotspot_paperclip.md`. The structure is + the ground-truth filter (auto-corrects literature↔structure numbering offsets). +4. **Unconditioned** (`[]`) — documented last resort. + +## 3. Align to the structure (the ordering guarantee) + +`align_hotspots_to_structure()` keeps only residues present in the coordinate file and +fills in the actual 3-letter identity from coordinates. **Beware UniProt→PDB numbering +mismatch:** PDB constructs are often truncated/engineered with author numbering offset +from UniProt. Always express hotspots in the numbering of the coordinate file the +designer consumes, verify the residue identity there, and **use whatever chain ID the +file actually uses** (often `A`, but read it — never assume). + +Hotspot format consumed by Stage 2: + +```json +[ { "chain": "A", "residue": "ILE", "position": 37 }, + { "chain": "A", "residue": "TYR", "position": 39 } ] +``` + +## 4. Keep one compact epitope (prune) + +`_prune_hotspots()` enforces (a binder grips one local patch): + +- **Compactness ≤ 30 Å** — drop hotspots whose Cβ is > 30 Å from the densest cluster + centroid (removes distal outliers on other domains). +- **Count ≤ 15** — keep the 15 closest to the centroid. +- **Count ≥ 2** — a single residue is too weak to define an epitope. + +If a target has two distal patches, design a **separate binder per patch**. + +## 5. Size budget — binder + target ≤ 500 residues + +Complexa builds an O(n²) pair-feature map over the whole complex, and the AF2-Multimer +reward (JAX) preallocates a large GPU slice. `_crop_target_to_epitope()` crops an +oversized target to a contiguous window centered on the epitope, **preserving original +residue numbering** so hotspot ids and downstream Boltz2/OpenFold3 numbering stay valid. +With the default binder range (64–155), the target must be ≤ ~345 residues. With no +hotspots there is no epitope to center on (it falls back to the first N residues with a +warning) — supply hotspots. + +## 6. Preflight (no GPU) + +```bash +python scripts/preflight_design.py [ ...] +``` + +Per target it reports the conditioned length, re-aligned hotspots + their source, +compactness (Å), the ≤ 500 size budget, the count, and a **READY / NEEDS ATTENTION** +verdict. Review here before launching generation. + +**Stage 1 output:** `target.pdb` (single structure, possibly `target_cropped.pdb`) + +`hotspots.json` (the residue list). For an unconditioned design, pass `[]`. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/validation.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/validation.md new file mode 100644 index 0000000..ed7255d --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/references/validation.md @@ -0,0 +1,80 @@ +# Validation — independent refold, metrics, gates + +Validation is an **independent refold** of each binder–target complex — not +Complexa's internal scoring. Extract binder + target sequences, re-fold, score +the interface, gate, rank. Use a **single refolder** (Boltz2 default; OpenFold3 +optional — do not run both). Endpoints/auth: read `boltz2-nim` / `openfold3-nim`. + +## Per-chain conditioning policy + +"De novo" applies to the **binder**, not the target. + +- **Binder chain → single-sequence**, no MSA, no template (it is de novo, no + homologs). This is how BindCraft validates binders. +- **Target chain → MSA (default).** Build an MMseqs2 a3m and attach it as the + target polymer's `msa` (use `msa-search-nim`, or + `scripts/fetch_target_msa_colabfold.py` which also sanitizes non-standard + residues to `X` — the Boltz2 NIM rejects a3m with `B/J/O/U/Z/*`). +- **Target chain → structural template (optional, stringent).** Pass the known + `target.pdb/.cif` as a Boltz2 per-polymer `structural_templates` entry; build + the CIF with `scripts/pdb_to_boltz_template_cif.py` (plain gemmi output is + rejected — the template needs `label_seq_id` 1..N + populated + `_entity_poly_seq`). Use when you want to dock against the exact geometry. + +Leave the **binder** polymer with neither MSA nor template. + +## Two predictions per design + +1. **Holo** — binder + target (two protein chains), target conditioning above, + `write_full_pae: true`. → holo complex `.cif` + confidence + PAE. Source of + ipTM, ipSAE, complex pLDDT, binder-in-complex pLDDT, hotspot contact. +2. **Apo** — the binder sequence **alone** (single chain, single-sequence, no + target/MSA/template). → apo binder `.cif` + per-residue pLDDT. + +**Apo/holo stability (binder RMSD).** Superpose the holo binder chain onto the +apo binder (binder Cα only — same sequence), compute Cα RMSD. Small RMSD = the +binder is pre-organized/rigid (the signal you want); large RMSD = induced fit +(weaker design). + +## Decision metrics & gates (holo unless noted) + +| Metric | How | Gate | +|---|---|---| +| **ipTM** | Boltz2 `iptm_scores` / `pair_chains_iptm_scores`; OF3 direct | ≥ 0.65 | +| **complex pLDDT** | mean pLDDT over the holo complex | ≥ 0.70 | +| **binder pLDDT** | mean pLDDT over the **binder chain** in holo | ≥ 0.70 | +| **apo binder pLDDT** | mean pLDDT of the **apo** prediction | ≥ 0.70 | +| **ipSAE (min)** | per-interface ipSAE from the holo **PAE** (Dunbrack ipSAE; not returned directly); min over the binder↔target interface | ≥ 0.45 | +| **binder RMSD (apo↔holo)** | binder Cα RMSD after superposing holo onto apo | ≤ 2.5 Å | +| **hotspot contact** | each conditioned hotspot contacted if its Cβ < 13 Å of any binder Cβ (Cα for Gly); score = fraction contacted | ≥ 20% | +| **specificity margin** | interface confidence for the intended target vs a decoy/native partner | guard vs promiscuity | + +**Validation is always unconditioned** — the holo refold is given only the +binder + target **sequences**, never the hotspot list. The hotspot-contact check +is then an independent geometric test on the unconditioned complex (did the +binder land where it was conditioned). Skip it for unconditioned designs. + +> **Boltz2 `affinity_pic50` is ligand-only (protein–ligand)** — not produced for +> a protein–protein binder. Rank protein binders by interface confidence +> (ipTM/ipSAE) + pLDDT + apo/holo stability, not pIC50. Request affinity only for +> small-molecule binders. + +## Pass flag + record-keeping + +A design passes only if it clears **every** gate above (hotspot-contact skipped +for unconditioned designs). `validation_scores.json` (+ `.csv`) must hold **one +row per design** — pass and fail — each with all measured metrics, the boolean +`pass`, and a `failure_reason`: + +- `null`/empty for passers. +- For a failing design list **every** missed gate as `metric: measured vs + threshold`, e.g. `"ipTM=0.62 < 0.70; apo_binder_plddt=0.55 < 0.70; binder_rmsd=3.1 > 2.5"`. +- If a design could not be scored (refold/apo errored, PAE missing), record the + verbatim error as `failure_reason` and leave unmeasured metrics `null` — never + drop silently, never invent a value. + +`scripts/boltz2_refold.py` makes the **holo** Boltz2 calls (retry/backoff for HTTP 429) +and writes `validation/raw/*.json`; `scripts/validate_binders.py` then runs the **apo** +call and implements ipSAE, apo↔holo RMSD, hotspot contact, and gating into +`ranked_binders.json`. Run them together via `boltz2_refold.py --validate +scripts/validate_binders.py`. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/boltz2_refold.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/boltz2_refold.py new file mode 100755 index 0000000..8954271 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/boltz2_refold.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Stage-3 holo refold: independent Boltz2 prediction of each binder-target complex. + +This is the bridge between Complexa generation and `validate_binders.py`: +`validate_binders.py` scores from the **holo** Boltz2 responses under +`/validation/raw/*.json` (and runs the **apo** call itself), but does not +produce the holo responses. This script makes the holo Boltz2 calls — with +retry/backoff + throttling so a batch doesn't trip the hosted endpoint's rate limit +(HTTP 429) — writes them in the shape `validate_binders.py` expects, then (optionally) +chains `validate_binders.py` for apo + ipSAE + apo/holo RMSD + gate + rank. + +Reads the API key from $NVIDIA_API_KEY / $NGC_API_KEY (hosted only; local needs none). + +Examples + NVIDIA_API_KEY=nvapi-... python boltz2_refold.py \ + --run-dir outputs/pdl1 --pdbs inference/.../*.pdb \ + --validate scripts/validate_binders.py --hotspots outputs/pdl1/hotspots.json + python boltz2_refold.py --run-dir outputs/pdl1 --pdbs *.pdb --endpoint local +""" +from __future__ import annotations +import argparse, json, os, subprocess, sys, time, urllib.error, urllib.request +from pathlib import Path + +HOSTED_URL = "https://health.api.nvidia.com/v1/biology/mit/boltz2/predict" +# Local NIM: override host/port via $BOLTZ2_URL (e.g. a NIM on another container/host). +LOCAL_URL = os.environ.get("BOLTZ2_URL", "http://localhost:8000/biology/mit/boltz2/predict") +THREE_TO_ONE = { + "ALA":"A","ARG":"R","ASN":"N","ASP":"D","CYS":"C","GLN":"Q","GLU":"E","GLY":"G", + "HIS":"H","ILE":"I","LEU":"L","LYS":"K","MET":"M","PHE":"F","PRO":"P","SER":"S", + "THR":"T","TRP":"W","TYR":"Y","VAL":"V", +} + + +def chain_seqs(pdb_path: str) -> dict[str, str]: + chains: dict[str, list[str]] = {} + for line in Path(pdb_path).read_text().splitlines(): + if line[:6].strip() in ("ATOM", "HETATM") and line[12:16].strip() == "CA": + chains.setdefault(line[21], []).append(THREE_TO_ONE.get(line[17:20].strip(), "X")) + return {c: "".join(r) for c, r in chains.items()} + + +def post_with_retry(url: str, body: dict, headers: dict, max_retries: int = 5, + base_delay: float = 10.0, timeout: int = 1200) -> dict: + """POST JSON with exponential backoff on 429 / 5xx / transient network errors. + Honors a Retry-After header when present.""" + data = json.dumps(body).encode() + last = None + for attempt in range(max_retries + 1): + try: + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + last = e + if e.code not in (429, 500, 502, 503, 504) or attempt == max_retries: + raise + ra = e.headers.get("Retry-After") if e.headers else None + delay = float(ra) if (ra and str(ra).isdigit()) else base_delay * (2 ** attempt) + print(f" [retry] HTTP {e.code}; waiting {delay:.0f}s " + f"(attempt {attempt + 1}/{max_retries})", file=sys.stderr, flush=True) + time.sleep(min(delay, 120)) + except (urllib.error.URLError, TimeoutError) as e: + last = e + if attempt == max_retries: + raise + delay = base_delay * (2 ** attempt) + print(f" [retry] {type(e).__name__}; waiting {delay:.0f}s " + f"(attempt {attempt + 1}/{max_retries})", file=sys.stderr, flush=True) + time.sleep(min(delay, 120)) + raise last if last else RuntimeError("post_with_retry exhausted") + + +def boltz2_holo(target_seq: str, binder_seq: str, url: str, api_key: str | None, + max_retries: int) -> dict: + body = { + "polymers": [ + {"id": "A", "molecule_type": "protein", "sequence": target_seq}, + {"id": "B", "molecule_type": "protein", "sequence": binder_seq}, + ], + "recycling_steps": 3, "sampling_steps": 50, "diffusion_samples": 1, + "step_scale": 1.638, "output_format": "mmcif", "write_full_pae": True, + } + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return post_with_retry(url, body, headers, max_retries=max_retries) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--run-dir", required=True, type=Path) + ap.add_argument("--pdbs", nargs="+", required=True, help="Complexa complex PDB(s)") + ap.add_argument("--endpoint", choices=["hosted", "local"], default="hosted") + ap.add_argument("--url", default=None, help="override the Boltz2 URL") + ap.add_argument("--target-chain", default="A") + ap.add_argument("--binder-chain", default="B") + ap.add_argument("--throttle", type=float, default=5.0, + help="seconds to wait between holo calls (avoid rate limits)") + ap.add_argument("--max-retries", type=int, default=5) + ap.add_argument("--validate", default=None, help="path to validate_binders.py to chain after") + ap.add_argument("--hotspots", default=None, help="hotspots.json passed to validate_binders.py") + a = ap.parse_args() + + url = a.url or (HOSTED_URL if a.endpoint == "hosted" else LOCAL_URL) + key = None if a.endpoint == "local" else (os.environ.get("NVIDIA_API_KEY") + or os.environ.get("NGC_API_KEY")) + if a.endpoint == "hosted" and not key: + print("WARNING: hosted endpoint but no NVIDIA_API_KEY/NGC_API_KEY in env", file=sys.stderr) + raw_dir = a.run_dir / "validation" / "raw" + raw_dir.mkdir(parents=True, exist_ok=True) + + n_ok = 0 + pdbs = list(a.pdbs) + for i, pdb in enumerate(pdbs): + seqs = chain_seqs(pdb) + tgt, bnd = seqs.get(a.target_chain), seqs.get(a.binder_chain) + if not tgt or not bnd: + print(f"[skip] {pdb}: chains {list(seqs)} (need {a.target_chain}+{a.binder_chain})") + continue + name = f"cand{i:02d}" + print(f"[holo] {name}: target {len(tgt)}aa + binder {len(bnd)}aa -> Boltz2 ...", flush=True) + try: + resp = boltz2_holo(tgt, bnd, url, key, a.max_retries) + except Exception as e: # noqa: BLE001 + print(f"[holo] {name} FAILED: {e}") + continue + (raw_dir / f"{name}.json").write_text(json.dumps(resp)) + iptm = (resp.get("iptm_scores") or ["?"])[0] + print(f"[holo] {name} ok -> validation/raw/{name}.json (iptm={iptm})") + n_ok += 1 + if a.throttle and i < len(pdbs) - 1: + time.sleep(a.throttle) + print(f"=== {n_ok} holo refold(s) written ===") + + if a.validate and n_ok: + cmd = [sys.executable, a.validate, "--run-dir", str(a.run_dir), + "--endpoint", a.endpoint, "--target-chain", a.target_chain, + "--binder-chain", a.binder_chain] + if a.hotspots: + cmd += ["--hotspots", a.hotspots] + print("=== running:", " ".join(cmd), "===", flush=True) + return subprocess.run(cmd).returncode + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/check_setup.sh b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/check_setup.sh new file mode 100755 index 0000000..ad825f8 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/check_setup.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +# One-shot readiness check for the standalone (no-NIM) complexa-binder-design skill. +# Verifies the complexa CLI, repo/checkpoints, Python deps, ipSAE, AF2 status, and the +# validation endpoint env. Non-fatal: prints a checklist and a final verdict. +set +e + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PY="${PYTHON:-python3}" +COMPLEXA_BIN="${COMPLEXA_BIN:-complexa}" +ok=0; warn=0; err=0 +pass(){ echo " [OK] $1"; ok=$((ok+1)); } +note(){ echo " [warn] $1"; warn=$((warn+1)); } +fail(){ echo " [FAIL] $1"; err=$((err+1)); } + +echo "=== complexa-binder-design setup check ===" + +# 1. complexa CLI +if command -v "$COMPLEXA_BIN" >/dev/null 2>&1; then pass "complexa CLI: $(command -v "$COMPLEXA_BIN")" +else fail "complexa CLI not found (set COMPLEXA_BIN or activate the Proteina-Complexa venv)"; fi + +# 2. repo + config + checkpoints +if [ -n "$COMPLEXA_REPO" ] && [ -d "$COMPLEXA_REPO" ]; then + pass "COMPLEXA_REPO=$COMPLEXA_REPO" + cfg="${COMPLEXA_CONFIG:-configs/search_binder_local_pipeline.yaml}" + [ -f "$COMPLEXA_REPO/$cfg" ] && pass "pipeline config: $cfg" || fail "pipeline config missing: $cfg" + if ls "$COMPLEXA_REPO"/ckpts/complexa.ckpt >/dev/null 2>&1 && ls "$COMPLEXA_REPO"/ckpts/complexa_ae.ckpt >/dev/null 2>&1; then + pass "checkpoints: complexa.ckpt + complexa_ae.ckpt" + else note "checkpoints not in /ckpts (run 'complexa download --complexa-all' or set ++ckpt_path)"; fi +else fail "COMPLEXA_REPO unset or not a directory (needed for generation)"; fi + +# 3. Python deps +$PY - <<'PY' 2>/dev/null && pass "python deps: numpy + gemmi + pyyaml" || fail "missing python deps (pip/uv pip install numpy gemmi pyyaml)" +import numpy, gemmi, yaml +PY + +# 4. ipSAE vendored +[ -f "$SKILL_DIR/vendor/ipsae/ipsae.py" ] && pass "ipSAE present (vendor/ipsae/ipsae.py)" \ + || note "ipSAE not fetched yet — run: bash scripts/fetch_ipsae.sh" + +# 5. GPU / CUDA (best-effort) +$PY - <<'PY' 2>/dev/null +import sys +try: + import torch + print(" [OK] CUDA available" if torch.cuda.is_available() else " [warn] torch present but CUDA not available") +except Exception: + print(" [warn] torch not importable here (fine if you run complexa in its own venv)") +PY + +# 6. AF2 reward status +if [ -n "$AF2_DIR" ] && [ -d "$AF2_DIR" ]; then pass "AF2_DIR set ($AF2_DIR) — reward-guided search + AF2 pre-gate enabled" +else note "AF2_DIR not set — use single-pass + AF2 bypass (~generation.reward_model.reward_models.af2folding); selection falls to Boltz2"; fi + +# 7. analyze-stage tools (optional) +for t in FOLDSEEK_EXEC SC_EXEC DSSP_EXEC; do + v="${!t}"; { [ -n "$v" ] && [ -x "$v" ]; } && pass "$t=$v" || note "$t not set (only needed for full 'complexa design' analyze/diversity)" +done + +# 8. validation endpoint +if [ -n "$NVIDIA_API_KEY" ] || [ -n "$NGC_API_KEY" ]; then pass "NVIDIA_API_KEY/NGC_API_KEY set (hosted Boltz2/OF3 + NGC)" +else note "no NVIDIA_API_KEY/NGC_API_KEY — use a local Boltz2 NIM (--endpoint local) for Stage 3"; fi + +echo "----------------------------------------------" +echo " OK=$ok warn=$warn FAIL=$err" +[ "$err" -eq 0 ] && echo " => READY (warnings are optional features)" || echo " => NOT READY — fix [FAIL] items above" +exit 0 diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/complexa_design.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/complexa_design.py new file mode 100644 index 0000000..a4ff938 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/complexa_design.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Drive the open Proteina-Complexa `complexa` CLI: build argv -> run -> discover -> extract. + +Transport-agnostic and stdlib-only. Runs the upstream `complexa` Hydra CLI inside +your local checkout ($COMPLEXA_REPO) and reads the generated complex PDBs from +`./inference/`. Hotspots and binder length are target-dict-driven upstream, so +register the target first (`complexa target add ...`) and select it with --task-name. + +Examples + COMPLEXA_REPO=/path/to/Proteina-Complexa \ + python complexa_design.py run --task-name --run-name \ + --algorithm best-of-n --num-samples 8 --seed 0 --out outputs/ + python complexa_design.py extract outputs//inference/**/complex_0.pdb + +See references/complexa-cli.md for the full override list. +""" +from __future__ import annotations +import argparse, os, shutil, subprocess, sys, time +from pathlib import Path + +THREE_TO_ONE = { + "ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", "GLN": "Q", + "GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K", + "MET": "M", "PHE": "F", "PRO": "P", "SER": "S", "THR": "T", "TRP": "W", + "TYR": "Y", "VAL": "V", +} +DEFAULT_CONFIG = "configs/search_binder_local_pipeline.yaml" + + +def repo_root() -> Path: + r = os.environ.get("COMPLEXA_REPO") + if not r: + sys.exit("Set COMPLEXA_REPO to your Proteina-Complexa checkout " + "(https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa).") + p = Path(r).expanduser() + if not p.is_dir(): + sys.exit(f"COMPLEXA_REPO is not a directory: {p}") + return p + + +def build_argv(a) -> list[str]: + verb = "generate" if a.mode == "generate" else "design" + argv = [a.cli_bin, verb, a.config, f"++run_name={a.run_name}"] + if a.task_name: + argv.append(f"++generation.task_name={a.task_name}") + if a.algorithm: + argv.append(f"++generation.search.algorithm={a.algorithm}") + if a.num_samples is not None: + argv.append(f"++generation.dataloader.dataset.nres.nsamples={a.num_samples}") + if a.seed is not None: + argv.append(f"++seed={a.seed}") + if a.gen_njobs is not None: + argv.append(f"++gen_njobs={a.gen_njobs}") + if a.eval_njobs is not None: + argv.append(f"++eval_njobs={a.eval_njobs}") + if a.ckpt_path: + argv.append(f"++ckpt_path={a.ckpt_path}") + if a.ckpt_name: + argv.append(f"++ckpt_name={a.ckpt_name}") + if a.autoencoder_ckpt_path: + argv.append(f"++autoencoder_ckpt_path={a.autoencoder_ckpt_path}") + argv.extend(a.override or []) # caller escape hatch, appended last so it wins + return argv + + +def discover_complex_pdbs(root: Path, since: float | None = None) -> list[Path]: + inf = root / "inference" + if not inf.is_dir(): + return [] + pdbs = [p for p in inf.rglob("*.pdb") if p.is_file()] + if since is not None: + pdbs = [p for p in pdbs if p.stat().st_mtime >= since - 1] + return sorted(pdbs) + + +def extract(pdb_path: str) -> dict: + """Return {chain: sequence} from CA records (binder chain carries the seq).""" + chains: dict[str, list[str]] = {} + for line in Path(pdb_path).read_text().splitlines(): + if line[:6].strip() in ("ATOM", "HETATM") and line[12:16].strip() == "CA": + ch = line[21] + chains.setdefault(ch, []).append(THREE_TO_ONE.get(line[17:20].strip(), "X")) + seqs = {c: "".join(r) for c, r in chains.items()} + order = sorted(seqs, key=lambda c: len(seqs[c])) # shorter chain ~ binder + return {"file": pdb_path, + "chains": {c: {"len": len(s), "seq": s} for c, s in seqs.items()}, + "binder_chain_guess": order[0] if order else None} + + +def cmd_run(a) -> None: + root = repo_root() + argv = build_argv(a) + print("cwd:", root, file=sys.stderr) + print("cmd:", " ".join(argv), file=sys.stderr) + t0 = time.time() + proc = subprocess.run(argv, cwd=root) + if proc.returncode != 0: + sys.exit(f"complexa exited with code {proc.returncode}") + pdbs = discover_complex_pdbs(root, since=t0) + out = Path(a.out) if a.out else None + saved = [] + if out: + (out / "inference").mkdir(parents=True, exist_ok=True) + for p in pdbs: + dest = out / "inference" / p.name + shutil.copy2(p, dest) + saved.append(str(dest)) + (out / "command.txt").write_text(" ".join(argv) + "\n") + import json + report = [extract(p) for p in (saved or [str(p) for p in pdbs])] + print(json.dumps({"n_complexes": len(report), "binders": report}, indent=1)) + + +def cmd_extract(a) -> None: + import json + print(json.dumps([extract(p) for p in a.pdb], indent=1)) + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="cmd", required=True) + + def add_run_args(sp): + sp.add_argument("--config", default=DEFAULT_CONFIG) + # 'generate' (default) = lean path: reward-guided search (best-of-n) already + # AF2-selects during generation, emits co-designed seq+structure PDBs, and + # avoids the full pipeline's redundant re-fold (evaluate) + foldseek/sc (analyze). + # Use 'design' only if you specifically want Complexa's internal evaluate/analyze. + sp.add_argument("--mode", choices=["design", "generate"], default="generate") + sp.add_argument("--task-name") + sp.add_argument("--run-name", default="complexa_run") + sp.add_argument("--algorithm", default="best-of-n") + sp.add_argument("--num-samples", type=int) + sp.add_argument("--seed", type=int, default=0) + sp.add_argument("--gen-njobs", type=int) + sp.add_argument("--eval-njobs", type=int) + sp.add_argument("--ckpt-path"); sp.add_argument("--ckpt-name") + sp.add_argument("--autoencoder-ckpt-path") + sp.add_argument("--cli-bin", default=os.environ.get("COMPLEXA_BIN", "complexa")) + sp.add_argument("--override", nargs="*", help="extra ++key=value Hydra overrides") + sp.add_argument("--out", help="copy discovered complex PDBs here") + + sp = sub.add_parser("run", help="build + run complexa, then discover/extract") + add_run_args(sp) + sp = sub.add_parser("extract", help="extract per-chain sequences from complex PDB(s)") + sp.add_argument("pdb", nargs="+") + + a = p.parse_args() + if a.cmd == "run": + cmd_run(a) + elif a.cmd == "extract": + cmd_extract(a) + + +if __name__ == "__main__": + main() diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/fetch_ipsae.sh b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/fetch_ipsae.sh new file mode 100755 index 0000000..dafa008 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/fetch_ipsae.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +# Fetch the Dunbrack ipSAE script (MIT) into vendor/ipsae/ipsae.py. +# ipSAE is third-party and NOT redistributed with this skill; this script pulls it +# from the canonical source so validate_binders.py can compute ipSAE_min. +# +# Source : https://github.com/dunbracklab/IPSAE (ipsae.py) +# License: MIT (Roland L. Dunbrack Jr., Fox Chase Cancer Center) +# Paper : Dunbrack, bioRxiv 2025.02.10.637595 +set -euo pipefail + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEST="$SKILL_DIR/vendor/ipsae/ipsae.py" +mkdir -p "$(dirname "$DEST")" + +for ref in main master; do + URL="https://raw.githubusercontent.com/dunbracklab/IPSAE/${ref}/ipsae.py" + echo "Trying $URL ..." + if curl -fsSL "$URL" -o "$DEST"; then + echo "Saved ipSAE -> $DEST" + echo "Remember: ipSAE is MIT-licensed; keep vendor/ipsae/README.md attribution." + exit 0 + fi +done + +echo "ERROR: could not download ipsae.py. Download it manually from" >&2 +echo " https://github.com/dunbracklab/IPSAE and place it at $DEST" >&2 +exit 1 diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/fetch_target_msa_colabfold.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/fetch_target_msa_colabfold.py new file mode 100644 index 0000000..5a15d13 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/fetch_target_msa_colabfold.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Fetch a target MSA (a3m) from the public ColabFold MMseqs2 API. + +Used to produce the **target** polymer's MSA for the Stage 3 holo refold when no +entitled MSA-Search NIM (hosted or local) is available. The binder stays +single-sequence; only the target gets an MSA. This hits the same UniRef30 + +environmental databases the ColabFold / MSA-Search NIM uses, via the public +api.colabfold.com server (rate-limited; for occasional single-target use). + +Usage: + python fetch_target_msa_colabfold.py --seq-from-pdb target.pdb --chain A -o target.a3m + python fetch_target_msa_colabfold.py --seq MKT... -o target.a3m +""" +from __future__ import annotations + +import argparse +import io +import json +import sys +import tarfile +import time +import urllib.parse +import urllib.request + +HOST = "https://api.colabfold.com" +UA = "bionemo-nim-skills-colabfold-client/1.0" +AA3 = {"ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", "GLN": "Q", + "GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K", + "MET": "M", "PHE": "F", "PRO": "P", "SER": "S", "THR": "T", "TRP": "W", + "TYR": "Y", "VAL": "V"} + + +def seq_from_pdb(pdb: str, chain: str) -> str: + out, seen = [], set() + for ln in open(pdb): + if ln.startswith("ATOM") and ln[12:16].strip() == "CA" and ln[21] == chain: + key = ln[22:26] + if key in seen: + continue + seen.add(key) + out.append(AA3.get(ln[17:20].strip(), "X")) + return "".join(out) + + +_UP_OK = set("ACDEFGHIKLMNPQRSTVWYX") +_LO_OK = set("acdefghiklmnpqrstvwyx") + + +def sanitize_a3m(a3m: str) -> str: + """Map non-standard residue letters (B/J/O/U/Z/*, etc.) to X, preserving + case, gaps ('-'/'.'), and alignment columns. The Boltz2 NIM a3m validator + only accepts ARNDCQEGHILKMFPSTWYVX (+ lowercase insertions, '-'/'.'); raw + ColabFold hits can contain other letters and get rejected as + 'invalid characters in sequence N'.""" + out = [] + for ln in a3m.splitlines(): + if ln.startswith(">") or not ln: + out.append(ln) + continue + fixed = [] + for c in ln: + if c in _UP_OK or c in _LO_OK or c in "-.": + fixed.append(c) + elif c.islower(): + fixed.append("x") + else: + fixed.append("X") + out.append("".join(fixed)) + return "\n".join(out) + "\n" + + +def _post(path: str, data: dict) -> dict: + req = urllib.request.Request(f"{HOST}/{path}", + data=urllib.parse.urlencode(data).encode(), + headers={"User-Agent": UA}, method="POST") + with urllib.request.urlopen(req, timeout=120) as r: + return json.loads(r.read().decode()) + + +def _get_json(path: str) -> dict: + req = urllib.request.Request(f"{HOST}/{path}", headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=120) as r: + return json.loads(r.read().decode()) + + +def _download(ticket: str) -> bytes: + req = urllib.request.Request(f"{HOST}/result/download/{ticket}", + headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=300) as r: + return r.read() + + +def fetch_a3m(seq: str, mode: str = "env", poll_seconds: int = 10, + max_wait: int = 900) -> str: + query = f">101\n{seq}\n" + sub = _post("ticket/msa", {"q": query, "mode": mode}) + tid = sub.get("id") + status = sub.get("status") + if not tid: + raise SystemExit(f"submission failed: {sub}") + print(f"ticket {tid} status {status}", file=sys.stderr) + waited = 0 + while status in ("PENDING", "RUNNING", "UNKNOWN", "MAINTENANCE", None): + if status == "MAINTENANCE": + raise SystemExit("ColabFold API in MAINTENANCE; retry later") + time.sleep(poll_seconds) + waited += poll_seconds + if waited > max_wait: + raise SystemExit(f"timed out after {max_wait}s (last status {status})") + status = _get_json(f"ticket/{tid}").get("status") + print(f" ... {waited}s status {status}", file=sys.stderr) + if status != "COMPLETE": + raise SystemExit(f"ColabFold job ended with status {status}") + tar_bytes = _download(tid) + # Merge the a3m files in the result tar (uniref + env), keeping one query header. + a3m_parts = [] + with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tf: + for m in tf.getmembers(): + if m.name.endswith(".a3m"): + a3m_parts.append((m.name, tf.extractfile(m).read().decode(errors="replace"))) + if not a3m_parts: + raise SystemExit("no .a3m in ColabFold result tar") + a3m_parts.sort() # deterministic order + return sanitize_a3m("\n".join(txt for _, txt in a3m_parts)) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--seq") + g.add_argument("--seq-from-pdb") + ap.add_argument("--chain", default="A") + ap.add_argument("-o", "--out", required=True) + ap.add_argument("--mode", default="env", help="ColabFold MSA mode (default env = uniref+env)") + args = ap.parse_args() + + seq = args.seq or seq_from_pdb(args.seq_from_pdb, args.chain) + if not seq: + raise SystemExit("empty target sequence") + print(f"target sequence: {len(seq)} aa", file=sys.stderr) + a3m = fetch_a3m(seq, mode=args.mode) + n_seqs = a3m.count("\n>") + a3m.lstrip().startswith(">") + with open(args.out, "w") as fh: + fh.write(a3m) + print(f"wrote {args.out} (~{a3m.count('>')} sequences, {len(a3m)} chars)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/hotspot_strategy.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/hotspot_strategy.py new file mode 100644 index 0000000..220b177 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/hotspot_strategy.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Evidence-based binder-hotspot strategy from a UniProt entry JSON. + +WHY (verified on our 7-target run): UniProt ``Active site`` / ``Binding site`` +features annotate CATALYTIC / small-molecule sites — frequently **intracellular** +or buried, i.e. the WRONG surface for a protein binder: + * IL1R1 (P14778): the only ``Active site`` is residue 470 — the **cytoplasmic + TIR** domain (topology: cytoplasmic 357-569). Unreachable by a binder. + * HER2 (P04626): ``Binding``/``Active`` sites are the **cytoplasmic kinase** + ATP pocket (726-734, 753, 845; topology: cytoplasmic 676-1255). +Leading with those steered both membrane targets intracellular. + +NEW strategy (UniProt-only layer): + 1. ACCESSIBILITY — a binder can only reach the **extracellular topological + domain** of a membrane protein; restrict everything to it. Soluble proteins + (PIN1, AHSP) have no constraint. + 2. FUNCTIONAL EPITOPE candidates, filtered to the accessible range: + a. ``Mutagenesis`` residues with a binding/interaction effect — these are + experimentally validated functional residues, usually at interfaces + (HER2 317-318 ERBB3-dimerization; IL1R1 K131 ligand binding; + PIN1 K63/C113 catalysis; CEACAM1 N76/G81). + b. annotated interaction/adhesion ``Region`` (e.g. CEACAM1 homophilic + 39-142) — used to focus the crop and boost nearby residues. + c. ``Active site``/``Binding site``/``Site`` — kept ONLY when inside the + accessible range (valid for soluble enzymes like PIN1; auto-dropped + for the HER2 kinase / IL1R1 TIR). + +The GOLD standard (interface residues from a co-complex PDB — 1ITB, 1S78/1N8Z, +6MGP, 6XO1, 1Z8U …) is a separate, heavier step; this module is the +UniProt-derived layer plus the extracellular-accessibility crop it enables. +""" +from __future__ import annotations + +# Feature-type strings as they appear in the UniProtKB REST JSON `features` list. +_TOPO = "Topological domain" +_TM = "Transmembrane" +_SIGNAL = "Signal" +_MUTAGEN = "Mutagenesis" +_REGION = "Region" +_SITE_TYPES = ("Active site", "Binding site", "Site") + +# Mutagenesis descriptions that signal an interface/binding/functional role. +_FUNC_HINT = ("interact", "bind", "affinit", "dimer", "adhesion", "receptor", + "ligand", "signal", "reduc", "abolish", "impair", "loss", "decreas", + "epitope", "complex", "associat") +# Region descriptions that mark an interaction surface worth focusing on. +_REGION_HINT = ("interact", "bind", "adhesion", "dimer", "homophilic", + "heterophilic", "receptor", "epitope") + + +def _range(feat: dict): + loc = feat.get("location", {}) or {} + s = (loc.get("start") or {}).get("value") + e = (loc.get("end") or {}).get("value") + if s is None: + return None + return int(s), int(e if e is not None else s) + + +def accessibility(entry: dict) -> dict: + """Where a binder can physically reach. + + Returns ``{is_membrane, extracellular, note}`` where ``extracellular`` is a + list of ``(start, end)`` segments for a membrane protein, or ``None`` for a + soluble protein (whole chain accessible).""" + feats = entry.get("features", []) or [] + tms = [r for r in (_range(f) for f in feats if f.get("type") == _TM) if r] + ecd = [r for r in (_range(f) for f in feats + if f.get("type") == _TOPO + and "extracellular" in (f.get("description", "") or "").lower()) if r] + if not tms: + return {"is_membrane": False, "extracellular": None, + "note": "soluble — whole chain accessible"} + return {"is_membrane": True, "extracellular": (ecd or None), + "note": (f"membrane protein; extracellular segments {ecd}" if ecd + else "membrane protein but no extracellular TOPO_DOM — using whole chain")} + + +def _accessible(pos: int, segs) -> bool: + return True if not segs else any(s <= pos <= e for s, e in segs) + + +def functional_hotspots(entry: dict, extracellular) -> tuple[list[dict], list[str]]: + """UniProt-derived candidate hotspots, filtered to the accessible range. + + ``extracellular`` = list of ``(s,e)`` segments, or ``None`` (soluble → all + accessible). Returns ``(hotspots, messages)`` where each hotspot is + ``{chain, position, source, description}`` and ``source`` is one of + ``mutagenesis`` | ``region`` | ``catalytic``.""" + feats = entry.get("features", []) or [] + msgs: list[str] = [] + interaction_regions = [] # (start, end, desc) — focus zones, not expanded wholesale + points: list[tuple[int, str, str]] = [] # (pos, source, desc) + + for f in feats: + ftype = f.get("type") + rng = _range(f) + if not rng: + continue + s, e = rng + desc = f.get("description", "") or "" + dlow = desc.lower() + if ftype == _REGION and any(h in dlow for h in _REGION_HINT): + interaction_regions.append((s, e, desc)) + elif ftype == _MUTAGEN and any(h in dlow for h in _FUNC_HINT): + points.append((s, ftype, desc)) # mutagenesis = single residue + elif ftype in _SITE_TYPES: + for pos in range(s, e + 1): + points.append((pos, "catalytic", f"{ftype}: {desc}".strip(": "))) + + # Filter to the accessible (extracellular) range; drop the rest. + n_dropped = 0 + kept: dict[int, dict] = {} + for pos, ftype, desc in points: + if not _accessible(pos, extracellular): + n_dropped += 1 + continue + src = "mutagenesis" if ftype == _MUTAGEN else "catalytic" + # mutagenesis preferred over catalytic if both land on the same residue + if pos not in kept or (src == "mutagenesis" and kept[pos]["source"] == "catalytic"): + kept[pos] = {"chain": "A", "position": pos, "source": src, "description": desc[:80]} + if n_dropped: + msgs.append(f"dropped {n_dropped} UniProt functional residue(s) outside the " + "extracellular/accessible range (e.g. catalytic/cytoplasmic sites)") + + hotspots = [kept[p] for p in sorted(kept)] + if interaction_regions: + rdesc = "; ".join(f"{s}-{e} ({d})" for s, e, d in interaction_regions[:3]) + msgs.append(f"interaction region(s) annotated: {rdesc} — use to focus the epitope crop") + # If we have no point residues but do have an interaction region, seed the + # region midpoints so the design is at least centered on the right surface. + if not hotspots: + for s, e, d in interaction_regions: + mid = (s + e) // 2 + if _accessible(mid, extracellular): + hotspots.append({"chain": "A", "position": mid, "source": "region", + "description": d[:80]}) + return hotspots, msgs + + +def resolve_hotspots(entry: dict, pdb_fallback: bool = True) -> tuple[list[dict], list[int] | None, str, list[str]]: + """Top-level hotspot resolver (consensus rule — fail loud, not silently wrong). + + Order: (1) **UniProt-functional + accessibility** — the trusted default; it is + deterministic and was empirically correct where it fired, and when it finds + nothing it fails LOUDLY (empty → preflight flags it). (2) **PDB co-complex + interface** — only as a fallback WHEN UniProt is empty, and flagged for review + (it can return crystal/non-biological contacts or the wrong partner for + multi-domain crystal structures; the gold-standard slot is earned only once the + extractor does biological-assembly + BSA scoring + partner matching). + Returns ``(hotspots, accessible_segments, provenance, messages)``.""" + acc = accessibility(entry) + segs = acc["extracellular"] + msgs = [f"accessibility: {acc['note']}"] + + # 1. UniProt functional (trusted): mutagenesis + accessible sites, ECD-filtered. + hs, hmsgs = functional_hotspots(entry, segs) + msgs += hmsgs + if hs: + return hs, segs, "uniprot_functional", msgs + + # 2. PDB co-complex interface — fallback only, REVIEW required. + if pdb_fallback: + try: + import pdb_interface as _PI + iface, info = _PI.interface_hotspots(entry) + except Exception as e: # noqa: BLE001 + iface, info = [], {"note": f"pdb-interface error: {type(e).__name__}"} + if segs: + iface = [h for h in iface if _accessible(h["position"], segs)] + if iface: + msgs.append(f"UniProt empty → PDB interface fallback: {info.get('pdb')} " + f"(partner {info.get('partner_chains')}, id {info.get('identity')}, " + f"{len(iface)} accessible residue(s)) — REVIEW for crystal contacts/partner") + return iface, segs, f"pdb_interface:{info.get('pdb')}(review)", msgs + msgs.append(f"no usable PDB co-complex interface ({info.get('note', '')})") + return [], segs, "none", msgs diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pdb_interface.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pdb_interface.py new file mode 100644 index 0000000..d69c4e4 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pdb_interface.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Gold-standard binder hotspots: the PROTEIN-PROTEIN INTERFACE residues from a +co-complex PDB structure, mapped to UniProt numbering. + +Why (verified on our 7-target run): UniProt Active/Binding-site features annotate +catalytic/ligand pockets (often intracellular). The real binder epitope is where a +*protein partner* actually contacts the target — exactly what a co-complex +crystal/cryo-EM structure shows. This module: + 1. takes the target's PDB IDs (from its UniProt cross-references), + 2. finds a structure where the target chain contacts ANOTHER protein chain, + 3. computes the target's interface residues (heavy-atom contact, <= cutoff Å), + 4. maps them from PDB author numbering -> UniProt numbering by aligning the PDB + chain sequence to the UniProt sequence (valid on the AFDB model, which uses + UniProt numbering). + +Network + gemmi only; numbering solved by alignment (no SIFTS API needed). +""" +from __future__ import annotations + +import tempfile +import urllib.request +from pathlib import Path + +_AA3to1 = { + "ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", "GLN": "Q", + "GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K", + "MET": "M", "PHE": "F", "PRO": "P", "SER": "S", "THR": "T", "TRP": "W", + "TYR": "Y", "VAL": "V", "MSE": "M", +} + + +def pdb_ids_from_uniprot_entry(entry: dict) -> list[str]: + """PDB IDs listed in a UniProtKB entry JSON (cross-references).""" + ids = [] + for xref in entry.get("uniProtKBCrossReferences", []) or []: + if xref.get("database") == "PDB": + pid = xref.get("id") + if pid: + ids.append(pid.upper()) + # dedup, keep order + seen, out = set(), [] + for p in ids: + if p not in seen: + seen.add(p) + out.append(p) + return out + + +def _read_cif(pdb_id: str, timeout: int = 120): + """Download an mmCIF and parse with gemmi (via temp file — version-robust).""" + import gemmi + data = urllib.request.urlopen( + f"https://files.rcsb.org/download/{pdb_id.lower()}.cif", timeout=timeout + ).read() + with tempfile.NamedTemporaryFile("wb", suffix=".cif", delete=True) as fh: + fh.write(data) + fh.flush() + st = gemmi.read_structure(fh.name) + st.setup_entities() + return st + + +def _chain_seq(chain): + """Ordered (auth_seqid, one_letter, resname) for amino-acid residues.""" + out = [] + for res in chain: + aa = _AA3to1.get(res.name.upper()) + if aa: + out.append((res.seqid.num, aa, res.name.upper())) + return out + + +def _best_offset(chain_seq, uni_seq: str): + """Integer k maximizing matches of uni_seq[auth_num + k - 1] == aa. + Returns (k, identity_fraction). Tries k=0 (PDB already in UniProt numbering) + first, then scans the feasible window.""" + if not chain_seq: + return 0, 0.0 + nums = [n for n, _, _ in chain_seq] + lo, hi = 1 - min(nums), len(uni_seq) - max(nums) + order = [0] + [k for k in range(lo, hi + 1) if k != 0] # try 0 first + best_k, best_frac = 0, -1.0 + for k in order: + m = tot = 0 + for n, aa, _ in chain_seq: + i = n + k - 1 + if 0 <= i < len(uni_seq): + tot += 1 + m += (uni_seq[i] == aa) + if tot: + frac = m / tot + if frac > best_frac: + best_frac, best_k = frac, k + if frac >= 0.97: + break + return best_k, best_frac + + +def interface_hotspots(entry: dict, contact_cutoff: float = 5.0, + max_pdbs: int = 10) -> tuple[list[dict], dict]: + """Interface hotspots (UniProt numbering) from the best available co-complex. + + Returns (hotspots, info). Each hotspot: {chain:'A', position:int, residue:3-letter, + source:'pdb_interface'}. info records the chosen pdb/partner/identity. Empty list + on no suitable complex or any failure (caller falls back to UniProt/Paperclip).""" + try: + import gemmi + except Exception: # noqa: BLE001 + return [], {"note": "gemmi unavailable"} + uni_seq = (entry.get("sequence") or {}).get("value") or "" + if not uni_seq: + return [], {"note": "no UniProt sequence"} + tried = [] + for pid in pdb_ids_from_uniprot_entry(entry)[:max_pdbs]: + try: + st = _read_cif(pid) + if len(st) == 0: + continue + model = st[0] + chains = [ch for ch in model if len(_chain_seq(ch)) >= 20] + if len(chains) < 2: + tried.append(f"{pid}:<2 chains") + continue + # target chain = best sequence match to our UniProt + scored = sorted( + ((_best_offset(_chain_seq(ch), uni_seq), ch) for ch in chains), + key=lambda x: x[0][1], reverse=True) + (k, frac), tgt = scored[0] + if frac < 0.80: + tried.append(f"{pid}:no-uniprot-chain({frac:.2f})") + continue + partner_names = {ch.name for ch in chains if ch.name != tgt.name} + if not partner_names: + tried.append(f"{pid}:no-partner") + continue + resname = {res.seqid.num: res.name.upper() for res in tgt + if res.name.upper() in _AA3to1} + ns = gemmi.NeighborSearch(model, st.cell, contact_cutoff + 1.0).populate() + H = gemmi.Element("H") + iface = set() + for res in tgt: + if res.name.upper() not in _AA3to1: + continue + for atom in res: + if atom.element == H: + continue + for mark in ns.find_atoms(atom.pos, "\0", radius=contact_cutoff): + cra = mark.to_cra(model) + if cra.chain.name in partner_names and cra.atom.element != H: + iface.add(res.seqid.num) + break + else: + continue + break + if not iface: + tried.append(f"{pid}:no-contacts") + continue + hotspots = [] + for auth in sorted(iface): + uni = auth + k + if 1 <= uni <= len(uni_seq): + hotspots.append({"chain": "A", "position": uni, + "residue": resname.get(auth), "source": "pdb_interface"}) + return hotspots, {"pdb": pid, "target_chain": tgt.name, + "partner_chains": sorted(partner_names), "identity": round(frac, 2), + "offset": k, "n_interface": len(hotspots), "cutoff": contact_cutoff} + except Exception as e: # noqa: BLE001 + tried.append(f"{pid}:{type(e).__name__}") + continue + return [], {"note": "no co-complex interface found", "tried": tried[:12]} diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pdb_to_boltz_template_cif.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pdb_to_boltz_template_cif.py new file mode 100644 index 0000000..d8c1fa0 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pdb_to_boltz_template_cif.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Convert a target PDB to an mmCIF that the Boltz2 NIM accepts as a +`structural_templates` entry. + +Boltz2's template parser (`boltz.data.parse.mmcif.parse_polymer`) does +`res_name = sequence[label_seq_id - 1]`, so the template mmCIF MUST have: + * `_entity_poly_seq` / a populated canonical sequence (`full_sequence`), and + * `_atom_site.label_seq_id` numbered 1..N for the polymer. + +A plain `gemmi.Structure.make_mmcif_document()` from a PDB leaves +`label_seq_id` as `.` and the canonical sequence empty, which makes the NIM +raise `IndexError: list index out of range` ("Failed to parse input response"). +This script populates both, then re-parses the result the same way Boltz does +to verify it before writing. + +Usage: + python pdb_to_boltz_template_cif.py target.pdb target.cif [--chain A] +""" +from __future__ import annotations + +import argparse +import sys + +import gemmi + + +def pdb_to_template_cif(pdb_path: str, chain_id: str) -> tuple[str, int]: + st = gemmi.read_structure(pdb_path) + st.setup_entities() + if chain_id not in [c.name for c in st[0]]: + raise SystemExit(f"chain {chain_id} not in {pdb_path} " + f"(have {[c.name for c in st[0]]})") + poly = st[0][chain_id].get_polymer() + names = [r.name for r in poly] + if not names: + raise SystemExit(f"chain {chain_id} has no polymer residues") + # canonical sequence on the polymer entities, then contiguous label_seq + for ent in st.entities: + if ent.entity_type == gemmi.EntityType.Polymer: + ent.full_sequence = names + st.assign_label_seq_id() + for i, res in enumerate(poly, start=1): + res.label_seq = i + return st.make_mmcif_document().as_string(), len(names) + + +def verify(cif: str, expected_len: int) -> None: + """Re-parse the way Boltz does and assert the polymer is well-formed.""" + block = gemmi.cif.read_string(cif)[0] + st2 = gemmi.make_structure_from_block(block) + st2.setup_entities() + polys = [e for e in st2.entities if e.entity_type == gemmi.EntityType.Polymer] + if not polys or len(polys[0].full_sequence) != expected_len: + raise SystemExit("verification failed: canonical sequence missing/short " + f"(got {len(polys[0].full_sequence) if polys else 0}, " + f"want {expected_len})") + lsids = [r.label_seq for r in st2[0][0].get_polymer()] + if any(x is None for x in lsids) or lsids[:1] != [1]: + raise SystemExit(f"verification failed: label_seq not 1..N ({lsids[:3]}...)") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("pdb") + ap.add_argument("cif_out") + ap.add_argument("--chain", default="A", help="target chain ID (default A)") + args = ap.parse_args() + + cif, n = pdb_to_template_cif(args.pdb, args.chain) + verify(cif, n) + with open(args.cif_out, "w") as fh: + fh.write(cif) + print(f"wrote {args.cif_out} ({len(cif)} chars); chain {args.chain}, {n} residues; " + f"verified label_seq 1..{n} + canonical sequence.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pipeline.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pipeline.py new file mode 100644 index 0000000..ff846db --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/pipeline.py @@ -0,0 +1,1271 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Orchestrator for the Complexa protein-binder-design pipeline (public release). + +`run(...)` is a GENERATOR that yields `Event`s so a caller can stream stage-by-stage +progress. Modes: + + * mode="score_existing" — score/explore an already-produced run directory: runs + validate_binders.py on existing holo + apo refolds and returns ranked binders. + No GPU, fully self-contained. + + * mode="full" — live run from a target: resolve structure + hotspots + (Stage 1), register the target and run generation via the OPEN `complexa` + CLI in $COMPLEXA_REPO (Stage 2: generate→filter→evaluate→analyze, AF2-reward + gated), build the target MSA, then emit a Stage-3 handoff for the INDEPENDENT + Boltz2/OpenFold3 NIM refold (driven by the agent / boltz2-nim skill); once the + refolds exist it scores them. Requires a GPU host for Complexa + a Boltz2/OF3 + endpoint for validation. + +This module shells out to separately-tested pieces rather than re-implementing them: +scripts/validate_binders.py, scripts/fetch_target_msa_colabfold.py, +vendor/science-skills/.../fetch_structure.py, and the `complexa` CLI. There is no +Slurm/sbatch or private-NIM dependency. +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterator + +# UniProt accession + PDB-ID shapes, for classifying a free-text target. +_UNIPROT_RE = re.compile( + r"^(?:[OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9](?:[A-Z][A-Z0-9]{2}[0-9]){1,2})$") +_PDB_RE = re.compile(r"^[0-9][A-Za-z0-9]{3}$") + +# --------------------------------------------------------------------------- +# Public layout: this module lives in /scripts/. Stage-1 tooling is +# vendored under /vendor/; Stage-2 drives the OPEN `complexa` CLI in the +# user's Proteina-Complexa checkout ($COMPLEXA_REPO) — no Slurm, no demo NIM. +import os + +SCRIPTS = Path(__file__).resolve().parent +SKILL_DIR = SCRIPTS.parent +VENDOR = SKILL_DIR / "vendor" +OUTPUTS = Path(os.environ.get("COMPLEXA_OUTPUTS", "outputs")) # cwd-relative by default + +FETCH_STRUCTURE = VENDOR / "science-skills" / "alphafold_database_fetch_and_analyze" / "scripts" / "fetch_structure.py" +UNIPROT_TOOLS = VENDOR / "science-skills" / "uniprot_database" / "scripts" / "uniprot_tools.py" +FETCH_MSA = SCRIPTS / "fetch_target_msa_colabfold.py" +PDB_TO_TEMPLATE = SCRIPTS / "pdb_to_boltz_template_cif.py" +VALIDATE_BINDERS = SCRIPTS / "validate_binders.py" + +# Open Proteina-Complexa release (https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa). +# Set COMPLEXA_REPO to your local checkout; Stage 2 runs the `complexa` CLI there. +COMPLEXA_BIN = os.environ.get("COMPLEXA_BIN", "complexa") +COMPLEXA_CONFIG = os.environ.get("COMPLEXA_CONFIG", "configs/search_binder_local_pipeline.yaml") + + +def _complexa_repo() -> Path: + """Resolve $COMPLEXA_REPO lazily (only Stage 2 needs it; Stage 1 / scoring don't).""" + r = os.environ.get("COMPLEXA_REPO") + if not r: + raise RuntimeError( + "Set COMPLEXA_REPO to your Proteina-Complexa checkout " + "(https://github.com/NVIDIA-Digital-Bio/Proteina-Complexa) to run generation.") + p = Path(r).expanduser() + if not p.is_dir(): + raise RuntimeError(f"COMPLEXA_REPO is not a directory: {p}") + return p + + +# Back-compat alias used by the extraction/discovery helpers below; resolves to the +# user's open checkout instead of the old Slurm run-root. +def _targets_dict() -> Path: + return _complexa_repo() / "configs" / "targets" / "targets_dict.yaml" + +# Complexa builds O(n^2) pairwise features over the FULL (target + binder) complex, +# and the JAX AF2-Multimer reward model (beam search) preallocates a big slice of +# the GPU, so the whole complex must stay small or generate OOMs. GLOBAL RULE: the +# combined binder + target must total <= MAX_COMPLEX_RESIDUES. The binder length +# range is BINDER_LENGTH; the target is cropped (around the epitope) so that +# target + the LONGEST binder still fits the budget. +BINDER_LENGTH = (64, 155) # (min, max) designed binder length +MAX_COMPLEX_RESIDUES = 500 # hard cap on target + binder residues, total + +# GPU fan-out (gen/eval njobs for `complexa design`). Each GPU evaluates a pool of +# candidates, AF2/RF3-reward-scored, written to the binder_results CSV with +# self_complex_i_pTM / self_complex_pLDDT. Public default is 1 GPU; raise to your +# GPU count for more parallelism/diversity. +N_DEVICES_DEFAULT = 1 # gen/eval njobs — set to your available GPU count +# No per-device cap: the AF2 gate is the SOLE selector — EVERY AF2-passing design +# is Boltz2-validated. MAX_VALIDATE_CEILING is only a runaway guard (a single +# freak run can't submit tens of thousands of Boltz2 folds). Set high enough to +# never bite in practice (the largest observed AF2-pass pool was PIN1 at 381). +MAX_VALIDATE_CEILING = 2000 + +# AF2 quality gate (PRIMARY selector). Rather than blindly Boltz2-validating a +# fixed top-N, we forward to Boltz2 ONLY the designs the generator's own +# AF2-Multimer is already confident in: interface pTM AND pLDDT both above +# threshold. Both columns are 0-1 scaled in the Complexa results CSV. A design +# that AF2 itself scores poorly (e.g. the collapsed v1/v2-mismatch backbones had +# i_pTM~0.08, pLDDT~0.56) is not worth an independent Boltz2 re-prediction. +AF2_IPTM_MIN = 0.70 # self_complex_i_pTM must exceed this +AF2_PLDDT_MIN = 0.70 # self_complex_pLDDT must exceed this + + +def validation_count(n_devices: int, n_validated: int = 0) -> int: + """How many AF2-passing designs go to Boltz2. The AF2 gate (``AF2_IPTM_MIN`` / + ``AF2_PLDDT_MIN``) is the SOLE selector — EVERY design that clears it is + validated (no per-device cap). ``n_validated <= 0`` => MAX_VALIDATE_CEILING + (effectively unlimited, runaway guard only); a positive value is an explicit + user cap. NOTE: validation refolds the whole set, so a very large AF2-pass pool + can be slow; cap it with an explicit n_validated if needed.""" + return int(n_validated) if int(n_validated) > 0 else MAX_VALIDATE_CEILING + + +def _max_target_residues(binder_max: int = BINDER_LENGTH[1]) -> int: + """Largest target that keeps (target + longest binder) <= MAX_COMPLEX_RESIDUES. + With the default 155-residue max binder this is 345.""" + return max(1, MAX_COMPLEX_RESIDUES - binder_max) + + +# Hotspot sanity (bindclaw convention): a binder grips ONE local epitope, so the +# hotspot set must be small and spatially compact — not scattered across domains. +HOTSPOT_MIN_RESIDUES = 1 # >=1 is acceptable (a single anchor hotspot is OK) +HOTSPOT_MAX_RESIDUES = 15 # bindclaw DEFAULT_MAX_HOTSPOT_RESIDUES +HOTSPOT_MAX_SPREAD_A = 30.0 # drop hotspots > this far (Å) from the epitope cluster + + +@dataclass +class Event: + """A streamed progress event.""" + stage: str + status: str # "start" | "info" | "ok" | "error" + message: str + data: dict = field(default_factory=dict) + + def line(self) -> str: + icon = {"start": "▶", "info": "·", "ok": "✓", "error": "✗"}.get(self.status, "·") + return f"{icon} [{self.stage}] {self.message}" + + +# --------------------------------------------------------------------------- helpers +def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 1800) -> subprocess.CompletedProcess: + return subprocess.run([str(c) for c in cmd], cwd=(str(cwd) if cwd else None), + capture_output=True, text=True, timeout=timeout) + + +def list_run_dirs() -> list[str]: + """Existing run directories under OUTPUTS that have a validation/ folder.""" + if not OUTPUTS.is_dir(): + return [] + return sorted(str(p) for p in OUTPUTS.iterdir() if (p / "validation").is_dir()) + + +# --------------------------------------------------------------------------- target resolution +def resolve_target_spec(text: str, organism_id: str = "9606") -> dict: + """Turn a free-text target NAME into a structure spec — the only thing the user + types. Resolves across ALL organisms (not just human) so allergens, viral, and + other non-human targets work (e.g. an allergen common name → its UniProt accession). + + Strategy: human+reviewed first (so a common human protein name stays human), + then any reviewed organism, then any entry; raw text first, then an auto-spaced + variant ('DerF21' → 'Der F 21') for allergen-style names. A UniProt accession or + a 4-char PDB ID typed directly is still accepted, but the user need not know one.""" + t = text.strip() + if _UNIPROT_RE.match(t.upper()): + return {"uniprot": t.upper(), "resolved_from": f"{t} (UniProt accession)"} + if _PDB_RE.match(t): + return {"pdb": t.upper(), "resolved_from": f"{t} (PDB ID)"} + + # Match on protein NAME / gene exactly (not fuzzy full-text relevance, which + # confidently returns the WRONG protein — e.g. freeform 'DerF21' matched human + # RhoA). Build name variants: raw, a generic case/digit-spaced form + # ('DerF21'→'Der F 21'), and an allergen-nomenclature form for 'Genus-species-num' + # names ('derf21'/'DerF21'→'der f 21', 'Blag2'→'Bla g 2'). + spaced = re.sub(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\d)", " ", t) + m_all = re.match(r"^([A-Za-z]{3})([A-Za-z])(\d+)$", t) + allergen = f"{m_all.group(1)} {m_all.group(2)} {m_all.group(3)}" if m_all else None + forms: list[str] = [] + for f in (t, spaced, allergen): + if f and f not in forms: + forms.append(f) + # PREFER REVIEWED (Swiss-Prot) across ALL forms before any unreviewed entry — + # reviewed entries are the ones with AFDB models; unreviewed TrEMBL hits (e.g. + # A0A922HUI2) often have no AlphaFold structure. Within reviewed, human first. + queries: list[str] = [] + for scope in (f" AND organism_id:{organism_id} AND reviewed:true", " AND reviewed:true"): + for f in forms: + queries.append(f'(protein_name:"{f}" OR gene:"{f}"){scope}') + for f in forms: # unreviewed fallback, last resort + queries.append(f'(protein_name:"{f}" OR gene:"{f}")') + + def _search(q: str) -> dict | None: + p = _run([sys.executable, UNIPROT_TOOLS, "search", q, "--limit", "1", + "--fields", "accession,id,protein_name,organism_name"], timeout=120) + try: + res = json.loads(p.stdout) + results = res.get("results", res if isinstance(res, list) else []) + return results[0] if results else None + except Exception: # noqa: BLE001 + return None + + hit = next((h for h in (_search(q) for q in queries) if h), None) + acc = hit.get("primaryAccession") if hit else None + if not acc: + raise RuntimeError( + f"could not find a UniProt entry for '{text}'. Check the spelling, or try " + "the protein's common gene/protein name or UniProt accession, or upload a structure.") + org = (hit.get("organism", {}) or {}).get("scientificName", "") + return {"uniprot": acc, "resolved_from": f"{t} → UniProt {acc}" + (f" ({org})" if org else "")} + + +# --------------------------------------------------------------------------- Stage 1 +def resolve_target(spec: dict, run_dir: Path) -> Iterator[Event]: + """Resolve a target structure (Stage 1). spec one of: + {"uniprot": "P00533"} | {"pdb": "1WWW"} | {"pdb_path": "/path.pdb"}. + Writes run_dir/target.pdb (or .cif). Hotspots are best-effort from UniProt + features when a UniProt id is given (numbering == AFDB numbering).""" + run_dir.mkdir(parents=True, exist_ok=True) + if spec.get("uniprot"): + acc = spec["uniprot"] + yield Event("stage1", "start", f"fetching AFDB model for UniProt {acc}") + p = _run([sys.executable, FETCH_STRUCTURE, acc, "-o", run_dir], timeout=600) + cif = next(iter(sorted(run_dir.glob(f"AF-{acc}-*model*.cif"))), None) + if cif is None: + # fetch_structure.py prints "not found in the AlphaFold Database" to + # STDOUT (returncode 0), so surface stdout+stderr, not just stderr. + detail = " ".join((p.stdout + " " + p.stderr).split())[-400:] + raise RuntimeError( + f"no AlphaFold model for UniProt {acc} — {detail or 'AFDB has no entry for this accession.'} " + "(Unreviewed/TrEMBL entries often lack an AFDB model; try the reviewed " + "Swiss-Prot accession, a PDB ID, or upload a structure.)") + (run_dir / "target.cif").write_text(cif.read_text()) + yield Event("stage1", "ok", f"AFDB model saved ({cif.name}); numbering = UniProt") + yield from _uniprot_hotspots(acc, run_dir) + elif spec.get("pdb"): + pid = spec["pdb"] + yield Event("stage1", "start", f"fetching RCSB {pid}") + p = _run([sys.executable, FETCH_STRUCTURE, "--pdb", pid, "-o", run_dir], timeout=300) \ + if "--pdb" in FETCH_STRUCTURE.read_text() else None + # fetch_structure.py (vendored) is AFDB-only; RCSB is a plain download + import urllib.request + data = urllib.request.urlopen(f"https://files.rcsb.org/download/{pid.lower()}.pdb", timeout=120).read() + (run_dir / "target.pdb").write_bytes(data) + yield Event("stage1", "ok", f"RCSB {pid} saved ({len(data)} bytes); " + "verify hotspot numbering against this file (auth numbering may be offset)") + elif spec.get("pdb_path"): + src = Path(spec["pdb_path"]) + (run_dir / "target.pdb").write_text(src.read_text()) + yield Event("stage1", "ok", f"using uploaded file {src.name} (verify hotspot numbering)") + elif spec.get("cif_path"): + src = Path(spec["cif_path"]) + (run_dir / "target.cif").write_text(src.read_text()) + yield Event("stage1", "ok", f"using uploaded file {src.name} (verify hotspot numbering)") + else: + raise ValueError("target spec needs one of: uniprot, pdb, pdb_path, cif_path") + + +def _uniprot_hotspots(acc: str, run_dir: Path) -> Iterator[Event]: + """Best-effort hotspot candidates from UniProt Active/Binding-site features.""" + # Never clobber an existing richer hotspots.json (e.g. Paperclip-derived) on a + # re-run — preserve it so the run stays conditioned. + existing = run_dir / "hotspots.json" + if existing.exists(): + try: + prev = json.loads(existing.read_text()) + prev_hs = prev.get("hotspot_residues") if isinstance(prev, dict) else prev + if prev_hs: + yield Event("stage1", "ok", + f"keeping existing hotspots.json ({len(prev_hs)} residues) — not overwriting", + {"hotspots": prev_hs[:20]}) + return + except Exception: # noqa: BLE001 + pass + yield Event("stage1", "info", f"reading UniProt features for {acc}") + p = _run([sys.executable, UNIPROT_TOOLS, "get", acc], timeout=300) + if p.returncode != 0: + yield Event("stage1", "info", "UniProt features unavailable; leaving hotspots empty") + return + try: + entry = json.loads(p.stdout) + entry = entry if "features" in entry else entry.get("results", [entry])[0] + except Exception: + yield Event("stage1", "info", "could not parse UniProt entry; hotspots empty") + return + # Evidence-based strategy (hotspot_strategy.resolve_hotspots): the + # PROTEIN-PROTEIN INTERFACE residues from a co-complex PDB (gold standard) -> + # UniProt functional residues (mutagenesis + accessible sites), all restricted + # to the EXTRACELLULAR/accessible range. Replaces 'Active/Binding site first', + # which annotates catalytic/intracellular pockets — the wrong surface for a + # binder epitope (verified: IL1R1 470 = cytoplasmic TIR; HER2 = kinase ATP site). + try: + import hotspot_strategy as _HS + hs, segs, provenance, hmsgs = _HS.resolve_hotspots(entry) + except Exception as e: # noqa: BLE001 + yield Event("stage1", "info", f"hotspot strategy error ({type(e).__name__}); hotspots empty") + hs, segs, provenance, hmsgs = [], None, "none", [] + for m in hmsgs: + yield Event("stage1", "info", m) + out = {"target": acc, "uniprot": acc, + "numbering": "AFDB == UniProt numbering; verify residue identity in the cif", + "source": provenance, "accessible_segments": segs, + "hotspot_residues": hs} + (run_dir / "hotspots.json").write_text(json.dumps(out, indent=2)) + if hs: + yield Event("stage1", "ok", + f"{len(hs)} hotspot candidate(s) [{provenance}] " + "(downstream pruning enforces compactness + count)", + {"hotspots": hs[:20]}) + else: + # Nothing from PDB interface or UniProt — escalate to the Paperclip + # full-text literature fallback rather than silently going unconditioned. + yield Event("stage1", "info", + f"no PDB-interface or UniProt functional hotspots for {acc} — fall back to the " + "Paperclip literature search (prompts/hotspot_paperclip.md), then re-run with " + "--hotspots. Proceeding as-is would design UNCONDITIONED.", + {"needs_paperclip": True, "hotspots": []}) + + +# --------------------------------------------------------------------------- structure alignment +def _structure_residue_index(structure_path: Path) -> dict[tuple[str, int], str]: + """Map (chain_id, residue_number) -> 3-letter residue name from a pdb/cif.""" + import gemmi + st = gemmi.read_structure(str(structure_path)) + idx: dict[tuple[str, int], str] = {} + if len(st) == 0: + return idx + for chain in st[0]: + for res in chain: + idx[(chain.name, res.seqid.num)] = res.name + return idx + + +def align_hotspots_to_structure( + hotspots: list[dict], structure_path: Path) -> tuple[list[dict], list[dict]]: + """Keep only hotspots whose (chain, position) exist in the structure coords. + + This is the deterministic 'ordering' guard: UniProt/literature residue + numbers (UniProt-canonical) are validated against the structure the designer + actually consumes (AFDB == UniProt numbering; experimental/cropped PDBs are + often offset). Returns (kept, dropped). Each kept hotspot gets `residue` set + to the actual 3-letter name read from coordinates, plus `identity_match` when + the caller supplied an expected residue. Dropped hotspots get `drop_reason`. + If the structure can't be read, hotspots pass through unchanged (fail open). + """ + try: + idx = _structure_residue_index(Path(structure_path)) + except Exception: # noqa: BLE001 — never let alignment crash the run + return list(hotspots), [] + if not idx: + return list(hotspots), [] + kept: list[dict] = [] + dropped: list[dict] = [] + for hs in hotspots: + chain = str(hs.get("chain", "A")) + pos = hs.get("position") + actual = idx.get((chain, pos)) + if actual is None: + dropped.append({**hs, "drop_reason": f"{chain}{pos} not in structure coordinates"}) + continue + expected = str(hs.get("residue") or "").upper() + rec = {**hs, "residue": actual} + if expected and expected != actual: + rec["identity_match"] = False + rec["expected_residue"] = expected + elif expected: + rec["identity_match"] = True + kept.append(rec) + return kept, dropped + + +# --------------------------------------------------------------------------- Paperclip hotspot fallback +_AA3_NAMES = ("Ala", "Arg", "Asn", "Asp", "Cys", "Gln", "Glu", "Gly", "His", "Ile", + "Leu", "Lys", "Met", "Phe", "Pro", "Ser", "Thr", "Trp", "Tyr", "Val") +_AA3_RE = re.compile(r"\b(" + "|".join(_AA3_NAMES) + r")\s*-?\s*(\d{1,4})\b", re.I) + + +def _paperclip_available() -> bool: + import shutil + return shutil.which("paperclip") is not None + + +def _uniprot_name(acc: str) -> list[str]: + """Common protein name(s) + gene for an accession — Paperclip search terms. + + Includes UniProt SHORT names (the protein's common short name) which is what the + binding/epitope literature actually uses; the verbose recommendedName is poor for search.""" + p = _run([sys.executable, UNIPROT_TOOLS, "get", acc], timeout=120) + shorts: list[str] = [] + longs: list[str] = [] + try: + e = json.loads(p.stdout) + e = e if "proteinDescription" in e else e.get("results", [e])[0] + pd = e.get("proteinDescription", {}) + for blk in [pd.get("recommendedName", {})] + pd.get("alternativeNames", []): + fv = blk.get("fullName", {}).get("value") + if fv: + longs.append(fv) + for sn in blk.get("shortNames", []): + sv = sn.get("value") + if sv: + shorts.append(sv) + for g in e.get("genes", []): + gv = g.get("geneName", {}).get("value") + if gv: + shorts.append(gv) + except Exception: # noqa: BLE001 + pass + # short names first (best for literature search), then full names + return shorts + longs + + +def paperclip_hotspots(acc: str, structure_path: Path, run_dir: Path) -> Iterator[Event]: + """Agent-free Paperclip literature fallback (runs when UniProt has no hotspots). + + Drives the `paperclip` CLI (search → map) to pull residue-level epitope/binding + evidence from full-text papers, then keeps ONLY residues whose 3-letter identity + matches the resolved structure — auto-correcting a literature↔structure numbering + offset (e.g. mature vs full-length). The structure is the ground-truth filter, so + fuzzy/wrong residue mentions are discarded. Writes hotspots.json on success.""" + if not _paperclip_available(): + yield Event("stage1", "info", + "paperclip CLI not found in PATH — cannot run literature fallback; " + "proceeding UNCONDITIONED.") + return + names = _uniprot_name(acc) + # Prefer a clean, searchable designation (strip isoform '.0101' / verbose + # prefixes): pull a short allergen-style name if present, else the first name. + short = None + for n in names: + m = re.search(r"\b([A-Z][a-z]{2} [a-z] \d+)\b", n) + if m: + short = m.group(1) + break + name = short or (names[0] if names else acc) + yield Event("stage1", "start", f"Paperclip literature search for '{name}' hotspots") + sid = None + # paperclip (>=0.1.4) REQUIRES a source: `-s pmc` = full-text PubMed Central, the + # richest source for residue-level mutagenesis / binding / interface evidence. + for q in (f"{name} binding epitope residues", f"{name} mutagenesis hot spot", + f"{name} interface contact residues"): + r = _run(["paperclip", "search", "-s", "pmc", q, "-n", "6"], timeout=120) + m = re.search(r"\[(s_[0-9a-f]+)\]", r.stdout or "") + if m: + sid = m.group(1) + break + if not sid: + yield Event("stage1", "info", f"Paperclip found no papers for '{name}' — UNCONDITIONED.") + return + yield Event("stage1", "info", f"Paperclip result set {sid}; extracting residue numbers") + mp = _run(["paperclip", "map", "--from", sid, + f"Extract ALL specific {name} residue numbers that are antibody/IgE epitope, " + "binding, interface, or mutagenesis hot spots. Output each as 3-letter code + " + "number, e.g. Tyr56."], timeout=200) + text = mp.stdout or "" + fm = re.search(r"(/\S*map_\S+\.txt)", text) + if fm: + c = _run(["paperclip", "cat", fm.group(1)], timeout=60) + text += "\n" + (c.stdout or "") + from collections import Counter + mentions = Counter((aa.upper(), int(pos)) for aa, pos in _AA3_RE.findall(text)) + cand = sorted(mentions) + if not cand: + yield Event("stage1", "info", + f"Paperclip returned no parseable residue numbers for '{name}' — UNCONDITIONED.") + return + try: + idx = _structure_residue_index(Path(structure_path)) + except Exception: # noqa: BLE001 + idx = {} + # Find the literature→structure numbering offset that confirms the most residues. + best_off, best_n = 0, 0 + for off in range(-30, 31): + n = sum(1 for aa, pos in cand if idx.get(("A", pos + off)) == aa) + if n > best_n: + best_n, best_off = n, off + if best_n < 3: + yield Event("stage1", "info", + f"Paperclip proposed {len(cand)} residue(s) but only {best_n} match the " + f"structure ({structure_path.name}) — numbering mismatch, proceeding " + "UNCONDITIONED (verify manually).") + return + # Rank by how often each residue is discussed (proxy for importance) and keep a + # focused epitope — conditioning Complexa on dozens of scattered residues is bad. + _CAP = 10 + seen: set[int] = set() + confirmed: list[dict] = [] + for aa, pos in sorted(cand, key=lambda k: mentions[k], reverse=True): + sp = pos + best_off + if idx.get(("A", sp)) == aa and sp not in seen: + seen.add(sp) + confirmed.append({"chain": "A", "position": sp, "residue": aa, + "source": "paperclip", "lit_position": pos, + "mentions": mentions[(aa, pos)]}) + if len(confirmed) >= _CAP: + break + confirmed.sort(key=lambda h: h["position"]) + wrapper = {"target": acc, "source": "paperclip", + "numbering": f"aligned to {structure_path.name} (offset {best_off:+d} from literature)", + "hotspot_residues": confirmed} + (run_dir / "hotspots.json").write_text(json.dumps(wrapper, indent=2)) + (run_dir / "hotspots.txt").write_text( + f"{name} ({acc}) hot spots — derived from full-text literature via Paperclip.\n" + f"Numbering aligned to {structure_path.name} (literature offset {best_off:+d}).\n\n" + + "\n".join(f" {h['residue']}{h['position']} (chain A; lit {h['lit_position']})" + for h in confirmed) + "\n") + yield Event("stage1", "ok", + f"{len(confirmed)} structure-confirmed hotspot(s) from Paperclip " + f"(offset {best_off:+d})", {"hotspots": confirmed[:20]}) + + +# --------------------------------------------------------------------------- Stage 2 (Complexa, live) +def _ensure_pdb(structure_path: Path, run_dir: Path) -> Path: + """Complexa consumes a PDB target. Convert a .cif to run_dir/target.pdb if needed.""" + structure_path = Path(structure_path) + if structure_path.suffix.lower() == ".pdb": + return structure_path + import gemmi + st = gemmi.read_structure(str(structure_path)) + st.setup_entities() + out = run_dir / "target.pdb" + out.write_text(st.make_pdb_string()) + return out + + +def _target_input_segments(structure_path: Path, chain_default: str = "A") -> str: + """Per-chain contiguous residue segments in Complexa 'A1-115, B5-90' form.""" + try: + idx = _structure_residue_index(Path(structure_path)) + except Exception: # noqa: BLE001 + idx = {} + by_chain: dict[str, list[int]] = {} + for (ch, pos) in idx: + by_chain.setdefault(ch, []).append(pos) + segs: list[str] = [] + for ch, positions in sorted(by_chain.items()): + s = sorted(set(positions)) + if not s: + continue + start = prev = s[0] + for r in s[1:]: + if r == prev + 1: + prev = r + else: + segs.append(f"{ch}{start}-{prev}") + start = prev = r + segs.append(f"{ch}{start}-{prev}") + return ", ".join(segs) + + +def _prune_hotspots(hotspots: list[dict], structure_path: Path, + max_residues: int = HOTSPOT_MAX_RESIDUES, + max_dist: float = HOTSPOT_MAX_SPREAD_A) -> tuple[list[dict], list[dict], list[str]]: + """Enforce epitope sanity (bindclaw convention): a binder grips ONE local patch. + + 1. **Compactness** — drop hotspots whose Cβ (Cα fallback) is > ``max_dist`` Å from + the densest hotspot cluster's centroid (removes distal outliers on other + domains, e.g. CD45 A1169 sitting ~270 residues from the A821-A897 cluster). + 2. **Count cap** — keep at most ``max_residues`` (the ones closest to the centroid). + + Returns (kept, dropped, messages). Reads coords from ``structure_path``; if they + can't be read, or there are <=1 hotspots, returns the input unchanged (fail open). + Hotspots whose residue isn't found in the structure are kept (not penalised).""" + import math + hs = list(hotspots or []) + if len(hs) <= 1: + return hs, [], [] + try: + import gemmi + st = gemmi.read_structure(str(structure_path)) + st.setup_entities() + model = st[0] + except Exception: # noqa: BLE001 — never let a coord read break the run + return hs, [], [] + + def _coord(h): + ch, pos = str(h.get("chain", "A")), int(h["position"]) + for chain in model: + if chain.name != ch: + continue + for res in chain: + if res.seqid.num == pos: + a = res.find_atom("CB", "*") or res.find_atom("CA", "*") + return (a.pos.x, a.pos.y, a.pos.z) if a else None + return None + + coords = [] + for h in hs: + try: + coords.append(_coord(h)) + except (KeyError, TypeError, ValueError): + coords.append(None) + idx = [i for i, c in enumerate(coords) if c is not None] + if len(idx) <= 1: + return hs, [], [] + + def d(a, b): + return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) + + # densest anchor → cluster centroid + anchor = max(idx, key=lambda i: sum(1 for j in idx if d(coords[i], coords[j]) <= max_dist)) + cluster = [j for j in idx if d(coords[anchor], coords[j]) <= max_dist] + cx = tuple(sum(coords[j][k] for j in cluster) / len(cluster) for k in range(3)) + within = sorted((j for j in idx if d(coords[j], cx) <= max_dist), + key=lambda j: d(coords[j], cx)) + capped = within[:max_residues] + keep = set(capped) | {i for i in range(len(hs)) if coords[i] is None} # fail-open on no-coord + kept = [hs[i] for i in range(len(hs)) if i in keep] + dropped = [hs[i] for i in range(len(hs)) if i not in keep] + msgs: list[str] = [] + if dropped: + dd = sorted({f"{x.get('chain', 'A')}{x.get('position')}" for x in dropped}) + msgs.append( + f"hotspot sanity: kept {len(kept)}, dropped {len(dropped)} residue(s) outside the " + f"epitope (> {max_dist:.0f} Å from the cluster centroid, or beyond the " + f"{max_residues}-residue cap): {dd} — a binder targets one local patch") + return kept, dropped, msgs + + +def _crop_target_to_epitope(structure_path: Path, hotspots: list[dict], run_dir: Path, + max_residues: int | None = None) -> tuple[Path, list[str]]: + """Enforce the target-size budget so (target + binder) <= MAX_COMPLEX_RESIDUES. + ``max_residues`` defaults to ``_max_target_residues()`` (= 500 - longest binder + = 345). If the target is within budget, return it unchanged with no messages. + Otherwise crop to a contiguous window centered on the epitope (hotspot residues), + preserving ORIGINAL residue numbering so hotspot ids and downstream + (Boltz2/OpenFold3) numbering stay valid, write it to ``run_dir/target_cropped.pdb``, + and return that path plus human-readable messages describing the crop. + + Chains with no hotspots are dropped when cropping. With no hotspots at all there + is no epitope to center on, so the target is truncated to the first + ``max_residues`` residues and a warning is emitted (an unconditioned design on a + truncated target is rarely what you want — supply hotspots).""" + import gemmi + from collections import defaultdict + if max_residues is None: + max_residues = _max_target_residues() + st = gemmi.read_structure(str(structure_path)) + st.setup_entities() + if len(st) == 0: + return structure_path, [] + model = st[0] + total = sum(len(ch) for ch in model) + if total <= max_residues: + return structure_path, [] + + hot_by_chain: dict[str, list[int]] = defaultdict(list) + for h in hotspots or []: + try: + hot_by_chain[str(h.get("chain", "A"))].append(int(h["position"])) + except (KeyError, TypeError, ValueError): + continue + + new_st = gemmi.Structure() + new_st.cell = st.cell + new_st.spacegroup_hm = st.spacegroup_hm + new_model = gemmi.Model("1") + half = max_residues // 2 + dropped_hot: list[int] = [] + kept_windows: list[str] = [] + for chain in model: + new_chain = gemmi.Chain(chain.name) + if hot_by_chain: + if chain.name not in hot_by_chain: + continue # no epitope on this chain — drop it + hs = sorted(hot_by_chain[chain.name]) + center = (hs[0] + hs[-1]) // 2 + lo, hi = center - half, center + half + for res in chain: + if lo <= res.seqid.num <= hi: + new_chain.add_residue(res) + dropped_hot += [p for p in hs if not (lo <= p <= hi)] + else: + for res in chain: # no hotspots: keep the first max_residues, in order + if len(new_chain) >= max_residues: + break + new_chain.add_residue(res) + if len(new_chain): + new_model.add_chain(new_chain) + nums = [r.seqid.num for r in new_chain] + kept_windows.append(f"{new_chain.name}{min(nums)}-{max(nums)}") + new_st.add_model(new_model) + new_st.setup_entities() + out = run_dir / "target_cropped.pdb" + out.write_text(new_st.make_pdb_string()) + + kept_n = sum(len(ch) for ch in new_model) + budget = f"{max_residues}-residue target cap (binder+target <= {MAX_COMPLEX_RESIDUES})" + msgs: list[str] = [] + if hot_by_chain: + msgs.append( + f"target has {total} residues (> {budget}); cropped to the " + f"epitope window {', '.join(kept_windows)} ({kept_n} residues, original numbering kept)") + if dropped_hot: + msgs.append( + f"WARNING: {len(dropped_hot)} hotspot(s) lay outside the {max_residues}-residue " + f"window and were dropped: {sorted(set(dropped_hot))} — they are too far from the " + "main epitope to share one binder; design a separate binder for them if needed") + else: + msgs.append( + f"WARNING: target has {total} residues (> {budget}) and NO " + f"hotspots to center on; truncated to the first {kept_n} residues. Provide hotspots so " + "the crop covers the real epitope") + return out, msgs + + +def register_complexa_target(task_name: str, structure_path: Path, hotspots: list[dict], + binder_length: tuple[int, int] = BINDER_LENGTH, + chain: str = "A") -> dict: + """Append/overwrite a Complexa targets_dict.yaml entry in the OPEN checkout + ($COMPLEXA_REPO). gemmi-based so it handles .cif and .pdb; flock-guarded with an + atomic temp-file rename so parallel runs can't shred the YAML. Empty `hotspots` + => unconditioned design. Returns the written entry. + + (Equivalent to `complexa target add --pdb --chain + --span --hotspots ... --binder-length `; we write the YAML + directly so a custom target PDB can be staged into the repo's asset tree.)""" + import fcntl + import os + import shutil + import tempfile + import yaml + targets_dict = _targets_dict() + targets_dict.parent.mkdir(parents=True, exist_ok=True) + lock_path = targets_dict.with_suffix(targets_dict.suffix + ".lock") + # Stage the target PDB inside the repo's asset tree so the CLI/container resolves + # it regardless of cwd (no host/container path remapping needed on the open CLI). + tgt_dir = _complexa_repo() / "assets" / "target_data" / "binder_pipeline" + tgt_dir.mkdir(parents=True, exist_ok=True) + tgt_pdb = tgt_dir / f"{task_name}.pdb" # name by task so targets never collide + if Path(structure_path).resolve() != tgt_pdb.resolve(): + shutil.copy2(structure_path, tgt_pdb) + entry = { + "target_path": str(tgt_pdb), + "target_input": _target_input_segments(structure_path, chain) or f"{chain}1-500", + "hotspot_residues": [f"{h.get('chain', 'A')}{h.get('position')}" for h in (hotspots or [])], + "binder_length": [int(binder_length[0]), int(binder_length[1])], + "pdb_id": None, + "source": "binder-pipeline-runtime", + "target_filename": task_name, + } + with open(lock_path, "a+") as lk: + fcntl.flock(lk.fileno(), fcntl.LOCK_EX) + try: + data = (yaml.safe_load(targets_dict.read_text()) or {}) if targets_dict.exists() else {} + data.setdefault("target_dict_cfg", {})[task_name] = entry + fd, tmp = tempfile.mkstemp(dir=str(targets_dict.parent), prefix=".td.", suffix=".yaml.tmp") + with os.fdopen(fd, "w") as f: + yaml.dump(data, f, default_flow_style=False, allow_unicode=True) + os.replace(tmp, targets_dict) + finally: + fcntl.flock(lk.fileno(), fcntl.LOCK_UN) + return entry + + +def submit_complexa(task_name: str, run_name: str, n_devices: int = 1, + algorithm: str = "best-of-n", seed: int = 0, + num_samples: int | None = None) -> Iterator[Event]: + """Run the FULL Complexa binder pipeline via the open `complexa design` CLI. + + Runs generate→filter→evaluate→analyze in the user's checkout ($COMPLEXA_REPO), + so the co-designed `self` sequence + AF2 reward metrics (self_complex_i_pTM / + pLDDT) land in the results CSVs. `n_devices` maps to gen/eval GPU parallelism + (one GPU per job). Synchronous — the CLI blocks until the run completes.""" + repo = _complexa_repo() + cmd = [COMPLEXA_BIN, "design", COMPLEXA_CONFIG, + f"++run_name={run_name}", + f"++generation.task_name={task_name}", + f"++generation.search.algorithm={algorithm}", + f"++seed={seed}", + f"++gen_njobs={n_devices}", f"++eval_njobs={n_devices}"] + if num_samples is not None: + cmd.append(f"++generation.dataloader.dataset.nres.nsamples={num_samples}") + yield Event("stage2", "start", + f"running `complexa design` for {task_name} " + f"(algorithm={algorithm}, seed={seed}, gen/eval njobs={n_devices}); " + "AF2 reward gate selects which designs go to Boltz2") + yield Event("stage2", "info", "cmd: " + " ".join(cmd) + f" (cwd={repo})") + p = _run(cmd, cwd=repo, timeout=int(os.environ.get("COMPLEXA_TIMEOUT_S", "21600"))) + if p.returncode != 0: + raise RuntimeError( + "`complexa design` failed (rc=" + f"{p.returncode}): {(p.stderr or p.stdout)[-600:]}") + yield Event("stage2", "ok", f"complexa design finished for run '{run_name}'") + + +_AA20 = set("ACDEFGHIKLMNPQRSTVWY") + + +def _looks_like_sequence(v: str) -> bool: + """A string that IS an amino-acid sequence: ≥20 standard residues, ≥2 types. + (Quality/complexity is judged separately by _max_aa_fraction.)""" + v = (v or "").strip().upper() + return len(v) >= 20 and set(v) <= _AA20 and len(set(v)) >= 2 + + +MAX_AA_FRACTION = 0.20 # reject a binder if any single amino acid exceeds this fraction + + +def _max_aa_fraction(v: str) -> float: + """Largest single-amino-acid fraction in the sequence (0..1). Complexa's `self` + sequences are often degenerate poly-X (e.g. poly-Lys/Thr) that cannot fold — a + high value flags those so we DON'T waste Boltz2 validation on them.""" + from collections import Counter + s = (v or "").strip().upper() + if not s: + return 1.0 + return max(Counter(s).values()) / len(s) + + +def _find_inference_dir(task_name: str, run_name: str) -> Path | None: + """Most-recently-modified Complexa inference dir for this run/task.""" + root = _complexa_repo() / "inference" + if not root.exists(): + return None + cands = sorted([d for d in root.iterdir() + if d.is_dir() and (run_name in d.name or task_name in d.name)], + key=lambda d: d.stat().st_mtime, reverse=True) + return cands[0] if cands else None + + +def _find_combined_csvs(task_name: str, run_name: str) -> list[Path]: + """The clean per-design binder-results CSVs for THIS run (one per device). + + Complexa's evaluate writes many CSVs per run; only ``binder_results_*.csv`` + carries per-design sequences (``self_sequence``) + interface scores + (``self_complex_i_pTM``). The RAW/transposed/aggregated/timing/all_successes + files have incompatible schemas or no sequences — globbing ``*.csv`` pulls + those (and other runs of the same target), which breaks column detection. + So: match only ``binder_results_*.csv`` and scope strictly to ``run_name``; + only widen to ``task_name`` if the strict match finds nothing.""" + def _scan(match: str) -> list[Path]: + hits: list[Path] = [] + repo = _complexa_repo() + for root in (repo / "evaluation_results", repo / "inference", repo / "results"): + if root.exists(): + hits += [p for p in root.rglob("binder_results_*.csv") + if match in str(p) and "transposed" not in p.name] + return hits + cands = _scan(run_name) or _scan(task_name) + # dedup, newest first + seen, out = set(), [] + for p in sorted(cands, key=lambda p: p.stat().st_mtime, reverse=True): + if p not in seen: + seen.add(p) + out.append(p) + return out + + +def extract_complexa_designs(run_dir: Path, task_name: str, run_name: str, + n_top: int = 50) -> Iterator[Event]: + """Pull REAL (inverse-folded) binder sequences + refolded PDBs from the + Complexa results CSV into design/ + sequences/binders_complexa_native.fasta. + + Schema-tolerant: the sequence column is detected by CONTENT (values that are + 20-letter amino-acid strings), the score column by name. Fails LOUD if no + usable (non-poly-Gly) sequence is found, rather than feeding a placeholder + into validation. NOTE: the exact Complexa results-CSV schema is confirmed on + the first real GPU run; this parser is content-based to tolerate it.""" + import csv + yield Event("stage2", "start", + f"extracting designs (real sequences) from Complexa results, keeping top {n_top}") + csv_paths = _find_combined_csvs(task_name, run_name) + if not csv_paths: + yield Event("stage2", "error", + "Complexa finished but no per-design results CSV was found under " + f"{_complexa_repo()}/(evaluation_results|inference|results) for run '{run_name}'. " + "The pipeline may have stopped before evaluate/analyze — check the Slurm log.") + return + rows: list[dict] = [] + for cp in csv_paths: + try: + with open(cp, newline="") as fh: + rows += list(csv.DictReader(fh)) + except OSError: + continue + csv_path = csv_paths[0] # representative (newest) for column detection + provenance + if not rows: + yield Event("stage2", "error", f"results CSVs ({len(csv_paths)}) were all empty") + return + yield Event("stage2", "info", + f"aggregated {len(rows)} design rows from {len(csv_paths)} results CSV(s)") + cols = list(rows[0].keys()) + # Prefer the co-designed `self` sequence column explicitly (we generate with + # sequence_types=[self]); NEVER pick `target_sequence` (that's the receptor, + # not the binder). Only fall back to content detection if no self column. + seq_col = next((c for c in cols if c.lower() in ("self_sequence", "self_seq", "self")), None) + if seq_col is None: + seq_col = next((c for c in cols if "self" in c.lower() and "seq" in c.lower()), None) + if seq_col is None: + seq_col = next((c for c in cols + if "target" not in c.lower() + and any(_looks_like_sequence(r.get(c, "")) for r in rows[:10])), None) + if seq_col is None: + yield Event("stage2", "error", + f"results CSV {csv_path.name} has no amino-acid sequence column " + f"(columns: {cols}).") + return + + def _find_col(subs: list[str]) -> str | None: + return next((c for c in cols if any(s in c.lower() for s in subs)), None) + + # AF2-Multimer metrics from the beam-search reward model (0-1 scaled): + # self_complex_i_pTM (interface pTM) and self_complex_pLDDT. Prefer the + # `self_complex_` columns; fall back to any i_ptm/plddt column. + iptm_col = next((c for c in cols if c.lower() == "self_complex_i_ptm"), None) \ + or _find_col(["i_ptm", "iptm"]) + plddt_col = next((c for c in cols if c.lower() == "self_complex_plddt"), None) \ + or _find_col(["plddt"]) + score_col = iptm_col or plddt_col # ranking key (AF2 i_pTM) + pdb_col = _find_col(["pdb_path", "sample_path", "pdb_filename", "pdb", "structure"]) + + def _num(r: dict, col: str | None) -> float: + try: + return float(r.get(col, "") or 0) if col else 0.0 + except (ValueError, TypeError): + return 0.0 + + def _score(r: dict) -> float: + return _num(r, score_col) + + usable = [r for r in rows if _looks_like_sequence(r.get(seq_col, ""))] + if not usable: + yield Event("stage2", "error", + f"{csv_path.name} had {len(rows)} rows but none carry a usable amino-acid " + f"sequence in column '{seq_col}' — the evaluate step produced no sequences.") + return + # Complexity filter: DO NOT validate degenerate poly-X sequences (Complexa `self` + # is often poly-Lys/Thr/Ile that cannot fold). Drop any design where a single + # amino acid exceeds MAX_AA_FRACTION (20%) — they only waste Boltz2 validation. + n_before = len(usable) + diverse = [r for r in usable if _max_aa_fraction(r.get(seq_col, "")) <= MAX_AA_FRACTION] + n_dropped = n_before - len(diverse) + if n_dropped: + yield Event("stage2", "info", + f"complexity filter: dropped {n_dropped}/{n_before} low-complexity design(s) " + f"(a single amino acid > {int(MAX_AA_FRACTION * 100)}%) — not validating those") + usable = diverse + if not usable: + yield Event("stage2", "error", + f"all {n_before} designs are low-complexity (single AA > {int(MAX_AA_FRACTION * 100)}%) " + "— nothing worth validating. Switch to MPNN sequences (cx_beam_search_mpnn) for " + "foldable designs, then re-run.") + return + # AF2 QUALITY GATE (primary selector). Forward to Boltz2 only the designs the + # generator's own AF2-Multimer is already confident in: self_complex_i_pTM AND + # self_complex_pLDDT both > 0.70. We validate ALL designs that pass (not a fixed + # top-N); n_top is only a safety cap. This avoids Boltz2-re-predicting collapsed + # / low-confidence backbones (which scored i_pTM~0.08, pLDDT~0.56). + n_pre_gate = len(usable) + if iptm_col or plddt_col: + passed = [r for r in usable + if (not iptm_col or _num(r, iptm_col) > AF2_IPTM_MIN) + and (not plddt_col or _num(r, plddt_col) > AF2_PLDDT_MIN)] + _gate_parts = [] + if iptm_col: + _gate_parts.append(f"AF2 i_pTM>{AF2_IPTM_MIN:.2f}") + if plddt_col: + _gate_parts.append(f"pLDDT>{AF2_PLDDT_MIN:.2f}") + gate_desc = " & ".join(_gate_parts) + yield Event("stage2", "info", + f"AF2 gate ({gate_desc}): {len(passed)}/{n_pre_gate} design(s) pass " + "→ Boltz2-validating all of them") + if not passed: + best_iptm = max((_num(r, iptm_col) for r in usable), default=0.0) if iptm_col else None + best_plddt = max((_num(r, plddt_col) for r in usable), default=0.0) if plddt_col else None + yield Event("stage2", "error", + f"NO design cleared the AF2 gate ({gate_desc}) — best AF2 " + f"i_pTM={best_iptm}, pLDDT={best_plddt}. The generator is not confident in " + "any binder, so nothing is worth an independent Boltz2 re-prediction. " + "Likely causes: collapsed/low-quality backbones, wrong hotspots, or use MPNN " + "sequences (cx_beam_search_mpnn). Not submitting Boltz2.") + return + usable = passed + else: + yield Event("stage2", "info", + "no AF2 i_pTM/pLDDT column found in results CSV — skipping AF2 gate, " + f"falling back to top-{max(1, n_top)} by available score") + usable.sort(key=_score, reverse=True) + # No per-target cap: validate EVERY AF2-passing design. n_top is only the + # runaway ceiling (MAX_VALIDATE_CEILING) unless the user set an explicit cap. + if len(usable) > max(1, n_top): + yield Event("stage2", "info", + f"AF2-passing pool ({len(usable)}) exceeds the runaway ceiling {max(1, n_top)}; " + f"keeping the top {max(1, n_top)} by AF2 i_pTM for Boltz2") + usable = usable[:max(1, n_top)] + else: + yield Event("stage2", "info", + f"validating ALL {len(usable)} AF2-passing design(s) with Boltz2 (no cap)") + design = run_dir / "design" + seqs = run_dir / "sequences" + design.mkdir(parents=True, exist_ok=True) + seqs.mkdir(parents=True, exist_ok=True) + # Save the raw Complexa results into the run's output dir (co-located). + import shutil + cdir = run_dir / "complexa" + cdir.mkdir(parents=True, exist_ok=True) + shutil.copy2(csv_path, cdir / csv_path.name) + inf = _find_inference_dir(task_name, run_name) + if inf is not None and inf.exists(): + (cdir / "backbones").mkdir(exist_ok=True) + for bb in inf.rglob("*.pdb"): + try: + shutil.copy2(bb, cdir / "backbones" / bb.name) + except OSError: + pass + fasta = [] + for i, r in enumerate(usable, 1): + name = f"rank{i:02d}_{task_name}" + fasta.append(f">{name}\n{r[seq_col].strip().upper()}") + if pdb_col and r.get(pdb_col): + raw = Path(r[pdb_col]) + # Complexa writes pdb_path RELATIVE TO THE REPO ROOT ($COMPLEXA_REPO), + # e.g. "./evaluation_results/.../job_.../*.pdb" — NOT relative to the CSV + # file. Try the repo root first, then csv_path.parent. + if raw.is_absolute(): + src = raw + else: + src = _complexa_repo() / raw + if not src.exists(): + src = csv_path.parent / raw + if src.exists(): + (design / f"{name}.pdb").write_text(src.read_text()) + (seqs / "binders_complexa_native.fasta").write_text("\n".join(fasta) + "\n") + yield Event("stage2", "ok", + f"extracted {len(usable)} binder sequence(s) → sequences/binders_complexa_native.fasta " + f"(source {csv_path.name}, seq col '{seq_col}'" + + (f", ranked by '{score_col}'" if score_col else "") + ")", + {"n_designs": len(usable)}) + + +def fetch_target_msa(run_dir: Path) -> Iterator[Event]: + yield Event("stage3", "start", "building target MSA (ColabFold)") + out = run_dir / "target.a3m" + p = _run([sys.executable, FETCH_MSA, "--seq-from-pdb", run_dir / "target.pdb", "-o", out], timeout=1200) + if p.returncode != 0: + raise RuntimeError(f"MSA fetch failed: {p.stderr[-400:]}") + yield Event("stage3", "ok", f"target MSA written ({out.name})") + + +def validation_handoff(run_dir: Path, conditioning: str) -> Iterator[Event]: + """Stage-3 is an INDEPENDENT refold via the Boltz2/OpenFold3 NIM (a different + model family than Complexa's AF2/RF3 reward+evaluate). Generation is automated + above; the NIM calls are driven by the agent (boltz2-nim / openfold3-nim skill). + This emits the exact handoff so the agent knows what to produce, after which + `score()` / `validate_binders.py` reads it and applies the gate. + + For each binder in sequences/binders_complexa_native.fasta, the agent runs: + * HOLO: binder (single-seq) + target (MSA default / template optional), + write_full_pae=true → validation/raw/.json (+ cif) + * APO: binder alone → validation/apo/.apo.cif + """ + binders = run_dir / "sequences" / "binders_complexa_native.fasta" + yield Event("stage3", "start", + f"independent validation handoff ({conditioning}) — drive Boltz2/OF3 via the NIM skill") + yield Event("stage3", "info", + f"binders: {binders}; target: {run_dir / 'target.pdb'}; " + f"target conditioning: {conditioning} " + f"({'target.a3m' if conditioning == 'msa' else 'target.cif template'}). " + "Write holo Boltz2 responses to validation/raw/*.json and apo cifs to " + "validation/apo/*.apo.cif, then run validate_binders.py (or score()).") + + +# --------------------------------------------------------------------------- scoring +def score(run_dir: Path, hotspots: Path | None, apo_dir: Path | None = None) -> Iterator[Event]: + yield Event("gate", "start", "scoring designs against the validation gate") + cmd = [sys.executable, VALIDATE_BINDERS, "--run-dir", run_dir, "--no-apo"] + if hotspots and Path(hotspots).exists(): + cmd += ["--hotspots", hotspots] + if apo_dir: + cmd += ["--apo-dir", apo_dir] + p = _run(cmd, timeout=1800) + if p.returncode != 0: + raise RuntimeError(f"scoring failed: {p.stderr[-500:]}") + ranked_path = run_dir / "ranked_binders.json" + ranked = json.loads(ranked_path.read_text()) if ranked_path.exists() else [] + n_pass = sum(1 for r in ranked if r.get("pass")) + yield Event("gate", "ok", f"scored {len(ranked)} designs; {n_pass} pass the gate", + {"ranked": ranked, "n_pass": n_pass, "ranked_path": str(ranked_path)}) + + +# --------------------------------------------------------------------------- top-level +def run(mode: str = "score_existing", *, run_dir: str | None = None, + target: dict | None = None, target_text: str | None = None, + target_file: str | None = None, target_key: str | None = None, + conditioning: str = "msa", n_validated: int = 0, + n_devices: int = N_DEVICES_DEFAULT, + hotspots: str | None = None, apo_dir: str | None = None) -> Iterator[Event]: + """Stream the pipeline. See module docstring for modes. + + ``n_validated <= 0`` auto-couples to ``n_devices x VALIDATED_PER_DEVICE`` (the + number of AF2-ranked designs forwarded to Boltz2): 16 GPUs -> 64 validated.""" + n_validated = validation_count(n_devices, n_validated) + try: + if mode == "score_existing": + if not run_dir: + raise ValueError("score_existing needs run_dir") + rd = Path(run_dir).expanduser() + if not rd.is_absolute() and not rd.exists(): + rd = OUTPUTS / run_dir + yield Event("init", "ok", f"scoring existing run {rd.name}") + hs = hotspots or (rd / "hotspots.json" if (rd / "hotspots.json").exists() else None) + ad = apo_dir or (rd / "validation" / "apo" if (rd / "validation" / "apo").exists() else None) + yield from score(rd, hs, ad) + return + + if mode == "full": + # Resolve the target from: an uploaded file, free text (name/UniProt/ + # PDB), or an explicit spec dict. + if target_file: + ext = Path(target_file).suffix.lower() + spec = {"cif_path": target_file} if ext == ".cif" else {"pdb_path": target_file} + label = Path(target_file).stem + elif target_text: + spec = resolve_target_spec(target_text) + label = spec.get("uniprot") or spec.get("pdb") or "target" + elif target: + spec = target + label = target.get("uniprot") or target.get("pdb") or "target" + else: + raise ValueError("full mode needs target_text, target_file, or target spec") + rd = OUTPUTS / f"{label}_app" + yield Event("init", "start", + f"full run → {rd.name} (N={n_validated}, {conditioning})") + if spec.get("resolved_from"): + yield Event("init", "info", f"resolved target: {spec['resolved_from']}") + yield from resolve_target(spec, rd) + # Resolve final hotspots: an explicitly provided file (e.g. the + # Paperclip fallback output) wins over the UniProt-derived set. Either + # way, align to the resolved structure so Stage 2 never conditions on a + # residue absent from the coordinates (the 'ordering' guarantee). + structure = rd / "target.cif" if (rd / "target.cif").exists() else rd / "target.pdb" + if hotspots and Path(hotspots).exists(): + yield Event("stage1", "info", f"using provided hotspots file: {Path(hotspots).name}") + hsrc = json.loads(Path(hotspots).read_text()) + elif (rd / "hotspots.json").exists(): + hsrc = json.loads((rd / "hotspots.json").read_text()) + else: + hsrc = [] + hs_in = hsrc.get("hotspot_residues", []) if isinstance(hsrc, dict) else hsrc + # UniProt gave nothing → run the Paperclip literature fallback automatically. + if not hs_in and structure.exists() and spec.get("uniprot"): + yield from paperclip_hotspots(spec["uniprot"], structure, rd) + if (rd / "hotspots.json").exists(): + hj = json.loads((rd / "hotspots.json").read_text()) + hs_in = hj.get("hotspot_residues", []) if isinstance(hj, dict) else hj + if hs_in and structure.exists(): + kept, dropped = align_hotspots_to_structure(hs_in, structure) + if dropped: + yield Event("stage1", "info", + f"aligned hotspots to {structure.name}: dropped {len(dropped)} " + "off-structure residue(s) — " + + "; ".join(d.get("drop_reason", "") for d in dropped[:6])) + wrapper = hsrc if isinstance(hsrc, dict) else {} + wrapper["hotspot_residues"] = kept + wrapper["numbering"] = f"aligned to {structure.name} coordinates" + (rd / "hotspots.json").write_text(json.dumps(wrapper, indent=2)) + yield Event("stage1", "ok", f"{len(kept)} structure-aligned hotspot(s) ready", + {"hotspots": kept[:20]}) + elif not hs_in: + yield Event("stage1", "info", + "no hotspots (UniProt empty and none provided) — run the Paperclip " + "fallback (prompts/hotspot_paperclip.md) and re-run with --hotspots, " + "or proceed UNCONDITIONED.") + # Stage 2 — register the target + run the FULL Complexa pipeline, then + # extract real (inverse-folded) binder sequences. A pre-staged FASTA or + # an explicit target_key short-circuits parts of this. + binders = rd / "sequences" / "binders_complexa_native.fasta" + if binders.exists(): + yield Event("stage2", "info", f"reusing pre-staged {binders}") + else: + pdb_for_complexa = _ensure_pdb(structure, rd) + task = target_key or ("app_" + re.sub(r"[^A-Za-z0-9]+", "_", label).strip("_")) + final_hs: list = [] + if (rd / "hotspots.json").exists(): + hj = json.loads((rd / "hotspots.json").read_text()) + final_hs = hj.get("hotspot_residues", hj) if isinstance(hj, dict) else hj + # Hotspot sanity: a binder targets ONE compact epitope. Drop distal + # outliers (> HOTSPOT_MAX_SPREAD_A from the cluster) and cap at + # HOTSPOT_MAX_RESIDUES, so generation + the crop center on a real patch. + if final_hs and not target_key: + final_hs, _dropped_hs, _hs_msgs = _prune_hotspots(final_hs, pdb_for_complexa) + for m in _hs_msgs: + yield Event("stage1", "info", m) + # A binder needs >=2 hotspots to define an epitope; 1 is too weak. + if 0 < len(final_hs) < HOTSPOT_MIN_RESIDUES: + yield Event("stage1", "info", + f"WARNING: only {len(final_hs)} hotspot residue after sanity " + f"pruning (need >= {HOTSPOT_MIN_RESIDUES}). A single residue is " + "too weak to define an epitope — add hotspots (Paperclip/" + "literature) or this design is effectively unconditioned.") + already_registered = False + _td_path = _targets_dict() + if _td_path.exists(): + try: + import yaml + td = yaml.safe_load(_td_path.read_text()) or {} + already_registered = task in td.get("target_dict_cfg", {}) + except Exception: # noqa: BLE001 + pass + if target_key: + # User-managed, pre-registered target — trust it as-is. + yield Event("stage2", "info", + f"target '{task}' supplied explicitly — reusing existing entry " + "(not overwriting)") + else: + # Enforce the target-size cap; crop oversized targets to the + # epitope so Complexa's O(n^2) pair features don't OOM. + pdb_for_complexa, crop_msgs = _crop_target_to_epitope( + pdb_for_complexa, final_hs, rd) + for m in crop_msgs: + yield Event("stage1", "info", m) + # A full run just freshly resolved the structure + hotspots, so + # ALWAYS (re)register with the current result rather than reusing a + # possibly-stale entry (e.g. an earlier unconditioned 0-hotspot run). + if already_registered: + yield Event("stage2", "info", + f"refreshing registration for '{task}' with current structure " + f"+ {len(final_hs)} hotspot(s)") + entry = register_complexa_target(task, pdb_for_complexa, final_hs) + yield Event("stage2", "info", + f"registered Complexa target '{task}' " + f"(target_input={entry['target_input']}, " + f"{len(entry['hotspot_residues'])} hotspot(s), " + f"binder_length={entry['binder_length']})") + run_name = f"{task}_{time.strftime('%Y%m%d_%H%M%S')}" + yield from submit_complexa(task, run_name, n_devices=n_devices) + # Apply the AF2-reward gate + complexity filter; forward passers to + # independent Boltz2 validation (n_validated caps the pool). + yield from extract_complexa_designs(rd, task, run_name, + n_top=max(1, int(n_validated))) + if not binders.exists(): + return # extract_complexa_designs already emitted a specific error + if conditioning == "msa": + yield from fetch_target_msa(rd) + # Stage 3 is an independent NIM refold driven by the agent; emit the handoff. + yield from validation_handoff(rd, conditioning) + # If holo/apo refolds already exist (agent produced them, or a prior run), + # score immediately; otherwise stop after the handoff. + if (rd / "validation" / "raw").is_dir(): + apo_dir = (rd / "validation" / "apo") if (rd / "validation" / "apo").exists() else None + yield from score(rd, rd / "hotspots.json" if (rd / "hotspots.json").exists() else None, + apo_dir) + return + + raise ValueError(f"unknown mode {mode}") + except Exception as e: # noqa: BLE001 + yield Event("error", "error", f"{type(e).__name__}: {e}") + + +if __name__ == "__main__": + # tiny CLI for testing without the UI: score an existing run + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("--mode", default="score_existing") + ap.add_argument("--run-dir") + ap.add_argument("--apo-dir") + a = ap.parse_args() + for ev in run(mode=a.mode, run_dir=a.run_dir, apo_dir=a.apo_dir): + print(ev.line()) + if ev.stage == "gate" and ev.status == "ok": + for r in ev.data.get("ranked", [])[:10]: + print(f" #{r.get('rank')} {r.get('name','')[:46]:46} pass={r.get('pass')} " + f"ipSAEmin={r.get('ipsae_min')} rmsd={r.get('binder_rmsd')}") diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/preflight_design.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/preflight_design.py new file mode 100644 index 0000000..40f8098 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/preflight_design.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Pre-flight design planner / validator — run BEFORE app.py to see, per target, +exactly what would be conditioned on and whether it satisfies the design rules. +No GPU, no Slurm: just fetch the structure + UniProt, choose hotspots, re-align +them to the (possibly cropped) structure, and check every constraint. + +For each target it reports and validates: + * conditioned target LENGTH (after extracellular restriction + epitope crop) + * hotspot POSITIONS, re-aligned to the structure (identity verified; numbering + preserved through any truncation) + * size budget: target + longest binder <= MAX_COMPLEX_RESIDUES (500) + * compactness: hotspot pairwise diameter <= 30 Å (else pick a compact subset) + * count: 2 <= n_hotspots <= 15 + +Usage: + python3 scripts/preflight_design.py IL1R1 HER2 PIN1 TNFL9 EFNB1 CEACAM1 AHSP + python3 scripts/preflight_design.py P04626 # by accession +""" +from __future__ import annotations + +import json +import math +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) # Stage-1 modules live alongside this script +import pipeline as P # noqa: E402 +import hotspot_strategy as HS # noqa: E402 + +MAX_DIAMETER_A = 30.0 # hotspot epitope must fit within this pairwise diameter + + +# ----------------------------------------------------------------- structure helpers +def _model(structure_path: Path): + import gemmi + st = gemmi.read_structure(str(structure_path)) + st.setup_entities() + return st[0] if len(st) else None + + +def _residue_index(model): + """(chain, pos) -> 3-letter residue name, for alignment/identity checks.""" + idx = {} + for ch in model: + for res in ch: + idx[(ch.name, res.seqid.num)] = res.name + return idx + + +def _cb_coords(model, hotspots): + """{position: (x,y,z)} using Cβ (Cα fallback) for the hotspot chain.""" + want = {(str(h.get("chain", "A")), int(h["position"])) for h in hotspots} + out = {} + for ch in model: + for res in ch: + key = (ch.name, res.seqid.num) + if key in want: + a = res.find_atom("CB", "*") or res.find_atom("CA", "*") + if a: + out[res.seqid.num] = (a.pos.x, a.pos.y, a.pos.z) + return out + + +def _dist(a, b): + return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) + + +def _pairwise_diameter(coords: dict): + pts = list(coords.values()) + return max((_dist(p, q) for i, p in enumerate(pts) for q in pts[i + 1:]), default=0.0) + + +def _compact_subset(positions, coords, max_d=MAX_DIAMETER_A): + """Largest spatially compact subset with pairwise diameter <= max_d. + Greedy: seed the residue with the most neighbours within max_d, then add the + nearest residue that keeps the whole set's diameter <= max_d.""" + pos = [p for p in positions if p in coords] + if len(pos) <= 1: + return pos + nbr = {p: sum(1 for q in pos if _dist(coords[p], coords[q]) <= max_d) for p in pos} + seed = max(pos, key=lambda p: nbr[p]) + chosen = [seed] + while True: + best, bestd = None, None + for p in pos: + if p in chosen: + continue + if all(_dist(coords[p], coords[c]) <= max_d for c in chosen): + d = min(_dist(coords[p], coords[c]) for c in chosen) + if bestd is None or d < bestd: + best, bestd = p, d + if best is None: + break + chosen.append(best) + return sorted(chosen) + + +def _accessible_residue_count(model, segs): + """How many residues fall inside the accessible (extracellular) segments.""" + if not segs: + return sum(len(ch) for ch in model) + n = 0 + for ch in model: + for res in ch: + if any(s <= res.seqid.num <= e for s, e in segs): + n += 1 + return n + + +# ----------------------------------------------------------------- per-target plan +def plan(target: str, binder_max: int = None) -> dict: + binder_max = binder_max if binder_max is not None else P.BINDER_LENGTH[1] + cap_target = P.MAX_COMPLEX_RESIDUES - binder_max + rep = {"target": target, "checks": {}} + spec = P.resolve_target_spec(target) + acc = spec.get("uniprot") + rep["uniprot"] = acc + rep["resolved_from"] = spec.get("resolved_from") + if not acc: + rep["error"] = "could not resolve to a UniProt accession" + return rep + + with tempfile.TemporaryDirectory() as td: + td = Path(td) + # fetch AFDB structure + P._run([sys.executable, str(P.FETCH_STRUCTURE), acc, "-o", str(td)], timeout=600) + cif = next(iter(sorted(td.glob(f"AF-{acc}-*model*.cif"))), None) + if cif is None: + rep["error"] = "no AFDB model" + return rep + model = _model(cif) + full_len = sum(len(ch) for ch in model) + rep["full_length"] = full_len + + # UniProt entry -> accessibility + functional hotspots + p = P._run([sys.executable, str(P.UNIPROT_TOOLS), "get", acc], timeout=300) + entry = json.loads(p.stdout) + entry = entry if "features" in entry else entry.get("results", [entry])[0] + # Same resolver the live pipeline uses: PDB co-complex interface (gold) -> + # UniProt functional, restricted to the accessible/extracellular range. + hs, segs, provenance, hmsgs = HS.resolve_hotspots(entry) + acc_info = HS.accessibility(entry) + rep["topology"] = acc_info["note"] + rep["accessible_residues"] = _accessible_residue_count(model, segs) + rep["source"] = provenance + rep["uniprot_messages"] = hmsgs + rep["raw_hotspots"] = [f"{h['chain']}{h['position']}({h.get('source', '?')})" for h in hs] + + # align to structure: keep only residues present, attach identity + idx = _residue_index(model) + aligned = [] + for h in hs: + key = (str(h.get("chain", "A")), int(h["position"])) + if key in idx: + aligned.append({**h, "residue": idx[key]}) + rep["aligned_hotspots"] = [f"{h['chain']}{h['position']}({h['residue']})" for h in aligned] + + # compactness: pairwise diameter; subset if too sparse + coords = _cb_coords(model, aligned) + positions = [h["position"] for h in aligned if h["position"] in coords] + diam = _pairwise_diameter(coords) + rep["diameter_A_raw"] = round(diam, 1) + if diam > MAX_DIAMETER_A and len(positions) > 1: + keep = set(_compact_subset(positions, coords)) + dropped = [p for p in positions if p not in keep] + aligned = [h for h in aligned if h["position"] in keep] + rep["compaction"] = f"diameter {diam:.0f} Å > {MAX_DIAMETER_A:.0f} → kept {sorted(keep)}, dropped {sorted(dropped)}" + coords = {p: coords[p] for p in keep} + diam = _pairwise_diameter(coords) + rep["diameter_A_final"] = round(diam, 1) + + # count: max 15 (closest to centroid), min 2 + if len(aligned) > P.HOTSPOT_MAX_RESIDUES and coords: + cx = tuple(sum(coords[h["position"]][k] for h in aligned if h["position"] in coords) / len(coords) for k in range(3)) + aligned = sorted(aligned, key=lambda h: _dist(coords.get(h["position"], cx), cx))[:P.HOTSPOT_MAX_RESIDUES] + rep["final_hotspots"] = [f"{h['chain']}{h['position']}({h.get('residue','?')})" for h in aligned] + n = len(aligned) + rep["n_hotspots"] = n + + # conditioning length: accessible region, then epitope crop to fit the cap + cond_len = rep["accessible_residues"] if segs else full_len + crop_note = (f"extracellular {segs}" if segs else "whole chain") + if cond_len > cap_target and aligned: + hot_pos = sorted(h["position"] for h in aligned) + lo, hi = hot_pos[0], hot_pos[-1] + pad = max(0, (cap_target - (hi - lo + 1)) // 2) + w_lo, w_hi = lo - pad, hi + pad + # count residues kept inside the window AND accessible segments + kept = [res.seqid.num for ch in model for res in ch + if w_lo <= res.seqid.num <= w_hi and (not segs or any(s <= res.seqid.num <= e for s, e in segs))] + cond_len = len(kept) + crop_note = f"epitope crop A{min(kept)}-{max(kept)} within {('ECD ' if segs else '')}cap" + rep["conditioned_length"] = cond_len + rep["conditioning"] = crop_note + + # ---- validations ---- + rep["checks"]["size<=500"] = (cond_len + binder_max <= P.MAX_COMPLEX_RESIDUES, + f"{cond_len}+{binder_max}={cond_len + binder_max}") + rep["checks"]["compact<=30A"] = (diam <= MAX_DIAMETER_A, f"{diam:.0f} Å") + rep["checks"][">=2_hotspots"] = (n >= P.HOTSPOT_MIN_RESIDUES, str(n)) + rep["checks"]["<=15_hotspots"] = (n <= P.HOTSPOT_MAX_RESIDUES, str(n)) + return rep + + +def _fmt(rep: dict) -> str: + L = [] + head = f"━━━ {rep['target']} ({rep.get('uniprot','?')}) ━━━" + L.append(head) + if rep.get("error"): + L.append(f" ERROR: {rep['error']}") + return "\n".join(L) + L.append(f" full length: {rep['full_length']} aa | {rep['topology']}") + L.append(f" conditioned on: {rep['conditioned_length']} aa ({rep['conditioning']})") + L.append(f" hotspots (UniProt): raw={rep['raw_hotspots']}") + if rep.get("compaction"): + L.append(f" compaction: {rep['compaction']}") + L.append(f" FINAL hotspots ({rep['n_hotspots']}): {rep['final_hotspots'] or '— NONE (needs PDB-interface/Paperclip)'}") + for m in rep.get("uniprot_messages", []): + L.append(f" · {m}") + ok = lambda b: "✓" if b else "✗" + for name, (passed, detail) in rep["checks"].items(): + L.append(f" [{ok(passed)}] {name}: {detail}") + ready = all(p for p, _ in rep["checks"].values()) + L.append(f" => {'READY' if ready else 'NEEDS ATTENTION'}") + return "\n".join(L) + + +def main(): + targets = sys.argv[1:] or ["IL1R1", "HER2", "PIN1", "TNFL9", "EFNB1", "CEACAM1", "AHSP"] + for t in targets: + try: + print(_fmt(plan(t))) + except Exception as e: # noqa: BLE001 + print(f"━━━ {t} ━━━\n EXCEPTION: {type(e).__name__}: {e}") + print() + + +if __name__ == "__main__": + main() diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/setup_af2_params.sh b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/setup_af2_params.sh new file mode 100755 index 0000000..c5ae229 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/setup_af2_params.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +# Set up AlphaFold2-Multimer params for Complexa's reward-guided search (best-of-n / +# beam-search / fk-steering / mcts). Downloads the public AF2 params (no auth) if +# missing and creates the `params/` subdir layout colabdesign expects. +# +# colabdesign resolves params from BOTH `/params_model_*.npz` AND +# `/params/...`, and `casp_model_names()` does `os.listdir(/params)` — +# so the flat AF2 tar (which extracts `params_model_*.npz` at top level) needs a +# `params/` symlink dir or model enumeration fails. This script creates it. +# +# Usage: +# bash setup_af2_params.sh [AF2_DIR] +# # default AF2_DIR = ${COMPLEXA_REPO:-.}/community_models/ckpts/AF2 +# Then: export AF2_DIR= +set -euo pipefail + +AF2_DIR="${1:-${COMPLEXA_REPO:-.}/community_models/ckpts/AF2}" +TAR_URL="https://storage.googleapis.com/alphafold/alphafold_params_2022-12-06.tar" +mkdir -p "$AF2_DIR" + +if ! ls "$AF2_DIR"/params_model_*_multimer_v3.npz >/dev/null 2>&1; then + echo "Downloading AF2 params (~5 GB, public, no auth) -> $AF2_DIR ..." + wget -q --show-progress -O "$AF2_DIR/af2.tar" "$TAR_URL" + echo "Extracting ..." + tar -xf "$AF2_DIR/af2.tar" -C "$AF2_DIR" + rm -f "$AF2_DIR/af2.tar" +else + echo "AF2 params already present in $AF2_DIR" +fi + +# Create the params/ subdir colabdesign enumerates, symlinking the flat .npz files. +mkdir -p "$AF2_DIR/params" +( cd "$AF2_DIR/params" && ln -sf ../params_model_*.npz . ) +n=$(ls "$AF2_DIR"/params/params_model_*.npz 2>/dev/null | wc -l) +echo "params/ symlinks: $n" + +if ! ls "$AF2_DIR"/params/params_model_1_multimer_v3.npz >/dev/null 2>&1; then + echo "ERROR: multimer_v3 params not found under $AF2_DIR/params" >&2 + exit 1 +fi + +ABS="$(cd "$AF2_DIR" && pwd)" +echo "AF2 ready. Export this for reward-guided search:" +echo " export AF2_DIR=$ABS" diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/validate_binders.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/validate_binders.py new file mode 100644 index 0000000..326e1d8 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/scripts/validate_binders.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Validate Proteina-Complexa binders against the pipeline gate (Stage 3). + +For each designed binder this computes, on an *independent* Boltz2 refold: + + * ipTM — from the holo Boltz2 response (``iptm_scores``) + * complex pLDDT — holo ``complex_plddt_scores`` (0-1) + * binder pLDDT — mean Cα pLDDT of the binder chain in the holo complex + * ipSAE (min) — canonical Dunbrack ipsae.py on the holo PAE matrix, + min over the two asymmetric interface directions + * apo binder pLDDT — a *new* Boltz2 prediction of the binder ALONE + * binder apo↔holo RMSD— Cα RMSD after Kabsch superposition (binder stability) + * hotspot contact % — fraction of conditioned hotspots with a binder + Cβ–Cβ contact < 13 Å (Cα for GLY) + +Gate (all must hold): ipsae_min>=0.45 AND iptm>=0.65 AND binder_plddt>=0.70 AND +complex_plddt>=0.70 AND apo_binder_plddt>=0.70 AND binder_rmsd<=2.5 AND +(hotspot_contact_frac>=0.20 when the design was hotspot-conditioned). + +Every design — pass AND fail — is written to validation_scores.json/.csv and +ranked_binders.json/.csv, each with a ``pass`` flag and a ``failure_reason`` +listing *all* gates missed (or the verbatim error if a design could not be scored). + +Validation is always UNCONDITIONED: the refold sees only sequences, never the +hotspot list; the hotspot check is an independent geometric test on the result. + +Usage: + python validate_binders.py --run-dir outputs/_ --hotspots + # recompute-only (no live apo predictions): + python validate_binders.py --run-dir --hotspots --no-apo +""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +import numpy as np + +# ----------------------------------------------------------------------------- gate defaults +GATE = { + "ipsae_min": ("min", 0.45), + "iptm": ("min", 0.65), + "binder_plddt": ("min", 0.70), + "complex_plddt": ("min", 0.70), + "apo_binder_plddt": ("min", 0.70), + "binder_rmsd": ("max", 2.50), + "hotspot_contact_frac": ("min", 0.20), +} + +HOSTED_URL = "https://health.api.nvidia.com/v1/biology/mit/boltz2/predict" +# Local NIM: override host/port via $BOLTZ2_URL (e.g. a NIM on another container/host). +LOCAL_URL = os.environ.get("BOLTZ2_URL", "http://localhost:8000/biology/mit/boltz2/predict") + +THREE_TO_ONE = { + "ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", "GLN": "Q", + "GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K", + "MET": "M", "PHE": "F", "PRO": "P", "SER": "S", "THR": "T", "TRP": "W", + "TYR": "Y", "VAL": "V", +} + + +# ----------------------------------------------------------------------------- env / auth +def load_api_key(env_files: list[Path] | None = None) -> str | None: + """Shell env first, then optional .env files; NVIDIA_API_KEY -> NGC_API_KEY.""" + for var in ("NVIDIA_API_KEY", "NGC_API_KEY"): + if os.environ.get(var): + return os.environ[var] + for env_path in (env_files or []): + if not env_path or not env_path.is_file(): + continue + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + k = k.strip() + v = v.strip().strip('"').strip("'") + if k in ("NVIDIA_API_KEY", "NGC_API_KEY") and v: + return v + return None + + +# ----------------------------------------------------------------------------- mmCIF parsing +def parse_cif_atoms(cif_text: str) -> list[dict]: + """Minimal _atom_site loop parser for Boltz2 mmCIF. Returns ATOM rows.""" + cols: list[str] = [] + atoms: list[dict] = [] + for line in cif_text.splitlines(): + s = line.strip() + if s.startswith("_atom_site."): + cols.append(s.split(".", 1)[1]) + continue + if cols and (s.startswith("ATOM") or s.startswith("HETATM")): + f = s.split() + if len(f) < len(cols): + continue + idx = {c: i for i, c in enumerate(cols)} + chain = f[idx.get("auth_asym_id", idx["label_asym_id"])] + resnum = f[idx["label_seq_id"]] + if resnum == ".": + continue # ligand + atoms.append({ + "chain": chain, + "resnum": int(resnum), + "resname": f[idx["label_comp_id"]], + "atom": f[idx["label_atom_id"]], + "xyz": np.array([float(f[idx["Cartn_x"]]), + float(f[idx["Cartn_y"]]), + float(f[idx["Cartn_z"]])]), + "bfac": float(f[idx["B_iso_or_equiv"]]), + }) + elif cols and atoms and (s.startswith("loop_") or (s.startswith("_") and not s.startswith("_atom_site."))): + break + return atoms + + +def chain_ca(atoms: list[dict], chain: str) -> list[dict]: + """CA atoms of a chain, ordered by residue number.""" + cas = [a for a in atoms if a["chain"] == chain and a["atom"] == "CA"] + return sorted(cas, key=lambda a: a["resnum"]) + + +def chain_sequence(atoms: list[dict], chain: str) -> str: + return "".join(THREE_TO_ONE.get(a["resname"], "X") for a in chain_ca(atoms, chain)) + + +def chain_mean_ca_plddt(atoms: list[dict], chain: str) -> float: + """Mean Cα B-factor (=pLDDT) of a chain, normalised to 0-1 (B-factor is 0-100).""" + bf = [a["bfac"] for a in chain_ca(atoms, chain)] + return float(np.mean(bf) / 100.0) if bf else float("nan") + + +def residue_cb(atoms: list[dict], chain: str, resnum: int) -> np.ndarray | None: + """Cβ coord of a residue (Cα for glycine / missing Cβ).""" + res = [a for a in atoms if a["chain"] == chain and a["resnum"] == resnum] + if not res: + return None + for a in res: + if a["atom"] == "CB": + return a["xyz"] + for a in res: + if a["atom"] == "CA": + return a["xyz"] + return None + + +def chain_cb_coords(atoms: list[dict], chain: str) -> np.ndarray: + coords = [] + seen = set() + for a in sorted([x for x in atoms if x["chain"] == chain], key=lambda x: x["resnum"]): + if a["resnum"] in seen: + continue + cb = residue_cb(atoms, chain, a["resnum"]) + if cb is not None: + coords.append(cb) + seen.add(a["resnum"]) + return np.array(coords) + + +# ----------------------------------------------------------------------------- geometry +def kabsch_rmsd(P: np.ndarray, Q: np.ndarray) -> float: + """Cα RMSD of P onto Q after optimal superposition. P,Q are (N,3), aligned 1:1.""" + if P.shape != Q.shape or len(P) == 0: + return float("nan") + Pc = P - P.mean(axis=0) + Qc = Q - Q.mean(axis=0) + H = Pc.T @ Qc + U, _, Vt = np.linalg.svd(H) + d = np.sign(np.linalg.det(Vt.T @ U.T)) + D = np.diag([1.0, 1.0, d]) + R = Vt.T @ D @ U.T + P_rot = Pc @ R.T + return float(np.sqrt(np.mean(np.sum((P_rot - Qc) ** 2, axis=1)))) + + +def hotspot_contacts(atoms: list[dict], target_chain: str, binder_chain: str, + hotspots: list[dict], cutoff: float = 13.0) -> dict: + """Fraction of hotspots with a binder Cβ within `cutoff` Å of the hotspot Cβ.""" + binder_cb = chain_cb_coords(atoms, binder_chain) + details = [] + n_contact = 0 + for hs in hotspots: + pos = hs.get("position") + cb = residue_cb(atoms, target_chain, pos) + if cb is None or len(binder_cb) == 0: + details.append({"position": pos, "contacted": False, "min_cb_dist": None}) + continue + dmin = float(np.min(np.linalg.norm(binder_cb - cb, axis=1))) + contacted = dmin < cutoff + n_contact += int(contacted) + details.append({"position": pos, "contacted": contacted, "min_cb_dist": round(dmin, 2)}) + frac = n_contact / len(hotspots) if hotspots else None + return {"n_hotspots": len(hotspots), "n_contacted": n_contact, + "contact_frac": frac, "cutoff": cutoff, "per_hotspot": details} + + +# ----------------------------------------------------------------------------- ipSAE +def run_ipsae(ipsae_py: Path, cif_text: str, pae: np.ndarray, + pair_chains_iptm: dict | None, workdir: Path, + pae_cutoff: int = 10, dist_cutoff: int = 10) -> dict: + """Run canonical ipsae.py in Boltz mode; return ipsae_min/max + iptm_af.""" + stem = "model" + cif_path = workdir / f"{stem}.cif" + cif_path.write_text(cif_text) + np.savez(workdir / f"pae_{stem}.npz", pae=pae) + if pair_chains_iptm is not None: + (workdir / f"confidence_{stem}.json").write_text( + json.dumps({"pair_chains_iptm": pair_chains_iptm})) + cmd = [sys.executable, str(ipsae_py), str(workdir / f"pae_{stem}.npz"), + str(cif_path), str(pae_cutoff), str(dist_cutoff)] + proc = subprocess.run(cmd, capture_output=True, text=True) + out_txt = workdir / f"{stem}_{pae_cutoff:02d}_{dist_cutoff:02d}.txt" + if not out_txt.exists(): + raise RuntimeError(f"ipsae.py produced no output: {proc.stdout}\n{proc.stderr}") + asym = {} + iptm_af = None + for line in out_txt.read_text().splitlines(): + f = line.split() + if len(f) < 6 or f[0] == "Chn1": + continue + if f[4] == "asym": + asym[(f[0], f[1])] = float(f[5]) # ipSAE (d0res) column + try: + iptm_af = float(f[8]) + except (IndexError, ValueError): + pass + vals = list(asym.values()) + return { + "ipsae_min": min(vals) if vals else None, + "ipsae_max": max(vals) if vals else None, + "ipsae_asym": {f"{a}->{b}": v for (a, b), v in asym.items()}, + "iptm_af_ipsae": iptm_af, + } + + +# ----------------------------------------------------------------------------- Boltz2 apo call +def boltz2_predict_apo(seq: str, url: str, api_key: str | None, + recycling_steps: int = 3, sampling_steps: int = 50, + max_retries: int = 5, base_delay: float = 10.0) -> dict: + """Single-chain (apo) Boltz2 prediction with exponential backoff on rate limits + (HTTP 429) / 5xx / transient network errors, honoring Retry-After when present.""" + body = { + "polymers": [{"id": "A", "molecule_type": "protein", "sequence": seq}], + "recycling_steps": recycling_steps, + "sampling_steps": sampling_steps, + "diffusion_samples": 1, + "step_scale": 1.638, + "output_format": "mmcif", + } + headers = {"Content-Type": "application/json"} + if api_key: # hosted needs Bearer auth; local NIM needs none + headers["Authorization"] = f"Bearer {api_key}" + data = json.dumps(body).encode() + last = None + for attempt in range(max_retries + 1): + try: + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=900) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + last = e + if e.code not in (429, 500, 502, 503, 504) or attempt == max_retries: + raise + ra = e.headers.get("Retry-After") if e.headers else None + delay = float(ra) if (ra and str(ra).isdigit()) else base_delay * (2 ** attempt) + print(f" [retry] apo HTTP {e.code}; waiting {min(delay,120):.0f}s " + f"(attempt {attempt + 1}/{max_retries})", file=sys.stderr, flush=True) + time.sleep(min(delay, 120)) + except (urllib.error.URLError, TimeoutError) as e: + last = e + if attempt == max_retries: + raise + time.sleep(min(base_delay * (2 ** attempt), 120)) + raise last if last else RuntimeError("apo prediction retries exhausted") + + +# ----------------------------------------------------------------------------- gating +def evaluate_gate(metrics: dict, hotspot_conditioned: bool) -> tuple[bool, str | None]: + reasons = [] + for key, (mode, thr) in GATE.items(): + if key == "hotspot_contact_frac" and not hotspot_conditioned: + continue + val = metrics.get(key) + if val is None or (isinstance(val, float) and np.isnan(val)): + reasons.append(f"{key}=NA (not measured)") + continue + if mode == "min" and val < thr: + reasons.append(f"{key}={val:.3f} < {thr}") + elif mode == "max" and val > thr: + reasons.append(f"{key}={val:.3f} > {thr}") + return (len(reasons) == 0), ("; ".join(reasons) if reasons else None) + + +# ----------------------------------------------------------------------------- main +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--run-dir", required=True, type=Path) + ap.add_argument("--hotspots", type=Path, help="hotspots.json (omit => unconditioned)") + ap.add_argument("--target-chain", default="A") + ap.add_argument("--binder-chain", default="B") + ap.add_argument("--endpoint", choices=["hosted", "local"], default="hosted") + ap.add_argument("--env-file", default=None, + help="optional .env to read NVIDIA_API_KEY/NGC_API_KEY from " + "(shell env always takes precedence)") + ap.add_argument("--no-apo", action="store_true", + help="skip live apo predictions; record apo/RMSD as not-run") + ap.add_argument("--apo-dir", type=Path, default=None, + help="reuse apo predictions from this dir (default: /validation/apo). " + "apo is binder-only so it is identical across target-conditioning modes.") + ap.add_argument("--pae-cutoff", type=int, default=10) + ap.add_argument("--dist-cutoff", type=int, default=10) + ap.add_argument("--contact-cutoff", type=float, default=13.0) + args = ap.parse_args() + + skill_root = Path(__file__).resolve().parents[1] + ipsae_py = skill_root / "vendor" / "ipsae" / "ipsae.py" + if not ipsae_py.exists(): + print(f"ERROR: ipSAE script not found at {ipsae_py}.\n" + f" Fetch it once: bash {skill_root}/scripts/fetch_ipsae.sh\n" + f" (see {skill_root}/vendor/ipsae/README.md for source + license)", + file=sys.stderr) + return 2 + + run_dir = args.run_dir + raw_dir = run_dir / "validation" / "raw" + cif_dir = run_dir / "validation" / "cif" + raws = sorted(raw_dir.glob("*.json")) + if not raws: + print(f"ERROR: no holo Boltz2 raw JSONs under {raw_dir}", file=sys.stderr) + return 2 + + hotspots = [] + hotspot_conditioned = False + if args.hotspots and args.hotspots.exists(): + hdata = json.loads(args.hotspots.read_text()) + hotspots = hdata.get("hotspot_residues", hdata if isinstance(hdata, list) else []) + hotspot_conditioned = len(hotspots) > 0 + + url = HOSTED_URL if args.endpoint == "hosted" else LOCAL_URL + env_files: list[Path] = [] + if args.env_file: + env_files.append(Path(args.env_file)) + if os.environ.get("COMPLEXA_SKILL_ENV"): + env_files.append(Path(os.environ["COMPLEXA_SKILL_ENV"])) + env_files.append(skill_root / ".env") + api_key = None if args.endpoint == "local" else load_api_key(env_files) + + apo_dir = args.apo_dir if args.apo_dir is not None else (run_dir / "validation" / "apo") + apo_dir.mkdir(parents=True, exist_ok=True) + + rows = [] + for raw_path in raws: + name = raw_path.name[:-5] # strip .json + rec: dict = {"name": name, "failure_reason": None} + try: + raw = json.loads(raw_path.read_text()) + holo_cif = raw["structures"][0]["structure"] + atoms = parse_cif_atoms(holo_cif) + pae = np.array(raw["pae"][0]) + pair_iptm = raw.get("pair_chains_iptm_scores", [None])[0] + + rec["iptm"] = float(raw["iptm_scores"][0]) + rec["complex_plddt"] = float(raw["complex_plddt_scores"][0]) + rec["binder_plddt"] = chain_mean_ca_plddt(atoms, args.binder_chain) + rec["binder_len"] = len(chain_ca(atoms, args.binder_chain)) + binder_seq = chain_sequence(atoms, args.binder_chain) + rec["binder_seq"] = binder_seq + + with tempfile.TemporaryDirectory() as td: + ips = run_ipsae(ipsae_py, holo_cif, pae, pair_iptm, Path(td), + args.pae_cutoff, args.dist_cutoff) + rec.update({"ipsae_min": ips["ipsae_min"], "ipsae_max": ips["ipsae_max"], + "ipsae_asym": ips["ipsae_asym"]}) + + if hotspot_conditioned: + hc = hotspot_contacts(atoms, args.target_chain, args.binder_chain, + hotspots, args.contact_cutoff) + rec["hotspot_contact_frac"] = hc["contact_frac"] + rec["hotspot_detail"] = hc + else: + rec["hotspot_contact_frac"] = None + + # ---- apo prediction + RMSD ---- + # Prefer a pre-computed apo cif (from slurm/run_boltz2_apo_batch.slurm); + # fall back to a live call only when no precomputed apo exists. + rec["apo_binder_plddt"] = None + rec["binder_rmsd"] = None + apo_cif_path = apo_dir / f"{name}.apo.cif" + apo_raw_path = apo_dir / "raw" / f"{name}.json" + apo_cif = None + apo_plddt = None + if apo_cif_path.exists(): + apo_cif = apo_cif_path.read_text() + if apo_raw_path.exists(): + apo_raw = json.loads(apo_raw_path.read_text()) + cps = apo_raw.get("complex_plddt_scores") or apo_raw.get("confidence_scores") + apo_plddt = float(cps[0]) if cps else None + rec["apo_status"] = "precomputed" + elif args.no_apo: + rec["apo_status"] = "skipped (--no-apo, no precomputed apo)" + else: + try: + apo = boltz2_predict_apo(binder_seq, url, api_key) + apo_cif = apo["structures"][0]["structure"] + apo_cif_path.write_text(apo_cif) + cps = apo.get("complex_plddt_scores") or apo.get("confidence_scores") + apo_plddt = float(cps[0]) if cps else None + rec["apo_status"] = f"live ({args.endpoint})" + except Exception as e: # noqa: BLE001 + rec["apo_status"] = f"apo prediction failed: {e}" + + if apo_cif is not None: + apo_atoms = parse_cif_atoms(apo_cif) + apo_chain = sorted({a["chain"] for a in apo_atoms})[0] + rec["apo_binder_plddt"] = apo_plddt if apo_plddt is not None \ + else chain_mean_ca_plddt(apo_atoms, apo_chain) + holo_ca = np.array([a["xyz"] for a in chain_ca(atoms, args.binder_chain)]) + apo_ca = np.array([a["xyz"] for a in chain_ca(apo_atoms, apo_chain)]) + n = min(len(holo_ca), len(apo_ca)) + rec["binder_rmsd"] = kabsch_rmsd(holo_ca[:n], apo_ca[:n]) + + passed, reason = evaluate_gate(rec, hotspot_conditioned) + rec["pass"] = passed + rec["failure_reason"] = reason + except Exception as e: # noqa: BLE001 + rec["pass"] = False + rec["failure_reason"] = f"scoring error: {e}" + rows.append(rec) + + # ---- rank: passers first, then by ipsae_min desc (None last) ---- + def sort_key(r): + return (not r.get("pass", False), + -(r.get("ipsae_min") or -1.0), + -(r.get("iptm") or -1.0)) + rows.sort(key=sort_key) + for i, r in enumerate(rows, 1): + r["rank"] = i + + val_dir = run_dir / "validation" + (val_dir / "validation_scores.json").write_text(json.dumps(rows, indent=2)) + (run_dir / "ranked_binders.json").write_text(json.dumps(rows, indent=2)) + + csv_cols = ["rank", "name", "pass", "ipsae_min", "ipsae_max", "iptm", + "binder_plddt", "complex_plddt", "apo_binder_plddt", "binder_rmsd", + "hotspot_contact_frac", "binder_len", "apo_status", "failure_reason"] + for path in (val_dir / "validation_scores.csv", run_dir / "ranked_binders.csv"): + with open(path, "w", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=csv_cols, extrasaction="ignore") + w.writeheader() + for r in rows: + w.writerow({k: r.get(k) for k in csv_cols}) + + n_pass = sum(1 for r in rows if r.get("pass")) + print(f"Scored {len(rows)} designs; {n_pass} PASS the gate " + f"({'hotspot-conditioned' if hotspot_conditioned else 'unconditioned'}).") + print(f"Wrote: {val_dir/'validation_scores.json'}, {run_dir/'ranked_binders.json'} (+ .csv)") + for r in rows: + flag = "PASS" if r.get("pass") else "fail" + print(f" [{flag}] {r['name'][:48]:48} ipSAEmin={r.get('ipsae_min')} " + f"iptm={r.get('iptm')} bplddt={_f(r.get('binder_plddt'))} " + f"rmsd={_f(r.get('binder_rmsd'))} hs={r.get('hotspot_contact_frac')}") + return 0 + + +def _f(x): + return f"{x:.3f}" if isinstance(x, float) else x + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/ipsae/README.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/ipsae/README.md new file mode 100644 index 0000000..29602de --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/ipsae/README.md @@ -0,0 +1,22 @@ +# vendor/ipsae + +`scripts/validate_binders.py` computes **ipSAE** (interaction prediction Score from +Aligned Errors) using the canonical script from the Dunbrack lab. That script is +**third-party and not redistributed here** — fetch it once: + +```bash +bash ../../scripts/fetch_ipsae.sh # writes ipsae.py into this directory +``` + +This downloads `ipsae.py` to `vendor/ipsae/ipsae.py`, where the validator expects it. + +## Attribution + +- **Source:** (`ipsae.py`) +- **Author:** Roland L. Dunbrack Jr., Fox Chase Cancer Center +- **License:** MIT (per the script header: may be modified and redistributed for + non-commercial and commercial use, as long as the attribution is reproduced) +- **Reference:** Dunbrack, "Rēs ipSAE loquunt: What's wrong with AlphaFold's ipTM + score and how to fix it," bioRxiv 2025.02.10.637595. + +ipSAE runs in Boltz mode here: `ipsae.py `. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/ipsae/VENDOR.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/ipsae/VENDOR.md new file mode 100644 index 0000000..e39e94d --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/ipsae/VENDOR.md @@ -0,0 +1,36 @@ +# Vendored: ipsae.py (Dunbrack lab) + +`ipsae.py` is vendored **verbatim, unmodified** so the pipeline's interface score +(ipSAE) comes from the canonical reference implementation rather than a re-derivation. + +| | | +|---|---| +| **Source** | https://github.com/DunbrackLab/IPSAE | +| **File** | `https://raw.githubusercontent.com/DunbrackLab/IPSAE/main/ipsae.py` | +| **Version** | v4 (header dated "January 3, 2026: Fixed Boltz2 issues") | +| **Retrieved** | 2026-06-10 | +| **License** | MIT (per the script header: free to modify/redistribute for non-commercial and commercial use, provided the header information is reproduced) | +| **Paper** | Dunbrack, "Rēs ipSAE loquunt: What's wrong with AlphaFold's ipTM score and how to fix it", bioRxiv 2025.02.10.637595 | + +## Why this version + +v4 explicitly supports **Boltz / Boltz2** outputs in both PDB and mmCIF form and fixed +chain-ID handling for Boltz2 (the header notes the 2026-01-03 Boltz2 fix). The Boltz +invocation is: + +``` +python ipsae.py +``` + +It reads the PAE matrix from the `.npz` key `pae`; the sibling `plddt_*.npz` and +`confidence_*.json` files are **optional** (they only affect the pDockQ and `ipTM_af` +report columns, not the ipSAE value). ipSAE depends only on `pae_cutoff` (paper +default **10**); `dist_cutoff` affects only the interface-residue count columns. + +## How the pipeline calls it + +`scripts/validate_binders.py` converts each Boltz2 NIM response into the file trio +ipsae.py expects (`.cif`, `pae_.npz`, optional `confidence_.json`), +runs ipsae.py as a subprocess, and parses the `*__.txt` output. For a +binder↔target complex it takes **`ipsae_min` = min(asym A→B, asym B→A)** of the +`ipSAE` (d0res) column. Do not edit `ipsae.py` here; re-vendor from upstream to update. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/LICENSE b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/VENDOR.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/VENDOR.md new file mode 100644 index 0000000..f033e95 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/VENDOR.md @@ -0,0 +1,50 @@ +# Vendored: google-deepmind/science-skills (Stage-1 target + hotspot tooling) + +Two DeepMind skills are vendored to make pipeline **Stage 1** executable in this +repo: resolve a target structure from AFDB and read UniProt features for hotspots. + +| | | +|---|---| +| **Source** | https://github.com/google-deepmind/science-skills | +| **Retrieved** | 2026-06-11 (raw from `main`) | +| **License** | Apache-2.0 (see `LICENSE` in this directory) | +| **Skills** | `alphafold_database_fetch_and_analyze/`, `uniprot_database/` | + +## What each provides + +- `alphafold_database_fetch_and_analyze/scripts/fetch_structure.py` — UniProt ID + → AFDB model (mmCIF, pLDDT in B-factor) + PAE JSON + metadata, via the AFDB + prediction API (`/api/prediction/`, which resolves the current model + version — the legacy `…model_v4.cif` path is stale; models are now v6). +- `alphafold_database_fetch_and_analyze/scripts/analyze_plddt.py`, + `analyze_pae.py` — confidence / domain-boundary analysis. **Unmodified**; + pure stdlib (`dependencies = []`), run with `python3` directly. +- `uniprot_database/scripts/uniprot_tools.py` — `get`/`search`/`map`/`count`/ + `sparql`/`stream` over UniProtKB; `get ` returns the full entry incl. + `features` (Active site / Binding site / Site / Mutagenesis / …) used to seed + hotspots. + +## Modifications (Apache-2.0 §4: changes are stated in-file) + +The upstream scripts depend on an internal `scienceskillscommon.http_client` +package installed via `uv` inline-script metadata. To run standalone here with +**no `uv` and no extra packages**, in `fetch_structure.py` and `uniprot_tools.py`: + +- the `# /// script … ///` `uv` block and the + `from science_skills…scienceskillscommon import http_client` import were removed; +- a small **stdlib-`urllib` shim** was added (same `HttpClient.fetch` / + `fetch_json` / `fetch_bytes` / `stream_lines`, `HttpResponse`, `HttpError` + interface), aliased as `http_client` so the rest of each file is unchanged. + +All AFDB / UniProt query logic is otherwise upstream-verbatim. Each modified file +carries a `# MODIFIED for bionemo-nim-skills` note. `analyze_*.py` and the +`SKILL.md` files are verbatim. Re-vendor from upstream to update. + +## Verified (2026-06-11) + +- `fetch_structure.py P04637 -o …` and `P00533 -o …` → downloaded v6 cif + PAE. +- `uniprot_tools.py get P00533` → 321 features; hotspot candidates Asp837 + (Active site) + ATP-pocket Binding sites. +- AFDB numbering == UniProt numbering (AFDB res 837 = ASP837), so UniProt + feature positions map onto the AFDB model directly; verify identity in the cif. + Experimental PDBs (RCSB) still need SIFTS/alignment remapping. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/SKILL.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/SKILL.md new file mode 100644 index 0000000..30d33af --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/SKILL.md @@ -0,0 +1,115 @@ +--- +name: alphafold-database-fetch-and-analyze +description: > + Retrieve and analyze AlphaFold predicted structures for a protein. Use when + the user provides a specific UniProt Accession ID and wants structural + confidence metrics (pLDDT), domain boundary analysis, or disorder + assessment. Do not use if the user only has a protein name, gene name, + or amino acid sequence — ask for a UniProt ID first. +--- + +# AlphaFold Database: Fetch and Analyze + +## Prerequisites + +1. **`uv`**: Read the `uv` skill and follow its Setup instructions to ensure + `uv` is installed and on PATH. +2. **User Notification**: If LICENSE_NOTIFICATION.txt does not already exist in + this skill directory then (1) prominently notify the user to check the terms + at https://alphafold.ebi.ac.uk/, then (2) create the file recording the + notification text and timestamp. + +## Overview + +Downloads AlphaFold predicted structures (mmCIF) and Predicted Aligned Error +(PAE) matrices from the AlphaFold Database for a given UniProt ID, then performs +automated heuristic analysis on structural confidence (pLDDT), intrinsically +disordered regions, rigid domain boundaries, and inter-domain flexibility. + +**Do NOT use when:** + +- The user only has a protein name, gene name, or amino acid sequence (no + UniProt ID) — ask them to look up the ID on + [UniProt](https://www.uniprot.org). +- The user wants to search for structural homologs (use **Foldseek**). +- The user wants to run AlphaFold predictions on a custom sequence. +- The user needs experimental PDB structures (use **RCSB PDB**). + +## Core Rules + +- **Use the Wrapper**: ALWAYS execute the provided helper scripts to query the + database rather than accessing the database directly. The scripts + automatically enforce the required rate limit gracefully. +- Do not attempt to calculate domain boundaries or assess structural disorder + yourself; always rely on the output provided by the script. +- If this skill is used, ensure this is mentioned in the output. + +## Utility Scripts + +**1. Fetch Structure Files** + +Downloads the `.cif` structure file, `_predicted_aligned_error.json`, and API +metadata JSON (`-metadata.json`) for a UniProt ID. Handles fragment fallback for +very large proteins. + +Examples: + +```bash +uv run scripts/fetch_structure.py P00520 -o /path/to/output/ +uv run scripts/fetch_structure.py P04637 -o /path/to/custom_results/ +``` + +Always specify `-o` with an absolute path or a path relative to the user's +project root, never a path relative to the skill directory. + +**2. Analyze pLDDT Confidence** + +Reads pLDDT confidence metrics from a saved AFDB metadata JSON file (produced by +`fetch_structure.py`) and prints a heuristic confidence assessment (structured, +disordered, mixed). + +Example: + +```bash +uv run scripts/analyze_plddt.py ./data/AF-P00520-F1-metadata.json +``` + +**3. Analyze PAE / Domain Boundaries** + +Reads a downloaded PAE JSON file and detects rigid domain boundaries using a +sliding-window PAE heuristic. + +Example: + +```bash +uv run scripts/analyze_pae.py ./data/AF-P00520-F1-predicted_aligned_error_v6.json +``` + +## Interpreting the Output + +The script prints analysis to stdout. Read it carefully and synthesize the +results for the user: + +1. **Isoform / Large Protein Warning (MANDATORY):** Check the script output for + any `[!] WARNING` lines. If the script reports that no canonical entry was + found and an isoform was used, or if the protein is very large (>2700 AAs), + you **MUST** prominently relay this warning to the user. Do not omit this + warning. +2. **Synthesize the Structural Analysis**: Combine the "pLDDT Conclusion" and + the "PAE Structural Conclusion" into a single, cohesive overall summary. + Describe the protein's overall folding confidence, the presence of + disordered regions, and its rigid domain layout. +3. Highlight the supporting metrics: + - Overall Global pLDDT and the breakdown of fraction confidence + (especially Very Low vs. Very High). + - Domain Boundary Analysis (number of distinct global domains and their + specific residue ranges). +4. **Explicit Disorder Warning:** If the analysis concludes that the protein is + highly intrinsically disordered (e.g., high fraction of <50 pLDDT or lack of + rigid domains), issue a separate, prominent warning. Advise the user against + proceeding with whole-protein downstream structural analysis (like Foldseek + or docking). If small ordered domains exist amidst the disorder, advise the + user to restrict any future analysis strictly to those specific residue + boundaries. +5. Remind the user that per-residue pLDDT is embedded in the B-factor column of + the downloaded mmCIF file. diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/analyze_pae.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/analyze_pae.py new file mode 100644 index 0000000..6c51afe --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/analyze_pae.py @@ -0,0 +1,212 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Analyzes Predicted Aligned Error (PAE) and detects domain boundaries.""" + +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// + +import argparse +import itertools +import json +import os + + +def find_sub_domains(pae_matrix, distance_cutoff=7.0, min_domain_size=40): + """Identifies structurally independent sub-domains based on the PAE matrix.""" + n_res = len(pae_matrix) + domains = [] + current_domain = [] + + for i in range(n_res): + if not current_domain: + current_domain.append(i) + continue + + window_size = min(20, len(current_domain)) + recent_res = current_domain[-window_size:] + + pae_sum = sum(pae_matrix[r][i] + pae_matrix[i][r] for r in recent_res) + avg_pae = pae_sum / (2.0 * window_size) + + if avg_pae < distance_cutoff: + current_domain.append(i) + else: + if len(current_domain) >= min_domain_size: + domains.append(current_domain) + current_domain = [i] + + if len(current_domain) >= min_domain_size: + domains.append(current_domain) + + domain_boundaries = [] + for comp in domains: + start = comp[0] + 1 + end = comp[-1] + 1 + domain_boundaries.append([start, end]) + + return domain_boundaries + + +def merge_global_domains(boundaries, pae_matrix, merge_cutoff=15.0): + """Merges sub-domains if the average PAE between them is below cutoff.""" + if not boundaries: + return [] + + if len(boundaries) == 1: + merged = boundaries + else: + merged = [boundaries[0]] + + for i in range(1, len(boundaries)): + prev_end = merged[-1][1] - 1 + curr_start = boundaries[i][0] - 1 + + lookback = max(merged[-1][0] - 1, prev_end - 30) + lookfwd = min(boundaries[i][1] - 1, curr_start + 30) + + pae_sum = 0 + n_pairs = 0 + for r1 in range(lookback, prev_end + 1): + for r2 in range(curr_start, lookfwd + 1): + pae_sum += pae_matrix[r1][r2] + pae_matrix[r2][r1] + n_pairs += 2 + + if n_pairs > 0 and (pae_sum / n_pairs) < merge_cutoff: + merged[-1][1] = boundaries[i][1] + else: + merged.append(boundaries[i]) + + filtered_merged = [dom for dom in merged if (dom[1] - dom[0] + 1) > 50] + + return filtered_merged + + +def analyze_pae(pae_file): + """Parses a PAE JSON file and calculates structural domain metrics.""" + print( + "\n[*] Analyzing Predicted Aligned Error (PAE) from" + f" {os.path.basename(pae_file)}..." + ) + try: + with open(pae_file, "r") as f: + data = json.load(f)[0] + + if "predicted_aligned_error" in data: + pae = data["predicted_aligned_error"] + elif "distance" in data: + pae = data["distance"] + else: + print( + " [!] Could not locate PAE matrix in JSON keys:" + f" {list(data.keys())}" + ) + return + + flat_pae = list(itertools.chain.from_iterable(pae)) + if not flat_pae: + print(" [!] PAE matrix is empty.") + return + + mean_pae = sum(flat_pae) / len(flat_pae) + max_pae = max(flat_pae) + min_pae = min(flat_pae) + confident_pairs = sum(1 for p in flat_pae if p < 5.0) / len(flat_pae) * 100 + + print(f" -> PAE Matrix Shape: {len(pae)}x{len(pae[0])}") + print(f" -> Mean Error: {mean_pae:.2f} Å") + print( + f" -> Max Error: {max_pae:.2f} Å (suggests max possible distance" + " between domains)" + ) + print(f" -> Min Error: {min_pae:.2f} Å") + print( + " -> Fraction of confident residue pairs (<5Å PAE):" + f" {confident_pairs:.1f}%" + ) + + sub_domains = find_sub_domains(pae, distance_cutoff=7.0, min_domain_size=40) + global_domains = merge_global_domains(sub_domains, pae, merge_cutoff=15.0) + + print("\n[*] Domain Boundary Analysis:") + if not global_domains: + print(" -> No distinct rigidly-folded domains detected (>50 AAs).") + else: + print( + " -> Number of distinct Global Domains detected:" + f" {len(global_domains)}" + ) + for i, (start, end) in enumerate(global_domains, 1): + print( + f" Domain {i}: residues {start} - {end} (Length:" + f" {end - start + 1} AAs)" + ) + + print("\n[*] PAE Structural Conclusion:") + if len(global_domains) == 1: + conclusion = ( + "The protein consists of a single well-folded, rigid composite" + " domain." + ) + elif len(global_domains) > 1: + conclusion = ( + f"The protein has {len(global_domains)} independently positioned" + " global domains separated by truly flexible joints." + ) + else: + conclusion = ( + "The protein is likely entirely disordered or lacks rigid" + " tertiary structure." + ) + print(f" -> {conclusion}") + + return { + "pae_file": os.path.basename(pae_file), + "matrix_shape": f"{len(pae)}x{len(pae[0])}", + "mean_pae": round(mean_pae, 2), + "max_pae": round(max_pae, 2), + "min_pae": round(min_pae, 2), + "confident_pairs_pct": round(confident_pairs, 1), + "domains": [ + {"start": s, "end": e, "length": e - s + 1} + for s, e in global_domains + ], + "conclusion": conclusion, + } + + except (IOError, json.JSONDecodeError) as e: + print(f" [!] Failed to analyze PAE file: {e}") + return None + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description=( + "Analyze PAE matrix and detect domain boundaries from an AlphaFold" + " PAE JSON file" + ) + ) + parser.add_argument( + "pae_file", + help=( + "Path to the PAE JSON file (e.g.," + " AF-P04637-F1-predicted_aligned_error_v6.json)" + ), + ) + + args = parser.parse_args() + + analyze_pae(args.pae_file) diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/analyze_plddt.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/analyze_plddt.py new file mode 100644 index 0000000..d3baaab --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/analyze_plddt.py @@ -0,0 +1,125 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Analyzes pLDDT confidence metrics from a saved AFDB metadata JSON file.""" + +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// + +import argparse +import json +import sys + +CONFIDENT_THRESHOLD = 0.7 +MODERATE_THRESHOLD = 0.4 +NOTABLE_DISORDER_THRESHOLD = 0.15 +MIXED_DISORDER_THRESHOLD = 0.3 +MOSTLY_DISORDERED_THRESHOLD = 0.5 + + +def analyze_plddt(metadata_file): + """Loads an AFDB metadata JSON and analyzes pLDDT metrics.""" + try: + with open(metadata_file, "r") as f: + entry = json.load(f) + except (IOError, json.JSONDecodeError) as e: + print(f"[!] Error reading metadata file: {e}") + sys.exit(1) + + return _analyze_entry(entry) + + +def _analyze_entry(entry): + """Parses and analyzes pLDDT confidence metrics from the API payload.""" + accession = entry.get("uniprotAccession", "Unknown") + print(f"\n[*] AlphaFold pLDDT Metrics for Accession: {accession}") + global_plddt = entry.get("globalMetricValue", 0.0) + frac_vlow = entry.get("fractionPlddtVeryLow", 0.0) + frac_low = entry.get("fractionPlddtLow", 0.0) + frac_conf = entry.get("fractionPlddtConfident", 0.0) + frac_vhigh = entry.get("fractionPlddtVeryHigh", 0.0) + + print("-" * 65) + print(f" -> Overall Global pLDDT : {global_plddt:.2f}") + print(f" -> Fraction Very Low (<50): {frac_vlow:.3f} ({frac_vlow*100:.1f}%)") + print(f" -> Fraction Low (50-70) : {frac_low:.3f} ({frac_low*100:.1f}%)") + print(f" -> Fraction Confident : {frac_conf:.3f} ({frac_conf*100:.1f}%)") + print( + f" -> Fraction Very High : {frac_vhigh:.3f} ({frac_vhigh*100:.1f}%)" + ) + print("-" * 65) + + conf_total = frac_conf + frac_vhigh + + print("[*] pLDDT Conclusion:") + if conf_total >= CONFIDENT_THRESHOLD: + if frac_vlow > NOTABLE_DISORDER_THRESHOLD: + conclusion = ( + "Protein is mostly confidently predicted, but contains notable" + " disordered regions." + ) + else: + conclusion = ( + "Protein is confidently predicted and likely fully" + " ordered/structured." + ) + elif conf_total >= MODERATE_THRESHOLD: + if frac_vlow >= MIXED_DISORDER_THRESHOLD: + conclusion = ( + "Protein has a mixture of confidently predicted structured" + " domains and significant intrinsically disordered regions." + ) + else: + conclusion = ( + "Protein has moderate prediction confidence. Certain regions" + " might be flexible or poorly predicted." + ) + else: + if frac_vlow >= MOSTLY_DISORDERED_THRESHOLD: + conclusion = ( + "Protein is mostly poorly predicted, likely being highly" + " intrinsically disordered." + ) + else: + conclusion = "Protein prediction is of low confidence overall." + print(f" -> {conclusion}") + print() + + return { + "uniprot_id": accession, + "global_plddt": global_plddt, + "fractions": { + "very_low": frac_vlow, + "low": frac_low, + "confident": frac_conf, + "very_high": frac_vhigh, + }, + "conclusion": conclusion, + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Analyze pLDDT confidence metrics from an AFDB metadata file" + ) + parser.add_argument( + "metadata_file", + help="Path to the metadata JSON file (e.g., AF-P04637-F1-metadata.json)", + ) + + args = parser.parse_args() + + analyze_plddt(args.metadata_file) diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/fetch_structure.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/fetch_structure.py new file mode 100644 index 0000000..502e0fd --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/alphafold_database_fetch_and_analyze/scripts/fetch_structure.py @@ -0,0 +1,200 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fetches AlphaFold structure files (mmCIF + PAE) for a UniProt ID.""" + +# MODIFIED for bionemo-nim-skills (2026-06-11): the original imported +# `scienceskillscommon.http_client` (installed via `uv` inline-script metadata). +# To make this runnable standalone in this repo with no `uv`/extra packages, the +# `uv` script block and that import were removed and replaced by the small +# stdlib-`urllib` shim below, which preserves the same fetch_json / fetch_bytes / +# HttpError(status_code) interface the rest of this file uses. AlphaFold fetch +# logic is unchanged. Original: google-deepmind/science-skills (Apache-2.0). + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +class HttpError(Exception): + """Minimal stand-in for scienceskillscommon.http_client.HttpError.""" + + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code + + +class _HttpClient: + """Stdlib shim providing fetch_json / fetch_bytes (drop-in for the original).""" + + def __init__(self, base_url, qps=1.0): + self.base_url = base_url + self._headers = {"User-Agent": "bionemo-nim-skills-afdb-fetch/1.0"} + + def fetch_bytes(self, url): + try: + req = urllib.request.Request(url, headers=self._headers) + with urllib.request.urlopen(req, timeout=120) as r: + return r.read() + except urllib.error.HTTPError as e: + raise HttpError(str(e), status_code=e.code) from e + except urllib.error.URLError as e: + raise HttpError(str(e)) from e + + def fetch_json(self, url): + return json.loads(self.fetch_bytes(url).decode()) + + +CLIENT = _HttpClient("https://alphafold.ebi.ac.uk", qps=1.0) + + +def fetch_structure(uniprot_id, output_dir): + """Downloads the mmCIF file and PAE JSON for a given UniProt ID from AFDB.""" + uniprot_id = uniprot_id.strip().upper() + api_url = f"https://alphafold.ebi.ac.uk/api/prediction/{uniprot_id}" + is_fragment = False + + os.makedirs(output_dir, exist_ok=True) + print(f"[*] Requesting AlphaFold data for UniProt ID: {uniprot_id}") + + try: + data = CLIENT.fetch_json(api_url) + except HttpError as e: + if e.status_code == 404: + print( + f"\n[!] Error: UniProt ID '{uniprot_id}' was not found in" + " the AlphaFold Database." + ) + print( + " Please double-check the ID for typos, or verify it" + " has an AFDB entry." + ) + else: + print(f"[!] HTTP error fetching API data: {e}") + sys.exit(1) + + if not data: + print(f"[!] No AlphaFold data returned for {uniprot_id}") + sys.exit(1) + + # The API may return multiple entries (e.g. isoforms) for a single + # UniProt ID. Prefer the canonical entry whose accession matches exactly. + entry = None + for e in data: + if e.get("uniprotAccession") == uniprot_id: + entry = e + break + # If no canonical entry exists (common for very large proteins like + # Dystrophin), fall back to the longest available isoform so the user + # gets the most complete structure. + if entry is None: + entry = max(data, key=lambda e: e.get("sequenceEnd", 0)) + entry_acc = entry.get("uniprotAccession", "unknown") + entry_len = entry.get("sequenceEnd", 0) + print( + "[!] WARNING: No canonical AFDB entry found for" + f" '{uniprot_id}'. Using longest available isoform" + f" '{entry_acc}' ({entry_len} amino acids) instead." + " The full-length protein may not be available in AFDB." + ) + + max_seq_len = max((e.get("sequenceEnd", 0) for e in data), default=0) + if max_seq_len > 2700: + print( + f"[!] WARNING: Protein {uniprot_id} is massive" + f" ({max_seq_len} amino acids). Only the first entry has" + " been downloaded. The full protein may span many more" + " fragments in AFDB." + ) + is_fragment = True + + entry_acc = entry.get("uniprotAccession", uniprot_id) + metadata_filename = f"AF-{entry_acc}-F1-metadata.json" + metadata_path = os.path.join(output_dir, metadata_filename) + with open(metadata_path, "w") as f: + json.dump(entry, f, indent=2) + print(f" -> Saved API metadata to: {metadata_path}") + + cif_url = entry.get("cifUrl") + pae_url = entry.get("paeDocUrl") + + urls_to_fetch = [] + if cif_url: + urls_to_fetch.append(cif_url) + if pae_url: + urls_to_fetch.append(pae_url) + + success_count = 0 + + for url in urls_to_fetch: + filename = url.split("/")[-1] + file_path = os.path.join(output_dir, filename) + + print(f" -> Fetching {filename}...") + + try: + file_bytes = CLIENT.fetch_bytes(url) + with open(file_path, "wb") as f: + f.write(file_bytes) + + print(f" [+] Saved to: {file_path}") + success_count += 1 + + except HttpError as e: + if e.status_code == 404: + print(f" [!] Error 404: {filename} not found.") + else: + print(f" [!] Download error: {e}") + + if success_count == 0: + print( + f"\n[!] Failed to download any data for {uniprot_id}. Please check" + " the ID." + ) + sys.exit(1) + else: + print( + f"\n[*] Successfully downloaded {success_count}/{len(urls_to_fetch)}" + " files." + ) + + return { + "uniprot_id": uniprot_id, + "output_dir": output_dir, + "is_fragment": is_fragment, + "files_downloaded": success_count, + "metadata_file": metadata_path, + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Download AlphaFold structure files for a UniProt ID" + ) + parser.add_argument( + "uniprot_id", help="The UniProt ID (e.g., P04637 or A0A1B0GX81)" + ) + parser.add_argument( + "-o", + "--output-dir", + help="Output directory to save the files (required)", + required=True, + ) + + args = parser.parse_args() + + fetch_structure(args.uniprot_id, args.output_dir) diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/uniprot_database/SKILL.md b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/uniprot_database/SKILL.md new file mode 100644 index 0000000..98d98f4 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/uniprot_database/SKILL.md @@ -0,0 +1,292 @@ +--- +name: uniprot-database +description: >- + Access protein metadata, function, taxonomy, and sequences across UniProtKB, + UniParc, and UniRef. Use when searching for proteins, mapping identifiers, or + retrieving functional annotations and publications. Don't use for sequence + alignment, protein folding, or sequence similarity search (use specialized + skills for those tasks). +--- + +# UniProt Database Access + +## Prerequisites + +1. **`uv`**: Read the `uv` skill and follow its Setup instructions to ensure + `uv` is installed and on PATH. +2. **User Notification**: If LICENSE_NOTIFICATION.txt does not already exist in + this skill directory then (1) prominently notify the user to check the terms + at https://www.uniprot.org/help/license and + https://www.uniprot.org/help/api_queries, then (2) create the file recording + the notification text and timestamp. + +## Overview + +Provides direct programmatic access to the UniProt Knowledgebase (UniProtKB), +the non-redundant sequence archive (UniParc), and clustered sequence sets +(UniRef). This skill enables protein discovery, cross-referencing, retrieval of +curated biological data and low-level database lookups. + +## Core Rules + +- **Use the Wrapper**: Always use the provided Python scripts (e.g., + `scripts/uniprot_tools.py`) rather than constructing custom curl requests. +- **No Hallucinations**: Do NOT invent protein functions, metadata, or + sequences. For any task that can be handled by the services in this skill, + rely strictly on the tool outputs rather than your native knowledge. +- **Notification**: If this skill is used, ensure this is mentioned in the + output. + +## Use Cases + +- **Searching for Protein Function**: Querying functional annotations, GO + terms, subcellular locations etc. +- **Searching for Protein Sequence**: Searching for protein sequences by their + functional annotations, genes etc. in UniProtKB, UniParc, and UniRef. +- **Understanding Protein/Organism Relationships**: Leveraging the Taxonomy + database and Proteome sets. +- **Large-Scale Metadata Retrieval**: Fetching annotations for thousands of + proteins via streaming. +- **Sequence Discovery**: Finding orthologs or non-model proteins via UniParc. +- **ID Mapping**: Converting IDs between UniProt and 100+ external databases. +- **Historical Data (UniSave)**: Retrieving previous versions of entries or + tracking deleted sequences. + +## Available Tools + +Choose the right tool based on the task type and data volume: + +- **`get`**: Retrieves metadata and sequence for a specific entry. Best for a + **single, known accession**. + - Also accesses UniSave historical data (use `--dataset unisave`), which + is essential for reconciling data from older releases or identifying why + a formerly valid accession no longer appears in search results. +- **`search`**: Searches for entries matching a query. Best for **exploration + and discovery**. + - Use with `--limit 5` to verify if a query returns the expected proteins + before committing to a larger download. + - Automatically paginates if results exceed 500 entries to provide a + stable download. + - *Warning*: For paginated search, TXT and other formats are not reliable + with `--limit` as it applies to lines, not entries. + - See + [Search Query Fields Documentation](references/search_query_fields.md). +- **`stream`**: Streams all matching entries. Best for **bulk retrieval** of + large datasets (up to 10,000,000 entries). + - Does NOT support `--limit`; always returns the full result set. + - Use `search` with `--limit` if you need a subset. +- **`count`**: Counts entries matching a query. Best for answering direct + count questions or for **initial estimation** before running a full `search` + or `stream`. +- **`sparql`**: Executes graph queries for complex discovery. Best for + counting, exact sequence matches, and multi-database queries. + - See [SPARQL Examples](references/sparql_examples.md). +- **`map`**: Converts IDs between UniProt and 100+ databases. Best for ID + mapping tasks. + - See [ID Mapping Documentation](references/id_mapping_documentation.md). + - **`search` vs. `map`**: Try `search` first before resorting to `map` if + not explicitly requested by the user. E.g., an external ID might be + searchable in UniParc but fail to map to UniProtKB. + +## Workflows + +### Typical Protein Research Workflow + +Copy this checklist and track progress: + +- [ ] Step 1: Identify target protein(s) and organism(s). +- [ ] Step 2: Search UniProtKB for reviewed entries (`reviewed:true`). +- [ ] Step 3: If no reviewed entries, search unreviewed or use UniParc for + sequence discovery. +- [ ] Step 4: Map external IDs (e.g., Ensembl, PDB) to UniProt Accessions if + necessary. +- [ ] Step 5: Retrieve functional metadata or sequence in desired format + (JSON, FASTA). + +### Handling Search Misses (e.g. Gene Search in Non-Model Organisms) + +If a direct query (e.g., `gene:SYMBOL`) fails: + +1. **Pivot to Protein Name**: Search for the common protein name (e.g., + `protein_name:Alpha-crystallin A`). +2. **Use UniParc**: Search the UniParc dataset, which integrates sequences from + across all of life, even if they aren't fully annotated in UniProtKB. +3. **Check Orthologs/Canonical**: Resolve the Human/Mouse ortholog first to + find the correct naming/mnemonic. + +### Bulk Retrieval Priorities + +> [!IMPORTANT] Always prefer **`stream`** or **`sparql`** for bulk data. +> `search` is suitable for exploration; if results exceed 500 entries, it +> automatically paginates to provide a stable download. + +- **Priority 0: `count`**: ALWAYS check the result count before running a + `search` or `stream`. +- **Priority 1: `stream`**: The primary method for bulk data retrieval (up to + 10M entries). Does NOT support `--limit`; always returns all results. +- **Priority 2: `sparql`**: Best for complex filtering and exact matching + during retrieval. + +### Sequence-Based Search (Exact Match) + +> [!IMPORTANT] Use **SPARQL** when searching for a protein by its full amino +> acid sequence. The REST API `/search` endpoint does not support direct +> sequence-string lookups. For any non-exact match use specialized sequence +> similarity search skills. Use UniParc if you cannot find query in UniProt. + +**SPARQL Query Pattern (UniProt):** + +```text +PREFIX up: +PREFIX rdf: +SELECT ?protein ?name WHERE { + ?protein a up:Protein ; + up:sequence/rdf:value "SEQUENCE_HERE" . + OPTIONAL { + ?protein up:recommendedName/up:fullName ?name . + } +} +``` + +**SPARQL Query Pattern (UniParc):** + +```text +PREFIX up: +PREFIX rdf: + +SELECT ?uniparc ?val WHERE { + GRAPH { + ?uniparc a up:Sequence ; + rdf:value ?val . + FILTER (?val = "SEQUENCE_HERE") + } +} +``` + +### Counting Entries Efficiently + +> [!IMPORTANT] Use **`count`** or **`SPARQL`** for counting entries (e.g., "How +> many proteins in Human?"). + +**Counting Pattern (Proteins per Organism):** + +```text +PREFIX up: +PREFIX taxon: +SELECT (COUNT(?protein) AS ?count) WHERE { + ?protein a up:Protein ; + up:reviewed true ; + up:organism taxon:9606 . +} +``` + +### REST Search Syntax + +- **No Commas in Lists**: Commas are treated as literals. Use capitalized `OR` + to separate items. + * Grouped: `accession:(P12345 OR P67890)` + * Repeated: `accession:P12345 OR accession:P67890` +- **Space = AND**: E.g., `gene:p53 human` searches for both. + +## Example Commands + +Below are example commands for each mode of `uniprot_tools.py`. + +Count total number of entries for a given query. + +```bash +uv run scripts/uniprot_tools.py count "taxonomy_id:9606" +``` + +Search for entries. + +```bash +uv run scripts/uniprot_tools.py search "gene:p53 AND reviewed:true" --limit 5 +``` + +Retrieve a single entry by accession. + +```bash +uv run scripts/uniprot_tools.py get P04637 +``` + +Retrieve Historical/Deleted Entry (UniSave). + +```bash +uv run scripts/uniprot_tools.py get P04637 --dataset unisave +``` + +Stream large result sets for bulk retrieval (returns ALL matched entries, no +`--limit` support). + +```bash +uv run scripts/uniprot_tools.py stream "taxonomy_id:9606 AND reviewed:true" --format tsv --fields accession,gene_names > human_reviewed.tsv +``` + +Map IDs from one database to another. + +```bash +uv run scripts/uniprot_tools.py map "P04637" --from_db UniProtKB_AC-ID --to_db Gene_Name +``` + +Execute graph queries with SPARQL. + +```bash +uv run scripts/uniprot_tools.py sparql 'PREFIX up: SELECT ?protein WHERE { ?protein a up:Protein ; up:reviewed true . } LIMIT 5' +``` + +## Common Mistakes + +- **Using `name:` instead of `protein_name:`**: `name:` is not a supported + query term, use `protein_name:` instead. +- **Ignoring UniParc**: Non-model organisms might only exist in UniParc. +- **Confusing Accession with UPI**: UniProtKB Accessions (e.g., `P04637`) are + linked to functional metadata; UniParc IDs (`UPI...`) are for sequences + only. You can find cross-references from UniParc IDs to UniProtKB Accessions + using the ID Mapping tool. +- **Using UniProtKB-AC as Target in ID Mapping**: Use `UniProtKB` instead. +- **Giving up on Complex Queries**: If a complex search query fails, try to + use SPARQL instead of giving up. +- **Using IDs Without Verifying Meaning**: NEVER assume you know the meaning + of an ID (e.g. keyword, GO term, Pfam ID etc.). ALWAYS look up the natural + language description/meaning of an ID in UniProt before using it for search + to ensure it matches your intended search term. +- **Ignoring Citation Noise in Broad Searches**: Broad text searches (`search + "term"`) frequently return false positives (e.g., common maintenance + proteins) because UniProt searches full metadata, including publication + titles. ALWAYS prefer field-specific filters like `cc_function:` or + `protein_name:` for functional discovery. +- **Forgetting to Quote Short Search Terms**: Short, unquoted terms (e.g., + `lanM`) can match substrings in organism names (e.g., *Lan*cefieldella) or + other fields. Use quotes and field prefixes (e.g., `gene:lanM`) to isolate + true hits. +- **Manipulating Protein Sequences Directly**: Always use code and tools for + sequence-based operations. Do not attempt to edit, truncate, or modify + protein sequences manually. +- **Over-using Search for Bulk Data**: DO NOT use `search` for retrieving + millions of entries if `stream` or `sparql` can do the job. Streaming is + more efficient for very large datasets. Note that `stream` has a hard limit + of 10,000,000 outputs and does NOT support `--limit`. +- **Forgetting to Check Data Volume**: ALWAYS perform a `count` before running + a `search` without `--limit` or before using `stream`. Unlimited queries can + take a long time and consume significant resources if millions of entries + are returned. +- **Using `--limit` with `stream`**: The `stream` command does NOT support + `--limit`. If you need a limited number of results, use `search` with + `--limit` instead. +- **Forgetting the License Notice**: Do not neglect to state that the UniProt + Database was used and to advise the user to review the licensing terms when + presenting results for the **first time**. Even if the task is concise, this + attribution is required in the first response containing UniProt data. + +## Reference Materials + +- [SPARQL Examples](references/sparql_examples.md) +- [Search Query Fields Documentation](references/search_query_fields.md) +- [ID Mapping Documentation](references/id_mapping_documentation.md) +- [UniProt Evidence Docs](https://www.uniprot.org/help/evidences) +- **Underlying API Endpoints** (Used by `scripts/uniprot_tools.py`): + - `get`, `search`, `stream`, `count` -> `rest.uniprot.org/{dataset}/` + - `map` -> `rest.uniprot.org/idmapping/` + - `sparql` -> `sparql.uniprot.org/sparql` + - `get --dataset unisave` -> `rest.uniprot.org/unisave/` diff --git a/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/uniprot_database/scripts/uniprot_tools.py b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/uniprot_database/scripts/uniprot_tools.py new file mode 100644 index 0000000..66f06a8 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/complexa-binder-design/vendor/science-skills/uniprot_database/scripts/uniprot_tools.py @@ -0,0 +1,509 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Uniprot tools for accessing UniProtKB, UniParc, and UniRef.""" + +# MODIFIED for bionemo-nim-skills (2026-06-11): replaced the +# `scienceskillscommon.http_client` import (installed via `uv` inline-script +# metadata) with the small stdlib-`urllib` shim below, aliased as `http_client` +# so every `http_client.X` reference in this file works unchanged. UniProt query +# logic is untouched. Original: google-deepmind/science-skills (Apache-2.0). + +from __future__ import annotations + +import argparse +import gzip +import json +import re +import sys +import time +import types +from typing import Any, Iterator +import urllib.error +import urllib.parse +import urllib.request + + +class _HttpError(Exception): + """Stand-in for scienceskillscommon.http_client.HttpError.""" + + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code + + +class _HttpResponse: + """Minimal response: .headers (case-insensitive), .data (bytes), .encoding.""" + + def __init__(self, headers, data, encoding="utf-8"): + self.headers = headers + self.data = data + self.encoding = encoding + + +class _HttpClient: + """Stdlib shim for the subset used here: fetch / fetch_json / stream_lines.""" + + def __init__(self, base_url, qps=1.0): + self.base_url = base_url + self._ua = {"User-Agent": "bionemo-nim-skills-uniprot/1.0"} + + def _request(self, url, headers=None, method="GET", data=None): + hdrs = dict(self._ua) + if headers: + hdrs.update(headers) + body = data.encode() if isinstance(data, str) else data + return urllib.request.Request(url, headers=hdrs, method=method, data=body) + + def fetch(self, url, headers=None, method="GET", data=None): + try: + with urllib.request.urlopen(self._request(url, headers, method, data), timeout=120) as r: + charset = r.headers.get_content_charset() or "utf-8" + return _HttpResponse(r.headers, r.read(), charset) + except urllib.error.HTTPError as e: + raise _HttpError(str(e), status_code=e.code) from e + except urllib.error.URLError as e: + raise _HttpError(str(e)) from e + + def fetch_json(self, url, headers=None, method="GET", data=None): + resp = self.fetch(url, headers=headers, method=method, data=data) + raw = resp.data + if raw[:2] == b"\x1f\x8b": + raw = gzip.decompress(raw) + return json.loads(raw.decode(resp.encoding)) + + def stream_lines(self, url, headers=None): + resp = self.fetch(url, headers=headers) + raw = resp.data + if raw[:2] == b"\x1f\x8b": + raw = gzip.decompress(raw) + for line in raw.decode(resp.encoding).splitlines(): + yield line + + +http_client = types.SimpleNamespace( + HttpClient=_HttpClient, HttpResponse=_HttpResponse, HttpError=_HttpError +) + + +class UniProtError(Exception): + """Custom exception for UniProt tool errors.""" + + +BASE_URL = "https://rest.uniprot.org" +SPARQL_URL = "https://sparql.uniprot.org/sparql" +CLIENT = http_client.HttpClient(BASE_URL, qps=1.0) +SPARQL_CLIENT = http_client.HttpClient(SPARQL_URL, qps=1.0) + + +def _add_params_to_url(url: str, params: dict[str, Any] | None = None) -> str: + """Adds URL parameters to a URL.""" + if params: + sep = "&" if "?" in url else "?" + url += f"{sep}{urllib.parse.urlencode(params, doseq=True)}" + return url + + +def _get_header(resp: http_client.HttpResponse, header_name: str) -> str: + """Returns the value of a given header, checking both lower and upper case.""" + return resp.headers.get(header_name) or resp.headers.get(header_name.lower()) + + +def _get_decompressed_data(resp: http_client.HttpResponse) -> str: + """Decompresses gzipped data from a response if necessary and decodes it.""" + data = resp.data + # UniProt sometimes double-gzips content. + if data.startswith(b"\x1f\x8b"): + data = gzip.decompress(data) + return data.decode(resp.encoding) + + +def _fetch( + url: str, method="GET", headers=None, data=None, *, as_json=False +) -> dict[str, Any] | str: + """Fetch JSON and parse, handling server double-gzipping content.""" + if not headers: + headers = {} + if as_json: + headers |= {"Accept": "application/json"} + response = CLIENT.fetch(url, headers=headers, method=method, data=data) + decoded_data = _get_decompressed_data(response) + if as_json: + return json.loads(decoded_data) + else: + return decoded_data + + +def search_proteins( + query: str, + dataset: str = "uniprotkb", + output_format: str = "json", + limit: int | None = None, + fields: list[str] | None = None, +) -> Iterator[dict[str, Any] | str]: + """Search proteins in a UniProt dataset with automatic pagination.""" + url = f"{BASE_URL}/{dataset}/search" + params: dict[str, Any] = { + "query": query, + "format": output_format, + } + # Determine if automatic pagination is needed + # UniProt has a hard limit of 500 for the 'size' parameter. + use_pagination = limit is None or limit > 500 + request_size = min(limit, 500) if limit is not None else 500 + params["size"] = request_size + + if fields: + params["fields"] = ",".join(fields) + + if not use_pagination: + + def _single_request_iterator(): + full_url = _add_params_to_url(url, params) + yield _fetch(full_url, as_json=(output_format == "json")) + + return _single_request_iterator() + + # Pagination logic + def _paginate_generator(): + next_url = url + current_params = params + fetched_count = 0 + total_results = None + header = None + + while next_url: + full_url = _add_params_to_url(next_url, current_params) + resp = CLIENT.fetch(full_url) + if total_results is None: + total_results = _get_header(resp, "X-Total-Results") + data = _get_decompressed_data(resp) + if output_format == "json": + data = json.loads(data) + + # Extract results from this page to handle limits + page_results = [] + + if isinstance(data, dict) and "results" in data: + page_results = data["results"] + elif isinstance(data, str): + if output_format == "fasta": + # Split by '>' at the start of a line + parts = re.split(r"(?m)^>", data) + page_results = [">" + p for p in parts if p.strip()] + elif output_format == "tsv": + # UniProt includes TSV headers on each page. + lines = data.strip().splitlines() + if lines: + if header is None: # Store the header only from the first page. + header = lines[0] + page_results = lines[1:] + else: + page_results = [] + else: + page_results = data.strip().splitlines() + + # Apply limit if necessary + if limit is not None: + remaining = limit - fetched_count + if remaining <= 0: + break + if len(page_results) > remaining: + page_results = page_results[:remaining] + # This reconstruction only executes when we need to truncate results. + # + # No Trimming Needed: If limit is None, or if the current page results + # fit within the remaining limit, data already contains the full page + # content as received from the server (either as a parsed dict for + # JSON or a raw string for FASTA/others). We can just yield it. + # + # Trimming Needed: We only need to reconstruct data if we had to slice + # page_results to respect the limit. In that case, build a new data + # object from the truncated page_results. + # + # TSV is the only exception (handled below) where we always + # reconstruct the data, regardless of whether we applied a limit or + # not. This is because we are actively modifying the content by + # removing the header lines from subsequent pages, so we can never + # just yield the raw server response for TSV after the first page. + if isinstance(data, dict): + data["results"] = page_results + elif output_format == "fasta": + data = "".join(page_results) + elif output_format != "tsv": + data = "\n".join(page_results) + + # Reconstruct TSV data to ensure headers are only on the first page + if output_format == "tsv": + page_data = "\n".join(page_results) + if fetched_count == 0 and header: + data = header + "\n" + page_data + else: + data = page_data + + fetched_count += len(page_results) + + if total_results: + print( + f"Progress: {fetched_count} / {total_results} fetched", + file=sys.stderr, + ) + else: + print(f"Progress: {fetched_count} fetched", file=sys.stderr) + + yield data + + if limit is not None and fetched_count >= limit: + break + + link_header = _get_header(resp, "Link") + if link_header and 'rel="next"' in link_header: + next_url = link_header.split(";")[0].strip("<>") + current_params = None # Params are already in the URL + else: + next_url = None + + return _paginate_generator() + + +def get_count(query: str, dataset: str = "uniprotkb") -> int: + """Retrieve the total number of hits for a query.""" + url = f"{BASE_URL}/{dataset}/search" + params = {"query": query, "size": 1, "format": "json"} + resp = CLIENT.fetch(_add_params_to_url(url, params)) + return int(_get_header(resp, "X-Total-Results") or 0) + + +def get_entry( + accession: str, + dataset: str = "uniprotkb", + output_format: str = "json", +) -> dict[str, Any] | str: + """Retrieve a single UniProt entry.""" + url = f"{BASE_URL}/{dataset}/{accession}" + params = {"format": output_format} + full_url = _add_params_to_url(url, params) + return _fetch(full_url, as_json=(output_format == "json")) + + +def run_id_mapping(ids: list[str], from_db: str, to_db: str) -> dict[str, Any]: + """Execute the ID mapping workflow.""" + # 1. Submit job + submit_url = f"{BASE_URL}/idmapping/run" + form_dict = { + "from": from_db, + "to": to_db, + "ids": ",".join(ids), + } + data = urllib.parse.urlencode(form_dict).encode("utf-8") + headers = {"Content-Type": "application/x-www-form-urlencoded"} + job_id = _fetch( + submit_url, method="POST", headers=headers, data=data, as_json=True + )["jobId"] + + # 2. Poll for status + status_url = f"{BASE_URL}/idmapping/status/{job_id}" + results_resp = None + while True: + status_resp = _fetch(status_url, as_json=True) + if not isinstance(status_resp, dict): + raise UniProtError( + f"ID mapping job status response is not a dict: {status_resp}" + ) + + # Check if we were redirected to results (or results are in the status resp) + if "results" in status_resp: + results_resp = status_resp + break + + job_status = status_resp.get("jobStatus") + if job_status == "FINISHED": + break + if job_status == "FAILED": + raise UniProtError(f"ID mapping job failed: {status_resp.get('errors')}") + print(f"ID Mapping Job status: {job_status}") + time.sleep(2) + + # 3. Get results (if not already fetched during status poll) + if results_resp: + return results_resp + + results_url = f"{BASE_URL}/idmapping/results/{job_id}" + return _fetch(results_url, as_json=True) + + +def sparql_query(query: str) -> dict[str, Any]: + """Execute a SPARQL query.""" + params = {"query": query, "format": "json"} + return SPARQL_CLIENT.fetch_json(_add_params_to_url(SPARQL_URL, params)) + + +def stream_results( + query: str, + dataset: str = "uniprotkb", + output_format: str = "tsv", + fields: list[str] | None = None, +) -> Iterator[str]: + """Stream all results for a bulk query using the /stream endpoint. + + The /stream endpoint always returns the full result set (up to 10M entries). + It does NOT support limiting the number of results. Use `search_proteins` + with a `limit` parameter if you need a subset of results. + + Args: + query: The search query. + dataset: The dataset to search in. + output_format: The output format. + fields: The fields to retrieve. + + Yields: + str: Each line of the result set. + """ + url = f"{BASE_URL}/{dataset}/stream" + params = {"query": query, "format": output_format} + headers = {"Accept-Encoding": "identity"} + if fields: + params["fields"] = ",".join(fields) + full_url = _add_params_to_url(url, params) + fetched_count = 0 + for line in CLIENT.stream_lines(full_url, headers=headers): + if line: + fetched_count += 1 + if fetched_count % 1000 == 0: + print(f"Progress: {fetched_count} lines fetched...", file=sys.stderr) + yield line + print(f"Total fetched lines: {fetched_count}", file=sys.stderr) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command") + + # Search command + s_parser = subparsers.add_parser("search", help="Search proteins") + s_parser.add_argument("query", help="Query string") + s_parser.add_argument( + "--dataset", + default="uniprotkb", + help="Dataset to search in (e.g. uniprotkb, uniparc, unipref)", + ) + s_parser.add_argument( + "--limit", type=int, help="Total number of results to return" + ) + s_parser.add_argument("--format", default="json") + s_parser.add_argument("--fields") + + # Get command + g_parser = subparsers.add_parser("get", help="Get protein entry") + g_parser.add_argument("accession") + g_parser.add_argument( + "--dataset", + default="uniprotkb", + help="Dataset to search in (e.g. uniprotkb, uniparc, unipref)", + ) + g_parser.add_argument("--format", default="json") + + # Map command + m_parser = subparsers.add_parser("map", help="Map IDs") + m_parser.add_argument("ids", help="Comma-separated IDs") + m_parser.add_argument("--from_db", required=True) + m_parser.add_argument("--to_db", required=True) + + # Count command + c_parser = subparsers.add_parser("count", help="Count results for a query") + c_parser.add_argument("query") + c_parser.add_argument( + "--dataset", + default="uniprotkb", + help="Dataset to search in (e.g. uniprotkb, uniparc, unipref)", + ) + + # SPARQL command + sp_parser = subparsers.add_parser("sparql", help="Run SPARQL query") + sp_parser.add_argument("query") + + # Stream command + st_parser = subparsers.add_parser( + "stream", + help="Stream ALL results for a bulk query (up to 10M entries, no limit)", + ) + st_parser.add_argument("query") + st_parser.add_argument( + "--dataset", + default="uniprotkb", + help="Dataset to search in (e.g. uniprotkb, uniparc, unipref)", + ) + st_parser.add_argument("--format", default="tsv") + st_parser.add_argument("--fields") + + args = parser.parse_args() + + # Validate that --format is lowercase (UniProt API requires lowercase). + if hasattr(args, "format") and args.format != args.format.lower(): + parser.error( + f"Invalid format '{args.format}': format must be lowercase" + f" (e.g. 'json', 'tsv', 'fasta'). Got '{args.format}'," + f" did you mean '{args.format.lower()}'?" + ) + + if args.command == "search": + search_fields = args.fields.split(",") if args.fields else None + result_iterator = search_proteins( + args.query, + args.dataset, + output_format=args.format, + limit=args.limit, + fields=search_fields, + ) + for page in result_iterator: + if args.format == "json": + print(json.dumps(page, indent=2)) + else: + print(page) + elif args.command == "get": + result = get_entry( + args.accession, + args.dataset, + output_format=args.format, + ) + if args.format == "json": + print(json.dumps(result, indent=2)) + else: + print(result) + elif args.command == "count": + print(get_count(args.query, args.dataset)) + elif args.command == "map": + print( + json.dumps( + run_id_mapping( + args.ids.split(","), + args.from_db, + args.to_db, + ), + indent=2, + ) + ) + elif args.command == "sparql": + print(json.dumps(sparql_query(args.query), indent=2)) + elif args.command == "stream": + stream_fields = args.fields.split(",") if args.fields else None + for row in stream_results( + args.query, + args.dataset, + output_format=args.format, + fields=stream_fields, + ): + print(row) + elif not args.command: + parser.print_help() diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/LICENSE b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/LICENSE new file mode 100644 index 0000000..834d9d9 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/README.md b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/README.md new file mode 100644 index 0000000..282aaa0 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/README.md @@ -0,0 +1,55 @@ +# protein-binder-design (Agent Skill) + +De novo protein binder design as an **Agent Skill**: an agent composes BioNeMo +NIMs — **RFdiffusion → ProteinMPNN → Boltz2 / OpenFold3** — to diffuse binder +backbones, design sequences, co-fold each binder with the target, validate, and +rank, writing a reproducible run manifest. + +## Layout + +``` +protein-binder-design/ +├── SKILL.md # entry point (agent reads this first) +├── references/ +│ ├── pipeline.md # stage-by-stage orchestration + per-NIM request shapes +│ ├── validation.md # controls, success-rate methodology, metric defs +│ └── manifest.md # run-manifest schema +├── scripts/ +│ ├── manifest.py # campaign manifest (create/score/filter/rank/CSV) +│ ├── pdb_utils.py # PDB parse, chain extract, residue remap +│ ├── metrics.py # Kabsch CA-RMSD (self-consistency) +│ ├── controls.py # scrambled negative controls +│ └── registry.py # target-registry loader +├── assets/targets.json # EXAMPLE target registry (verify before real use) +└── evals/ # trigger + assertion evals +``` + +## Prerequisites + +- A Skill-aware agent (Claude Code / Cursor / Codex, etc.). +- Access to the BioNeMo NIMs you intend to use (RFdiffusion, ProteinMPNN, Boltz2 + and/or OpenFold3, optional MSA-Search) — hosted at + [build.nvidia.com](https://build.nvidia.com) or self-hosted via NGC. +- Python ≥ 3.10 with `numpy` (`pip install numpy`). Scripts are otherwise stdlib. + +## Configure + +Pick hosted or local once (see `SKILL.md` → Configuration). For hosted: + +```bash +export NVIDIA_API_KEY=nvapi-... +``` + +## Run (from an agent) + +Open this folder in your agent and prompt, e.g.: + +> Design 10 binders against `` (give a name, UniProt accession, or PDB + +> chain, and the epitope/hotspots); validate with Boltz2 and give me a ranked table. + +The agent loads `SKILL.md`, follows `references/pipeline.md`, and writes results +under `runs/_/` (manifest + `candidates.csv` + a short report). + +## License + +Apache-2.0 (see `LICENSE`). diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/SKILL.md b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/SKILL.md new file mode 100755 index 0000000..5cf456d --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/SKILL.md @@ -0,0 +1,116 @@ +--- +name: protein-binder-design +description: > + Orchestrate an end-to-end de novo protein binder design campaign against a protein target by composing BioNeMo NIM skills. Use for binder design, minibinder design, de novo binders, RFdiffusion + ProteinMPNN + Boltz2/OpenFold3 pipelines, epitope/hotspot-targeted design, in-silico binder validation, and ranking designs by interface confidence. +license: Apache-2.0 +compatibility: "numpy>=1.24; requests>=2.28" +allowed-tools: Bash, Read, Write, AskUserQuestion +--- + +# Protein Binder Design (workflow) + +Run a de novo binder design campaign by composing atomic NIM skills. This skill +owns orchestration, handoff contracts, filtering, validation, and the run +manifest. It does NOT duplicate per-NIM API details — defer those to each +atomic skill's `SKILL.md`. + +## Composed skills + +| Step | Skill | Owns | +|---|---|---| +| Backbones | `rfdiffusion-nim` | binder backbone PDBs (contigs + hotspots) | +| Sequences | `proteinmpnn-nim` | sequences for each backbone | +| Co-fold / score | `boltz2-nim` or `openfold3-nim` | binder–target complex + confidence / ipTM | +| MSA (optional) | `msa-search-nim` | target A3M for higher-quality folding | + +The atomic NIM skills are recommended companions (one per NIM, from the BioNeMo +NIM skill set). They are **not required**: `references/pipeline.md` carries the +concrete request shape for every NIM call, so an agent with NIM access can follow +this skill standalone. For endpoints/auth see **Configuration** below. + +## Pipeline + +1. **Target prep** — get the target PDB + epitope; map epitope/hotspot author + residue numbers to RFdiffusion `hotspot_res` strings; optionally build a + target MSA with `msa-search-nim`. +2. **Backbones** (`rfdiffusion-nim`) — binder contig + `hotspot_res`; N backbones. +3. **Sequences** (`proteinmpnn-nim`) — k sequences per backbone; drop the + native/WT row from `mfasta`. +4. **Co-fold + score** (`boltz2-nim` / `openfold3-nim`) — co-fold binder+target; + collect interface confidence (ipTM) and binder pLDDT. +5. **Self-consistency** — CA-RMSD between the RFdiffusion backbone and the + predicted binder (`scripts/metrics.py`). +6. **Filter + rank** — apply thresholds; rank survivors; write manifest + CSV. + +Full handoff contracts, branching, and the cost funnel: `references/pipeline.md`. + +## Handoff contracts (the fragile glue) + +- RFdiffusion `output_pdb` → ProteinMPNN `input_pdb` (inline PDB text). +- ProteinMPNN `mfasta` → Boltz2 binder polymer `sequence` (exclude the + native/WT row; pair scores only with designed rows). +- Epitope author residue numbers → 1-based sequence indices: remap with + `scripts/pdb_utils.py:remap_to_seq_index`. RFdiffusion `hotspot_res` uses + chain+author strings like `"A50"`; Boltz2 pocket/contacts use 1-based indices. +- Boltz2 complex `.cif` → binder chain → self-consistency RMSD vs the backbone. + +## Run manifest (reproducibility backbone) + +Every campaign writes `manifest.json` (+ `candidates.csv`) under a run dir via +`scripts/manifest.py`. It records lineage, params, scores, artifacts, filter +status, and controls — enabling ranking, resumability, validation, and the +final report. Schema and usage: `references/manifest.md`. + +## Filters (defaults) + +- ipTM ≥ 0.8, binder pLDDT ≥ 80, self-consistency RMSD ≤ 2.0 Å. +- Override per campaign and record overrides in the manifest `filters`. + +## Validation + +Always run controls and report a **success rate**, not just top scores. +Negative controls via `scripts/controls.py` (scrambled sequences); positive +controls = published binders re-scored through the same pipeline. Benchmark +targets live in `assets/targets.json` (`scripts/registry.py`). Methodology and +metric definitions: `references/validation.md`. + +## Human-in-the-loop + cost + +- Confirm target, epitope/hotspots, binder length range, and hosted-vs-local + with the user before generating backbones (AskUserQuestion). +- Co-folding is the expensive stage: co-fold a capped shortlist, review, then + expand. State hosted vs local once and reuse it across all NIM calls. + +## Responsible use + +De novo binder design is dual-use. Decline requests aimed at enhancing pathogen +fitness, toxin potency, or bioweapon function; keep designs to legitimate +research and therapeutic intent. + +## Configuration (NIM access) + +Each composed NIM is reached over HTTP; choose **hosted** or **local** once and +reuse it for every call: + +- **Hosted** (managed): base URL `https://health.api.nvidia.com/v1/...` per NIM at + [build.nvidia.com](https://build.nvidia.com); set `NVIDIA_API_KEY` (sent as + `Authorization: Bearer`). Read keys from the env — never hardcode them. +- **Local** (self-hosted NGC containers): point each NIM at its local URL + (e.g. `http://localhost:8000/...`); local NIMs need no auth header. To **launch** the + NIMs yourself (docker run per NIM, persistent caches, health checks, and the GPU + **profile‑selection gotcha** — some NIMs (e.g. Boltz2) need `NIM_MODEL_PROFILE` pinned + on GPUs that have no bundled profile, while others (RFdiffusion/ProteinMPNN) auto‑select + by compute capability): see **`references/local-nim-setup.md`**. + +Per-NIM paths, request/response schemas, and worked `curl`/Python examples live in +`references/pipeline.md`. + +## Scripts + +- `scripts/manifest.py` — campaign manifest (create / load / score / filter / rank / CSV). +- `scripts/pdb_utils.py` — PDB parse, chain extract, sequence, residue remap, CA coords. +- `scripts/metrics.py` — Kabsch CA-RMSD for self-consistency. +- `scripts/controls.py` — scrambled negative controls. +- `scripts/registry.py` + `assets/targets.json` — **example** benchmark target + registry (illustrative epitopes — verify against the cited structure before a + real campaign). Replace with your own targets. diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/assets/targets.json b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/assets/targets.json new file mode 100644 index 0000000..03ff59f --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/assets/targets.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "note": "TEMPLATE only — replace with your own targets (or add via scripts/registry.py). Epitope/hotspot residues use PDB author numbering of the cited structure and MUST be remapped to 1-based sequence indices (scripts/pdb_utils.remap_to_seq_index) before use by tools that expect sequence indices. Always verify epitope_resnums and any published_binders against the cited structure/literature before a real campaign.", + "targets": [ + { + "name": "", + "aliases": [""], + "type": "", + "pdb_id": "", + "target_chain": "", + "epitope_resnums": [], + "epitope_status": "define and verify the epitope from ", + "published_binders": [] + } + ] +} diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/local-nim-setup.md b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/local-nim-setup.md new file mode 100644 index 0000000..31423c0 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/local-nim-setup.md @@ -0,0 +1,80 @@ +# Running the BioNeMo NIMs locally (self-hosted) + +Use this when you want to self-host RFdiffusion, ProteinMPNN, and a co-folder (Boltz2) +instead of the managed `build.nvidia.com` endpoints — e.g. to avoid rate limits or to +keep a campaign self-contained on one node. The pipeline logic is unchanged; only the +base URL changes (no `Authorization` header for local NIMs). + +## Prerequisites + +- Docker with the NVIDIA container runtime (`docker run --gpus ...` works). +- An NGC API key in `NGC_API_KEY` (used to pull images and download model weights). +- `docker login nvcr.io -u '$oauthtoken' -p "$NGC_API_KEY"` once. + +## Images + +| NIM | Image | Idle GPU | Serves | +|---|---|---|---| +| RFdiffusion | `nvcr.io/nim/ipd/rfdiffusion:latest` | ~24 GB | `:8000/v1/biology/ipd/rfdiffusion/generate` | +| ProteinMPNN | `nvcr.io/nim/ipd/proteinmpnn:latest` | ~1.5 GB | `:8000/v1/biology/ipd/proteinmpnn/predict` | +| Boltz2 | `nvcr.io/nim/mit/boltz2:latest` | ~8 GB | `:8000/biology/mit/boltz2/predict` | + +All three co-fit on one ≥48 GB GPU (~33 GB idle together). + +## Launch pattern + +Give each NIM its own persistent cache (so weights download once), a name, and a port. +Mount the cache at `/opt/nim/.cache` and make it writable: + +```bash +mkdir -p ~/nimcache_rfd ~/nimcache_pmpnn ~/nimcache_boltz2 && chmod 777 ~/nimcache_* +docker run -d --name rfdiffusion --gpus device=0 --shm-size=4g \ + -e NGC_API_KEY -v ~/nimcache_rfd:/opt/nim/.cache -p 8081:8000 \ + nvcr.io/nim/ipd/rfdiffusion:latest +docker run -d --name proteinmpnn --gpus device=0 --shm-size=4g \ + -e NGC_API_KEY -v ~/nimcache_pmpnn:/opt/nim/.cache -p 8082:8000 \ + nvcr.io/nim/ipd/proteinmpnn:latest +docker run -d --name boltz2 --gpus device=0 --shm-size=8g \ + -e NGC_API_KEY -v ~/nimcache_boltz2:/opt/nim/.cache -p 8083:8000 \ + nvcr.io/nim/mit/boltz2:latest +``` + +Wait for readiness (first start downloads weights — minutes): + +```bash +curl -fsS http://localhost:8081/v1/health/ready && echo RFD_OK +curl -fsS http://localhost:8082/v1/health/ready && echo PMPNN_OK +curl -fsS http://localhost:8083/v1/health/ready && echo BOLTZ2_OK +``` + +If the NIMs and your client share a user-defined docker network, reach them by container +name instead of published ports (e.g. `http://rfdiffusion:8000/...`). + +## GPU profile selection + +Most of these NIMs **auto-select** a profile by compute capability (SM) and just work on +a supported GPU. Some NIMs match profiles by **exact GPU model**, so on a GPU that has no +bundled profile the container exits early with `NIMProfileIDNotFound` / "0 profiles found". +If that happens, list the bundled profiles and pin the one that matches **your** GPU's +compute capability: + +```bash +# 1) list the profiles this NIM ships and their tags (gpu / compute capability / precision / backend) +docker run --rm --gpus device=0 -e NGC_API_KEY \ + list-model-profiles +# 2) choose the profile whose tags match YOUR GPU (compute capability first), then pin it: +docker run -d --name --gpus device=0 --shm-size=8g \ + -e NGC_API_KEY -e NIM_MODEL_PROFILE= \ + -v ~/nimcache_:/opt/nim/.cache -p 8083:8000 +``` + +A TRT-optimized engine built for one GPU generally loads on another of the **same compute +capability** (you may see a benign cross-device warning). Always select the profile for the +hardware you are running on — **do not copy a `NIM_MODEL_PROFILE` id from another machine**, +and check the NIM's support matrix for your exact GPU. + +## Then point the pipeline at local + +Set the local base URLs and drop the `Authorization` header. Request/response shapes for +every NIM are in `references/pipeline.md` — only the URL/auth changes between hosted and +local. diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/manifest.md b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/manifest.md new file mode 100755 index 0000000..0f520e0 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/manifest.md @@ -0,0 +1,71 @@ +# Run Manifest & Directory Convention + +A campaign writes one `manifest.json` (machine state) and one `candidates.csv` +(human/spreadsheet view) under a run directory. The manifest is the backbone for +ranking, resumability, validation, and the final report. + +## Directory layout + +```text +runs/_/ +├── manifest.json # campaign state (see schema below) +├── candidates.csv # flat table, regenerated from the manifest +├── backbones/ # RFdiffusion output PDBs +├── sequences/ # ProteinMPNN mfasta files +└── complexes/ # Boltz2/OpenFold3 co-folded .cif files +``` + +## Schema (`manifest.json`) + +```json +{ + "schema_version": "1.0", + "campaign": "protein-binder-design", + "created": "2026-06-12T18:00:00+00:00", + "run_dir": "runs/_", + "target": { "name": "", "pdb_id": "", "chain": "" }, + "mode": "hosted", + "params": { "n_backbones": 100, "seqs_per_backbone": 8, "binder_len": "60-90" }, + "filters": { "iptm_min": 0.8, "binder_plddt_min": 80.0, "self_consistency_rmsd_max": 2.0 }, + "stages": [ { "stage": "rfdiffusion", "ts": "...", "n": 100 } ], + "candidates": [ + { + "id": "bb003_seq02", + "backbone_id": "bb003", + "sequence": "....", + "scores": { "proteinmpnn_nll": 1.02, "iptm": 0.84, "binder_plddt": 86.2, "self_consistency_rmsd": 1.4 }, + "artifacts": { "backbone_pdb": "backbones/bb003.pdb", "complex_cif": "complexes/bb003_seq02.cif" }, + "passed_filter": true, + "is_control": false, + "control_type": null, + "created": "..." + } + ] +} +``` + +## Usage (`scripts/manifest.py`) + +```python +import sys; sys.path.insert(0, "scripts") +from manifest import Manifest + +m = Manifest.create(run_dir="runs/demo", target={"name": "X", "chain": "A"}, mode="hosted") +m.log_stage("rfdiffusion", n=100) +m.upsert_candidate("bb003_seq02", backbone_id="bb003", sequence="MKT...") +m.set_scores("bb003_seq02", iptm=0.84, binder_plddt=86.2, self_consistency_rmsd=1.4) +m.add_artifact("bb003_seq02", "complex_cif", "complexes/bb003_seq02.cif") +m.apply_filters() +top = m.rank(by="iptm", passed_only=True) +m.to_csv() + +# resume a campaign later +m2 = Manifest.load("runs/demo") +``` + +## Resumability + +Because state lives in `manifest.json`, an interrupted campaign resumes by +loading the manifest and skipping candidates that already have the needed +scores/artifacts. Long campaigns should checkpoint after each expensive stage +(`m.save()` is called automatically by the mutation helpers). diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/pipeline.md b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/pipeline.md new file mode 100755 index 0000000..d2af20a --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/pipeline.md @@ -0,0 +1,151 @@ +# Binder Design Pipeline — Orchestration & Handoff Contracts + +This is the detailed orchestration for the `protein-binder-design` workflow. +The agent reasons over these steps and delegates each NIM call to the atomic +skill. Deterministic glue (parsing, remapping, RMSD, manifest) uses the bundled +`scripts/`. + +## 0. Setup + +- Decide hosted vs local **once** (ask the user) and reuse for every NIM call. +- Create a run directory and manifest: + +```python +import sys; sys.path.insert(0, "scripts") +from manifest import Manifest +m = Manifest.create( + run_dir="runs/_", + target={"name": "", "pdb_id": "", "chain": ""}, + mode="hosted", + params={"n_backbones": 100, "seqs_per_backbone": 8, "binder_len": "60-90"}, +) +``` + +## 1. Target prep + +- Obtain the target structure (experimental PDB, or predict with `openfold2-nim` + / `openfold3-nim` / `boltz2-nim` if none exists). +- Identify epitope/hotspot residues (from literature, the registry, or the user) + in **PDB author numbering**. +- Remap to 1-based sequence indices for tools that need them: + +```python +from pdb_utils import remap_to_seq_index +target_pdb = open(".pdb").read() +seq_idx = remap_to_seq_index(target_pdb, chain="", author_resnums=[]) +``` + +- RFdiffusion `hotspot_res` instead uses chain+author strings, e.g. + `["E453", "E455", "E456", "E486"]` (no remap needed there). +- Optional: build a target MSA with `msa-search-nim` if you will fold/co-fold + the target with evolutionary context. + +### Human-in-the-loop gate +Before generating backbones, confirm with the user: target chain, epitope/ +hotspot set, binder length range, number of backbones, sequences per backbone. + +## 2. Backbones — `rfdiffusion-nim` + +Binder design mode: pass the target `input_pdb`, a contig combining the target +segment and a generated binder segment, and `hotspot_res`. + +```python +# delegate the actual request to the rfdiffusion-nim skill +payload = { + "input_pdb": target_pdb, + "contigs": "E1-200/0 60-90", # keep target E1-200, chain break, generate 60-90 aa binder + "hotspot_res": ["E453", "E455", "E456", "E486", "E489", "E493", "E501"], + "diffusion_steps": 50, +} +# -> result["output_pdb"] is one backbone +``` + +Generate N backbones (loop with distinct seeds / repeated calls). Save each and +register it: + +```python +m.upsert_candidate("bb003", backbone_id="bb003") +m.add_artifact("bb003", "backbone_pdb", "runs/.../backbones/bb003.pdb") +``` + +## 3. Sequences — `proteinmpnn-nim` + +For each backbone, design k sequences. Redesign the **binder chain only** +(`input_pdb_chains=[binder_chain]`) so the target chain stays fixed. RFdiffusion +may renumber/rename chains, so re-read the backbone PDB to get the binder chain +ID and length first (`pdb_utils.py`). **Drop the native/WT row** from `mfasta` +and pair scores only with designed rows. + +```python +payload = { + "input_pdb": backbone_pdb, + "input_pdb_chains": [binder_chain], # redesign binder, keep target fixed + "num_seq_per_target": 8, + "sampling_temp": [0.1, 0.2], + "use_soluble_model": True, # for soluble binders +} +# parse result["mfasta"]: keep headers without 'native'/'wt'; zip with result["scores"] +m.upsert_candidate("bb003_seq02", backbone_id="bb003", sequence=designed_seq) +m.set_scores("bb003_seq02", proteinmpnn_nll=score) +``` + +## 4. Co-fold + score — `boltz2-nim` or `openfold3-nim` + +Co-fold each designed binder **with the target** as a 2-chain complex. Use the +binder sequence + target sequence (and target MSA if built). + +- `openfold3-nim` returns an explicit `iptm_score` (interface) and pLDDT. +- `boltz2-nim` returns `confidence_scores`; use it as the complex confidence. + +```python +# delegate to boltz2-nim / openfold3-nim +polymers = [ + {"id": "A", "molecule_type": "protein", "sequence": designed_seq}, # binder (single-seq MSA is standard for de novo) + {"id": "B", "molecule_type": "protein", "sequence": target_seq}, # target (+ MSA) +] +m.set_scores("bb003_seq02", iptm=iptm, binder_plddt=plddt, boltz2_confidence=conf) +m.add_artifact("bb003_seq02", "complex_cif", "runs/.../complexes/bb003_seq02.cif") +``` + +### Cost funnel +Co-folding is the expensive stage. Co-fold a **capped shortlist** first (e.g. +best ProteinMPNN NLL per backbone), review, then expand. Reserve high +`diffusion_samples` / `recycling_steps` for the final survivors. + +## 5. Self-consistency RMSD + +Compare the RFdiffusion backbone to the predicted binder chain (from the +co-folded complex). Low RMSD = the sequence is predicted to fold back into the +designed backbone. + +```python +from metrics import ca_rmsd_from_pdb +# extract the binder chain from the predicted complex, then: +rmsd = ca_rmsd_from_pdb(predicted_binder_pdb, backbone_pdb) +m.set_scores("bb003_seq02", self_consistency_rmsd=rmsd) +``` + +(Convert mmCIF→PDB or parse CA atoms from the binder chain; `pdb_utils` reads +PDB ATOM records.) + +## 6. Filter + rank + report + +```python +m.apply_filters() # uses manifest filters (ipTM/pLDDT/RMSD) +top = m.rank(by="iptm", descending=True, passed_only=True)[:20] +m.to_csv() # candidates.csv next to manifest.json +print(m.summary()) # {n_candidates, n_passed, n_controls} +``` + +Produce a short report: target + epitope, params, success rate, the top +designs with their scores and artifact paths, and how they compare to controls +(`references/validation.md`). + +## Branching summary + +- **No target structure** → predict it first (`openfold2/3-nim` or `boltz2-nim`). +- **Target needs evolutionary context** → `msa-search-nim` before co-folding. +- **Interface metric** → prefer OpenFold3 `iptm_score`; Boltz2 `confidence_scores` + is the fallback complex-confidence signal. +- **Binder MSA** → keep single-sequence for de novo binders (standard); do not + fabricate a binder MSA. diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/validation.md b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/validation.md new file mode 100755 index 0000000..a1e9441 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/references/validation.md @@ -0,0 +1,69 @@ +# Binder Design Validation + +All outputs are in-silico, so validation means computational benchmarking. +Never report only top scores — report a **success rate** and compare against +controls. + +## Metrics + +| Metric | Source | Pass guide | Meaning | +|---|---|---|---| +| Interface confidence (ipTM) | OpenFold3 `iptm_score` · Boltz2 `confidence_scores` | ≥ 0.8 | predicted interface quality | +| Binder pLDDT | OpenFold3 / Boltz2 | ≥ 80 | binder fold confidence | +| Self-consistency RMSD | `scripts/metrics.py` (Kabsch CA-RMSD) | ≤ 2.0 Å | designed backbone vs predicted | +| Sequence quality (NLL) | ProteinMPNN `scores` | lower better | sequence–backbone compatibility | + +There is no protein–protein affinity NIM, so ipTM + self-consistency RMSD are +the binder proxy (the Bennett et al. 2023 filter pattern). + +## Controls + +Run controls through the **identical** pipeline so score distributions are +comparable. + +- **Negative controls** — scrambled binder sequences (preserve composition): + +```python +import sys; sys.path.insert(0, "scripts") +from controls import make_scrambled_controls +negs = make_scrambled_controls([designed_seq], n=5, seed=42) +# co-fold each, then register with is_control=True, control_type="scrambled" +m.upsert_candidate("ctrl_neg_01", is_control=True, control_type="scrambled", sequence=negs[0]) +``` + +- **Positive controls** — published binder sequences for the same target + (`assets/targets.json` → `published_binders`), co-folded the same way and + marked `control_type="published"`. + +A working pipeline separates designed/published positives from scrambled +negatives in the ipTM and RMSD distributions. + +## Success rate + +The metric the field actually quotes — fraction of designs passing the filter: + +```python +s = m.summary() +success_rate = s["n_passed"] / max(s["n_candidates"], 1) +``` + +Use it to compare pipeline configs (diffusion steps, sampling temperature, +sequences/backbone) rather than over-interpreting any single design. + +## Published comparison + +Re-score literature winners through your exact pipeline and check your top +designs land in the same ipTM/RMSD regime. Absolute scores are not comparable +across pipelines — only same-pipeline comparisons are meaningful. + +Benchmark targets come from the target registry (`assets/targets.json`) — add your own +with `scripts/registry.py`. Always confirm epitope residues against the cited structure +before use; registry entries flag illustrative residue lists. + +## Caveats + +- In-silico triage, not experimental validation. Prefer relative ranking and + distribution separation over absolute claims. +- ipTM can be optimistic; corroborate with self-consistency RMSD and ProteinMPNN + NLL before prioritizing. +- Keep all artifacts, payloads, and the manifest together for reproducibility. diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/controls.py b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/controls.py new file mode 100644 index 0000000..e3cc6bc --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/controls.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Negative controls for binder validation: composition-preserving scrambles.""" +from __future__ import annotations + +import random + + +def scramble_sequence(seq, seed=0): + """Return a shuffled sequence (same amino-acid composition, new order).""" + rng = random.Random(seed) + chars = list(seq) + rng.shuffle(chars) + return "".join(chars) + + +def make_scrambled_controls(seqs, n=5, seed=0): + """Generate ``n`` scrambled negative controls drawn from ``seqs``.""" + rng = random.Random(seed) + if not seqs: + return [] + out = [] + for i in range(n): + base = seqs[i % len(seqs)] + out.append(scramble_sequence(base, seed=rng.randint(0, 2 ** 31 - 1))) + return out diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/manifest.py b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/manifest.py new file mode 100644 index 0000000..c29c519 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/manifest.py @@ -0,0 +1,179 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Run manifest for protein-binder-design campaigns. + +A campaign manifest is a single JSON file that records every candidate's +lineage, scores, artifacts, and filter status. It is the backbone for ranking, +resumability, validation, and the final report. No third-party dependencies. +""" +from __future__ import annotations + +import csv +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SCHEMA_VERSION = "1.0" + +DEFAULT_FILTERS = { + "iptm_min": 0.8, + "binder_plddt_min": 80.0, + "self_consistency_rmsd_max": 2.0, +} + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class Manifest: + """Read/write wrapper around a campaign ``manifest.json``.""" + + def __init__(self, data: dict[str, Any], path: Path): + self.data = data + self.path = Path(path) + + # ---- lifecycle ------------------------------------------------------- + @classmethod + def create( + cls, + run_dir: str | Path, + target: dict[str, Any], + mode: str = "hosted", + params: dict[str, Any] | None = None, + filters: dict[str, Any] | None = None, + ) -> "Manifest": + run_dir = Path(run_dir) + run_dir.mkdir(parents=True, exist_ok=True) + data = { + "schema_version": SCHEMA_VERSION, + "campaign": "protein-binder-design", + "created": _now(), + "run_dir": str(run_dir), + "target": target, + "mode": mode, + "params": params or {}, + "filters": dict(filters) if filters else dict(DEFAULT_FILTERS), + "stages": [], + "candidates": [], + } + m = cls(data, run_dir / "manifest.json") + m.save() + return m + + @classmethod + def load(cls, path: str | Path) -> "Manifest": + path = Path(path) + if path.is_dir(): + path = path / "manifest.json" + return cls(json.loads(path.read_text()), path) + + def save(self) -> Path: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(self.data, indent=2)) + return self.path + + # ---- mutation -------------------------------------------------------- + def log_stage(self, name: str, **info: Any) -> None: + self.data["stages"].append({"stage": name, "ts": _now(), **info}) + self.save() + + def _find(self, cid: str) -> dict[str, Any] | None: + for c in self.data["candidates"]: + if c["id"] == cid: + return c + return None + + def upsert_candidate(self, cid: str, **fields: Any) -> dict[str, Any]: + c = self._find(cid) + if c is None: + c = { + "id": cid, + "backbone_id": None, + "sequence": None, + "scores": {}, + "artifacts": {}, + "passed_filter": None, + "is_control": False, + "control_type": None, + "created": _now(), + } + self.data["candidates"].append(c) + c.update({k: v for k, v in fields.items() if v is not None}) + self.save() + return c + + def set_scores(self, cid: str, **scores: Any) -> dict[str, Any]: + c = self.upsert_candidate(cid) + c["scores"].update({k: v for k, v in scores.items() if v is not None}) + self.save() + return c + + def add_artifact(self, cid: str, key: str, path: str | Path) -> dict[str, Any]: + c = self.upsert_candidate(cid) + c["artifacts"][key] = str(path) + self.save() + return c + + # ---- analysis -------------------------------------------------------- + def apply_filters(self) -> None: + f = self.data["filters"] + for c in self.data["candidates"]: + s = c.get("scores", {}) + checks = [] + if f.get("iptm_min") is not None and s.get("iptm") is not None: + checks.append(s["iptm"] >= f["iptm_min"]) + if f.get("binder_plddt_min") is not None and s.get("binder_plddt") is not None: + checks.append(s["binder_plddt"] >= f["binder_plddt_min"]) + if ( + f.get("self_consistency_rmsd_max") is not None + and s.get("self_consistency_rmsd") is not None + ): + checks.append(s["self_consistency_rmsd"] <= f["self_consistency_rmsd_max"]) + c["passed_filter"] = bool(checks) and all(checks) + self.save() + + def rank( + self, + by: str = "iptm", + descending: bool = True, + passed_only: bool = False, + include_controls: bool = False, + ) -> list[dict[str, Any]]: + cands = self.data["candidates"] + if not include_controls: + cands = [c for c in cands if not c.get("is_control")] + if passed_only: + cands = [c for c in cands if c.get("passed_filter")] + cands = [c for c in cands if c.get("scores", {}).get(by) is not None] + return sorted(cands, key=lambda c: c["scores"][by], reverse=descending) + + def to_csv(self, path: str | Path | None = None) -> Path: + path = Path(path) if path else Path(self.data["run_dir"]) / "candidates.csv" + score_keys = sorted({k for c in self.data["candidates"] for k in c.get("scores", {})}) + cols = ["id", "backbone_id", "is_control", "control_type", "passed_filter"] + score_keys + with open(path, "w", newline="") as fh: + w = csv.writer(fh) + w.writerow(cols) + for c in self.data["candidates"]: + row = [ + c.get("id"), + c.get("backbone_id"), + c.get("is_control"), + c.get("control_type"), + c.get("passed_filter"), + ] + row += [c.get("scores", {}).get(k) for k in score_keys] + w.writerow(row) + return path + + def summary(self) -> dict[str, int]: + cands = [c for c in self.data["candidates"] if not c.get("is_control")] + passed = [c for c in cands if c.get("passed_filter")] + controls = [c for c in self.data["candidates"] if c.get("is_control")] + return { + "n_candidates": len(cands), + "n_passed": len(passed), + "n_controls": len(controls), + } diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/metrics.py b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/metrics.py new file mode 100644 index 0000000..0f6acde --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/metrics.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Deterministic structural metrics for binder design (numpy only).""" +from __future__ import annotations + +import numpy as np + + +def kabsch_rmsd(p, q): + """Minimal RMSD after optimal superposition of two (N, 3) coord sets.""" + p = np.asarray(p, dtype=float) + q = np.asarray(q, dtype=float) + if p.shape != q.shape or p.ndim != 2 or p.shape[1] != 3: + raise ValueError(f"coordinate shape mismatch: {p.shape} vs {q.shape}") + if p.shape[0] == 0: + raise ValueError("no coordinates provided") + pc = p - p.mean(axis=0) + qc = q - q.mean(axis=0) + h = pc.T @ qc + u, _, vt = np.linalg.svd(h) + d = np.sign(np.linalg.det(vt.T @ u.T)) + rot = vt.T @ np.diag([1.0, 1.0, d]) @ u.T + p_rot = pc @ rot.T + return float(np.sqrt(np.sum((p_rot - qc) ** 2) / p.shape[0])) + + +def ca_rmsd_from_pdb(pdb_a, pdb_b, chain_a=None, chain_b=None): + """CA-RMSD between two structures (by chain). Truncates to common length.""" + import os + import sys + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from pdb_utils import ca_coords + + a = ca_coords(pdb_a, chain_a) + b = ca_coords(pdb_b, chain_b) + n = min(len(a), len(b)) + if n == 0: + raise ValueError("no CA atoms found for RMSD") + return kabsch_rmsd(a[:n], b[:n]) diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/pdb_utils.py b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/pdb_utils.py new file mode 100644 index 0000000..7444571 --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/pdb_utils.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Dependency-free PDB parsing helpers for binder-design handoffs. + +Covers the fragile glue between NIM steps: +- extract a chain, keep ATOM records +- one-letter sequence from CA atoms +- map PDB author residue numbers -> 1-based sequence index (hotspot/pocket remap) +- CA coordinates for RMSD +""" +from __future__ import annotations + +THREE_TO_ONE = { + "ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", "GLN": "Q", + "GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K", + "MET": "M", "PHE": "F", "PRO": "P", "SER": "S", "THR": "T", "TRP": "W", + "TYR": "Y", "VAL": "V", "MSE": "M", "SEC": "U", "PYL": "O", +} + + +def _iter_atom_lines(pdb_text, chain=None): + for line in pdb_text.splitlines(): + if not line.startswith("ATOM"): + continue + if len(line) < 54: + continue + if chain is not None and line[21] != chain: + continue + yield line + + +def extract_chain(pdb_text, chain): + """Return PDB text containing only records for ``chain``.""" + keep = [] + for line in pdb_text.splitlines(): + if line.startswith(("ATOM", "HETATM", "TER")) and len(line) > 21 and line[21] == chain: + keep.append(line) + return "\n".join(keep) + + +def ca_residues(pdb_text, chain=None): + """Ordered list of (resName, resSeq, iCode, (x, y, z)) for CA atoms.""" + out = [] + for line in _iter_atom_lines(pdb_text, chain): + if line[12:16].strip() != "CA": + continue + res_name = line[17:20].strip() + res_seq = int(line[22:26]) + icode = line[26].strip() + x = float(line[30:38]); y = float(line[38:46]); z = float(line[46:54]) + out.append((res_name, res_seq, icode, (x, y, z))) + return out + + +def sequence(pdb_text, chain=None): + """One-letter sequence from CA atoms (unknown residues -> 'X').""" + return "".join(THREE_TO_ONE.get(r[0], "X") for r in ca_residues(pdb_text, chain)) + + +def residue_index_map(pdb_text, chain=None): + """Map PDB author residue id -> 1-based sequence index (CA order). + + Keys are stored both as the bare author number ('501') and, when an + insertion code is present, as number+icode ('501A'). + """ + mapping = {} + for i, (_, res_seq, icode, _) in enumerate(ca_residues(pdb_text, chain), start=1): + mapping[str(res_seq)] = i + if icode: + mapping[f"{res_seq}{icode}"] = i + return mapping + + +def remap_to_seq_index(pdb_text, chain, author_resnums): + """Convert PDB author residue numbers to 1-based sequence indices.""" + mapping = residue_index_map(pdb_text, chain) + out, missing = [], [] + for a in author_resnums: + key = str(a) + if key in mapping: + out.append(mapping[key]) + else: + missing.append(key) + if missing: + raise KeyError(f"residues not found in chain {chain!r}: {missing}") + return out + + +def ca_coords(pdb_text, chain=None): + """List of (x, y, z) for CA atoms in chain order.""" + return [r[3] for r in ca_residues(pdb_text, chain)] diff --git a/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/registry.py b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/registry.py new file mode 100644 index 0000000..cfa644b --- /dev/null +++ b/plugins/bionemo-agent-toolkit/skills/protein-binder-design/scripts/registry.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 OR CC-BY-4.0 +"""Benchmark target registry loader (reads assets/targets.json).""" +from __future__ import annotations + +import json +from pathlib import Path + +_DEFAULT = Path(__file__).resolve().parent.parent / "assets" / "targets.json" + + +def load_registry(path=None): + return json.loads(Path(path or _DEFAULT).read_text()) + + +def get_target(name, path=None): + reg = load_registry(path) + needle = name.lower() + for t in reg["targets"]: + names = [t["name"].lower()] + [a.lower() for a in t.get("aliases", [])] + if needle in names: + return t + available = [t["name"] for t in reg["targets"]] + raise KeyError(f"target {name!r} not in registry; available: {available}") From 086f63268bbfc5877731849e8803b4e497f49b87 Mon Sep 17 00:00:00 2001 From: jwilber Date: Tue, 14 Jul 2026 16:33:47 -0700 Subject: [PATCH 4/4] add plugin sync action Signed-off-by: jwilber --- .github/workflows/plugin-sync.yml | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/plugin-sync.yml diff --git a/.github/workflows/plugin-sync.yml b/.github/workflows/plugin-sync.yml new file mode 100644 index 0000000..37a79ee --- /dev/null +++ b/.github/workflows/plugin-sync.yml @@ -0,0 +1,33 @@ +name: plugin-sync + +# Fails a PR when the generated plugin payload (plugins/bionemo-agent-toolkit/) +# has drifted from the source skills — e.g. a skill was added to source but +# never added to skills.sh.json / regenerated into the plugin. +# +# No secrets, no Docker, no external tooling: it enforces the invariant that +# each plugin skill folder equals its source folder minus evals/. + +on: + pull_request: + paths: + - "nim-skills/**" + - "library-skills/**" + - "open-models-skills/**" + - "workflows/**" + - "plugins/**" + - "skills.sh.json" + - "scripts/plugin_sync.py" + - ".github/workflows/plugin-sync.yml" + push: + branches: [main] + +jobs: + plugin-sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check plugin payload is in sync with source skills + run: python scripts/plugin_sync.py --check