dh-cli 0.8.8__tar.gz → 0.10.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 (102) hide show
  1. {dh_cli-0.8.8 → dh_cli-0.10.0}/PKG-INFO +1 -1
  2. {dh_cli-0.8.8 → dh_cli-0.10.0}/pyproject.toml +1 -1
  3. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/__init__.py +4 -0
  4. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/__init__.py +0 -2
  5. dh_cli-0.10.0/src/dh_cli/batch/commands/equilibrator.py +319 -0
  6. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/finalize.py +67 -0
  7. dh_cli-0.10.0/src/dh_cli/batch/commands/rfd3.py +402 -0
  8. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/manifest.py +2 -0
  9. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/bedrock/commands.py +2 -5
  10. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_rotate.py +2 -6
  11. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/conftest.py +1 -1
  12. dh_cli-0.10.0/tests/test_equilibrator_command.py +229 -0
  13. dh_cli-0.10.0/tests/test_finalize_equilibrator.py +191 -0
  14. dh_cli-0.10.0/tests/test_finalize_rfd3.py +130 -0
  15. dh_cli-0.10.0/tests/test_rfd3_command.py +456 -0
  16. dh_cli-0.8.8/tests/bedrock/test_cursor_bedrock_isolation.py +0 -271
  17. {dh_cli-0.8.8 → dh_cli-0.10.0}/.gitignore +0 -0
  18. {dh_cli-0.8.8 → dh_cli-0.10.0}/LICENSE +0 -0
  19. {dh_cli-0.8.8 → dh_cli-0.10.0}/README.md +0 -0
  20. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/__init__.py +0 -0
  21. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/_identity.py +0 -0
  22. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/aws_batch.py +0 -0
  23. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/boltz.py +0 -0
  24. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/cancel.py +0 -0
  25. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/clean.py +0 -0
  26. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  27. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  28. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/local.py +0 -0
  29. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/logs.py +0 -0
  30. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/orca.py +0 -0
  31. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  32. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  33. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/retry.py +0 -0
  34. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/status.py +0 -0
  35. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/submit.py +0 -0
  36. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/train.py +0 -0
  37. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
  38. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/fasta_utils.py +0 -0
  39. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/h5_utils.py +0 -0
  40. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/job_id.py +0 -0
  41. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/batch/s3_transport.py +0 -0
  42. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/bedrock/__init__.py +0 -0
  43. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/bedrock/cost_report.py +0 -0
  44. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
  45. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/cloud_commands.py +0 -0
  46. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/codeartifact.py +0 -0
  47. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/engines_studios/__init__.py +0 -0
  48. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/engines_studios/api_client.py +0 -0
  49. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/engines_studios/auth.py +0 -0
  50. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  51. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/engines_studios/progress.py +0 -0
  52. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  53. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  54. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/github_commands.py +0 -0
  55. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/hz/__init__.py +0 -0
  56. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/hz/deploy.py +0 -0
  57. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/hz/local.py +0 -0
  58. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/hz/test.py +0 -0
  59. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/hz/tf.py +0 -0
  60. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/hz/users.py +0 -0
  61. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/main.py +0 -0
  62. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/utility_commands.py +0 -0
  63. {dh_cli-0.8.8 → dh_cli-0.10.0}/src/dh_cli/warehouse.py +0 -0
  64. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/batch/__init__.py +0 -0
  65. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/batch/test_aws_batch_resources.py +0 -0
  66. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/batch/test_image_override.py +0 -0
  67. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/batch/test_submit_cpu_only.py +0 -0
  68. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/batch/test_submit_image_validation.py +0 -0
  69. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/batch/test_submit_merge.py +0 -0
  70. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/conftest.py +0 -0
  71. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  72. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  73. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/fixtures/C_plain.json +0 -0
  74. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  75. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
  76. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  77. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  78. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_build_report.py +0 -0
  79. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_classify_arn.py +0 -0
  80. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
  81. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_cost_calc.py +0 -0
  82. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_cost_command.py +0 -0
  83. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
  84. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_key_command.py +0 -0
  85. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_render_formats.py +0 -0
  86. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_resolve_base_model.py +0 -0
  87. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/bedrock/test_s3_walker.py +0 -0
  88. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/__init__.py +0 -0
  89. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  90. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/test_identity.py +0 -0
  91. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/test_login.py +0 -0
  92. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/test_login_error_paths.py +0 -0
  93. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/test_login_security.py +0 -0
  94. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/test_logout.py +0 -0
  95. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/test_rotate.py +0 -0
  96. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/github/test_status.py +0 -0
  97. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/hz/test_init.py +0 -0
  98. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/hz/test_suites.py +0 -0
  99. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/hz/test_users.py +0 -0
  100. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/test_cloud_gcp.py +0 -0
  101. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/test_finalize_boltz_tar.py +0 -0
  102. {dh_cli-0.8.8 → dh_cli-0.10.0}/tests/test_finalize_protmpnn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.8.8
