dh-cli 0.9.0__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 (101) hide show
  1. {dh_cli-0.9.0 → dh_cli-0.10.0}/PKG-INFO +1 -1
  2. {dh_cli-0.9.0 → dh_cli-0.10.0}/pyproject.toml +1 -1
  3. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/__init__.py +2 -0
  4. {dh_cli-0.9.0 → 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.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/finalize.py +31 -0
  7. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/manifest.py +2 -0
  8. dh_cli-0.10.0/tests/test_equilibrator_command.py +229 -0
  9. dh_cli-0.10.0/tests/test_finalize_equilibrator.py +191 -0
  10. {dh_cli-0.9.0 → dh_cli-0.10.0}/.gitignore +0 -0
  11. {dh_cli-0.9.0 → dh_cli-0.10.0}/LICENSE +0 -0
  12. {dh_cli-0.9.0 → dh_cli-0.10.0}/README.md +0 -0
  13. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/__init__.py +0 -0
  14. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/_identity.py +0 -0
  15. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/aws_batch.py +0 -0
  16. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/boltz.py +0 -0
  17. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/cancel.py +0 -0
  18. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/clean.py +0 -0
  19. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  20. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  21. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/local.py +0 -0
  22. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/logs.py +0 -0
  23. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/orca.py +0 -0
  24. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  25. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  26. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/retry.py +0 -0
  27. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/rfd3.py +0 -0
  28. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/status.py +0 -0
  29. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/submit.py +0 -0
  30. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/train.py +0 -0
  31. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
  32. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/fasta_utils.py +0 -0
  33. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/h5_utils.py +0 -0
  34. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/job_id.py +0 -0
  35. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/s3_transport.py +0 -0
  36. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/bedrock/__init__.py +0 -0
  37. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/bedrock/commands.py +0 -0
  38. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/bedrock/cost_report.py +0 -0
  39. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
  40. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/cloud_commands.py +0 -0
  41. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/codeartifact.py +0 -0
  42. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/__init__.py +0 -0
  43. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/api_client.py +0 -0
  44. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/auth.py +0 -0
  45. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  46. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/progress.py +0 -0
  47. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  48. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  49. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/github_commands.py +0 -0
  50. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/__init__.py +0 -0
  51. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/deploy.py +0 -0
  52. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/local.py +0 -0
  53. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/test.py +0 -0
  54. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/tf.py +0 -0
  55. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/users.py +0 -0
  56. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/main.py +0 -0
  57. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/utility_commands.py +0 -0
  58. {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/warehouse.py +0 -0
  59. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/__init__.py +0 -0
  60. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_aws_batch_resources.py +0 -0
  61. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_image_override.py +0 -0
  62. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_submit_cpu_only.py +0 -0
  63. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_submit_image_validation.py +0 -0
  64. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_submit_merge.py +0 -0
  65. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/conftest.py +0 -0
  66. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  67. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  68. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/C_plain.json +0 -0
  69. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  70. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
  71. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  72. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  73. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_build_report.py +0 -0
  74. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_classify_arn.py +0 -0
  75. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
  76. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_cost_calc.py +0 -0
  77. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_cost_command.py +0 -0
  78. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
  79. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_key_command.py +0 -0
  80. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_render_formats.py +0 -0
  81. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_resolve_base_model.py +0 -0
  82. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_rotate.py +0 -0
  83. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_s3_walker.py +0 -0
  84. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/__init__.py +0 -0
  85. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/conftest.py +0 -0
  86. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  87. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_identity.py +0 -0
  88. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_login.py +0 -0
  89. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_login_error_paths.py +0 -0
  90. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_login_security.py +0 -0
  91. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_logout.py +0 -0
  92. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_rotate.py +0 -0
  93. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_status.py +0 -0
  94. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/hz/test_init.py +0 -0
  95. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/hz/test_suites.py +0 -0
  96. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/hz/test_users.py +0 -0
  97. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_cloud_gcp.py +0 -0
  98. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_finalize_boltz_tar.py +0 -0
  99. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_finalize_protmpnn.py +0 -0
  100. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_finalize_rfd3.py +0 -0
  101. {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_rfd3_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.9.0
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.9.0"
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
@@ -99,6 +100,7 @@ batch_cli.add_command(wait_for, name="wait-for")
99
100
 
100
101
  # Register pipeline commands
101
102
  batch_cli.add_command(embed_t5, name="embed-t5")
103
+ batch_cli.add_command(equilibrator, name="equilibrator")
102
104
  batch_cli.add_command(boltz)
103
105
  batch_cli.add_command(protmpnn)
104
106
  batch_cli.add_command(orca)
@@ -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
@@ -145,6 +146,8 @@ def finalize(job_id, output, force, keep_intermediates, full_output, skip_dedup,
145
146
  _finalize_protmpnn(output_dir, output_path)
146
147
  elif manifest.pipeline == "rfd3":
147
148
  _finalize_rfd3(output_dir, output_path)
149
+ elif manifest.pipeline == "equilibrator":
150
+ _finalize_equilibrator(output_dir, output_path, job_id=job_id)
148
151
  else:
149
152
  _finalize_generic(output_dir, output_path)
150
153
 
@@ -580,3 +583,31 @@ def _finalize_rfd3(output_dir: Path, output_path: Path):
580
583
 
581
584
  click.echo(f"Collated {copied_count} RFD3 config(s)")
582
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}")
@@ -90,6 +90,8 @@ class JobManifest(BaseModel):
90
90
 
91
91
  # Additional metadata
92
92
  image_uri: str | None = Field(None, description="Container image URI")
93
+ image_digest: str | None = Field(None, description="Resolved container image digest")
94
+ source_commit: str | None = Field(None, description="Assets/source repo commit for reproducibility")
93
95
  command: str | None = Field(None, description="Command to run")
94
96
  error_message: str | None = Field(None, description="Error message if failed")
95
97
 
@@ -0,0 +1,229 @@
1
+ """Tests for the `dh batch equilibrator` CLI command.
2
+
3
+ All AWS interactions are mocked; no real Batch calls. Uses Click's CliRunner
4
+ and unittest.mock, mirroring the existing dh-cli test conventions
5
+ (see test_rfd3_command.py).
6
+ """
7
+
8
+ import math
9
+ import re
10
+ import tempfile
11
+ from pathlib import Path
12
+ from unittest.mock import MagicMock, patch
13
+
14
+ import pytest
15
+ from click.testing import CliRunner
16
+
17
+ from dh_cli.batch.commands import equilibrator as eq_cmd
18
+
19
+ JOB_ID_RE = re.compile(r"^[a-z0-9]+-equilibrator-\d{8}-[0-9a-f]{8}$")
20
+
21
+
22
+ @pytest.fixture
23
+ def runner():
24
+ return CliRunner()
25
+
26
+
27
+ @pytest.fixture
28
+ def input_dir():
29
+ with tempfile.TemporaryDirectory() as tmpdir:
30
+ yield Path(tmpdir)
31
+
32
+
33
+ def _write_reactions(input_dir: Path, n: int, name: str = "reactions.txt") -> Path:
34
+ """Write ``n`` distinct reaction SMILES lines to an input file."""
35
+ lines = [f"CC{'C' * i}O.O>>CC{'C' * i}(=O)O" for i in range(n)]
36
+ p = input_dir / name
37
+ p.write_text("\n".join(lines) + "\n")
38
+ return p
39
+
40
+
41
+ def _submit_mock():
42
+ """A BatchClient whose submit_job returns a fake AWS job id and whose
43
+ job-definition preflight reports an ACTIVE dayhoff-equilibrator."""
44
+ client = MagicMock()
45
+ client.submit_job.return_value = "aws-job-123"
46
+ client.batch.describe_job_definitions.return_value = {
47
+ "jobDefinitions": [{"jobDefinitionName": "dayhoff-equilibrator", "status": "ACTIVE"}]
48
+ }
49
+ return client
50
+
51
+
52
+ def _invoke_submit(runner, input_file, args=(), client=None, base=None):
53
+ """Invoke the command on the submit path with AWS + metadata resolution mocked."""
54
+ client = client or _submit_mock()
55
+ if base is None:
56
+ base = Path(input_file).parent / "_jobs"
57
+ with (
58
+ patch.object(eq_cmd, "BatchClient", return_value=client),
59
+ patch.object(eq_cmd, "resolve_dependency", return_value="dep-aws-id"),
60
+ patch.object(eq_cmd, "_resolve_image_digest", return_value="sha256:deadbeef"),
61
+ patch.object(eq_cmd, "_resolve_assets_commit", return_value="abc1234"),
62
+ ):
63
+ result = runner.invoke(
64
+ eq_cmd.equilibrator,
65
+ [str(input_file), "--base-path", str(base), *args],
66
+ input="y\n",
67
+ )
68
+ return result, client, base
69
+
70
+
71
+ class TestJobIdAndDefaults:
72
+ def test_job_id_format(self, runner, input_dir):
73
+ rxns = _write_reactions(input_dir, 3)
74
+ result, client, _ = _invoke_submit(runner, rxns)
75
+ assert result.exit_code == 0, result.output
76
+ job_name = client.submit_job.call_args.kwargs["job_name"]
77
+ assert JOB_ID_RE.match(job_name), job_name
78
+
79
+ def test_default_queue_is_cpu(self, runner, input_dir):
80
+ rxns = _write_reactions(input_dir, 3)
81
+ result, client, _ = _invoke_submit(runner, rxns)
82
+ assert result.exit_code == 0, result.output
83
+ assert client.submit_job.call_args.kwargs["job_queue"] == "cpu-spot"
84
+ assert client.submit_job.call_args.kwargs["job_definition"] == "dayhoff-equilibrator"
85
+ assert eq_cmd.DEFAULT_QUEUE == "cpu-spot"
86
+ assert eq_cmd.DEFAULT_JOB_DEFINITION == "dayhoff-equilibrator"
87
+
88
+
89
+ class TestChunking:
90
+ def test_one_chunk_file_per_index(self, runner, input_dir):
91
+ n = 7
92
+ rxns = _write_reactions(input_dir, n)
93
+ base = input_dir / "_jobs"
94
+ result, client, base = _invoke_submit(runner, rxns, ["--workers", str(n)], base=base)
95
+ assert result.exit_code == 0, result.output
96
+
97
+ array_size = client.submit_job.call_args.kwargs["array_size"]
98
+ chunk_files = sorted(Path(base).rglob("input/chunk_*.txt"))
99
+ assert len(chunk_files) == array_size
100
+
101
+ # Index N's file is chunk_{N:03d}.txt.
102
+ names = {p.name for p in chunk_files}
103
+ for i in range(array_size):
104
+ assert f"chunk_{i:03d}.txt" in names
105
+
106
+ # Union of chunk lines equals the input reactions, in order.
107
+ original = [ln for ln in rxns.read_text().splitlines() if ln.strip()]
108
+ combined: list[str] = []
109
+ for p in chunk_files:
110
+ combined.extend(ln for ln in p.read_text().splitlines() if ln.strip())
111
+ assert combined == original
112
+
113
+ def test_chunking_default(self, runner, input_dir):
114
+ n = 5
115
+ rxns = _write_reactions(input_dir, n)
116
+ result, client, _ = _invoke_submit(runner, rxns)
117
+ assert result.exit_code == 0, result.output
118
+ expected = min(math.ceil(n / eq_cmd.REACTIONS_PER_WORKER), eq_cmd.MAX_WORKERS)
119
+ assert client.submit_job.call_args.kwargs["array_size"] == expected
120
+
121
+ def test_workers_override(self, runner, input_dir):
122
+ n = eq_cmd.REACTIONS_PER_WORKER * 10 # large input
123
+ rxns = _write_reactions(input_dir, n)
124
+ base = input_dir / "_jobs"
125
+ result, client, base = _invoke_submit(runner, rxns, ["--workers", "4"], base=base)
126
+ assert result.exit_code == 0, result.output
127
+ assert client.submit_job.call_args.kwargs["array_size"] == 4
128
+
129
+ chunk_files = sorted(Path(base).rglob("input/chunk_*.txt"))
130
+ assert len(chunk_files) == 4
131
+ counts = [len([ln for ln in p.read_text().splitlines() if ln.strip()]) for p in chunk_files]
132
+ # Balanced: chunk sizes differ by at most 1.
133
+ assert max(counts) - min(counts) <= 1
134
+ assert sum(counts) == n
135
+
136
+ def test_blank_lines_skipped(self, runner, input_dir):
137
+ p = input_dir / "reactions.txt"
138
+ p.write_text("CCO.O>>CC(=O)O\n\n \n CCCO.O>>CCC(=O)O \n\n")
139
+ result, client, _ = _invoke_submit(runner, p)
140
+ assert result.exit_code == 0, result.output
141
+ assert client.submit_job.call_args.kwargs["array_size"] == min(
142
+ math.ceil(2 / eq_cmd.REACTIONS_PER_WORKER), eq_cmd.MAX_WORKERS
143
+ )
144
+ # Only 2 non-blank reactions counted.
145
+ assert "2" in result.output
146
+
147
+
148
+ class TestDryRun:
149
+ def test_dry_run_no_submit(self, runner, input_dir):
150
+ rxns = _write_reactions(input_dir, 4)
151
+ base = input_dir / "_jobs"
152
+ client = _submit_mock()
153
+ with (
154
+ patch.object(eq_cmd, "BatchClient", return_value=client),
155
+ patch.object(eq_cmd, "_resolve_image_digest", return_value="sha256:deadbeef"),
156
+ patch.object(eq_cmd, "_resolve_assets_commit", return_value="abc1234"),
157
+ ):
158
+ result = runner.invoke(eq_cmd.equilibrator, [str(rxns), "--base-path", str(base), "--dry-run"])
159
+ assert result.exit_code == 0, result.output
160
+ client.submit_job.assert_not_called()
161
+ # No job directory created.
162
+ assert not list(Path(base).rglob("chunk_*.txt")) if base.exists() else True
163
+ # Plan is printed.
164
+ out = result.output
165
+ assert "4" in out # reaction count
166
+ assert eq_cmd.DEFAULT_QUEUE in out
167
+ assert eq_cmd.DEFAULT_JOB_DEFINITION in out
168
+
169
+
170
+ class TestEmptyInput:
171
+ def test_empty_input_errors(self, runner, input_dir):
172
+ p = input_dir / "empty.txt"
173
+ p.write_text("\n \n\n")
174
+ result, client, _ = _invoke_submit(runner, p)
175
+ assert result.exit_code != 0
176
+ client.submit_job.assert_not_called()
177
+
178
+
179
+ class TestManifest:
180
+ def test_manifest_written(self, runner, input_dir):
181
+ import json
182
+
183
+ rxns = _write_reactions(input_dir, 3)
184
+ base = input_dir / "_jobs"
185
+ result, client, base = _invoke_submit(runner, rxns, base=base)
186
+ assert result.exit_code == 0, result.output
187
+
188
+ manifests = list(Path(base).rglob("manifest.json"))
189
+ assert len(manifests) == 1
190
+ data = json.loads(manifests[0].read_text())
191
+ assert data["pipeline"] == "equilibrator"
192
+ assert data["batch"]["queue"] == "cpu-spot"
193
+ assert data["batch"]["job_definition"] == "dayhoff-equilibrator"
194
+ assert data["batch"]["array_size"] >= 1
195
+ assert data["input"]["num_chunks"] == data["batch"]["array_size"]
196
+ # D-14: reproducibility — image digest + equilibrator-assets commit.
197
+ blob = json.dumps(data)
198
+ assert "sha256:deadbeef" in blob
199
+ assert "abc1234" in blob
200
+
201
+
202
+ class TestSubmissionEnv:
203
+ def test_env_vars_passed(self, runner, input_dir):
204
+ rxns = _write_reactions(input_dir, 3)
205
+ result, client, _ = _invoke_submit(runner, rxns)
206
+ assert result.exit_code == 0, result.output
207
+ env = client.submit_job.call_args.kwargs["environment"]
208
+ assert "JOB_DIR" in env
209
+ assert "JOB_ID" in env
210
+ # D-10: worker derives its file from the array index, not a file count.
211
+ assert "BATCH_NUM_FILES" not in env
212
+
213
+
214
+ class TestSubmissionParams:
215
+ def test_retry_and_share_identifier(self, runner, input_dir):
216
+ rxns = _write_reactions(input_dir, 3)
217
+ with patch.object(eq_cmd, "get_aws_username", return_value="tester"):
218
+ result, client, _ = _invoke_submit(runner, rxns)
219
+ assert result.exit_code == 0, result.output
220
+ kwargs = client.submit_job.call_args.kwargs
221
+ assert kwargs["retry_attempts"] == 5
222
+ assert kwargs["timeout_seconds"] > 0
223
+
224
+
225
+ class TestRegistration:
226
+ def test_equilibrator_registered_on_group(self):
227
+ from dh_cli.batch import batch_cli
228
+
229
+ assert "equilibrator" in batch_cli.commands
@@ -0,0 +1,191 @@
1
+ """Tests for eQuilibrator JSON-lines chunk combining in finalize.
2
+
3
+ Covers the pure combiner (``_finalize_equilibrator``) and the S3
4
+ download-then-delete path via the finalize command dispatch.
5
+ """
6
+
7
+ import json
8
+ import tempfile
9
+ from pathlib import Path
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ import pytest
13
+
14
+ from dh_cli.batch.commands.finalize import _finalize_equilibrator
15
+
16
+
17
+ @pytest.fixture
18
+ def temp_dir():
19
+ with tempfile.TemporaryDirectory() as tmpdir:
20
+ yield Path(tmpdir)
21
+
22
+
23
+ def _record(input_index: int, dg: float | None = -10.5, error: str | None = None) -> dict:
24
+ """A worker output record with the full fixed schema."""
25
+ return {
26
+ "reaction": f"R{input_index}",
27
+ "equation": f"eq{input_index}",
28
+ "updated_equation": f"ueq{input_index}",
29
+ "dg_prime": dg,
30
+ "dg_prime_stddev": 1.0 if error is None else None,
31
+ "units": "kJ/mol",
32
+ "compound_map": {"a": 1, "b": 2},
33
+ "compound_refs": {"a": {"id": "MNXM1"}},
34
+ "error": error,
35
+ "input_index": input_index,
36
+ }
37
+
38
+
39
+ def _write_chunk(output_dir: Path, index: int, records: list[dict], done: bool = True) -> None:
40
+ output_dir.mkdir(parents=True, exist_ok=True)
41
+ path = output_dir / f"chunk_{index:03d}.jsonl"
42
+ path.write_text("".join(json.dumps(r) + "\n" for r in records))
43
+ if done:
44
+ (output_dir / f"chunk_{index:03d}.done").touch()
45
+
46
+
47
+ def _read_jsonl(path: Path) -> list[dict]:
48
+ return [json.loads(ln) for ln in path.read_text().splitlines() if ln.strip()]
49
+
50
+
51
+ class TestCombine:
52
+ def test_combine_multiple_chunks(self, temp_dir):
53
+ output_dir = temp_dir / "output"
54
+ _write_chunk(output_dir, 0, [_record(0), _record(1)])
55
+ _write_chunk(output_dir, 1, [_record(2), _record(3)])
56
+ _write_chunk(output_dir, 2, [_record(4)])
57
+
58
+ out = temp_dir / "combined.jsonl"
59
+ _finalize_equilibrator(output_dir, out)
60
+
61
+ recs = _read_jsonl(out)
62
+ assert len(recs) == 5
63
+ assert [r["input_index"] for r in recs] == [0, 1, 2, 3, 4]
64
+
65
+ def test_records_passthrough(self, temp_dir):
66
+ output_dir = temp_dir / "output"
67
+ rec = _record(0)
68
+ _write_chunk(output_dir, 0, [rec])
69
+
70
+ out = temp_dir / "combined.jsonl"
71
+ _finalize_equilibrator(output_dir, out)
72
+
73
+ got = _read_jsonl(out)[0]
74
+ assert got == rec
75
+ assert got["compound_map"] == {"a": 1, "b": 2}
76
+ assert got["compound_refs"] == {"a": {"id": "MNXM1"}}
77
+
78
+ def test_error_records_included(self, temp_dir):
79
+ output_dir = temp_dir / "output"
80
+ _write_chunk(output_dir, 0, [_record(0), _record(1, dg=None, error="bad reaction")])
81
+
82
+ out = temp_dir / "combined.jsonl"
83
+ _finalize_equilibrator(output_dir, out)
84
+
85
+ recs = _read_jsonl(out)
86
+ assert len(recs) == 2
87
+ errored = [r for r in recs if r["error"] is not None]
88
+ assert len(errored) == 1
89
+ assert errored[0]["error"] == "bad reaction"
90
+
91
+ def test_done_markers_dropped(self, temp_dir):
92
+ output_dir = temp_dir / "output"
93
+ _write_chunk(output_dir, 0, [_record(0)])
94
+ _write_chunk(output_dir, 1, [_record(1)])
95
+
96
+ out = temp_dir / "combined.jsonl"
97
+ _finalize_equilibrator(output_dir, out)
98
+
99
+ # The finalized artifact is exactly the combined file — no .done, no chunks.
100
+ siblings = list(out.parent.iterdir())
101
+ assert siblings == [out]
102
+ assert not list(out.parent.glob("*.done"))
103
+ assert not list(out.parent.glob("chunk_*.jsonl"))
104
+
105
+ def test_missing_chunk_tolerated(self, temp_dir):
106
+ output_dir = temp_dir / "output"
107
+ _write_chunk(output_dir, 0, [_record(0)])
108
+ # index 1 produced no output (missing chunk).
109
+ _write_chunk(output_dir, 2, [_record(2)])
110
+
111
+ out = temp_dir / "combined.jsonl"
112
+ _finalize_equilibrator(output_dir, out) # must not raise
113
+
114
+ recs = _read_jsonl(out)
115
+ assert [r["input_index"] for r in recs] == [0, 2]
116
+
117
+
118
+ class TestOutputAutoname:
119
+ def test_output_dir_autoname(self, temp_dir):
120
+ output_dir = temp_dir / "output"
121
+ _write_chunk(output_dir, 0, [_record(0)])
122
+
123
+ dest_dir = temp_dir / "results"
124
+ dest_dir.mkdir()
125
+ job_id = "tester-equilibrator-20260707-abcd1234"
126
+
127
+ _finalize_equilibrator(output_dir, dest_dir, job_id=job_id)
128
+
129
+ expected = dest_dir / f"{job_id}.jsonl"
130
+ assert expected.exists()
131
+ assert len(_read_jsonl(expected)) == 1
132
+
133
+
134
+ class TestS3Finalize:
135
+ def test_s3_download_before_combine(self, temp_dir):
136
+ """For an S3-mode job, finalize downloads the output prefix, combines,
137
+ then deletes the S3 prefix (existing convention)."""
138
+ from dh_cli.batch.commands import finalize as fin
139
+
140
+ base = temp_dir / "base"
141
+ base.mkdir()
142
+ # Job dir with an input/ so completion checks are inert.
143
+ job_id = "tester-equilibrator-20260707-abcd1234"
144
+ (base / job_id / "input").mkdir(parents=True)
145
+
146
+ manifest = MagicMock()
147
+ manifest.status = "submitted"
148
+ manifest.pipeline = "equilibrator"
149
+ manifest.storage_mode = "s3"
150
+ manifest.s3_prefix = "s3://dayhoff-atlas/ephemeral/batch-jobs/job/"
151
+ manifest.output = MagicMock()
152
+
153
+ downloaded = {}
154
+
155
+ def fake_download(prefix, local_dir):
156
+ # Simulate S3 output containing two chunks.
157
+ _write_chunk(Path(local_dir), 0, [_record(0)])
158
+ _write_chunk(Path(local_dir), 1, [_record(1)])
159
+ downloaded["prefix"] = prefix
160
+
161
+ deleted = {}
162
+
163
+ def fake_delete(prefix):
164
+ deleted["prefix"] = prefix
165
+
166
+ dest = temp_dir / "out"
167
+ dest.mkdir()
168
+
169
+ with (
170
+ patch.object(fin, "load_manifest", return_value=manifest),
171
+ patch.object(fin, "save_manifest_s3"),
172
+ patch("dh_cli.batch.s3_transport.download_directory", side_effect=fake_download),
173
+ patch("dh_cli.batch.s3_transport.delete_prefix", side_effect=fake_delete),
174
+ ):
175
+ from click.testing import CliRunner
176
+
177
+ runner = CliRunner()
178
+ result = runner.invoke(
179
+ fin.finalize,
180
+ [job_id, "--output", str(dest), "--base-path", str(base)],
181
+ )
182
+
183
+ assert result.exit_code == 0, result.output
184
+ # Downloaded from the manifest's output prefix.
185
+ assert downloaded["prefix"].startswith("s3://dayhoff-atlas/ephemeral/batch-jobs/job/")
186
+ # Deleted the S3 prefix after combining.
187
+ assert deleted["prefix"] == manifest.s3_prefix
188
+ # Combined output written.
189
+ out_file = dest / f"{job_id}.jsonl"
190
+ recs = _read_jsonl(out_file)
191
+ assert [r["input_index"] for r in recs] == [0, 1]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes