@moldesk/adapters 0.0.0-stage → 0.0.3

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bindcraft2/index.d.ts +2 -0
  3. package/dist/bindcraft2/index.js +348 -0
  4. package/dist/bindcraft2/index.test.d.ts +1 -0
  5. package/dist/bindcraft2/index.test.js +232 -0
  6. package/dist/boltz/index.d.ts +2 -0
  7. package/dist/boltz/index.js +285 -0
  8. package/dist/boltz/index.test.d.ts +1 -0
  9. package/dist/boltz/index.test.js +368 -0
  10. package/dist/catalog.d.ts +6 -0
  11. package/dist/catalog.js +37 -0
  12. package/dist/catalog.test.d.ts +1 -0
  13. package/dist/catalog.test.js +33 -0
  14. package/dist/diffdock/index.d.ts +2 -0
  15. package/dist/diffdock/index.js +470 -0
  16. package/dist/diffdock/index.test.d.ts +1 -0
  17. package/dist/diffdock/index.test.js +595 -0
  18. package/dist/index.d.ts +121 -0
  19. package/dist/index.js +2 -0
  20. package/dist/ligandmpnn/index.d.ts +2 -0
  21. package/dist/ligandmpnn/index.js +80 -0
  22. package/dist/ligandmpnn/index.test.d.ts +1 -0
  23. package/dist/ligandmpnn/index.test.js +85 -0
  24. package/dist/opendde/index.d.ts +2 -0
  25. package/dist/opendde/index.js +551 -0
  26. package/dist/opendde/index.test.d.ts +1 -0
  27. package/dist/opendde/index.test.js +577 -0
  28. package/dist/outputs.d.ts +8 -0
  29. package/dist/outputs.js +79 -0
  30. package/dist/outputs.test.d.ts +1 -0
  31. package/dist/outputs.test.js +69 -0
  32. package/dist/params.d.ts +13 -0
  33. package/dist/params.js +69 -0
  34. package/dist/params.test.d.ts +1 -0
  35. package/dist/params.test.js +41 -0
  36. package/dist/planned.d.ts +1 -0
  37. package/dist/planned.js +9 -0
  38. package/dist/proteinmpnn/index.d.ts +2 -0
  39. package/dist/proteinmpnn/index.js +84 -0
  40. package/dist/proteinmpnn/index.test.d.ts +1 -0
  41. package/dist/proteinmpnn/index.test.js +101 -0
  42. package/package.json +32 -3
  43. package/README.md +0 -3
