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.
Files changed (109) hide show
  1. {dh_cli-0.10.2 → dh_cli-0.11.0}/.gitignore +5 -0
  2. {dh_cli-0.10.2 → dh_cli-0.11.0}/PKG-INFO +1 -1
  3. {dh_cli-0.10.2 → dh_cli-0.11.0}/pyproject.toml +1 -1
  4. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/__init__.py +2 -0
  5. dh_cli-0.11.0/src/dh_cli/batch/commands/disco.py +491 -0
  6. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/finalize.py +39 -0
  7. dh_cli-0.11.0/tests/test_disco_command.py +561 -0
  8. dh_cli-0.11.0/tests/test_disco_jobdef.py +171 -0
  9. dh_cli-0.11.0/tests/test_finalize_disco.py +164 -0
  10. dh_cli-0.11.0/tests/test_finalize_disco_dispatch.py +67 -0
  11. {dh_cli-0.10.2 → dh_cli-0.11.0}/LICENSE +0 -0
  12. {dh_cli-0.10.2 → dh_cli-0.11.0}/README.md +0 -0
  13. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/__init__.py +0 -0
  14. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/_identity.py +0 -0
  15. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/aws_batch.py +0 -0
  16. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/__init__.py +0 -0
  17. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/boltz.py +0 -0
  18. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/cancel.py +0 -0
  19. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/clean.py +0 -0
  20. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  21. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/equilibrator.py +0 -0
  22. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/esmfold2.py +0 -0
  23. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  24. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/local.py +0 -0
  25. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/logs.py +0 -0
  26. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/orca.py +0 -0
  27. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  28. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  29. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/retry.py +0 -0
  30. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/rfd3.py +0 -0
  31. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/status.py +0 -0
  32. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/submit.py +0 -0
  33. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/train.py +0 -0
  34. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
  35. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/fasta_utils.py +0 -0
  36. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/h5_utils.py +0 -0
  37. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/job_id.py +0 -0
  38. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/manifest.py +0 -0
  39. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/batch/s3_transport.py +0 -0
  40. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/bedrock/__init__.py +0 -0
  41. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/bedrock/commands.py +0 -0
  42. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/bedrock/cost_report.py +0 -0
  43. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
  44. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/cloud_commands.py +0 -0
  45. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/codeartifact.py +0 -0
  46. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/__init__.py +0 -0
  47. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/api_client.py +0 -0
  48. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/auth.py +0 -0
  49. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  50. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/progress.py +0 -0
  51. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  52. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  53. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/github_commands.py +0 -0
  54. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/__init__.py +0 -0
  55. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/deploy.py +0 -0
  56. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/local.py +0 -0
  57. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/test.py +0 -0
  58. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/tf.py +0 -0
  59. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/hz/users.py +0 -0
  60. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/main.py +0 -0
  61. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/utility_commands.py +0 -0
  62. {dh_cli-0.10.2 → dh_cli-0.11.0}/src/dh_cli/warehouse.py +0 -0
  63. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/__init__.py +0 -0
  64. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_aws_batch_resources.py +0 -0
  65. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_image_override.py +0 -0
  66. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_submit_cpu_only.py +0 -0
  67. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_submit_image_validation.py +0 -0
  68. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/batch/test_submit_merge.py +0 -0
  69. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/conftest.py +0 -0
  70. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  71. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  72. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/C_plain.json +0 -0
  73. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  74. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
  75. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  76. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  77. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_build_report.py +0 -0
  78. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_classify_arn.py +0 -0
  79. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
  80. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_cost_calc.py +0 -0
  81. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_cost_command.py +0 -0
  82. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
  83. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_key_command.py +0 -0
  84. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_render_formats.py +0 -0
  85. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_resolve_base_model.py +0 -0
  86. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_rotate.py +0 -0
  87. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/bedrock/test_s3_walker.py +0 -0
  88. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/__init__.py +0 -0
  89. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/conftest.py +0 -0
  90. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  91. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_identity.py +0 -0
  92. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_login.py +0 -0
  93. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_login_error_paths.py +0 -0
  94. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_login_security.py +0 -0
  95. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_logout.py +0 -0
  96. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_rotate.py +0 -0
  97. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/github/test_status.py +0 -0
  98. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/hz/test_init.py +0 -0
  99. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/hz/test_suites.py +0 -0
  100. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/hz/test_users.py +0 -0
  101. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_cloud_gcp.py +0 -0
  102. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_equilibrator_command.py +0 -0
  103. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_esmfold2_cli.py +0 -0
  104. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_boltz_tar.py +0 -0
  105. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_equilibrator.py +0 -0
  106. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_esmfold2.py +0 -0
  107. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_protmpnn.py +0 -0
  108. {dh_cli-0.10.2 → dh_cli-0.11.0}/tests/test_finalize_rfd3.py +0 -0
  109. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.10.2
3
+ Version: 0.11.0
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.10.2"
7
+ version = "0.11.0"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -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