@moldesk/adapters 0.0.0-stage → 0.0.2

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,577 @@
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 { stageCompanionInputs } from "@moldesk/runtime";
8
+ import { validateParams } from "../params.js";
9
+ import { openddeAdapter } from "./index.js";
10
+ const dirs = [];
11
+ function tempDir() {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-opendde-test-"));
13
+ dirs.push(dir);
14
+ return dir;
15
+ }
16
+ afterEach(() => {
17
+ for (const dir of dirs.splice(0))
18
+ fs.rmSync(dir, { recursive: true, force: true });
19
+ });
20
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
21
+ 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";
22
+ const A3M_TEXT = ">query\nACDEFGHIK\n";
23
+ const CIF_TEXT = "data_tiny\n#\nloop_\n_atom_site.group_PDB\n_atom_site.id\nATOM 1\n";
24
+ function protein(extra = {}) {
25
+ return { proteinChain: { sequence: "ACDEFGHIK", count: 1, ...extra } };
26
+ }
27
+ function writeJob(dir, jobs, name = "job.json") {
28
+ const file = path.join(dir, name);
29
+ fs.writeFileSync(file, JSON.stringify(jobs));
30
+ return file;
31
+ }
32
+ function tinyJob(dir) {
33
+ return writeJob(dir, [{ name: "tiny", modelSeeds: [101], sequences: [protein()] }]);
34
+ }
35
+ function defaults(params = {}) {
36
+ const result = validateParams(openddeAdapter.params ?? [], params);
37
+ expect(result.errors).toEqual([]);
38
+ return result.effective;
39
+ }
40
+ function runContext(dir, inputPath, overrides = {}) {
41
+ const outputDir = path.join(dir, "run", "output");
42
+ fs.mkdirSync(outputDir, { recursive: true });
43
+ return {
44
+ manifestName: "opendde",
45
+ inputPath,
46
+ outputDir,
47
+ modelDir: path.join(dir, "model"),
48
+ assetsDir: path.join(dir, "model", "assets"),
49
+ params: defaults({ device: "mps" }),
50
+ runtimeExecutable: path.join(dir, "model", ".venv", "bin", "python"),
51
+ platform: "darwin-arm64",
52
+ runner: fakePdbExporter(),
53
+ ...overrides,
54
+ };
55
+ }
56
+ /** Stands in for the venv's biotite conversion: writes a .pdb next to each .cif
57
+ * passed as argv, except those listed in `failFor`. */
58
+ function fakePdbExporter(failFor = [], calls = []) {
59
+ return async (command, args) => {
60
+ calls.push({ command, args });
61
+ const errors = {};
62
+ for (const cif of args.slice(2)) {
63
+ if (failFor.some((suffix) => cif.endsWith(suffix)))
64
+ errors[cif] = "ValueError: chain ID too long";
65
+ else
66
+ fs.writeFileSync(cif.replace(/\.cif$/, ".pdb"), "ATOM 1 N ALA A 1 0.000 0.000 0.000 1.00 90.00 N\nEND\n");
67
+ }
68
+ return { code: 0, stdout: `${JSON.stringify(errors)}\n`, stderr: "" };
69
+ };
70
+ }
71
+ function argValue(args, flag) {
72
+ const index = args.indexOf(flag);
73
+ return index === -1 ? undefined : args[index + 1];
74
+ }
75
+ describe("openddeAdapter.validateInput", () => {
76
+ it("accepts the checked-in examples", async () => {
77
+ await expect(openddeAdapter.validateInput(path.join(repoRoot, "examples/opendde/tiny.json"))).resolves.toBeUndefined();
78
+ await expect(openddeAdapter.validateInput(path.join(repoRoot, "examples/opendde/ligand-file.json"))).resolves.toBeUndefined();
79
+ });
80
+ it("rejects a missing file and a non-.json extension", async () => {
81
+ const dir = tempDir();
82
+ await expect(openddeAdapter.validateInput(path.join(dir, "missing.json"))).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
83
+ const yaml = path.join(dir, "job.yaml");
84
+ fs.writeFileSync(yaml, "[]");
85
+ await expect(openddeAdapter.validateInput(yaml)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
86
+ });
87
+ it.each([
88
+ ["malformed JSON", "{ nope"],
89
+ ["an object instead of a list", JSON.stringify({ name: "a", sequences: [] })],
90
+ ["an empty list", "[]"],
91
+ ["a job without sequences", JSON.stringify([{ name: "a" }])],
92
+ ["empty sequences", JSON.stringify([{ name: "a", sequences: [] }])],
93
+ ["an unknown entity key", JSON.stringify([{ name: "a", sequences: [{ peptide: { sequence: "A" } }] }])],
94
+ ["two entity keys in one item", JSON.stringify([{ name: "a", sequences: [{ ...protein(), ion: { ion: "MG", count: 1 } }] }])],
95
+ ["a non-integer modelSeed", JSON.stringify([{ name: "a", modelSeeds: [1.5], sequences: [protein()] }])],
96
+ ["an out-of-range modelSeed", JSON.stringify([{ name: "a", modelSeeds: [2 ** 32], sequences: [protein()] }])],
97
+ ["duplicate job names", JSON.stringify([{ name: "a", sequences: [protein()] }, { name: "a", sequences: [protein()] }])],
98
+ ])("rejects %s", async (_label, text) => {
99
+ const dir = tempDir();
100
+ const file = path.join(dir, "job.json");
101
+ fs.writeFileSync(file, text);
102
+ await expect(openddeAdapter.validateInput(file)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
103
+ });
104
+ it.each(["", "../escape", "a/b", ".hidden", "err", "ERR", "moldesk-resolved-job.json", "a\nb"])("rejects unsafe job name %j", async (name) => {
105
+ const dir = tempDir();
106
+ const file = writeJob(dir, [{ name, sequences: [protein()] }]);
107
+ await expect(openddeAdapter.validateInput(file)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
108
+ });
109
+ it("accepts relative MSA/template/ligand references inside the job directory", async () => {
110
+ const dir = tempDir();
111
+ fs.mkdirSync(path.join(dir, "msa"));
112
+ fs.writeFileSync(path.join(dir, "msa", "pairing.a3m"), A3M_TEXT);
113
+ fs.writeFileSync(path.join(dir, "msa", "non_pairing.a3m"), A3M_TEXT);
114
+ fs.writeFileSync(path.join(dir, "hits.hhr"), "hits\n");
115
+ fs.writeFileSync(path.join(dir, "lig.sdf"), SDF_TEXT);
116
+ fs.writeFileSync(path.join(dir, "rna.a3m"), ">q\nGUAC\n");
117
+ const file = writeJob(dir, [{
118
+ name: "complex",
119
+ sequences: [
120
+ protein({ pairedMsaPath: "msa/pairing.a3m", unpairedMsaPath: "msa/non_pairing.a3m", templatesPath: "hits.hhr" }),
121
+ { rnaSequence: { sequence: "GUAC", count: 1, unpairedMsaPath: "rna.a3m" } },
122
+ { ligand: { ligand: "FILE_lig.sdf", count: 1 } },
123
+ { ligand: { ligand: "CCD_ATP", count: 1 } },
124
+ ],
125
+ }]);
126
+ await expect(openddeAdapter.validateInput(file)).resolves.toBeUndefined();
127
+ });
128
+ it("rejects references that escape the job directory (traversal or absolute)", async () => {
129
+ const dir = tempDir();
130
+ const outside = tempDir();
131
+ fs.writeFileSync(path.join(outside, "x.a3m"), A3M_TEXT);
132
+ for (const ref of [`../${path.basename(outside)}/x.a3m`, path.join(outside, "x.a3m")]) {
133
+ const file = writeJob(dir, [{ name: "a", sequences: [protein({ unpairedMsaPath: ref })] }]);
134
+ await expect(openddeAdapter.validateInput(file)).rejects.toMatchObject({ code: "UNSAFE_COMPANION_INPUT_PATH" });
135
+ }
136
+ });
137
+ it("rejects missing, empty, directory, and wrong-extension references", async () => {
138
+ const dir = tempDir();
139
+ fs.mkdirSync(path.join(dir, "dir.a3m"));
140
+ fs.writeFileSync(path.join(dir, "empty.a3m"), "");
141
+ fs.writeFileSync(path.join(dir, "lig.txt"), SDF_TEXT);
142
+ const cases = [
143
+ protein({ unpairedMsaPath: "missing.a3m" }),
144
+ protein({ unpairedMsaPath: "empty.a3m" }),
145
+ protein({ unpairedMsaPath: "dir.a3m" }),
146
+ protein({ pairedMsaPath: "lig.txt" }),
147
+ { ligand: { ligand: "FILE_lig.txt", count: 1 } },
148
+ { ligand: { ligand: "FILE_", count: 1 } },
149
+ protein({ templatesPath: 42 }),
150
+ ];
151
+ for (const entity of cases) {
152
+ const file = writeJob(dir, [{ name: "a", sequences: [entity] }]);
153
+ await expect(openddeAdapter.validateInput(file)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
154
+ }
155
+ });
156
+ it("rejects the legacy directory-valued proteinChain.msa field", async () => {
157
+ const dir = tempDir();
158
+ const file = writeJob(dir, [{ name: "a", sequences: [protein({ msa: { precomputed_msa_dir: "./msa", pairing_db: "uniref100" } })] }]);
159
+ await expect(openddeAdapter.validateInput(file)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
160
+ });
161
+ });
162
+ describe("openddeAdapter.resolveCompanionInputs", () => {
163
+ it("returns no companions for a pure sequence/SMILES/CCD job (single-file behavior)", async () => {
164
+ const dir = tempDir();
165
+ const file = writeJob(dir, [{ name: "a", sequences: [protein(), { ligand: { ligand: "CCO", count: 1 } }, { ligand: { ligand: "CCD_ATP", count: 1 } }] }]);
166
+ await expect(openddeAdapter.resolveCompanionInputs(file)).resolves.toEqual([]);
167
+ });
168
+ it("returns one uniquely-id'd ref per referenced file, with FILE_ stripped", async () => {
169
+ const dir = tempDir();
170
+ const file = writeJob(dir, [
171
+ { name: "a", sequences: [protein({ pairedMsaPath: "p.a3m", unpairedMsaPath: "u.a3m" }), { ligand: { ligand: "FILE_lig.sdf", count: 1 } }] },
172
+ { name: "b", sequences: [{ rnaSequence: { sequence: "GUAC", count: 1, unpairedMsaPath: "u.a3m" } }] },
173
+ ]);
174
+ await expect(openddeAdapter.resolveCompanionInputs(file)).resolves.toEqual([
175
+ { id: "j0-s0-paired-msa", sourcePath: "p.a3m" },
176
+ { id: "j0-s0-unpaired-msa", sourcePath: "u.a3m" },
177
+ { id: "j0-s1-ligand-file", sourcePath: "lig.sdf" },
178
+ { id: "j1-s0-unpaired-msa", sourcePath: "u.a3m" },
179
+ ]);
180
+ });
181
+ });
182
+ describe("openddeAdapter params and defaults", () => {
183
+ it("defaults to a bounded, offline smoke-test configuration", () => {
184
+ expect(defaults()).toEqual({ samples: 1, steps: 200, cycles: 10, use_msa: false, use_template: false, use_rna_msa: false, deterministic: false });
185
+ });
186
+ it("resolves the device default from the installed runtime platform, not the host", () => {
187
+ const ctx = (platform) => ({ manifestName: "opendde", installed: { runtime: { kind: "python", python: { platform } } } });
188
+ expect(openddeAdapter.resolveParamDefaults(ctx("darwin-arm64"))).toEqual({ device: "mps" });
189
+ expect(openddeAdapter.resolveParamDefaults(ctx("linux-x64"))).toEqual({ device: "cuda" });
190
+ expect(openddeAdapter.resolveParamDefaults(ctx(undefined))).toEqual({});
191
+ });
192
+ it("rejects out-of-range and unknown params", () => {
193
+ const params = openddeAdapter.params ?? [];
194
+ const cases = [{ samples: "0" }, { steps: "0" }, { cycles: "-1" }, { seed: "-1" }, { seed: String(2 ** 32) }, { device: "tpu" }, { use_msa: "yes" }, { dtype: "bf16" }];
195
+ for (const bad of cases) {
196
+ expect(validateParams(params, bad).errors.length).toBeGreaterThan(0);
197
+ }
198
+ });
199
+ });
200
+ describe("openddeAdapter.command", () => {
201
+ it("builds the exact MPS command for a pure sequence job, running the stored input directly", async () => {
202
+ const dir = tempDir();
203
+ const input = tinyJob(dir);
204
+ const context = runContext(dir, input);
205
+ const spec = await openddeAdapter.command(context);
206
+ const rootDir = path.join(context.assetsDir, "opendde-root");
207
+ expect(spec.executable).toBe(path.join(dir, "model", ".venv", "bin", "opendde"));
208
+ expect(spec.args).toEqual([
209
+ "pred",
210
+ "-i", input,
211
+ "-o", context.outputDir,
212
+ "-n", "opendde_v1",
213
+ "--load_checkpoint_path", path.join(rootDir, "checkpoint", "opendde.pt"),
214
+ "--device", "mps",
215
+ "--dtype", "fp32",
216
+ "--sample", "1",
217
+ "--step", "200",
218
+ "--cycle", "10",
219
+ "--use_msa", "false",
220
+ "--use_template", "false",
221
+ "--use_rna_msa", "false",
222
+ "--deterministic", "false",
223
+ ]);
224
+ expect(spec.env).toEqual({
225
+ OPENDDE_ROOT_DIR: rootDir,
226
+ OPENDDE_DEPENDENCY_URL: "https://downloads-disabled.moldesk.invalid",
227
+ OPENDDE_COMMON_URL: "https://downloads-disabled.moldesk.invalid",
228
+ OPENDDE_SEARCH_DATABASE_URL: "https://downloads-disabled.moldesk.invalid",
229
+ });
230
+ expect(fs.existsSync(path.join(context.outputDir, "moldesk-resolved-job.json"))).toBe(false);
231
+ });
232
+ it("maps user params onto the pinned CLI flags, including --seeds", async () => {
233
+ const dir = tempDir();
234
+ const context = runContext(dir, tinyJob(dir), {
235
+ params: defaults({ device: "mps", samples: "3", steps: "50", cycles: "4", seed: "7", use_msa: "true", deterministic: "true" }),
236
+ });
237
+ const { args } = await openddeAdapter.command(context);
238
+ expect(argValue(args, "--sample")).toBe("3");
239
+ expect(argValue(args, "--step")).toBe("50");
240
+ expect(argValue(args, "--cycle")).toBe("4");
241
+ expect(argValue(args, "--seeds")).toBe("7");
242
+ expect(argValue(args, "--use_msa")).toBe("true");
243
+ expect(argValue(args, "--deterministic")).toBe("true");
244
+ });
245
+ it("builds a CUDA command on the linux-x64 install", async () => {
246
+ const dir = tempDir();
247
+ const context = runContext(dir, tinyJob(dir), { platform: "linux-x64", params: defaults({ device: "cuda" }) });
248
+ expect(argValue((await openddeAdapter.command(context)).args, "--device")).toBe("cuda");
249
+ });
250
+ it("allows an explicit CPU run on either platform", async () => {
251
+ const dir = tempDir();
252
+ for (const platform of ["darwin-arm64", "linux-x64"]) {
253
+ const context = runContext(dir, tinyJob(dir), { platform, params: defaults({ device: "cpu" }) });
254
+ expect(argValue((await openddeAdapter.command(context)).args, "--device")).toBe("cpu");
255
+ }
256
+ });
257
+ it("resolves device=auto to the installed accelerator, never to upstream's CPU-falling-back auto", async () => {
258
+ const dir = tempDir();
259
+ const mac = runContext(dir, tinyJob(dir), { params: defaults({ device: "auto" }) });
260
+ expect(argValue((await openddeAdapter.command(mac)).args, "--device")).toBe("mps");
261
+ const linux = runContext(dir, tinyJob(dir), { platform: "linux-x64", params: defaults({ device: "auto" }) });
262
+ expect(argValue((await openddeAdapter.command(linux)).args, "--device")).toBe("cuda");
263
+ const unknown = runContext(dir, tinyJob(dir), { platform: undefined, params: defaults({ device: "auto" }) });
264
+ await expect(openddeAdapter.command(unknown)).rejects.toMatchObject({ code: "INVALID_RUN_PARAMS" });
265
+ });
266
+ it("rejects device/platform mismatches before execution", async () => {
267
+ const dir = tempDir();
268
+ const cudaOnMac = runContext(dir, tinyJob(dir), { params: defaults({ device: "cuda" }) });
269
+ await expect(openddeAdapter.command(cudaOnMac)).rejects.toMatchObject({ code: "INVALID_RUN_PARAMS" });
270
+ const mpsOnLinux = runContext(dir, tinyJob(dir), { platform: "linux-x64", params: defaults({ device: "mps" }) });
271
+ await expect(openddeAdapter.command(mpsOnLinux)).rejects.toMatchObject({ code: "INVALID_RUN_PARAMS" });
272
+ });
273
+ it("rejects non-integer numeric params", async () => {
274
+ const dir = tempDir();
275
+ const cases = [{ samples: "1.5" }, { steps: "2.5" }, { cycles: "1.5" }, { seed: "3.2" }];
276
+ for (const bad of cases) {
277
+ const context = runContext(dir, tinyJob(dir), { params: defaults({ device: "mps", ...bad }) });
278
+ await expect(openddeAdapter.command(context)).rejects.toMatchObject({ code: "INVALID_RUN_PARAMS" });
279
+ }
280
+ });
281
+ it("rejects use_template=true (no template databases provisioned)", async () => {
282
+ const dir = tempDir();
283
+ const context = runContext(dir, tinyJob(dir), { params: defaults({ device: "mps", use_template: "true" }) });
284
+ await expect(openddeAdapter.command(context)).rejects.toMatchObject({ code: "INVALID_RUN_PARAMS" });
285
+ });
286
+ it("allows use_rna_msa=true only when every rnaSequence has a precomputed MSA", async () => {
287
+ const dir = tempDir();
288
+ fs.writeFileSync(path.join(dir, "rna.a3m"), ">q\nGUAC\n");
289
+ const without = writeJob(dir, [{ name: "a", sequences: [{ rnaSequence: { sequence: "GUAC", count: 1 } }] }], "without.json");
290
+ await expect(openddeAdapter.command(runContext(dir, without, { params: defaults({ device: "mps", use_rna_msa: "true" }) })))
291
+ .rejects.toMatchObject({ code: "INVALID_RUN_PARAMS" });
292
+ const withMsa = writeJob(dir, [{ name: "a", sequences: [{ rnaSequence: { sequence: "GUAC", count: 1, unpairedMsaPath: "rna.a3m" } }] }], "with.json");
293
+ const inputDir = path.join(dir, "run", "input");
294
+ fs.mkdirSync(inputDir, { recursive: true });
295
+ const staged = stageCompanionInputs({ jobFilePath: withMsa, refs: await openddeAdapter.resolveCompanionInputs(withMsa), inputDir });
296
+ const context = runContext(dir, withMsa, {
297
+ params: defaults({ device: "mps", use_rna_msa: "true" }),
298
+ companionInputs: staged.map((s) => ({ id: s.id, path: s.storedPath })),
299
+ });
300
+ expect(argValue((await openddeAdapter.command(context)).args, "--use_rna_msa")).toBe("true");
301
+ });
302
+ it("rewrites every companion reference to its staged copy and runs the rewritten job", async () => {
303
+ const dir = tempDir();
304
+ const jobDir = path.join(dir, "submitted");
305
+ fs.mkdirSync(jobDir);
306
+ fs.writeFileSync(path.join(jobDir, "p.a3m"), A3M_TEXT);
307
+ fs.writeFileSync(path.join(jobDir, "u.a3m"), A3M_TEXT);
308
+ fs.writeFileSync(path.join(jobDir, "lig.sdf"), SDF_TEXT);
309
+ const original = writeJob(jobDir, [{
310
+ name: "complex",
311
+ sequences: [protein({ pairedMsaPath: "p.a3m", unpairedMsaPath: "./u.a3m" }), { ligand: { ligand: "FILE_lig.sdf", count: 1 } }],
312
+ }]);
313
+ // Mirror core's run flow: snapshot the job, then stage its companions.
314
+ const inputDir = path.join(dir, "run", "input");
315
+ fs.mkdirSync(inputDir, { recursive: true });
316
+ const storedInput = path.join(inputDir, "job.json");
317
+ fs.copyFileSync(original, storedInput);
318
+ const staged = stageCompanionInputs({ jobFilePath: original, refs: await openddeAdapter.resolveCompanionInputs(original), inputDir });
319
+ // The run must not depend on the submitted files afterwards.
320
+ fs.rmSync(jobDir, { recursive: true, force: true });
321
+ const context = runContext(dir, storedInput, { companionInputs: staged.map((s) => ({ id: s.id, path: s.storedPath })) });
322
+ const { args } = await openddeAdapter.command(context);
323
+ const resolved = path.join(context.outputDir, "moldesk-resolved-job.json");
324
+ expect(argValue(args, "-i")).toBe(resolved);
325
+ const doc = JSON.parse(fs.readFileSync(resolved, "utf8"));
326
+ const chain = doc[0].sequences[0].proteinChain;
327
+ expect(chain.pairedMsaPath).toBe(path.join(inputDir, "j0-s0-paired-msa", "p.a3m"));
328
+ expect(chain.unpairedMsaPath).toBe(path.join(inputDir, "j0-s0-unpaired-msa", "u.a3m"));
329
+ expect(doc[0].sequences[1].ligand.ligand).toBe(`FILE_${path.join(inputDir, "j0-s1-ligand-file", "lig.sdf")}`);
330
+ for (const p of [chain.pairedMsaPath, chain.unpairedMsaPath])
331
+ expect(fs.existsSync(p)).toBe(true);
332
+ // The stored job snapshot itself is left untouched.
333
+ expect(JSON.parse(fs.readFileSync(storedInput, "utf8"))[0].sequences[0].proteinChain.pairedMsaPath).toBe("p.a3m");
334
+ });
335
+ it("fails clearly when a declared companion was not staged", async () => {
336
+ const dir = tempDir();
337
+ fs.writeFileSync(path.join(dir, "u.a3m"), A3M_TEXT);
338
+ const input = writeJob(dir, [{ name: "a", sequences: [protein({ unpairedMsaPath: "u.a3m" })] }]);
339
+ await expect(openddeAdapter.command(runContext(dir, input))).rejects.toMatchObject({ code: "COMPANION_INPUT_NOT_STAGED" });
340
+ });
341
+ it("stageCompanionInputs rejects a symlink escaping the job directory", async () => {
342
+ const dir = tempDir();
343
+ const outside = tempDir();
344
+ fs.writeFileSync(path.join(outside, "secret.a3m"), A3M_TEXT);
345
+ fs.symlinkSync(path.join(outside, "secret.a3m"), path.join(dir, "link.a3m"));
346
+ const input = writeJob(dir, [{ name: "a", sequences: [protein({ unpairedMsaPath: "link.a3m" })] }]);
347
+ const inputDir = path.join(dir, "run", "input");
348
+ fs.mkdirSync(inputDir, { recursive: true });
349
+ expect(() => stageCompanionInputs({ jobFilePath: input, refs: [{ id: "j0-s0-unpaired-msa", sourcePath: "link.a3m" }], inputDir }))
350
+ .toThrowError(expect.objectContaining({ code: "UNSAFE_COMPANION_INPUT_PATH" }));
351
+ });
352
+ });
353
+ describe("openddeAdapter.collectOutputs", () => {
354
+ function writePrediction(outputDir, job, seed, options = {}) {
355
+ const dir = path.join(outputDir, job, `seed_${seed}`, "predictions");
356
+ fs.mkdirSync(dir, { recursive: true });
357
+ fs.writeFileSync(path.join(dir, `${job}_sample_0.cif`), options.cif ?? CIF_TEXT);
358
+ fs.writeFileSync(path.join(dir, `${job}_summary_confidence_sample_0.json`), options.summary ?? JSON.stringify({ plddt: 80.1, ptm: 0.5, ranking_score: 0.4 }));
359
+ fs.writeFileSync(path.join(dir, `${job}_full_data_sample_0.json`), JSON.stringify({ atom_plddt: [1] }));
360
+ }
361
+ it("collects structures, confidence, and atom confidence per job", async () => {
362
+ const dir = tempDir();
363
+ const context = runContext(dir, tinyJob(dir));
364
+ writePrediction(context.outputDir, "tiny", 101);
365
+ const outputs = await openddeAdapter.collectOutputs(context);
366
+ expect(outputs.map((o) => [o.id, path.relative(context.outputDir, o.path)])).toEqual([
367
+ ["structures", "tiny/seed_101/predictions/tiny_sample_0.cif"],
368
+ ["confidence", "tiny/seed_101/predictions/tiny_summary_confidence_sample_0.json"],
369
+ ["atom_confidence", "tiny/seed_101/predictions/tiny_full_data_sample_0.json"],
370
+ ["structures_pdb", "tiny/seed_101/predictions/tiny_sample_0.pdb"],
371
+ ]);
372
+ });
373
+ it("exports PDB via the installed runtime's python, passing mmCIF paths as argv (no shell/interpolation)", async () => {
374
+ const dir = tempDir();
375
+ const calls = [];
376
+ const context = runContext(dir, tinyJob(dir), { runner: fakePdbExporter([], calls) });
377
+ writePrediction(context.outputDir, "tiny", 101);
378
+ await openddeAdapter.collectOutputs(context);
379
+ expect(calls).toHaveLength(1);
380
+ expect(calls[0].command).toBe(context.runtimeExecutable);
381
+ expect(calls[0].args[0]).toBe("-c");
382
+ expect(calls[0].args[1]).toContain("include_bonds=True");
383
+ expect(calls[0].args[1]).toContain('extra_fields=["b_factor"]');
384
+ expect(calls[0].args[1]).not.toContain(context.outputDir);
385
+ expect(calls[0].args.slice(2)).toEqual([path.join(context.outputDir, "tiny/seed_101/predictions/tiny_sample_0.cif")]);
386
+ });
387
+ it("records per-structure PDB export failures without failing a valid prediction", async () => {
388
+ const dir = tempDir();
389
+ const input = writeJob(dir, [{ name: "a", sequences: [protein()] }, { name: "b", sequences: [protein()] }]);
390
+ const context = runContext(dir, input, { runner: fakePdbExporter(["b_sample_0.cif"]) });
391
+ writePrediction(context.outputDir, "a", 1);
392
+ writePrediction(context.outputDir, "b", 1);
393
+ const outputs = await openddeAdapter.collectOutputs(context);
394
+ expect(outputs.filter((o) => o.id === "structures_pdb").map((o) => path.basename(o.path))).toEqual(["a_sample_0.pdb"]);
395
+ const errorsFile = outputs.find((o) => o.id === "pdb_export_errors").path;
396
+ expect(JSON.parse(fs.readFileSync(errorsFile, "utf8"))).toEqual({ "b/seed_1/predictions/b_sample_0.cif": "ValueError: chain ID too long" });
397
+ });
398
+ it("records a crashed PDB export process for every structure", async () => {
399
+ const dir = tempDir();
400
+ const context = runContext(dir, tinyJob(dir), { runner: async () => ({ code: 1, stdout: "", stderr: "ModuleNotFoundError: biotite" }) });
401
+ writePrediction(context.outputDir, "tiny", 101);
402
+ const outputs = await openddeAdapter.collectOutputs(context);
403
+ expect(outputs.some((o) => o.id === "structures_pdb")).toBe(false);
404
+ const errors = JSON.parse(fs.readFileSync(path.join(context.outputDir, "moldesk-pdb-export-errors.json"), "utf8"));
405
+ expect(errors["tiny/seed_101/predictions/tiny_sample_0.cif"]).toContain("ModuleNotFoundError: biotite");
406
+ });
407
+ it("does not accept unrelated .cif/.json files elsewhere under output", async () => {
408
+ const dir = tempDir();
409
+ const context = runContext(dir, tinyJob(dir));
410
+ fs.mkdirSync(path.join(context.outputDir, "tiny", "other"), { recursive: true });
411
+ fs.writeFileSync(path.join(context.outputDir, "stale.cif"), CIF_TEXT);
412
+ fs.writeFileSync(path.join(context.outputDir, "tiny", "other", "tiny_sample_0.cif"), CIF_TEXT);
413
+ fs.writeFileSync(path.join(context.outputDir, "tiny", "other", "tiny_summary_confidence_sample_0.json"), "{}");
414
+ await expect(openddeAdapter.collectOutputs(context)).rejects.toMatchObject({ code: "MISSING_REQUIRED_OUTPUT" });
415
+ });
416
+ it("fails when any job in a multi-job file produced nothing", async () => {
417
+ const dir = tempDir();
418
+ const input = writeJob(dir, [{ name: "a", sequences: [protein()] }, { name: "b", sequences: [protein()] }]);
419
+ const context = runContext(dir, input);
420
+ writePrediction(context.outputDir, "a", 1);
421
+ await expect(openddeAdapter.collectOutputs(context)).rejects.toMatchObject({ code: "MISSING_REQUIRED_OUTPUT" });
422
+ });
423
+ it("rejects an empty or non-mmCIF structure and an unparseable confidence summary", async () => {
424
+ const dir = tempDir();
425
+ const context = runContext(dir, tinyJob(dir));
426
+ writePrediction(context.outputDir, "tiny", 101, { cif: "" });
427
+ await expect(openddeAdapter.collectOutputs(context)).rejects.toMatchObject({ code: "INVALID_RUN_OUTPUT" });
428
+ const dir2 = tempDir();
429
+ const context2 = runContext(dir2, tinyJob(dir2));
430
+ writePrediction(context2.outputDir, "tiny", 101, { summary: "not json" });
431
+ await expect(openddeAdapter.collectOutputs(context2)).rejects.toMatchObject({ code: "INVALID_RUN_OUTPUT" });
432
+ });
433
+ it("collects the resolved job copy when one was written", async () => {
434
+ const dir = tempDir();
435
+ const context = runContext(dir, tinyJob(dir));
436
+ writePrediction(context.outputDir, "tiny", 101);
437
+ fs.writeFileSync(path.join(context.outputDir, "moldesk-resolved-job.json"), "[]");
438
+ const outputs = await openddeAdapter.collectOutputs(context);
439
+ expect(outputs.find((o) => o.id === "resolved_job")).toBeDefined();
440
+ });
441
+ });
442
+ describe("openddeAdapter.verifyInstallation", () => {
443
+ const ASSETS = [
444
+ { rel: "checkpoint/opendde.pt", size: 2_625_249_069 },
445
+ { rel: "common/components.cif", size: 490_777_362 },
446
+ { rel: "common/components.cif.rdkit_mol.pkl", size: 142_498_117 },
447
+ ];
448
+ function install(dir, options = {}) {
449
+ const modelDir = path.join(dir, "model");
450
+ const assetsDir = path.join(modelDir, "assets");
451
+ const bin = path.join(modelDir, ".venv", "bin");
452
+ fs.mkdirSync(bin, { recursive: true });
453
+ fs.writeFileSync(path.join(bin, "python"), "");
454
+ fs.writeFileSync(path.join(bin, "opendde"), "");
455
+ for (const asset of ASSETS) {
456
+ if (asset.rel === options.skip)
457
+ continue;
458
+ const file = path.join(assetsDir, "opendde-root", asset.rel);
459
+ fs.mkdirSync(path.dirname(file), { recursive: true });
460
+ fs.writeFileSync(file, "");
461
+ // Sparse file of the exact published size: size check passes, digest check can't.
462
+ fs.truncateSync(file, asset.rel === options.wrongSize ? 10 : asset.size);
463
+ }
464
+ return { modelDir, assetsDir };
465
+ }
466
+ const helpOk = "Usage: opendde pred --device [auto|cpu|cuda|mps] --load_checkpoint_path --use_msa --seeds";
467
+ it("fails when an asset is missing", async () => {
468
+ const dir = tempDir();
469
+ const { modelDir, assetsDir } = install(dir, { skip: "common/components.cif" });
470
+ const result = await openddeAdapter.verifyInstallation({ manifestName: "opendde", modelDir, assetsDir, platform: "darwin-arm64", runner: async () => ({ code: 0, stdout: "", stderr: "" }) });
471
+ expect(result.passed).toBe(false);
472
+ expect(result.output).toContain("components.cif");
473
+ });
474
+ it("fails on a wrong-sized asset before spawning anything", async () => {
475
+ const dir = tempDir();
476
+ const { modelDir, assetsDir } = install(dir, { wrongSize: "checkpoint/opendde.pt" });
477
+ const calls = [];
478
+ const result = await openddeAdapter.verifyInstallation({ manifestName: "opendde", modelDir, assetsDir, platform: "darwin-arm64", runner: async (cmd) => { calls.push(cmd); return { code: 0, stdout: "", stderr: "" }; } });
479
+ expect(result.passed).toBe(false);
480
+ expect(result.output).toContain("expected 2625249069 bytes");
481
+ expect(calls).toEqual([]);
482
+ });
483
+ function recordingRunner(help = helpOk, probe = { code: 0, stdout: "ok", stderr: "" }) {
484
+ const calls = [];
485
+ const runner = async (command, args) => {
486
+ calls.push({ command, args });
487
+ return args[0] === "-c" ? probe : { code: 0, stdout: help, stderr: "" };
488
+ };
489
+ return { calls, runner };
490
+ }
491
+ it("on darwin-arm64 runs the MPS probe (real MPS tensor op + pinned versions) and checks the CLI advertises mps", async () => {
492
+ const dir = tempDir();
493
+ const { modelDir, assetsDir } = install(dir);
494
+ const { calls, runner } = recordingRunner();
495
+ const result = await openddeAdapter.verifyInstallation({ manifestName: "opendde", modelDir, assetsDir, platform: "darwin-arm64", runner });
496
+ const probe = calls[0].args[1];
497
+ for (const needle of ['platform.system() == "Darwin"', 'platform.machine() == "arm64"', "torch.backends.mps.is_built()", "torch.backends.mps.is_available()", 'device="mps"', 'version("opendde")', '"1.1.1"', '"2.7.1"']) {
498
+ expect(probe).toContain(needle);
499
+ }
500
+ expect(probe).not.toContain("cuda");
501
+ expect(calls[1]).toEqual({ command: path.join(modelDir, ".venv", "bin", "opendde"), args: ["pred", "--help"] });
502
+ // Placeholder sparse assets reach the final digest check and fail it.
503
+ expect(result.passed).toBe(false);
504
+ expect(result.output).toContain("expected sha256 7b826620390afad877ee2babc6a4d0df81b94d3a0be030959853d6a7da0807cc");
505
+ }, 60_000);
506
+ it("on linux-x64 runs the CUDA probe (CUDA build, device, cuEquivariance, real CUDA op)", async () => {
507
+ const dir = tempDir();
508
+ const { modelDir, assetsDir } = install(dir);
509
+ const { calls, runner } = recordingRunner(helpOk.replace("mps", ""), { code: 1, stdout: "", stderr: "AssertionError: torch.cuda.is_available() is False" });
510
+ const result = await openddeAdapter.verifyInstallation({ manifestName: "opendde", modelDir, assetsDir, platform: "linux-x64", runner });
511
+ const probe = calls[0].args[1];
512
+ for (const needle of ["torch.version.cuda", "torch.cuda.is_available()", "torch.cuda.device_count()", "cuequivariance_torch", 'device="cuda"', 'version("opendde")']) {
513
+ expect(probe).toContain(needle);
514
+ }
515
+ expect(probe).not.toContain("mps");
516
+ expect(result).toEqual({ passed: false, output: "AssertionError: torch.cuda.is_available() is False" });
517
+ });
518
+ it("fails when the installed CLI does not advertise the expected device", async () => {
519
+ const dir = tempDir();
520
+ const { modelDir, assetsDir } = install(dir);
521
+ const { runner } = recordingRunner("Usage: opendde pred --device [auto|cpu|cuda] --load_checkpoint_path --use_msa --seeds");
522
+ const result = await openddeAdapter.verifyInstallation({ manifestName: "opendde", modelDir, assetsDir, platform: "darwin-arm64", runner });
523
+ expect(result.passed).toBe(false);
524
+ expect(result.output).toContain("did not advertise mps");
525
+ });
526
+ it("fails closed when the installed platform cannot be determined", async () => {
527
+ const dir = tempDir();
528
+ const { modelDir, assetsDir } = install(dir);
529
+ const { calls, runner } = recordingRunner();
530
+ const result = await openddeAdapter.verifyInstallation({ manifestName: "opendde", modelDir, assetsDir, runner });
531
+ expect(result.passed).toBe(false);
532
+ expect(calls).toEqual([]);
533
+ });
534
+ });
535
+ describe("opendde manifest consistency", () => {
536
+ const manifest = parseYaml(fs.readFileSync(path.join(repoRoot, "models/opendde/manifest.yaml"), "utf8"));
537
+ it("stays beta and advertises only live-verified platforms", () => {
538
+ expect(manifest.status).toBe("beta");
539
+ expect(manifest.hardware.platforms).toEqual(["darwin-arm64"]);
540
+ });
541
+ it("pins opendde 1.1.1 and torch 2.7.1 by artifact hash on every runtime, CUDA via the closed torchBackend field", () => {
542
+ for (const runtime of manifest.runtimes) {
543
+ const opendde = runtime.requirements.find((r) => r.name === "opendde");
544
+ const torch = runtime.requirements.find((r) => r.name === "torch");
545
+ expect(opendde.version).toBe("1.1.1");
546
+ expect(opendde.hashes).toEqual(["94b193bd360e017c9cfab08d199ec9333cb2919a49fe1d9270c659c4b2efe544"]);
547
+ expect(String(torch.version).split("+")[0]).toBe("2.7.1");
548
+ expect(torch.hashes).toHaveLength(1);
549
+ }
550
+ const linux = manifest.runtimes.find((r) => r.platforms.includes("linux-x64"));
551
+ expect(linux.torchBackend).toBe("cu126");
552
+ expect(linux.requirements.find((r) => r.name === "opendde").extras).toEqual(["gpu"]);
553
+ const mac = manifest.runtimes.find((r) => r.platforms.includes("darwin-arm64"));
554
+ expect(mac.torchBackend).toBeUndefined();
555
+ expect(mac.nvidiaGpu).toBeUndefined();
556
+ });
557
+ it("installs exactly the general checkpoint and CCD files under the adapter's OPENDDE_ROOT_DIR", () => {
558
+ expect(manifest.assets.map((a) => [a.target, a.sha256, a.sizeBytes])).toEqual([
559
+ ["opendde-root/checkpoint/opendde.pt", "7b826620390afad877ee2babc6a4d0df81b94d3a0be030959853d6a7da0807cc", 2625249069],
560
+ ["opendde-root/common/components.cif", "bb31ae5cf6c8bc669924313077cb4231ee5ffefd3a20118cd14f3ec89f8bb6a5", 490777362],
561
+ ["opendde-root/common/components.cif.rdkit_mol.pkl", "d1cfb71f5993a3ebea7c47877022d7f597bbfbaf86e28a4770e957da6c50cd35", 142498117],
562
+ ]);
563
+ for (const asset of manifest.assets) {
564
+ expect(asset.url).toMatch(/^https:\/\/huggingface\.co\/aurekaresearch\/OpenDDE\/resolve\/eddd563ce96571f784012edd8f045181c8f8627d\//);
565
+ }
566
+ });
567
+ it("declares the same outputs the adapter collects", () => {
568
+ expect(manifest.outputs.map((o) => [o.id, o.glob, o.required])).toEqual([
569
+ ["structures", "*/seed_*/predictions/*_sample_*.cif", true],
570
+ ["confidence", "*/seed_*/predictions/*_summary_confidence_sample_*.json", true],
571
+ ["atom_confidence", "*/seed_*/predictions/*_full_data_sample_*.json", false],
572
+ ["structures_pdb", "*/seed_*/predictions/*_sample_*.pdb", false],
573
+ ["pdb_export_errors", "moldesk-pdb-export-errors.json", false],
574
+ ["resolved_job", "moldesk-resolved-job.json", false],
575
+ ]);
576
+ });
577
+ });
@@ -0,0 +1,8 @@
1
+ import type { OutputSpec } from "@moldesk/registry";
2
+ import type { CollectedOutput } from "./index.js";
3
+ /**
4
+ * Resolves manifest-declared output globs against a run's output directory.
5
+ * Throws `MISSING_REQUIRED_OUTPUT` if any `required` output has no matches —
6
+ * a nonzero exit code alone is not sufficient (spec §4.4).
7
+ */
8
+ export declare function collectGlobOutputs(outputDir: string, outputs: OutputSpec[]): CollectedOutput[];