@@ -0,0 +1,595 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { parse as parseYaml } from "yaml";
7
+ import { diffdockAdapter } from "./index.js";
8
+ const dirs = [];
9
+ function tempDir() {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-diffdock-test-"));
11
+ dirs.push(dir);
12
+ return dir;
13
+ }
14
+ afterEach(() => {
15
+ for (const dir of dirs.splice(0))
16
+ fs.rmSync(dir, { recursive: true, force: true });
17
+ });
18
+ const PDB_TEXT = "HEADER TEST\nATOM 1 N MET A 1 11.104 13.207 2.070 1.00 20.00 N\nEND\n";
19
+ const SDF_TEXT = "ligand\n MoleculeDesk\n\n 1 0 0 0 0 0 0 0 0 0999 V2000\n 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\nM END\n$$$$\n";
20
+ function writeJob(dir, overrides = {}, options = {}) {
21
+ if (options.withProteinFile !== false)
22
+ fs.writeFileSync(path.join(dir, "protein.pdb"), PDB_TEXT);
23
+ const ligand = {};
24
+ if (options.withSmiles) {
25
+ ligand["smiles"] = "CCO";
26
+ }
27
+ else if (options.withLigandFile !== false) {
28
+ fs.writeFileSync(path.join(dir, "ligand.sdf"), SDF_TEXT);
29
+ ligand["path"] = "ligand.sdf";
30
+ }
31
+ const job = { proteinPath: "protein.pdb", ligand, ...overrides };
32
+ const jobPath = path.join(dir, "job.json");
33
+ fs.writeFileSync(jobPath, JSON.stringify(job));
34
+ return jobPath;
35
+ }
36
+ describe("diffdockAdapter.validateInput", () => {
37
+ it("accepts a job referencing a ligand file", async () => {
38
+ const dir = tempDir();
39
+ const jobPath = writeJob(dir);
40
+ await expect(diffdockAdapter.validateInput(jobPath)).resolves.toBeUndefined();
41
+ });
42
+ it("accepts a job referencing a ligand SMILES string", async () => {
43
+ const dir = tempDir();
44
+ const jobPath = writeJob(dir, {}, { withSmiles: true });
45
+ await expect(diffdockAdapter.validateInput(jobPath)).resolves.toBeUndefined();
46
+ });
47
+ it("rejects a missing job file", async () => {
48
+ const dir = tempDir();
49
+ await expect(diffdockAdapter.validateInput(path.join(dir, "missing.json"))).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
50
+ });
51
+ it("rejects a non-.json extension", async () => {
52
+ const dir = tempDir();
53
+ const jobPath = path.join(dir, "job.yaml");
54
+ fs.writeFileSync(jobPath, "{}");
55
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
56
+ });
57
+ it("rejects malformed JSON", async () => {
58
+ const dir = tempDir();
59
+ const jobPath = path.join(dir, "job.json");
60
+ fs.writeFileSync(jobPath, "{ not json");
61
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
62
+ });
63
+ it("rejects a ligand specifying both path and smiles", async () => {
64
+ const dir = tempDir();
65
+ fs.writeFileSync(path.join(dir, "ligand.sdf"), SDF_TEXT);
66
+ const jobPath = writeJob(dir, { ligand: { path: "ligand.sdf", smiles: "CCO" } });
67
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
68
+ });
69
+ it("rejects a ligand specifying neither path nor smiles", async () => {
70
+ const dir = tempDir();
71
+ const jobPath = writeJob(dir, { ligand: {} });
72
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
73
+ });
74
+ it("rejects a protein reference with the wrong extension", async () => {
75
+ const dir = tempDir();
76
+ fs.writeFileSync(path.join(dir, "protein.txt"), PDB_TEXT);
77
+ const jobPath = writeJob(dir, { proteinPath: "protein.txt" });
78
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
79
+ });
80
+ it("rejects a ligand file reference with an unsupported extension", async () => {
81
+ const dir = tempDir();
82
+ fs.writeFileSync(path.join(dir, "ligand.xyz"), SDF_TEXT);
83
+ const jobPath = writeJob(dir, { ligand: { path: "ligand.xyz" } });
84
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
85
+ });
86
+ it("rejects a protein reference that is a directory", async () => {
87
+ const dir = tempDir();
88
+ fs.mkdirSync(path.join(dir, "protein.pdb"));
89
+ const jobPath = writeJob(dir, { proteinPath: "protein.pdb" }, { withProteinFile: false, withSmiles: true });
90
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
91
+ });
92
+ it("rejects a protein file that does not look like a PDB", async () => {
93
+ const dir = tempDir();
94
+ fs.writeFileSync(path.join(dir, "protein.pdb"), "not a pdb file\njust text\n");
95
+ const jobPath = writeJob(dir, {}, { withProteinFile: false, withSmiles: true });
96
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
97
+ });
98
+ it("rejects an invalid SMILES string", async () => {
99
+ const dir = tempDir();
100
+ const jobPath = writeJob(dir, { ligand: { smiles: "not smiles!! spaces" } });
101
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
102
+ });
103
+ describe("jobName sanitization", () => {
104
+ it("accepts a plain identifier", async () => {
105
+ const dir = tempDir();
106
+ const jobPath = writeJob(dir, { jobName: "complex-A.1" });
107
+ await expect(diffdockAdapter.validateInput(jobPath)).resolves.toBeUndefined();
108
+ });
109
+ it("rejects an empty jobName", async () => {
110
+ const dir = tempDir();
111
+ const jobPath = writeJob(dir, { jobName: "" });
112
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
113
+ });
114
+ it("rejects a jobName containing a path separator", async () => {
115
+ const dir = tempDir();
116
+ const jobPath = writeJob(dir, { jobName: "a/b" });
117
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
118
+ });
119
+ it("rejects a jobName containing ..", async () => {
120
+ const dir = tempDir();
121
+ const jobPath = writeJob(dir, { jobName: "..secret" });
122
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
123
+ });
124
+ it("rejects a jobName containing a control character", async () => {
125
+ const dir = tempDir();
126
+ const jobPath = writeJob(dir, { jobName: "a\nb" });
127
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
128
+ });
129
+ it("rejects an overlong jobName", async () => {
130
+ const dir = tempDir();
131
+ const jobPath = writeJob(dir, { jobName: "a".repeat(200) });
132
+ await expect(diffdockAdapter.validateInput(jobPath)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
133
+ });
134
+ });
135
+ });
136
+ describe("diffdockAdapter against the checked-in example", () => {
137
+ it("validates examples/diffdock/job.json", async () => {
138
+ const exampleJob = fileURLToPath(new URL("../../../../examples/diffdock/job.json", import.meta.url));
139
+ await expect(diffdockAdapter.validateInput(exampleJob)).resolves.toBeUndefined();
140
+ });
141
+ });
142
+ describe("diffdockAdapter.resolveCompanionInputs", () => {
143
+ it("returns protein + ligand refs for a ligand-file job", async () => {
144
+ const dir = tempDir();
145
+ const jobPath = writeJob(dir);
146
+ const refs = await diffdockAdapter.resolveCompanionInputs(jobPath);
147
+ expect(refs).toEqual([
148
+ { id: "protein", sourcePath: "protein.pdb" },
149
+ { id: "ligand", sourcePath: "ligand.sdf" },
150
+ ]);
151
+ });
152
+ it("returns only a protein ref for a SMILES-ligand job", async () => {
153
+ const dir = tempDir();
154
+ const jobPath = writeJob(dir, {}, { withSmiles: true });
155
+ const refs = await diffdockAdapter.resolveCompanionInputs(jobPath);
156
+ expect(refs).toEqual([{ id: "protein", sourcePath: "protein.pdb" }]);
157
+ });
158
+ });
159
+ const UPSTREAM_CONFIG_YAML = `
160
+ actual_steps: 19
161
+ ckpt: best_ema_inference_epoch_model.pt
162
+ confidence_ckpt: best_model_epoch75.pt
163
+ confidence_model_dir: ./workdir/v1.1/confidence_model
164
+ model_dir: ./workdir/v1.1/score_model
165
+ samples_per_complex: 10
166
+ inference_steps: 20
167
+ sigma_schedule: expbeta
168
+ temp_sampling_tr: 1.170050527854316
169
+ old_filtering_model: true
170
+ `;
171
+ function writeInstalledSourceFixture(modelDir) {
172
+ const sourceDir = path.join(modelDir, "source");
173
+ fs.mkdirSync(sourceDir, { recursive: true });
174
+ fs.writeFileSync(path.join(sourceDir, "inference.py"), "# fixture\n");
175
+ fs.writeFileSync(path.join(sourceDir, "default_inference_args.yaml"), UPSTREAM_CONFIG_YAML);
176
+ }
177
+ describe("diffdockAdapter.command", () => {
178
+ it("builds the expected inference.py invocation and derives a run-scoped config", async () => {
179
+ const dir = tempDir();
180
+ const modelDir = path.join(dir, "model");
181
+ writeInstalledSourceFixture(modelDir);
182
+ const outputDir = path.join(dir, "run", "output");
183
+ fs.mkdirSync(outputDir, { recursive: true });
184
+ const jobPath = path.join(dir, "run", "input", "job.json");
185
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
186
+ fs.writeFileSync(jobPath, JSON.stringify({ jobName: "my-complex", proteinPath: "protein.pdb", ligand: { path: "ligand.sdf" } }));
187
+ const spec = await diffdockAdapter.command({
188
+ manifestName: "diffdock",
189
+ inputPath: jobPath,
190
+ outputDir,
191
+ modelDir,
192
+ assetsDir: path.join(modelDir, "assets"),
193
+ params: { samples_per_complex: 8, inference_steps: 15, batch_size: 4 },
194
+ runtimeExecutable: path.join(modelDir, ".venv", "bin", "python"),
195
+ platform: "linux-x64",
196
+ companionInputs: [
197
+ { id: "protein", path: "/run/input/protein/protein.pdb" },
198
+ { id: "ligand", path: "/run/input/ligand/ligand.sdf" },
199
+ ],
200
+ });
201
+ expect(spec.executable).toBe(path.join(modelDir, ".venv", "bin", "python"));
202
+ expect(spec.args[0]).toBe(path.join(modelDir, "source", "inference.py"));
203
+ expect(spec.args).toContain("--protein_path");
204
+ expect(spec.args).toContain("/run/input/protein/protein.pdb");
205
+ expect(spec.args).toContain("--ligand_description");
206
+ expect(spec.args).toContain("/run/input/ligand/ligand.sdf");
207
+ expect(spec.args).toContain("--complex_name");
208
+ expect(spec.args).toContain("my-complex");
209
+ expect(spec.args).toContain("--out_dir");
210
+ expect(spec.args).toContain(outputDir);
211
+ expect(spec.args).toContain("--model_dir");
212
+ expect(spec.args).toContain(path.join(modelDir, "assets", "diffdock-weights", "score_model"));
213
+ expect(spec.args).toContain("--confidence_model_dir");
214
+ expect(spec.args).toContain(path.join(modelDir, "assets", "diffdock-weights", "confidence_model"));
215
+ expect(spec.args).toContain("--samples_per_complex");
216
+ expect(spec.args).toContain("8");
217
+ expect(spec.args).toContain("--inference_steps");
218
+ expect(spec.args).toContain("15");
219
+ expect(spec.args).toContain("--batch_size");
220
+ expect(spec.args).toContain("4");
221
+ expect(spec.env).toEqual({ TORCH_HOME: path.join(modelDir, "assets", "torch-home") });
222
+ const configIndex = spec.args.indexOf("--config") + 1;
223
+ const configPath = spec.args[configIndex];
224
+ expect(configPath).toBe(path.join(outputDir, "moldesk-inference-config.yaml"));
225
+ const derived = parseYaml(fs.readFileSync(configPath, "utf8"));
226
+ // Stripped so MoleculeDesk's own CLI flags (not silently clobbered by the
227
+ // config-merge loop in inference.py's main()) take effect.
228
+ expect(derived["model_dir"]).toBeUndefined();
229
+ expect(derived["confidence_model_dir"]).toBeUndefined();
230
+ expect(derived["samples_per_complex"]).toBeUndefined();
231
+ expect(derived["inference_steps"]).toBeUndefined();
232
+ expect(derived["actual_steps"]).toBeUndefined();
233
+ // Every other tuned hyperparameter is preserved byte-for-byte.
234
+ expect(derived["ckpt"]).toBe("best_ema_inference_epoch_model.pt");
235
+ expect(derived["confidence_ckpt"]).toBe("best_model_epoch75.pt");
236
+ expect(derived["sigma_schedule"]).toBe("expbeta");
237
+ expect(derived["temp_sampling_tr"]).toBe(1.170050527854316);
238
+ expect(derived["old_filtering_model"]).toBe(true);
239
+ });
240
+ it("uses the job's ligand SMILES directly when no ligand file companion is staged", async () => {
241
+ const dir = tempDir();
242
+ const modelDir = path.join(dir, "model");
243
+ writeInstalledSourceFixture(modelDir);
244
+ const outputDir = path.join(dir, "run", "output");
245
+ fs.mkdirSync(outputDir, { recursive: true });
246
+ const jobPath = path.join(dir, "run", "input", "job.json");
247
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
248
+ fs.writeFileSync(jobPath, JSON.stringify({ proteinPath: "protein.pdb", ligand: { smiles: "CCO" } }));
249
+ const spec = await diffdockAdapter.command({
250
+ manifestName: "diffdock",
251
+ inputPath: jobPath,
252
+ outputDir,
253
+ modelDir,
254
+ assetsDir: path.join(modelDir, "assets"),
255
+ params: {},
256
+ runtimeExecutable: path.join(modelDir, ".venv", "bin", "python"),
257
+ companionInputs: [{ id: "protein", path: "/run/input/protein/protein.pdb" }],
258
+ });
259
+ expect(spec.args).toContain("--ligand_description");
260
+ expect(spec.args).toContain("CCO");
261
+ expect(spec.args).toContain("--complex_name");
262
+ expect(spec.args).toContain("complex_0");
263
+ });
264
+ it("throws a clear internal error when a declared companion input was not staged", async () => {
265
+ const dir = tempDir();
266
+ const modelDir = path.join(dir, "model");
267
+ writeInstalledSourceFixture(modelDir);
268
+ const outputDir = path.join(dir, "run", "output");
269
+ fs.mkdirSync(outputDir, { recursive: true });
270
+ const jobPath = path.join(dir, "run", "input", "job.json");
271
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
272
+ fs.writeFileSync(jobPath, JSON.stringify({ proteinPath: "protein.pdb", ligand: { path: "ligand.sdf" } }));
273
+ await expect(diffdockAdapter.command({
274
+ manifestName: "diffdock",
275
+ inputPath: jobPath,
276
+ outputDir,
277
+ modelDir,
278
+ assetsDir: path.join(modelDir, "assets"),
279
+ params: {},
280
+ runtimeExecutable: path.join(modelDir, ".venv", "bin", "python"),
281
+ companionInputs: [],
282
+ })).rejects.toMatchObject({ code: "COMPANION_INPUT_NOT_STAGED" });
283
+ });
284
+ });
285
+ describe("diffdockAdapter.collectOutputs", () => {
286
+ function writePrediction(outputDir, complexName) {
287
+ const complexDir = path.join(outputDir, complexName);
288
+ fs.mkdirSync(complexDir, { recursive: true });
289
+ fs.writeFileSync(path.join(complexDir, "rank1.sdf"), SDF_TEXT);
290
+ fs.writeFileSync(path.join(complexDir, "rank1_confidence0.85.sdf"), SDF_TEXT);
291
+ fs.writeFileSync(path.join(complexDir, "rank2_confidence-1.23.sdf"), SDF_TEXT);
292
+ }
293
+ it("throws INVALID_RUN_OUTPUT when a ranked-pose SDF is empty (a correctly-named file is not sufficient)", async () => {
294
+ const dir = tempDir();
295
+ const outputDir = path.join(dir, "output");
296
+ const complexDir = path.join(outputDir, "complex_0");
297
+ fs.mkdirSync(complexDir, { recursive: true });
298
+ fs.writeFileSync(path.join(complexDir, "rank1.sdf"), SDF_TEXT);
299
+ fs.writeFileSync(path.join(complexDir, "rank1_confidence0.85.sdf"), ""); // empty file
300
+ const jobPath = path.join(dir, "input", "job.json");
301
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
302
+ fs.writeFileSync(jobPath, JSON.stringify({ proteinPath: "protein.pdb", ligand: { smiles: "CCO" } }));
303
+ await expect(diffdockAdapter.collectOutputs({
304
+ manifestName: "diffdock",
305
+ inputPath: jobPath,
306
+ outputDir,
307
+ modelDir: dir,
308
+ assetsDir: path.join(dir, "assets"),
309
+ params: {},
310
+ runtimeExecutable: "python",
311
+ })).rejects.toMatchObject({ code: "INVALID_RUN_OUTPUT" });
312
+ });
313
+ it("throws INVALID_RUN_OUTPUT when a ranked-pose SDF has no positive atom count", async () => {
314
+ const dir = tempDir();
315
+ const outputDir = path.join(dir, "output");
316
+ const complexDir = path.join(outputDir, "complex_0");
317
+ fs.mkdirSync(complexDir, { recursive: true });
318
+ fs.writeFileSync(path.join(complexDir, "rank1.sdf"), SDF_TEXT);
319
+ // Malformed: counts line present but zero atoms.
320
+ fs.writeFileSync(path.join(complexDir, "rank1_confidence0.85.sdf"), "ligand\n MoleculeDesk\n\n 0 0 0 0 0 0 0 0 0 0999 V2000\nM END\n$$$$\n");
321
+ const jobPath = path.join(dir, "input", "job.json");
322
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
323
+ fs.writeFileSync(jobPath, JSON.stringify({ proteinPath: "protein.pdb", ligand: { smiles: "CCO" } }));
324
+ await expect(diffdockAdapter.collectOutputs({
325
+ manifestName: "diffdock",
326
+ inputPath: jobPath,
327
+ outputDir,
328
+ modelDir: dir,
329
+ assetsDir: path.join(dir, "assets"),
330
+ params: {},
331
+ runtimeExecutable: "python",
332
+ })).rejects.toMatchObject({ code: "INVALID_RUN_OUTPUT" });
333
+ });
334
+ it("throws INVALID_RUN_OUTPUT when the top pose (rank1.sdf) itself is empty", async () => {
335
+ const dir = tempDir();
336
+ const outputDir = path.join(dir, "output");
337
+ const complexDir = path.join(outputDir, "complex_0");
338
+ fs.mkdirSync(complexDir, { recursive: true });
339
+ fs.writeFileSync(path.join(complexDir, "rank1.sdf"), ""); // empty top pose
340
+ fs.writeFileSync(path.join(complexDir, "rank1_confidence0.85.sdf"), SDF_TEXT);
341
+ const jobPath = path.join(dir, "input", "job.json");
342
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
343
+ fs.writeFileSync(jobPath, JSON.stringify({ proteinPath: "protein.pdb", ligand: { smiles: "CCO" } }));
344
+ await expect(diffdockAdapter.collectOutputs({
345
+ manifestName: "diffdock",
346
+ inputPath: jobPath,
347
+ outputDir,
348
+ modelDir: dir,
349
+ assetsDir: path.join(dir, "assets"),
350
+ params: {},
351
+ runtimeExecutable: "python",
352
+ })).rejects.toMatchObject({ code: "INVALID_RUN_OUTPUT" });
353
+ });
354
+ it("collects ranked poses and synthesizes a structured poses_summary.json", async () => {
355
+ const dir = tempDir();
356
+ const outputDir = path.join(dir, "output");
357
+ writePrediction(outputDir, "complex_0");
358
+ const jobPath = path.join(dir, "input", "job.json");
359
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
360
+ fs.writeFileSync(jobPath, JSON.stringify({ proteinPath: "protein.pdb", ligand: { smiles: "CCO" } }));
361
+ const outputs = await diffdockAdapter.collectOutputs({
362
+ manifestName: "diffdock",
363
+ inputPath: jobPath,
364
+ outputDir,
365
+ modelDir: dir,
366
+ assetsDir: path.join(dir, "assets"),
367
+ params: {},
368
+ runtimeExecutable: "python",
369
+ });
370
+ expect(outputs.map((o) => o.id).sort()).toEqual(["poses_summary", "ranked_poses", "ranked_poses", "top_pose"]);
371
+ const summary = JSON.parse(fs.readFileSync(path.join(outputDir, "poses_summary.json"), "utf8"));
372
+ expect(summary.complexName).toBe("complex_0");
373
+ expect(summary.topPoseFile).toBe("rank1.sdf");
374
+ expect(summary.poses).toHaveLength(2);
375
+ expect(summary.poses[0]).toMatchObject({ rank: 1, confidence: 0.85, sdfFile: "rank1_confidence0.85.sdf", atomCount: 1 });
376
+ expect(summary.poses[1]).toMatchObject({ rank: 2, confidence: -1.23, sdfFile: "rank2_confidence-1.23.sdf", atomCount: 1 });
377
+ });
378
+ it("respects a custom jobName as the complex directory", async () => {
379
+ const dir = tempDir();
380
+ const outputDir = path.join(dir, "output");
381
+ writePrediction(outputDir, "my-complex");
382
+ const jobPath = path.join(dir, "input", "job.json");
383
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
384
+ fs.writeFileSync(jobPath, JSON.stringify({ jobName: "my-complex", proteinPath: "protein.pdb", ligand: { smiles: "CCO" } }));
385
+ const outputs = await diffdockAdapter.collectOutputs({
386
+ manifestName: "diffdock",
387
+ inputPath: jobPath,
388
+ outputDir,
389
+ modelDir: dir,
390
+ assetsDir: path.join(dir, "assets"),
391
+ params: {},
392
+ runtimeExecutable: "python",
393
+ });
394
+ expect(outputs.map((o) => o.id).sort()).toEqual(["poses_summary", "ranked_poses", "ranked_poses", "top_pose"]);
395
+ });
396
+ it("does not let the freshly-written poses_summary.json collide with the top_pose/ranked_poses `*/...` glob", async () => {
397
+ // Regression test: top_pose/ranked_poses use a single-segment `*` wildcard
398
+ // that matches ANY entry directly under outputDir, including a file (not
399
+ // just directories) — writing poses_summary.json before collecting those
400
+ // globs would make matchGlob try to readdir it as if it were a directory.
401
+ const dir = tempDir();
402
+ const outputDir = path.join(dir, "output");
403
+ writePrediction(outputDir, "complex_0");
404
+ const jobPath = path.join(dir, "input", "job.json");
405
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
406
+ fs.writeFileSync(jobPath, JSON.stringify({ proteinPath: "protein.pdb", ligand: { smiles: "CCO" } }));
407
+ const outputs = await diffdockAdapter.collectOutputs({
408
+ manifestName: "diffdock",
409
+ inputPath: jobPath,
410
+ outputDir,
411
+ modelDir: dir,
412
+ assetsDir: path.join(dir, "assets"),
413
+ params: {},
414
+ runtimeExecutable: "python",
415
+ });
416
+ expect(outputs.map((o) => o.id).sort()).toEqual(["poses_summary", "ranked_poses", "ranked_poses", "top_pose"]);
417
+ });
418
+ it("throws MISSING_REQUIRED_OUTPUT when no ranked poses were produced (a stray unrelated file must not satisfy it)", async () => {
419
+ const dir = tempDir();
420
+ const outputDir = path.join(dir, "output");
421
+ fs.mkdirSync(outputDir, { recursive: true });
422
+ fs.writeFileSync(path.join(outputDir, "some-unrelated-file.txt"), "not a pose");
423
+ const jobPath = path.join(dir, "input", "job.json");
424
+ fs.mkdirSync(path.dirname(jobPath), { recursive: true });
425
+ fs.writeFileSync(jobPath, JSON.stringify({ proteinPath: "protein.pdb", ligand: { smiles: "CCO" } }));
426
+ await expect(diffdockAdapter.collectOutputs({
427
+ manifestName: "diffdock",
428
+ inputPath: jobPath,
429
+ outputDir,
430
+ modelDir: dir,
431
+ assetsDir: path.join(dir, "assets"),
432
+ params: {},
433
+ runtimeExecutable: "python",
434
+ })).rejects.toMatchObject({ code: "MISSING_REQUIRED_OUTPUT" });
435
+ });
436
+ });
437
+ describe("diffdockAdapter.verifyInstallation", () => {
438
+ function createInstalledFixture(modelDir, assetsDir) {
439
+ fs.mkdirSync(path.join(modelDir, ".venv", "bin"), { recursive: true });
440
+ fs.writeFileSync(path.join(modelDir, ".venv", "bin", "python"), "#!/bin/sh\n");
441
+ writeInstalledSourceFixture(modelDir);
442
+ const scoreDir = path.join(assetsDir, "diffdock-weights", "score_model");
443
+ const confidenceDir = path.join(assetsDir, "diffdock-weights", "confidence_model");
444
+ fs.mkdirSync(scoreDir, { recursive: true });
445
+ fs.mkdirSync(confidenceDir, { recursive: true });
446
+ fs.writeFileSync(path.join(scoreDir, "model_parameters.yml"), "{}");
447
+ fs.writeFileSync(path.join(scoreDir, "best_ema_inference_epoch_model.pt"), "");
448
+ fs.writeFileSync(path.join(confidenceDir, "model_parameters.yml"), "{}");
449
+ fs.writeFileSync(path.join(confidenceDir, "best_model_epoch75.pt"), "");
450
+ const esmDir = path.join(assetsDir, "torch-home", "hub", "checkpoints");
451
+ fs.mkdirSync(esmDir, { recursive: true });
452
+ fs.writeFileSync(path.join(esmDir, "esm2_t33_650M_UR50D.pt"), "");
453
+ fs.writeFileSync(path.join(esmDir, "esm2_t33_650M_UR50D-contact-regression.pt"), "");
454
+ }
455
+ it("fails when installed weights are missing, even with a working venv", async () => {
456
+ const dir = tempDir();
457
+ const modelDir = path.join(dir, "model");
458
+ fs.mkdirSync(path.join(modelDir, ".venv", "bin"), { recursive: true });
459
+ fs.writeFileSync(path.join(modelDir, ".venv", "bin", "python"), "#!/bin/sh\n");
460
+ writeInstalledSourceFixture(modelDir);
461
+ const result = await diffdockAdapter.verifyInstallation({
462
+ manifestName: "diffdock",
463
+ modelDir,
464
+ assetsDir: path.join(modelDir, "assets"),
465
+ runner: async () => ({ code: 0, stdout: "should not run", stderr: "" }),
466
+ });
467
+ expect(result.passed).toBe(false);
468
+ expect(result.output).toContain("model_parameters.yml");
469
+ });
470
+ it("fails when the ABI/CUDA probe reports an error", async () => {
471
+ const dir = tempDir();
472
+ const modelDir = path.join(dir, "model");
473
+ const assetsDir = path.join(modelDir, "assets");
474
+ createInstalledFixture(modelDir, assetsDir);
475
+ const result = await diffdockAdapter.verifyInstallation({
476
+ manifestName: "diffdock",
477
+ modelDir,
478
+ assetsDir,
479
+ runner: async () => ({ code: 1, stdout: "", stderr: "torch.cuda.is_available() is False" }),
480
+ });
481
+ expect(result.passed).toBe(false);
482
+ expect(result.output).toContain("torch.cuda.is_available()");
483
+ });
484
+ it("passes and captures the ABI/CUDA probe script when weights, CUDA probe, and --help all succeed", async () => {
485
+ const dir = tempDir();
486
+ const modelDir = path.join(dir, "model");
487
+ const assetsDir = path.join(modelDir, "assets");
488
+ createInstalledFixture(modelDir, assetsDir);
489
+ let capturedScript = "";
490
+ const result = await diffdockAdapter.verifyInstallation({
491
+ manifestName: "diffdock",
492
+ modelDir,
493
+ assetsDir,
494
+ runner: async (_cmd, args, options) => {
495
+ if (args.includes("--help"))
496
+ return { code: 0, stdout: "usage: inference.py [-h] ... --protein_path PROTEIN_PATH ...", stderr: "" };
497
+ if ((args[1] ?? "").includes("import utils.so3") && options?.cwd) {
498
+ for (const name of [".so3_omegas_array4.npy", ".so3_cdf_vals4.npy", ".so3_score_norms4.npy", ".so3_exp_score_norms4.npy", ".p.npy", ".score.npy"]) {
499
+ fs.writeFileSync(path.join(options.cwd, name), "x");
500
+ }
501
+ }
502
+ if ((args[1] ?? "").includes("torch.cuda"))
503
+ capturedScript = args[1] ?? "";
504
+ return { code: 0, stdout: "cuda ok", stderr: "" };
505
+ },
506
+ });
507
+ expect(result.passed).toBe(true);
508
+ expect(capturedScript).toContain("torch.cuda.is_available()");
509
+ // Executes real operators, not just imports — see ABI_AND_CUDA_PROBE's doc comment.
510
+ expect(capturedScript).toContain("torch_scatter.scatter_add");
511
+ expect(capturedScript).toContain("torch_cluster.radius_graph");
512
+ expect(capturedScript).toContain("torch_sparse.coalesce");
513
+ });
514
+ it("builds the SO(3) cache once, in a stable directory, before running --help", async () => {
515
+ const dir = tempDir();
516
+ const modelDir = path.join(dir, "model");
517
+ const assetsDir = path.join(modelDir, "assets");
518
+ createInstalledFixture(modelDir, assetsDir);
519
+ const calls = [];
520
+ const result = await diffdockAdapter.verifyInstallation({
521
+ manifestName: "diffdock",
522
+ modelDir,
523
+ assetsDir,
524
+ runner: async (_cmd, args, options) => {
525
+ calls.push({ args, cwd: options?.cwd });
526
+ if (args.includes("--help"))
527
+ return { code: 0, stdout: "--protein_path", stderr: "" };
528
+ if ((args[1] ?? "").includes("import utils.so3") && options?.cwd) {
529
+ for (const name of [".so3_omegas_array4.npy", ".so3_cdf_vals4.npy", ".so3_score_norms4.npy", ".so3_exp_score_norms4.npy", ".p.npy", ".score.npy"]) {
530
+ fs.writeFileSync(path.join(options.cwd, name), "x");
531
+ }
532
+ }
533
+ return { code: 0, stdout: "ok", stderr: "" };
534
+ },
535
+ });
536
+ expect(result.passed).toBe(true);
537
+ const warmIdx = calls.findIndex((c) => (c.args[1] ?? "").includes("import utils.so3"));
538
+ const helpIdx = calls.findIndex((c) => c.args.includes("--help"));
539
+ expect(warmIdx).toBeGreaterThan(-1);
540
+ expect(warmIdx).toBeLessThan(helpIdx);
541
+ expect(calls[warmIdx]?.cwd).toBe(path.join(assetsDir, "so3-cache"));
542
+ expect(calls[helpIdx]?.cwd).toBe(path.join(assetsDir, "so3-cache"));
543
+ });
544
+ it("fails when the SO(3) cache build fails", async () => {
545
+ const dir = tempDir();
546
+ const modelDir = path.join(dir, "model");
547
+ const assetsDir = path.join(modelDir, "assets");
548
+ createInstalledFixture(modelDir, assetsDir);
549
+ const result = await diffdockAdapter.verifyInstallation({
550
+ manifestName: "diffdock",
551
+ modelDir,
552
+ assetsDir,
553
+ runner: async (_cmd, args) => (args[1] ?? "").includes("import utils.so3") ? { code: 1, stdout: "", stderr: "memory error" } : { code: 0, stdout: "ok", stderr: "" },
554
+ });
555
+ expect(result.passed).toBe(false);
556
+ expect(result.output).toContain("SO(3)/torus cache");
557
+ });
558
+ it("fails when the installed CLI does not respond as expected to --help", async () => {
559
+ const dir = tempDir();
560
+ const modelDir = path.join(dir, "model");
561
+ const assetsDir = path.join(modelDir, "assets");
562
+ createInstalledFixture(modelDir, assetsDir);
563
+ const result = await diffdockAdapter.verifyInstallation({
564
+ manifestName: "diffdock",
565
+ modelDir,
566
+ assetsDir,
567
+ runner: async (_cmd, args, options) => {
568
+ if (args.includes("--help"))
569
+ return { code: 1, stdout: "", stderr: "boom" };
570
+ if ((args[1] ?? "").includes("import utils.so3") && options?.cwd) {
571
+ for (const name of [".so3_omegas_array4.npy", ".so3_cdf_vals4.npy", ".so3_score_norms4.npy", ".so3_exp_score_norms4.npy", ".p.npy", ".score.npy"]) {
572
+ fs.writeFileSync(path.join(options.cwd, name), "x");
573
+ }
574
+ }
575
+ return { code: 0, stdout: "cuda ok", stderr: "" };
576
+ },
577
+ });
578
+ expect(result.passed).toBe(false);
579
+ expect(result.output).toContain("--help");
580
+ });
581
+ it("fails when the cache build exits cleanly but leaves cache files missing", async () => {
582
+ const dir = tempDir();
583
+ const modelDir = path.join(dir, "model");
584
+ const assetsDir = path.join(modelDir, "assets");
585
+ createInstalledFixture(modelDir, assetsDir);
586
+ const result = await diffdockAdapter.verifyInstallation({
587
+ manifestName: "diffdock",
588
+ modelDir,
589
+ assetsDir,
590
+ runner: async () => ({ code: 0, stdout: "ok", stderr: "" }),
591
+ });
592
+ expect(result.passed).toBe(false);
593
+ expect(result.output).toContain("incomplete");
594
+ });
595
+ });