dh-cli 0.10.2__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dh_cli-0.10.2 → dh_cli-0.11.0}/.gitignore +5 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/PKG-INFO +1 -1
- {dh_cli-0.10.2 → dh_cli-0.11.0}/pyproject.toml +1 -1
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/__init__.py +2 -0
- dh_cli-0.11.0/src/dh_cli/batch/commands/disco.py +491 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/finalize.py +39 -0
- dh_cli-0.11.0/tests/test_disco_command.py +561 -0
- dh_cli-0.11.0/tests/test_disco_jobdef.py +171 -0
- dh_cli-0.11.0/tests/test_finalize_disco.py +164 -0
- dh_cli-0.11.0/tests/test_finalize_disco_dispatch.py +67 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/LICENSE +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/README.md +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/equilibrator.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/esmfold2.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/rfd3.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/train.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/bedrock/commands.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/main.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/__init__.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_cost_command.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_key_command.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_rotate.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/__init__.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/conftest.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_identity.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_login.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_logout.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_status.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/hz/test_init.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/hz/test_users.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_equilibrator_command.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_esmfold2_cli.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_equilibrator.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_esmfold2.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_protmpnn.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_rfd3.py +0 -0
- {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_rfd3_command.py +0 -0
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
# nohup output
|
|
29
29
|
**/nohup.out
|
|
30
30
|
|
|
31
|
+
# Container build contexts stage multi-GB upstream source out-of-band before
|
|
32
|
+
# `docker build`; never commit these vendored trees.
|
|
33
|
+
containers/disco/DISCO/
|
|
34
|
+
containers/boltz/thunderboltz/
|
|
35
|
+
|
|
31
36
|
# Files with sensitive information
|
|
32
37
|
key.json
|
|
33
38
|
.env.secret
|
|
@@ -13,6 +13,7 @@ import click
|
|
|
13
13
|
from .commands.boltz import boltz
|
|
14
14
|
from .commands.cancel import cancel
|
|
15
15
|
from .commands.clean import clean
|
|
16
|
+
from .commands.disco import disco
|
|
16
17
|
from .commands.embed_t5 import embed_t5
|
|
17
18
|
from .commands.equilibrator import equilibrator
|
|
18
19
|
from .commands.esmfold2 import esmfold2
|
|
@@ -109,6 +110,7 @@ batch_cli.add_command(protmpnn)
|
|
|
109
110
|
batch_cli.add_command(orca)
|
|
110
111
|
batch_cli.add_command(protmpnn_to_boltz, name="protmpnn-to-boltz")
|
|
111
112
|
batch_cli.add_command(rfd3)
|
|
113
|
+
batch_cli.add_command(disco)
|
|
112
114
|
batch_cli.add_command(train)
|
|
113
115
|
|
|
114
116
|
__all__ = ["batch_cli"]
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""DISCO structure/design pipeline command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from ..aws_batch import BatchClient, BatchError, resolve_dependency
|
|
14
|
+
from ..job_id import generate_job_id, get_aws_username
|
|
15
|
+
from ..manifest import (
|
|
16
|
+
BATCH_JOBS_BASE,
|
|
17
|
+
BatchConfig,
|
|
18
|
+
InputConfig,
|
|
19
|
+
JobManifest,
|
|
20
|
+
JobStatus,
|
|
21
|
+
OutputConfig,
|
|
22
|
+
create_job_directory,
|
|
23
|
+
save_manifest,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DEFAULT_QUEUE = "a10g-1x-spot"
|
|
27
|
+
MAX_WORKERS = 50
|
|
28
|
+
FILES_PER_WORKER = 2
|
|
29
|
+
DEFAULT_JOB_DEFINITION = "dayhoff-disco"
|
|
30
|
+
DEFAULT_IMAGE_URI = "074735440724.dkr.ecr.us-east-1.amazonaws.com/dayhoff:disco-latest"
|
|
31
|
+
|
|
32
|
+
DISCO_TIMEOUT_SECONDS = 5400
|
|
33
|
+
DISCO_RETRY_ATTEMPTS = 2
|
|
34
|
+
|
|
35
|
+
# Task-01 spike measured VRAM fitting 24 GB A10G up to 250 aa (16.8 GB peak),
|
|
36
|
+
# with cost ~quadratic in tokens. Beyond this the A10G ceiling is unproven, so
|
|
37
|
+
# warn and point at the L40S fallback (D-15) rather than silently accepting it.
|
|
38
|
+
A10G_SAFE_PROTEIN_LENGTH = 250
|
|
39
|
+
|
|
40
|
+
HF_HUB_CACHE_DIR = "/primordial/.cache/huggingface"
|
|
41
|
+
CHECKPOINT_SUBTREE = "/primordial/.cache/foundry/checkpoints/disco"
|
|
42
|
+
PRIMORDIAL_CHECKPOINT_DIR = Path(CHECKPOINT_SUBTREE)
|
|
43
|
+
DISCO_CHECKPOINT_PATH = f"{CHECKPOINT_SUBTREE}/DISCO.pt"
|
|
44
|
+
CUTLASS_PATH = "/opt/cutlass"
|
|
45
|
+
|
|
46
|
+
_JOB_ID_PATTERN = re.compile(r"^[a-z0-9-]+-[a-z0-9]+-\d{8}-[0-9a-f]{8}$")
|
|
47
|
+
_ENTITY_KEYS = {"proteinChain", "ligand", "dnaSequence", "rnaSequence", "ion"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@click.command()
|
|
51
|
+
@click.argument("input_dir", type=click.Path(exists=True))
|
|
52
|
+
@click.option(
|
|
53
|
+
"--workers",
|
|
54
|
+
default=None,
|
|
55
|
+
type=int,
|
|
56
|
+
help="Number of parallel workers [default: ~1 per 2 files]",
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--queue",
|
|
60
|
+
default=DEFAULT_QUEUE,
|
|
61
|
+
help=f"Batch queue [default: {DEFAULT_QUEUE}]",
|
|
62
|
+
)
|
|
63
|
+
@click.option("--dry-run", is_flag=True, help="Show plan without submitting")
|
|
64
|
+
@click.option(
|
|
65
|
+
"--local",
|
|
66
|
+
"run_local",
|
|
67
|
+
is_flag=True,
|
|
68
|
+
help="Force local execution via Docker",
|
|
69
|
+
)
|
|
70
|
+
@click.option(
|
|
71
|
+
"--remote",
|
|
72
|
+
"run_remote",
|
|
73
|
+
is_flag=True,
|
|
74
|
+
help="Force Batch submission (override auto-detect)",
|
|
75
|
+
)
|
|
76
|
+
@click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
|
|
77
|
+
@click.option("--after", "after", multiple=True, help="Job ID(s) to wait for before starting")
|
|
78
|
+
def disco(
|
|
79
|
+
input_dir,
|
|
80
|
+
workers,
|
|
81
|
+
queue,
|
|
82
|
+
dry_run,
|
|
83
|
+
run_local,
|
|
84
|
+
run_remote,
|
|
85
|
+
base_path,
|
|
86
|
+
after,
|
|
87
|
+
):
|
|
88
|
+
"""Design structures with DISCO.
|
|
89
|
+
|
|
90
|
+
Processes a directory of native DISCO JSON config files, each a list of
|
|
91
|
+
jobs with sequences and optional ligand structure files.
|
|
92
|
+
|
|
93
|
+
\b
|
|
94
|
+
Examples:
|
|
95
|
+
# Run on a GPU engine (auto-detects GPU, runs locally)
|
|
96
|
+
dh batch disco input/
|
|
97
|
+
|
|
98
|
+
# Force remote Batch submission
|
|
99
|
+
dh batch disco input/ --remote
|
|
100
|
+
|
|
101
|
+
# Preview what would run
|
|
102
|
+
dh batch disco input/ --dry-run
|
|
103
|
+
|
|
104
|
+
# Run locally via Docker
|
|
105
|
+
dh batch disco input/ --local
|
|
106
|
+
"""
|
|
107
|
+
input_path = Path(input_dir).resolve()
|
|
108
|
+
|
|
109
|
+
if run_local:
|
|
110
|
+
_run_local_mode(input_path, base_path)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if not run_remote and not dry_run:
|
|
114
|
+
if _has_local_gpu():
|
|
115
|
+
click.echo("GPU detected — running locally (use --remote to override)")
|
|
116
|
+
_run_local_mode(input_path, base_path)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
_submit_batch_job(input_path, workers, queue, dry_run, base_path, after)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _has_local_gpu() -> bool:
|
|
123
|
+
"""Check if a local NVIDIA GPU is available."""
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["nvidia-smi"],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
timeout=5,
|
|
129
|
+
)
|
|
130
|
+
return result.returncode == 0
|
|
131
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _has_nvidia_runtime() -> bool:
|
|
136
|
+
"""Check if the NVIDIA container runtime is available to Docker."""
|
|
137
|
+
try:
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
["docker", "info", "--format", "{{json .Runtimes}}"],
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
timeout=5,
|
|
143
|
+
)
|
|
144
|
+
return result.returncode == 0 and "nvidia" in result.stdout
|
|
145
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _discover_configs(input_path: Path) -> list[Path]:
|
|
150
|
+
return sorted(p for p in input_path.glob("*.json") if not p.name.endswith(".disco-meta.json"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _referenced_file_ligands(config: Path) -> list[str]:
|
|
154
|
+
"""Return relative ligand paths referenced via a ``FILE_`` prefix."""
|
|
155
|
+
with open(config) as f:
|
|
156
|
+
jobs = json.load(f)
|
|
157
|
+
|
|
158
|
+
relpaths = []
|
|
159
|
+
for job in jobs:
|
|
160
|
+
for entry in job.get("sequences", []):
|
|
161
|
+
if not isinstance(entry, dict):
|
|
162
|
+
continue
|
|
163
|
+
ligand = entry.get("ligand")
|
|
164
|
+
if not isinstance(ligand, dict):
|
|
165
|
+
continue
|
|
166
|
+
ref = ligand.get("ligand")
|
|
167
|
+
if isinstance(ref, str) and ref.startswith("FILE_"):
|
|
168
|
+
relpaths.append(ref[len("FILE_") :])
|
|
169
|
+
return relpaths
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _warn_if_over_a10g_headroom(config: Path, name: str, sequences: list) -> None:
|
|
173
|
+
"""Warn (non-fatal) when a protein chain exceeds the A10G-measured length.
|
|
174
|
+
|
|
175
|
+
The spike only proved the 24 GB fit up to 250 aa (D-15); longer chains may
|
|
176
|
+
approach the ceiling, so point at the L40S fallback rather than silently
|
|
177
|
+
submitting.
|
|
178
|
+
"""
|
|
179
|
+
for entry in sequences:
|
|
180
|
+
if not isinstance(entry, dict):
|
|
181
|
+
continue
|
|
182
|
+
chain = entry.get("proteinChain")
|
|
183
|
+
if not isinstance(chain, dict):
|
|
184
|
+
continue
|
|
185
|
+
seq = chain.get("sequence")
|
|
186
|
+
if isinstance(seq, str) and len(seq) > A10G_SAFE_PROTEIN_LENGTH:
|
|
187
|
+
click.echo(
|
|
188
|
+
click.style(
|
|
189
|
+
f"Warning: {config.name} job {name!r} has a protein chain of "
|
|
190
|
+
f"{len(seq)} aa (> {A10G_SAFE_PROTEIN_LENGTH} aa measured-safe "
|
|
191
|
+
f"on A10G {DEFAULT_QUEUE}). VRAM may approach the 24 GB ceiling; "
|
|
192
|
+
"consider the l40s-1x-spot fallback (--queue).",
|
|
193
|
+
fg="yellow",
|
|
194
|
+
),
|
|
195
|
+
err=True,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_config(config: Path, input_root: Path):
|
|
200
|
+
"""Validate a single DISCO config; raise a clean CLI error on any violation."""
|
|
201
|
+
try:
|
|
202
|
+
jobs = json.loads(config.read_text())
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
raise click.ClickException(f"Config is not valid JSON: {config.name}")
|
|
205
|
+
|
|
206
|
+
if not isinstance(jobs, list) or not jobs:
|
|
207
|
+
raise click.ClickException(f"Config must be a non-empty list of jobs: {config.name}")
|
|
208
|
+
|
|
209
|
+
seen_names = set()
|
|
210
|
+
for job in jobs:
|
|
211
|
+
if not isinstance(job, dict):
|
|
212
|
+
raise click.ClickException(f"Job must be an object in {config.name}")
|
|
213
|
+
|
|
214
|
+
name = job.get("name")
|
|
215
|
+
if not name or not isinstance(name, str):
|
|
216
|
+
raise click.ClickException(f"Job missing 'name' in {config.name}")
|
|
217
|
+
|
|
218
|
+
if "/" in name or "\\" in name or ".." in name or name != Path(name).name:
|
|
219
|
+
raise click.ClickException(f"Invalid job name (path separators): {name!r}")
|
|
220
|
+
|
|
221
|
+
if name in seen_names:
|
|
222
|
+
raise click.ClickException(f"Duplicate job name in {config.name}: {name!r}")
|
|
223
|
+
seen_names.add(name)
|
|
224
|
+
|
|
225
|
+
sequences = job.get("sequences")
|
|
226
|
+
if not isinstance(sequences, list) or not sequences:
|
|
227
|
+
raise click.ClickException(f"Job {name!r} has no entities in {config.name}")
|
|
228
|
+
|
|
229
|
+
_warn_if_over_a10g_headroom(config, name, sequences)
|
|
230
|
+
|
|
231
|
+
_validate_file_ligands(config, input_root)
|
|
232
|
+
|
|
233
|
+
meta = config.with_name(f"{config.stem}.disco-meta.json")
|
|
234
|
+
if meta.exists():
|
|
235
|
+
try:
|
|
236
|
+
json.loads(meta.read_text())
|
|
237
|
+
except json.JSONDecodeError:
|
|
238
|
+
raise click.ClickException(f"Malformed sidecar: {meta.name}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _validate_file_ligands(config: Path, input_root: Path):
|
|
242
|
+
"""Reject absolute, escaping, symlink-escaping, or missing FILE_ ligands."""
|
|
243
|
+
input_root = input_root.resolve()
|
|
244
|
+
for relpath in _referenced_file_ligands(config):
|
|
245
|
+
if Path(relpath).is_absolute():
|
|
246
|
+
raise click.ClickException(f"FILE_ ligand path must be relative: {relpath}")
|
|
247
|
+
|
|
248
|
+
resolved = (input_root / relpath).resolve()
|
|
249
|
+
if not resolved.is_relative_to(input_root):
|
|
250
|
+
raise click.ClickException(f"FILE_ ligand path escapes input dir: {relpath}")
|
|
251
|
+
|
|
252
|
+
if not resolved.exists():
|
|
253
|
+
raise click.ClickException(f"FILE_ ligand file not found: {relpath}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _validate_inputs(input_path: Path) -> list[Path]:
|
|
257
|
+
"""Discover and validate configs; raise a clean CLI error on any violation."""
|
|
258
|
+
configs = _discover_configs(input_path)
|
|
259
|
+
if not configs:
|
|
260
|
+
raise click.ClickException(f"No DISCO JSON config files found in {input_path}")
|
|
261
|
+
|
|
262
|
+
for config in configs:
|
|
263
|
+
_validate_config(config, input_path)
|
|
264
|
+
|
|
265
|
+
return configs
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _copy_inputs_to_job_dir(input_path: Path, job_dir: Path) -> int:
|
|
269
|
+
"""Copy configs, their FILE_ ligands, and meta sidecars into the job dir."""
|
|
270
|
+
dest = job_dir / "input"
|
|
271
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
|
|
273
|
+
count = 0
|
|
274
|
+
for config in _discover_configs(input_path):
|
|
275
|
+
shutil.copy2(config, dest / config.name)
|
|
276
|
+
count += 1
|
|
277
|
+
|
|
278
|
+
for relpath in _referenced_file_ligands(config):
|
|
279
|
+
src = input_path / relpath
|
|
280
|
+
target = dest / relpath
|
|
281
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
shutil.copy2(src, target)
|
|
283
|
+
|
|
284
|
+
meta = config.with_name(f"{config.stem}.disco-meta.json")
|
|
285
|
+
if meta.exists():
|
|
286
|
+
shutil.copy2(meta, dest / meta.name)
|
|
287
|
+
|
|
288
|
+
return count
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _submit_batch_job(
|
|
292
|
+
input_path: Path,
|
|
293
|
+
workers: int | None,
|
|
294
|
+
queue: str,
|
|
295
|
+
dry_run: bool,
|
|
296
|
+
base_path: str,
|
|
297
|
+
after: tuple[str, ...],
|
|
298
|
+
):
|
|
299
|
+
"""Submit a DISCO job to AWS Batch."""
|
|
300
|
+
click.echo(f"Scanning {input_path} for JSON files...")
|
|
301
|
+
configs = _validate_inputs(input_path)
|
|
302
|
+
num_files = len(configs)
|
|
303
|
+
click.echo(f"Found {num_files} config(s) to process")
|
|
304
|
+
|
|
305
|
+
if workers is None:
|
|
306
|
+
workers = max(1, min(math.ceil(num_files / FILES_PER_WORKER), MAX_WORKERS))
|
|
307
|
+
array_size = min(num_files, workers)
|
|
308
|
+
|
|
309
|
+
job_id = generate_job_id("disco")
|
|
310
|
+
|
|
311
|
+
click.echo()
|
|
312
|
+
click.echo(f"Job ID: {job_id}")
|
|
313
|
+
click.echo(f"Input: {input_path}")
|
|
314
|
+
click.echo(f"Configs: {num_files}")
|
|
315
|
+
click.echo(f"Workers: {array_size}")
|
|
316
|
+
click.echo(f"Queue: {queue}")
|
|
317
|
+
click.echo(f"Job definition: {DEFAULT_JOB_DEFINITION}")
|
|
318
|
+
click.echo(f"Image: {DEFAULT_IMAGE_URI}")
|
|
319
|
+
|
|
320
|
+
if dry_run:
|
|
321
|
+
click.echo()
|
|
322
|
+
click.echo(click.style("Dry run - job not submitted (REMOTE plan)", fg="yellow"))
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
for jid in after:
|
|
326
|
+
if not _JOB_ID_PATTERN.match(jid):
|
|
327
|
+
raise click.ClickException(f"Invalid --after job ID: {jid!r}")
|
|
328
|
+
|
|
329
|
+
if not click.confirm("\nSubmit job?", default=True):
|
|
330
|
+
click.echo("Cancelled.")
|
|
331
|
+
raise SystemExit(0)
|
|
332
|
+
click.echo()
|
|
333
|
+
|
|
334
|
+
client = BatchClient()
|
|
335
|
+
|
|
336
|
+
response = client.batch.describe_job_definitions(jobDefinitionName=DEFAULT_JOB_DEFINITION, status="ACTIVE")
|
|
337
|
+
if not response.get("jobDefinitions"):
|
|
338
|
+
raise click.ClickException(
|
|
339
|
+
"dayhoff-disco job definition not yet deployed — apply Terraform in environments/dev first"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
job_dir = create_job_directory(job_id, base_path)
|
|
343
|
+
click.echo(f"Created job directory: {job_dir}")
|
|
344
|
+
|
|
345
|
+
click.echo("Copying input files...")
|
|
346
|
+
copied = _copy_inputs_to_job_dir(input_path, job_dir)
|
|
347
|
+
click.echo(f"Copied {copied} config file(s)")
|
|
348
|
+
|
|
349
|
+
manifest = JobManifest(
|
|
350
|
+
job_id=job_id,
|
|
351
|
+
user=job_id.split("-")[0],
|
|
352
|
+
pipeline="disco",
|
|
353
|
+
status=JobStatus.PENDING,
|
|
354
|
+
image_uri=DEFAULT_IMAGE_URI,
|
|
355
|
+
input=InputConfig(
|
|
356
|
+
source=str(input_path),
|
|
357
|
+
num_sequences=num_files,
|
|
358
|
+
num_chunks=array_size,
|
|
359
|
+
),
|
|
360
|
+
batch=BatchConfig(
|
|
361
|
+
queue=queue,
|
|
362
|
+
job_definition=DEFAULT_JOB_DEFINITION,
|
|
363
|
+
array_size=array_size,
|
|
364
|
+
),
|
|
365
|
+
output=OutputConfig(
|
|
366
|
+
destination=None,
|
|
367
|
+
finalized=False,
|
|
368
|
+
),
|
|
369
|
+
depends_on=list(after) if after else None,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
save_manifest(manifest, base_path)
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
resolved = [resolve_dependency(jid, base_path) for jid in after]
|
|
376
|
+
depends_on = [{"jobId": aws_id} for aws_id in resolved if aws_id is not None] or None
|
|
377
|
+
|
|
378
|
+
environment = {
|
|
379
|
+
"JOB_DIR": str(job_dir),
|
|
380
|
+
"JOB_ID": job_id,
|
|
381
|
+
"BATCH_ARRAY_SIZE": str(array_size),
|
|
382
|
+
"BATCH_NUM_FILES": str(num_files),
|
|
383
|
+
"HF_HUB_OFFLINE": "1",
|
|
384
|
+
"TRANSFORMERS_OFFLINE": "1",
|
|
385
|
+
"HF_HOME": HF_HUB_CACHE_DIR,
|
|
386
|
+
"DISCO_CHECKPOINT_PATH": DISCO_CHECKPOINT_PATH,
|
|
387
|
+
"load_checkpoint_path": DISCO_CHECKPOINT_PATH,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
batch_job_id = client.submit_job(
|
|
391
|
+
job_name=job_id,
|
|
392
|
+
job_definition=DEFAULT_JOB_DEFINITION,
|
|
393
|
+
job_queue=queue,
|
|
394
|
+
array_size=array_size,
|
|
395
|
+
environment=environment,
|
|
396
|
+
timeout_seconds=DISCO_TIMEOUT_SECONDS,
|
|
397
|
+
retry_attempts=DISCO_RETRY_ATTEMPTS,
|
|
398
|
+
depends_on=depends_on,
|
|
399
|
+
share_identifier=get_aws_username(),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
manifest.status = JobStatus.SUBMITTED
|
|
403
|
+
manifest.batch.job_id = batch_job_id
|
|
404
|
+
save_manifest(manifest, base_path)
|
|
405
|
+
|
|
406
|
+
click.echo()
|
|
407
|
+
click.echo(click.style("Job submitted successfully!", fg="green"))
|
|
408
|
+
click.echo()
|
|
409
|
+
click.echo(f"AWS Batch Job ID: {batch_job_id}")
|
|
410
|
+
if depends_on:
|
|
411
|
+
click.echo(f"Waiting on: {', '.join(after)}")
|
|
412
|
+
click.echo()
|
|
413
|
+
click.echo("Next steps:")
|
|
414
|
+
click.echo(f" Check status: dh batch status {job_id}")
|
|
415
|
+
click.echo(f" View logs: dh batch logs {job_id}")
|
|
416
|
+
click.echo(f" Cancel: dh batch cancel {job_id}")
|
|
417
|
+
click.echo()
|
|
418
|
+
click.echo("After completion:")
|
|
419
|
+
click.echo(f" Finalize: dh batch finalize {job_id} --output ./results/")
|
|
420
|
+
|
|
421
|
+
except BatchError as e:
|
|
422
|
+
manifest.status = JobStatus.FAILED
|
|
423
|
+
manifest.error_message = str(e)
|
|
424
|
+
save_manifest(manifest, base_path)
|
|
425
|
+
click.echo(click.style(f"Failed to submit job: {e}", fg="red"), err=True)
|
|
426
|
+
raise SystemExit(1)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _run_local_mode(input_path: Path, base_path: str = BATCH_JOBS_BASE):
|
|
430
|
+
"""Run DISCO locally in a Docker container after host-side fast-fail checks."""
|
|
431
|
+
machine = platform.machine()
|
|
432
|
+
if machine not in {"x86_64", "amd64"}:
|
|
433
|
+
raise click.ClickException(
|
|
434
|
+
f"Local DISCO requires an x86_64 host (got {machine}); "
|
|
435
|
+
"no local image exists for this architecture. Use --remote instead."
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if not PRIMORDIAL_CHECKPOINT_DIR.exists() or not any(PRIMORDIAL_CHECKPOINT_DIR.iterdir()):
|
|
439
|
+
raise click.ClickException(
|
|
440
|
+
f"DISCO checkpoints not found at {PRIMORDIAL_CHECKPOINT_DIR}; "
|
|
441
|
+
"this host cannot run DISCO locally. Use --remote instead."
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if not _has_nvidia_runtime():
|
|
445
|
+
raise click.ClickException(
|
|
446
|
+
"NVIDIA container runtime not available to Docker; cannot run DISCO locally. Use --remote instead."
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
configs = _validate_inputs(input_path)
|
|
450
|
+
|
|
451
|
+
temp_job_dir = input_path / ".local_disco_job"
|
|
452
|
+
if temp_job_dir.exists():
|
|
453
|
+
shutil.rmtree(temp_job_dir)
|
|
454
|
+
(temp_job_dir / "output").mkdir(parents=True)
|
|
455
|
+
_copy_inputs_to_job_dir(input_path, temp_job_dir)
|
|
456
|
+
|
|
457
|
+
click.echo(f"Running DISCO locally on {len(configs)} config(s)...")
|
|
458
|
+
|
|
459
|
+
cmd = [
|
|
460
|
+
"docker",
|
|
461
|
+
"run",
|
|
462
|
+
"--rm",
|
|
463
|
+
"--gpus",
|
|
464
|
+
"all",
|
|
465
|
+
"-v",
|
|
466
|
+
"/primordial:/primordial",
|
|
467
|
+
"-v",
|
|
468
|
+
f"{temp_job_dir}:{temp_job_dir}",
|
|
469
|
+
"-e",
|
|
470
|
+
f"JOB_DIR={temp_job_dir}",
|
|
471
|
+
"-e",
|
|
472
|
+
"AWS_BATCH_JOB_ARRAY_INDEX=0",
|
|
473
|
+
"-e",
|
|
474
|
+
"BATCH_ARRAY_SIZE=1",
|
|
475
|
+
"-e",
|
|
476
|
+
f"BATCH_NUM_FILES={len(configs)}",
|
|
477
|
+
"-e",
|
|
478
|
+
"HF_HUB_OFFLINE=1",
|
|
479
|
+
"-e",
|
|
480
|
+
"TRANSFORMERS_OFFLINE=1",
|
|
481
|
+
"-e",
|
|
482
|
+
f"HF_HOME={HF_HUB_CACHE_DIR}",
|
|
483
|
+
"-e",
|
|
484
|
+
f"CUTLASS_PATH={CUTLASS_PATH}",
|
|
485
|
+
"-e",
|
|
486
|
+
f"DISCO_CHECKPOINT_PATH={DISCO_CHECKPOINT_PATH}",
|
|
487
|
+
DEFAULT_IMAGE_URI,
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
click.echo(f"Running: {' '.join(cmd)}")
|
|
491
|
+
subprocess.run(cmd)
|
|
@@ -148,6 +148,8 @@ def finalize(job_id, output, force, keep_intermediates, full_output, skip_dedup,
|
|
|
148
148
|
_finalize_rfd3(output_dir, output_path)
|
|
149
149
|
elif manifest.pipeline == "esmfold2":
|
|
150
150
|
_finalize_esmfold2(output_dir, output_path)
|
|
151
|
+
elif manifest.pipeline == "disco":
|
|
152
|
+
_finalize_disco(output_dir, output_path)
|
|
151
153
|
elif manifest.pipeline == "equilibrator":
|
|
152
154
|
_finalize_equilibrator(output_dir, output_path, job_id=job_id)
|
|
153
155
|
else:
|
|
@@ -621,6 +623,43 @@ def _finalize_esmfold2(output_dir: Path, output_path: Path):
|
|
|
621
623
|
click.echo(click.style(f"Results: {output_path}", fg="green"))
|
|
622
624
|
|
|
623
625
|
|
|
626
|
+
def _finalize_disco(output_dir: Path, output_path: Path):
|
|
627
|
+
"""Collate completed DISCO per-config output dirs into the final output.
|
|
628
|
+
|
|
629
|
+
The worker promotes each config's `pdbs/`, `sequences/`, and `ERR/`
|
|
630
|
+
subtrees into `output/<stem>/` and drops a `output/disco_<stem>.done`
|
|
631
|
+
marker only when the config succeeded. Finalize copies each completed
|
|
632
|
+
config's directory into `output_path/<stem>/`, skipping configs without a
|
|
633
|
+
`.done` marker and the loose `disco_worker_<NNN>.done` per-worker-index
|
|
634
|
+
markers, which live in a distinct namespace.
|
|
635
|
+
"""
|
|
636
|
+
completed = []
|
|
637
|
+
for marker in sorted(output_dir.glob("disco_*.done")):
|
|
638
|
+
if marker.name.startswith("disco_worker_"):
|
|
639
|
+
continue
|
|
640
|
+
stem = marker.name[len("disco_") : -len(".done")]
|
|
641
|
+
if (output_dir / stem).is_dir():
|
|
642
|
+
completed.append(stem)
|
|
643
|
+
|
|
644
|
+
if not completed:
|
|
645
|
+
click.echo("No completed DISCO configs found in output directory.", err=True)
|
|
646
|
+
raise SystemExit(1)
|
|
647
|
+
|
|
648
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
649
|
+
|
|
650
|
+
copied_count = 0
|
|
651
|
+
for stem in completed:
|
|
652
|
+
dest = output_path / stem
|
|
653
|
+
if dest.exists():
|
|
654
|
+
click.echo(f" Skipping {stem} (already exists)")
|
|
655
|
+
continue
|
|
656
|
+
shutil.copytree(output_dir / stem, dest)
|
|
657
|
+
copied_count += 1
|
|
658
|
+
|
|
659
|
+
click.echo(f"Collated {copied_count} DISCO config(s)")
|
|
660
|
+
click.echo(click.style(f"Results: {output_path}", fg="green"))
|
|
661
|
+
|
|
662
|
+
|
|
624
663
|
def _finalize_equilibrator(output_dir: Path, output_path: Path, job_id: str | None = None) -> None:
|
|
625
664
|
"""Combine per-chunk JSONL worker outputs into one sorted JSONL file.
|
|
626
665
|
|