3
+ Version: 0.10.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.8.8"
7
+ version = "0.10.0"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -14,6 +14,7 @@ from .commands.boltz import boltz
14
14
  from .commands.cancel import cancel
15
15
  from .commands.clean import clean
16
16
  from .commands.embed_t5 import embed_t5
17
+ from .commands.equilibrator import equilibrator
17
18
  from .commands.finalize import finalize
18
19
  from .commands.list_jobs import list_jobs
19
20
  from .commands.local import local
@@ -22,6 +23,7 @@ from .commands.orca import orca
22
23
  from .commands.protmpnn import protmpnn
23
24
  from .commands.protmpnn_to_boltz import protmpnn_to_boltz
24
25
  from .commands.retry import retry
26
+ from .commands.rfd3 import rfd3
25
27
  from .commands.status import status
26
28
  from .commands.submit import submit
27
29
  from .commands.train import train
@@ -98,10 +100,12 @@ batch_cli.add_command(wait_for, name="wait-for")
98
100
 
99
101
  # Register pipeline commands
100
102
  batch_cli.add_command(embed_t5, name="embed-t5")
103
+ batch_cli.add_command(equilibrator, name="equilibrator")
101
104
  batch_cli.add_command(boltz)
102
105
  batch_cli.add_command(protmpnn)
103
106
  batch_cli.add_command(orca)
104
107
  batch_cli.add_command(protmpnn_to_boltz, name="protmpnn-to-boltz")
108
+ batch_cli.add_command(rfd3)
105
109
  batch_cli.add_command(train)
106
110
 
107
111
  __all__ = ["batch_cli"]
@@ -3,7 +3,6 @@
3
3
  from .boltz import boltz
4
4
  from .cancel import cancel
5
5
  from .embed_t5 import embed_t5
6
- from .finalize import finalize
7
6
  from .list_jobs import list_jobs
8
7
  from .local import local
9
8
  from .logs import logs
@@ -15,7 +14,6 @@ __all__ = [
15
14
  "boltz",
16
15
  "cancel",
17
16
  "embed_t5",
18
- "finalize",
19
17
  "list_jobs",
20
18
  "local",
21
19
  "logs",
@@ -0,0 +1,319 @@
1
+ """eQuilibrator reaction ΔG°' pipeline command."""
2
+
3
+ import math
4
+ import secrets
5
+ import subprocess
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from ..aws_batch import BatchClient, BatchError, resolve_dependency
12
+ from ..job_id import get_aws_username
13
+ from ..manifest import (
14
+ BATCH_JOBS_BASE,
15
+ BatchConfig,
16
+ InputConfig,
17
+ JobManifest,
18
+ JobStatus,
19
+ OutputConfig,
20
+ create_job_directory,
21
+ save_local_stub,
22
+ save_manifest,
23
+ save_manifest_s3,
24
+ )
25
+ from ..s3_transport import s3_job_prefix, upload_directory
26
+
27
+ DEFAULT_QUEUE = "cpu-spot"
28
+ DEFAULT_JOB_DEFINITION = "dayhoff-equilibrator"
29
+ DEFAULT_IMAGE_URI = "074735440724.dkr.ecr.us-east-1.amazonaws.com/dayhoff:equilibrator-latest"
30
+ REACTIONS_PER_WORKER = 1000
31
+ MAX_WORKERS = 50
32
+
33
+ ASSETS_REPO_PATH = "/primordial/equilibrator-assets"
34
+ # ~4s/reaction * REACTIONS_PER_WORKER + sqlite copy + headroom.
35
+ TIMEOUT_SECONDS = 3 * 3600
36
+
37
+
38
+ def _generate_job_id() -> str:
39
+ """Job ID as ``{username}-equilibrator-{YYYYMMDD}-{random8}``.
40
+
41
+ Built here (not via ``generate_job_id``) so the full ``equilibrator``
42
+ pipeline name survives — the generic helper truncates to 10 chars.
43
+ """
44
+ username = get_aws_username()
45
+ date_str = datetime.now().strftime("%Y%m%d")
46
+ return f"{username}-equilibrator-{date_str}-{secrets.token_hex(4)}"
47
+
48
+
49
+ def _is_primordial_path(path: Path) -> bool:
50
+ """Check if a path is on Primordial (EFS)."""
51
+ return str(path).startswith("/primordial/")
52
+
53
+
54
+ def _resolve_image_digest() -> str:
55
+ """Best-effort resolution of the container image digest via ECR.
56
+
57
+ Patched out in tests. Never crashes the command.
58
+ """
59
+ try:
60
+ result = subprocess.run(
61
+ [
62
+ "aws",
63
+ "ecr",
64
+ "describe-images",
65
+ "--repository-name",
66
+ "dayhoff",
67
+ "--image-ids",
68
+ "imageTag=equilibrator-latest",
69
+ "--query",
70
+ "imageDetails[0].imageDigest",
71
+ "--output",
72
+ "text",
73
+ ],
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=15,
77
+ )
78
+ digest = result.stdout.strip()
79
+ if result.returncode == 0 and digest and digest != "None":
80
+ return digest
81
+ except Exception:
82
+ pass
83
+ return DEFAULT_IMAGE_URI
84
+
85
+
86
+ def _resolve_assets_commit() -> str:
87
+ """Best-effort current equilibrator-assets git commit short SHA.
88
+
89
+ Patched out in tests. Returns "unknown" on failure.
90
+ """
91
+ try:
92
+ result = subprocess.run(
93
+ ["git", "-C", ASSETS_REPO_PATH, "rev-parse", "--short", "HEAD"],
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=10,
97
+ )
98
+ sha = result.stdout.strip()
99
+ if result.returncode == 0 and sha:
100
+ return sha
101
+ except Exception:
102
+ pass
103
+ return "unknown"
104
+
105
+
106
+ def _read_reactions(input_path: Path) -> list[str]:
107
+ """Return non-blank reaction lines from the input file, in order."""
108
+ return [line for line in input_path.read_text().splitlines() if line.strip()]
109
+
110
+
111
+ def _chunk_reactions(reactions: list[str], array_size: int) -> list[list[str]]:
112
+ """Split reactions into ``array_size`` contiguous, balanced chunks.
113
+
114
+ First ``n % k`` chunks get ceil(n/k) lines, the rest get floor(n/k).
115
+ """
116
+ n = len(reactions)
117
+ base = n // array_size
118
+ remainder = n % array_size
119
+ chunks: list[list[str]] = []
120
+ start = 0
121
+ for i in range(array_size):
122
+ size = base + (1 if i < remainder else 0)
123
+ chunks.append(reactions[start : start + size])
124
+ start += size
125
+ return chunks
126
+
127
+
128
+ def _write_chunks(input_dir: Path, chunks: list[list[str]]) -> None:
129
+ """Write each chunk to ``input_dir/chunk_{i:03d}.txt``."""
130
+ input_dir.mkdir(parents=True, exist_ok=True)
131
+ for i, chunk in enumerate(chunks):
132
+ (input_dir / f"chunk_{i:03d}.txt").write_text("\n".join(chunk) + "\n")
133
+
134
+
135
+ @click.command()
136
+ @click.argument("input_file", type=click.Path(exists=True))
137
+ @click.option(
138
+ "--workers",
139
+ default=None,
140
+ type=int,
141
+ help=f"Number of parallel workers [default: ~1 per {REACTIONS_PER_WORKER} reactions]",
142
+ )
143
+ @click.option("--queue", default=DEFAULT_QUEUE, help=f"Batch queue [default: {DEFAULT_QUEUE}]")
144
+ @click.option("--dry-run", is_flag=True, help="Show plan without submitting")
145
+ @click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
146
+ @click.option("--after", "after", multiple=True, help="Job ID(s) to wait for before starting")
147
+ def equilibrator(input_file, workers, queue, dry_run, base_path, after):
148
+ """Compute reaction ΔG°' with eQuilibrator.
149
+
150
+ Splits an input file of reaction SMILES (one per line) into chunks and
151
+ processes them in parallel using AWS Batch array jobs.
152
+
153
+ \b
154
+ Examples:
155
+ # Submit to AWS Batch (auto-sized workers)
156
+ dh batch equilibrator /primordial/reactions.txt
157
+
158
+ # Force a specific worker count
159
+ dh batch equilibrator reactions.txt --workers 20
160
+
161
+ # Preview what would run
162
+ dh batch equilibrator reactions.txt --dry-run
163
+ """
164
+ input_path = Path(input_file).resolve()
165
+
166
+ click.echo(f"Counting reactions in {input_path}...")
167
+ reactions = _read_reactions(input_path)
168
+ num_reactions = len(reactions)
169
+ click.echo(f"Found {num_reactions} reaction(s)")
170
+
171
+ if num_reactions == 0:
172
+ click.echo(click.style("Error: No reactions found in input file", fg="red"), err=True)
173
+ raise SystemExit(1)
174
+
175
+ if workers is None:
176
+ workers = min(math.ceil(num_reactions / REACTIONS_PER_WORKER), MAX_WORKERS)
177
+ array_size = min(workers, num_reactions)
178
+
179
+ use_s3 = not _is_primordial_path(input_path) and str(base_path) == BATCH_JOBS_BASE
180
+
181
+ job_id = _generate_job_id()
182
+
183
+ click.echo()
184
+ click.echo(f"Job ID: {job_id}")
185
+ click.echo(f"Input: {input_path}")
186
+ click.echo(f"Reactions: {num_reactions}")
187
+ click.echo(f"Workers: {array_size}")
188
+ click.echo(f"Queue: {queue}")
189
+ click.echo(f"Job definition: {DEFAULT_JOB_DEFINITION}")
190
+ click.echo(f"Storage: {'S3 (remote)' if use_s3 else 'Primordial'}")
191
+
192
+ if dry_run:
193
+ click.echo()
194
+ click.echo(click.style("Dry run - job not submitted", fg="yellow"))
195
+ return
196
+
197
+ if not click.confirm("\nSubmit job?", default=True):
198
+ click.echo("Cancelled.")
199
+ raise SystemExit(0)
200
+ click.echo()
201
+
202
+ client = BatchClient()
203
+
204
+ response = client.batch.describe_job_definitions(jobDefinitionName=DEFAULT_JOB_DEFINITION, status="ACTIVE")
205
+ if not response.get("jobDefinitions"):
206
+ raise click.ClickException(
207
+ "equilibrator job definition not yet deployed; apply Terraform in environments/dev first"
208
+ )
209
+
210
+ chunks = _chunk_reactions(reactions, array_size)
211
+ image_digest = _resolve_image_digest()
212
+ source_commit = _resolve_assets_commit()
213
+
214
+ s3_prefix = s3_job_prefix(job_id) if use_s3 else None
215
+ job_dir_path = Path(base_path) / job_id
216
+
217
+ if use_s3:
218
+ import tempfile as _tempfile
219
+
220
+ with _tempfile.TemporaryDirectory() as tmpdir:
221
+ tmp_input_dir = Path(tmpdir) / "input"
222
+ _write_chunks(tmp_input_dir, chunks)
223
+ click.echo("Uploading chunks to S3...")
224
+ upload_directory(tmp_input_dir, f"{s3_prefix}input/", glob="*.txt")
225
+ click.echo(f"Uploaded {array_size} chunks to S3")
226
+ else:
227
+ job_dir_path = create_job_directory(job_id, base_path)
228
+ click.echo(f"Created job directory: {job_dir_path}")
229
+ _write_chunks(job_dir_path / "input", chunks)
230
+ click.echo(f"Created {array_size} chunks")
231
+
232
+ manifest = JobManifest(
233
+ job_id=job_id,
234
+ user=job_id.split("-")[0],
235
+ pipeline="equilibrator",
236
+ status=JobStatus.PENDING,
237
+ image_uri=DEFAULT_IMAGE_URI,
238
+ image_digest=image_digest,
239
+ source_commit=source_commit,
240
+ storage_mode="s3" if use_s3 else "primordial",
241
+ s3_prefix=s3_prefix,
242
+ input=InputConfig(
243
+ source=str(input_path),
244
+ num_sequences=num_reactions,
245
+ num_chunks=array_size,
246
+ ),
247
+ batch=BatchConfig(
248
+ queue=queue,
249
+ job_definition=DEFAULT_JOB_DEFINITION,
250
+ array_size=array_size,
251
+ ),
252
+ output=OutputConfig(
253
+ destination=None,
254
+ finalized=False,
255
+ ),
256
+ depends_on=list(after) if after else None,
257
+ )
258
+
259
+ if use_s3:
260
+ save_manifest_s3(manifest)
261
+ save_local_stub(job_id, s3_prefix)
262
+ else:
263
+ save_manifest(manifest, base_path)
264
+
265
+ try:
266
+ resolved = [resolve_dependency(jid, base_path) for jid in after]
267
+ depends_on = [{"jobId": aws_id} for aws_id in resolved if aws_id is not None] or None
268
+
269
+ environment = {
270
+ "JOB_DIR": str(job_dir_path),
271
+ "JOB_ID": job_id,
272
+ }
273
+ if use_s3:
274
+ environment["STORAGE_MODE"] = "s3"
275
+ environment["S3_JOB_PREFIX"] = s3_prefix
276
+
277
+ batch_job_id = client.submit_job(
278
+ job_name=job_id,
279
+ job_definition=DEFAULT_JOB_DEFINITION,
280
+ job_queue=queue,
281
+ array_size=array_size,
282
+ environment=environment,
283
+ timeout_seconds=TIMEOUT_SECONDS,
284
+ retry_attempts=5,
285
+ depends_on=depends_on,
286
+ share_identifier=get_aws_username(),
287
+ )
288
+
289
+ manifest.status = JobStatus.SUBMITTED
290
+ manifest.batch.job_id = batch_job_id
291
+ if use_s3:
292
+ save_manifest_s3(manifest)
293
+ else:
294
+ save_manifest(manifest, base_path)
295
+
296
+ click.echo()
297
+ click.echo(click.style("Job submitted successfully!", fg="green"))
298
+ click.echo()
299
+ click.echo(f"AWS Batch Job ID: {batch_job_id}")
300
+ if depends_on:
301
+ click.echo(f"Waiting on: {', '.join(after)}")
302
+ click.echo()
303
+ click.echo("Next steps:")
304
+ click.echo(f" Check status: dh batch status {job_id}")
305
+ click.echo(f" View logs: dh batch logs {job_id}")
306
+ click.echo(f" Cancel: dh batch cancel {job_id}")
307
+ click.echo()
308
+ click.echo("After completion:")
309
+ click.echo(f" Finalize: dh batch finalize {job_id} --output ./results/")
310
+
311
+ except BatchError as e:
312
+ manifest.status = JobStatus.FAILED
313
+ manifest.error_message = str(e)
314
+ if use_s3:
315
+ save_manifest_s3(manifest)
316
+ else:
317
+ save_manifest(manifest, base_path)
318
+ click.echo(click.style(f"Failed to submit job: {e}", fg="red"), err=True)
319
+ raise SystemExit(1)
@@ -1,5 +1,6 @@
1
1
  """Finalize command for combining results and cleaning up."""
2
2
 
3
+ import json
3
4
  import shutil
4
5
  import tarfile
5
6
  import tempfile
@@ -143,6 +144,10 @@ def finalize(job_id, output, force, keep_intermediates, full_output, skip_dedup,
143
144
  _finalize_boltz(output_dir, output_path, full_output=full_output)
144
145
  elif manifest.pipeline == "protmpnn":
145
146
  _finalize_protmpnn(output_dir, output_path)
147
+ elif manifest.pipeline == "rfd3":
148
+ _finalize_rfd3(output_dir, output_path)
149
+ elif manifest.pipeline == "equilibrator":
150
+ _finalize_equilibrator(output_dir, output_path, job_id=job_id)
146
151
  else:
147
152
  _finalize_generic(output_dir, output_path)
148
153
 
@@ -544,3 +549,65 @@ def _finalize_protmpnn(output_dir: Path, output_path: Path):
544
549
 
545
550
  click.echo(f"{num_variants} variants from {num_configs} config(s), top confidence: {top_conf}")
546
551
  click.echo(click.style(f"Results: {output_path / 'results.csv'}", fg="green"))
552
+
553
+
554
+ def _finalize_rfd3(output_dir: Path, output_path: Path):
555
+ """Collate completed RFD3 per-config backbone dirs into the final output.
556
+
557
+ The worker promotes each config's flat `.cif.gz` + `.json` design files
558
+ into `output/<stem>/` and drops a `output/rfd3_<stem>.done` marker only
559
+ when validation passed. Finalize copies each completed config's directory
560
+ into `output_path/<stem>/`, skipping configs without a `.done` marker and
561
+ the loose worker-index markers.
562
+ """
563
+ completed = []
564
+ for marker in sorted(output_dir.glob("rfd3_*.done")):
565
+ stem = marker.name[len("rfd3_") : -len(".done")]
566
+ if (output_dir / stem).is_dir():
567
+ completed.append(stem)
568
+
569
+ if not completed:
570
+ click.echo("No completed RFD3 configs found in output directory.", err=True)
571
+ raise SystemExit(1)
572
+
573
+ output_path.mkdir(parents=True, exist_ok=True)
574
+
575
+ copied_count = 0
576
+ for stem in completed:
577
+ dest = output_path / stem
578
+ if dest.exists():
579
+ click.echo(f" Skipping {stem} (already exists)")
580
+ continue
581
+ shutil.copytree(output_dir / stem, dest)
582
+ copied_count += 1
583
+
584
+ click.echo(f"Collated {copied_count} RFD3 config(s)")
585
+ click.echo(click.style(f"Results: {output_path}", fg="green"))
586
+
587
+
588
+ def _finalize_equilibrator(output_dir: Path, output_path: Path, job_id: str | None = None) -> None:
589
+ """Combine per-chunk JSONL worker outputs into one sorted JSONL file.
590
+
591
+ Reads every ``chunk_*.jsonl`` under ``output_dir``, sorts all records by
592
+ ``input_index``, and writes them to the destination. A missing chunk
593
+ (gap in indices) is tolerated. If ``output_path`` is an existing
594
+ directory, the combined file is named ``{job_id}.jsonl`` inside it.
595
+ """
596
+ if output_path.is_dir():
597
+ output_path = output_path / f"{job_id}.jsonl"
598
+
599
+ records: list[dict] = []
600
+ for chunk_file in sorted(output_dir.glob("chunk_*.jsonl")):
601
+ for line in chunk_file.read_text().splitlines():
602
+ if line.strip():
603
+ records.append(json.loads(line))
604
+
605
+ records.sort(key=lambda r: r["input_index"])
606
+
607
+ output_path.parent.mkdir(parents=True, exist_ok=True)
608
+ with open(output_path, "w") as f:
609
+ for rec in records:
610
+ f.write(json.dumps(rec) + "\n")
611
+
612
+ shutil.rmtree(output_dir, ignore_errors=True)
613
+ click.echo(f"Combined {len(records)} records into {output_path}")