@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,551 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { MoldeskError } from "@moldesk/registry";
5
+ import { runCommand } from "@moldesk/runtime";
6
+ import { collectGlobOutputs } from "../outputs.js";
7
+ /**
8
+ * Adapter-written copy of the job with every companion reference rewritten to its
9
+ * staged path under the run's `input/` dir. Only written when the job references
10
+ * files; pure sequence/SMILES/CCD jobs run straight from the stored input.
11
+ */
12
+ const RESOLVED_JOB_FILE = "moldesk-resolved-job.json";
13
+ /** Written only when one or more mmCIF → PDB conversions failed. */
14
+ const PDB_EXPORT_ERRORS_FILE = "moldesk-pdb-export-errors.json";
15
+ /**
16
+ * `opendde pred` writes `<out_dir>/<job_name>/seed_<seed>/predictions/…`
17
+ * (`runner/dumper.py` `_get_dump_dir` with `group_name=""`, and the file names in
18
+ * `_save_structure`/`_save_confidence` at aurekaresearch/OpenDDE @
19
+ * ddfa1df8aff1babf1fddac4247b7d2351bd0ce9f). Must match `models/opendde/manifest.yaml`.
20
+ */
21
+ const OUTPUTS = [
22
+ { id: "structures", glob: "*/seed_*/predictions/*_sample_*.cif", required: true },
23
+ { id: "confidence", glob: "*/seed_*/predictions/*_summary_confidence_sample_*.json", required: true },
24
+ { id: "atom_confidence", glob: "*/seed_*/predictions/*_full_data_sample_*.json", required: false },
25
+ // Adapter-written PDB conversions of each mmCIF (see `exportPdb`).
26
+ { id: "structures_pdb", glob: "*/seed_*/predictions/*_sample_*.pdb", required: false },
27
+ { id: "pdb_export_errors", glob: PDB_EXPORT_ERRORS_FILE, required: false },
28
+ { id: "resolved_job", glob: RESOLVED_JOB_FILE, required: false },
29
+ ];
30
+ const MAX_SEED = 2 ** 32 - 1;
31
+ /**
32
+ * Params verified against the pinned `runner/batch_inference.py` `predict`
33
+ * click options. Defaults are a bounded, offline smoke-test configuration: one
34
+ * sample, upstream's standard 200 steps / 10 cycles, and no MSA/template/RNA-MSA
35
+ * search (so neither the public ColabFold server nor any search database is
36
+ * touched). No static `device` default — see `resolveParamDefaults`.
37
+ */
38
+ const PARAMS = [
39
+ { name: "device", type: "string", enum: ["auto", "mps", "cuda", "cpu"], description: "Inference device (platform-dependent default; auto resolves to the installed accelerator, never CPU)" },
40
+ { name: "samples", type: "number", default: 1, min: 1, description: "Number of diffusion samples (--sample)" },
41
+ { name: "steps", type: "number", default: 200, min: 1, description: "Diffusion steps (--step)" },
42
+ { name: "cycles", type: "number", default: 10, min: 1, description: "Pairformer cycles (--cycle)" },
43
+ { name: "seed", type: "number", min: 0, max: MAX_SEED, description: "Seed (--seeds); overrides the job's modelSeeds" },
44
+ { name: "use_msa", type: "boolean", default: false, description: "Use protein MSA (precomputed A3M paths in the job, else the public ColabFold server)" },
45
+ { name: "use_template", type: "boolean", default: false, description: "Use templates (not supported by MoleculeDesk yet; must stay false)" },
46
+ { name: "use_rna_msa", type: "boolean", default: false, description: "Use RNA MSA (requires a precomputed unpairedMsaPath on every rnaSequence)" },
47
+ { name: "deterministic", type: "boolean", default: false, description: "Enable deterministic PyTorch algorithms" },
48
+ ];
49
+ const INTEGER_PARAMS = ["samples", "steps", "cycles", "seed"];
50
+ /** Pinned versions (see `models/opendde/manifest.yaml`); bump together. */
51
+ const PINNED_OPENDDE_VERSION = "1.1.1";
52
+ const PINNED_TORCH_VERSION = "2.7.1";
53
+ /**
54
+ * Installed assets and their published identity, mirrored from the manifest's
55
+ * `assets:` (which in turn mirror upstream's `opendde/config/model_manifest.json`
56
+ * and `dependency_url.py` MANAGED_ASSETS). `InstallContext` carries no parsed
57
+ * manifest, so these are constants here, as for Boltz's pinned versions.
58
+ */
59
+ const MANAGED_ASSETS = [
60
+ { relPath: "checkpoint/opendde.pt", sizeBytes: 2_625_249_069, sha256: "7b826620390afad877ee2babc6a4d0df81b94d3a0be030959853d6a7da0807cc" },
61
+ { relPath: "common/components.cif", sizeBytes: 490_777_362, sha256: "bb31ae5cf6c8bc669924313077cb4231ee5ffefd3a20118cd14f3ec89f8bb6a5" },
62
+ { relPath: "common/components.cif.rdkit_mol.pkl", sizeBytes: 142_498_117, sha256: "d1cfb71f5993a3ebea7c47877022d7f597bbfbaf86e28a4770e957da6c50cd35" },
63
+ ];
64
+ /**
65
+ * Upstream falls back to downloading any missing/wrong-sized managed asset from
66
+ * these roots. Pointing them at a reserved, never-resolvable domain (RFC 2606
67
+ * `.invalid`) turns that silent download into an immediate failure, so a run can
68
+ * only ever use the install-time-verified files.
69
+ */
70
+ const DOWNLOADS_DISABLED_URL = "https://downloads-disabled.moldesk.invalid";
71
+ const ENTITY_KEYS = ["proteinChain", "dnaSequence", "rnaSequence", "ligand", "ion"];
72
+ /** Upstream's own `validate_sample_name` only forbids separators/`.`/`..`/`err`; this
73
+ * is stricter so a job name is always a plain directory segment under `output/`. */
74
+ const SAFE_JOB_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
75
+ const ALLOWED_EXTENSIONS = {
76
+ "paired-msa": [".a3m"],
77
+ "unpaired-msa": [".a3m"],
78
+ templates: [".a3m", ".hhr"],
79
+ "ligand-file": [".pdb", ".sdf", ".mol", ".mol2"],
80
+ };
81
+ function openddeError(code, message, remediation, details) {
82
+ return new MoldeskError({ code, message, remediation, details });
83
+ }
84
+ function invalidInput(message, remediation) {
85
+ return openddeError("INVALID_RUN_INPUT", message, remediation);
86
+ }
87
+ function invalidParams(message, remediation) {
88
+ return openddeError("INVALID_RUN_PARAMS", message, remediation);
89
+ }
90
+ function isObject(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value);
92
+ }
93
+ function isValidSeed(value) {
94
+ if (typeof value === "number")
95
+ return Number.isInteger(value) && value >= 0 && value <= MAX_SEED;
96
+ // Upstream accepts quoted decimal seeds for compatibility.
97
+ return typeof value === "string" && /^\d+$/.test(value) && Number(value) <= MAX_SEED;
98
+ }
99
+ /**
100
+ * Parses and structurally validates an OpenDDE job file (a non-empty top-level list
101
+ * of jobs, per upstream `docs/infer_json_format.md` and `validate_inference_jobs`).
102
+ * Enough to reject an obviously invalid job before loading a 2.6 GB checkpoint;
103
+ * sequence/chemistry validity is left to upstream.
104
+ */
105
+ function parseJobs(inputPath) {
106
+ let text;
107
+ try {
108
+ text = fs.readFileSync(inputPath, "utf8");
109
+ }
110
+ catch {
111
+ throw invalidInput(`OpenDDE job file not found: ${inputPath}.`, "Provide a path to an existing .json job file.");
112
+ }
113
+ let raw;
114
+ try {
115
+ raw = JSON.parse(text);
116
+ }
117
+ catch (error) {
118
+ throw invalidInput(`OpenDDE job file is not valid JSON: ${inputPath}. ${error instanceof Error ? error.message : String(error)}`, "Fix the JSON syntax error and retry.");
119
+ }
120
+ if (!Array.isArray(raw) || raw.length === 0) {
121
+ throw invalidInput(`OpenDDE job file must be a non-empty top-level JSON list of jobs: ${inputPath}.`, "Wrap the job in a list: [{\"name\": ..., \"sequences\": [...]}] (see examples/opendde/tiny.json).");
122
+ }
123
+ const seen = new Set();
124
+ return raw.map((job, jobIndex) => {
125
+ if (!isObject(job)) {
126
+ throw invalidInput(`OpenDDE job ${jobIndex} must be a JSON object: ${inputPath}.`, "See examples/opendde/tiny.json for the expected shape.");
127
+ }
128
+ const name = job["name"];
129
+ if (typeof name !== "string" || !SAFE_JOB_NAME_PATTERN.test(name) || name.toLowerCase() === "err" || name === RESOLVED_JOB_FILE) {
130
+ throw invalidInput(`OpenDDE job ${jobIndex} has a missing or unsafe "name": ${JSON.stringify(name)}.`, "Use a plain identifier (letters, digits, '.', '_', '-'; not starting with '.', not \"err\").");
131
+ }
132
+ if (seen.has(name)) {
133
+ throw invalidInput(`OpenDDE job name "${name}" is duplicated; outputs would collide.`, "Give every job in the file a unique name.");
134
+ }
135
+ seen.add(name);
136
+ const modelSeeds = job["modelSeeds"];
137
+ if (modelSeeds !== undefined && (!Array.isArray(modelSeeds) || !modelSeeds.every(isValidSeed))) {
138
+ throw invalidInput(`OpenDDE job "${name}" has an invalid "modelSeeds" (expected a list of integers in [0, ${MAX_SEED}]).`, "Fix modelSeeds, or remove it and pass --param seed=<n>.");
139
+ }
140
+ const sequences = job["sequences"];
141
+ if (!Array.isArray(sequences) || sequences.length === 0) {
142
+ throw invalidInput(`OpenDDE job "${name}" is missing a non-empty "sequences" list.`, "Add at least one entity (e.g. a proteinChain).");
143
+ }
144
+ sequences.forEach((entity, sequenceIndex) => {
145
+ const keys = isObject(entity) ? Object.keys(entity) : [];
146
+ if (keys.length !== 1 || !ENTITY_KEYS.includes(keys[0]) || !isObject(entity[keys[0]])) {
147
+ throw invalidInput(`OpenDDE job "${name}" sequences[${sequenceIndex}] must contain exactly one entity object (${ENTITY_KEYS.join(", ")}).`, "See upstream docs/infer_json_format.md.");
148
+ }
149
+ });
150
+ return { name, sequences: sequences };
151
+ });
152
+ }
153
+ function stringField(entity, field, label) {
154
+ const value = entity[field];
155
+ if (value === undefined)
156
+ return undefined;
157
+ if (typeof value !== "string" || value.trim().length === 0) {
158
+ throw invalidInput(`OpenDDE ${label} "${field}" must be a non-empty path string.`, "Fix or remove the field.");
159
+ }
160
+ return value;
161
+ }
162
+ /**
163
+ * Every file an OpenDDE job can reference, per the pinned revision's readers:
164
+ * `proteinChain.pairedMsaPath`/`unpairedMsaPath`/`templatesPath`,
165
+ * `rnaSequence.unpairedMsaPath`, and a `ligand.ligand` value prefixed `FILE_`.
166
+ * The legacy `proteinChain.msa` form (which names a *directory*) is rejected
167
+ * outright — directory inputs cannot be staged.
168
+ */
169
+ function collectFileReferences(jobs) {
170
+ const refs = [];
171
+ jobs.forEach((job, jobIndex) => {
172
+ job.sequences.forEach((wrapper, sequenceIndex) => {
173
+ const entityKey = Object.keys(wrapper)[0];
174
+ const entity = wrapper[entityKey];
175
+ const label = `job "${job.name}" sequences[${sequenceIndex}].${entityKey}`;
176
+ const add = (field, kind, sourcePath) => {
177
+ if (sourcePath === undefined)
178
+ return;
179
+ refs.push({ id: `j${jobIndex}-s${sequenceIndex}-${kind}`, jobIndex, sequenceIndex, entityKey, field, kind, sourcePath });
180
+ };
181
+ if (entityKey === "proteinChain") {
182
+ if (entity["msa"] !== undefined) {
183
+ throw invalidInput(`OpenDDE ${label} uses the legacy "msa" field, which references a directory.`, "Use pairedMsaPath/unpairedMsaPath pointing at .a3m files instead.");
184
+ }
185
+ add("pairedMsaPath", "paired-msa", stringField(entity, "pairedMsaPath", label));
186
+ add("unpairedMsaPath", "unpaired-msa", stringField(entity, "unpairedMsaPath", label));
187
+ add("templatesPath", "templates", stringField(entity, "templatesPath", label));
188
+ }
189
+ else if (entityKey === "rnaSequence") {
190
+ add("unpairedMsaPath", "unpaired-msa", stringField(entity, "unpairedMsaPath", label));
191
+ }
192
+ else if (entityKey === "ligand") {
193
+ const value = entity["ligand"];
194
+ if (typeof value !== "string" || value.trim().length === 0) {
195
+ throw invalidInput(`OpenDDE ${label} needs a non-empty "ligand" string (CCD_…, FILE_…, or SMILES).`, "See upstream docs/infer_json_format.md.");
196
+ }
197
+ if (value.startsWith("FILE_")) {
198
+ const filePath = value.slice("FILE_".length);
199
+ if (filePath.trim().length === 0)
200
+ throw invalidInput(`OpenDDE ${label} has an empty FILE_ ligand path.`, "Use FILE_<path to .sdf/.mol/.mol2/.pdb>.");
201
+ add("ligand", "ligand-file", filePath);
202
+ }
203
+ }
204
+ });
205
+ });
206
+ return refs;
207
+ }
208
+ function isWithin(root, target) {
209
+ const rel = path.relative(root, target);
210
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
211
+ }
212
+ /** Early, lexical checks so an obviously bad reference fails before a run directory
213
+ * exists. `stageCompanionInputs` repeats confinement (incl. symlinks) at copy time. */
214
+ function checkReference(jobDir, ref) {
215
+ const resolved = path.resolve(jobDir, ref.sourcePath);
216
+ const ext = path.extname(resolved).toLowerCase();
217
+ if (!ALLOWED_EXTENSIONS[ref.kind].includes(ext)) {
218
+ throw invalidInput(`OpenDDE ${ref.entityKey}.${ref.field} reference has an unsupported extension "${ext || "(none)"}": ${ref.sourcePath}.`, `Use one of: ${ALLOWED_EXTENSIONS[ref.kind].join(", ")}.`);
219
+ }
220
+ if (!isWithin(jobDir, resolved)) {
221
+ throw openddeError("UNSAFE_COMPANION_INPUT_PATH", `OpenDDE ${ref.entityKey}.${ref.field} reference resolves outside the job file's directory: ${ref.sourcePath}.`, "Place referenced files alongside (or below) the job file and use relative paths.", { sourcePath: ref.sourcePath, resolved, jobDir });
222
+ }
223
+ let stat;
224
+ try {
225
+ stat = fs.statSync(resolved);
226
+ }
227
+ catch {
228
+ throw invalidInput(`OpenDDE job references a missing file: ${resolved}.`, "Check the referenced path exists and retry.");
229
+ }
230
+ if (!stat.isFile() || stat.size === 0) {
231
+ throw invalidInput(`OpenDDE job reference is not a non-empty regular file: ${resolved}.`, "Reference a regular, non-empty file — directories are not supported.");
232
+ }
233
+ }
234
+ /** Writes the job with every file reference replaced by its staged absolute path. */
235
+ function writeResolvedJob(context, refs) {
236
+ const staged = new Map((context.companionInputs ?? []).map((c) => [c.id, c.path]));
237
+ // Same bytes `parseJobs` just validated, so the ref indices line up.
238
+ const doc = JSON.parse(fs.readFileSync(context.inputPath, "utf8"));
239
+ for (const ref of refs) {
240
+ const stagedPath = staged.get(ref.id);
241
+ if (stagedPath === undefined) {
242
+ throw openddeError("COMPANION_INPUT_NOT_STAGED", `Expected a staged companion input "${ref.id}" but none was found.`, "This is a MoleculeDesk bug — report it.", { id: ref.id });
243
+ }
244
+ const entity = doc[ref.jobIndex]["sequences"][ref.sequenceIndex][ref.entityKey];
245
+ entity[ref.field] = ref.kind === "ligand-file" ? `FILE_${stagedPath}` : stagedPath;
246
+ }
247
+ const target = path.join(context.outputDir, RESOLVED_JOB_FILE);
248
+ // "wx": never overwrite — the output dir is freshly allocated per run.
249
+ fs.writeFileSync(target, `${JSON.stringify(doc, null, 2)}\n`, { flag: "wx" });
250
+ return target;
251
+ }
252
+ /**
253
+ * Maps the `device` param to an explicit upstream `--device`. `auto` is resolved
254
+ * here to the installed runtime's accelerator rather than passed through, because
255
+ * upstream's own `auto` silently falls back to CPU when CUDA/MPS is unavailable.
256
+ * An explicit `mps`/`cuda` fails closed upstream (`select_torch_device` raises).
257
+ */
258
+ function resolveDevice(requested, platform) {
259
+ const device = requested ?? "auto";
260
+ if (device === "cpu")
261
+ return "cpu";
262
+ if (device === "auto") {
263
+ if (platform === "darwin-arm64")
264
+ return "mps";
265
+ if (platform === "linux-x64")
266
+ return "cuda";
267
+ throw invalidParams("--param device=auto cannot be resolved: the installed runtime's platform is unknown.", "Pass --param device=mps (Apple Silicon), device=cuda (Linux/NVIDIA), or device=cpu explicitly, or reinstall opendde.");
268
+ }
269
+ if (device === "mps" && platform !== "darwin-arm64") {
270
+ throw invalidParams("--param device=mps requires the darwin-arm64 installation of OpenDDE.", "Use --param device=cuda (or omit --param device) on Linux, or device=cpu.");
271
+ }
272
+ if (device === "cuda" && platform === "darwin-arm64") {
273
+ throw invalidParams("--param device=cuda is not valid for the darwin-arm64 installation (no CUDA build).", "Use --param device=mps (or omit --param device) on Apple Silicon, or device=cpu.");
274
+ }
275
+ if (device !== "mps" && device !== "cuda") {
276
+ throw invalidParams(`Unsupported --param device=${device}.`, "Use auto, mps, cuda, or cpu.");
277
+ }
278
+ return device;
279
+ }
280
+ function checkParams(params, jobs) {
281
+ for (const name of INTEGER_PARAMS) {
282
+ const value = params[name];
283
+ if (value !== undefined && !Number.isInteger(value)) {
284
+ throw invalidParams(`--param ${name} must be an integer, got ${String(value)}.`, `Pass a whole number for ${name}.`);
285
+ }
286
+ }
287
+ if (params.use_template === true) {
288
+ throw invalidParams("--param use_template=true is not supported by MoleculeDesk yet: template featurization needs the template search databases and fetches PDB mmCIF files at run time, neither of which this installation provisions.", "Omit use_template (default false).");
289
+ }
290
+ if (params.use_rna_msa === true) {
291
+ for (const job of jobs) {
292
+ job.sequences.forEach((wrapper, index) => {
293
+ const rna = wrapper["rnaSequence"];
294
+ if (rna !== undefined && typeof rna["unpairedMsaPath"] !== "string") {
295
+ throw invalidParams(`--param use_rna_msa=true needs a precomputed unpairedMsaPath on every rnaSequence; job "${job.name}" sequences[${index}] has none, and the RNA search databases (~90 GB) are not installed.`, "Add unpairedMsaPath (.a3m) to each rnaSequence, or omit use_rna_msa.");
296
+ }
297
+ });
298
+ }
299
+ }
300
+ }
301
+ function openddeExecutable(runtimeExecutable) {
302
+ return path.join(path.dirname(runtimeExecutable), "opendde");
303
+ }
304
+ async function sha256File(file) {
305
+ const hash = createHash("sha256");
306
+ for await (const chunk of fs.createReadStream(file))
307
+ hash.update(chunk);
308
+ return hash.digest("hex");
309
+ }
310
+ function looksLikeMmcif(text) {
311
+ return /^data_\S*/m.test(text) && text.includes("_atom_site.");
312
+ }
313
+ /** Every job in the submitted file must have produced at least one parseable
314
+ * structure and confidence summary; a partially successful multi-job run is not
315
+ * reported as success. */
316
+ function validateJobOutputs(outputDir, jobs, collected) {
317
+ for (const job of jobs) {
318
+ const prefix = path.join(outputDir, job.name) + path.sep;
319
+ const ofJob = (id) => collected.filter((o) => o.id === id && o.path.startsWith(prefix));
320
+ const structures = ofJob("structures");
321
+ const confidences = ofJob("confidence");
322
+ if (structures.length === 0 || confidences.length === 0) {
323
+ throw openddeError("MISSING_REQUIRED_OUTPUT", `OpenDDE produced no ${structures.length === 0 ? "structure" : "confidence summary"} for job "${job.name}".`, "Check stdout.log/stderr.log in the run directory for the underlying failure.", { job: job.name });
324
+ }
325
+ for (const output of structures) {
326
+ const text = fs.readFileSync(output.path, "utf8");
327
+ if (!looksLikeMmcif(text)) {
328
+ throw openddeError("INVALID_RUN_OUTPUT", `Structure is empty or not an mmCIF file: ${output.path}.`, "Check stdout.log/stderr.log in the run directory.", { path: output.path });
329
+ }
330
+ }
331
+ for (const output of confidences) {
332
+ let parsed;
333
+ try {
334
+ parsed = JSON.parse(fs.readFileSync(output.path, "utf8"));
335
+ }
336
+ catch {
337
+ parsed = undefined;
338
+ }
339
+ if (!isObject(parsed)) {
340
+ throw openddeError("INVALID_RUN_OUTPUT", `Confidence summary is not a JSON object: ${output.path}.`, "Check stdout.log/stderr.log in the run directory.", { path: output.path });
341
+ }
342
+ }
343
+ }
344
+ }
345
+ /**
346
+ * mmCIF → PDB with the installed environment's biotite (an exact opendde
347
+ * dependency, `biotite==1.4.0`), keeping per-atom pLDDT in the B-factor column
348
+ * and writing CONECT records for ligand bonds. Paths are passed as argv, never
349
+ * interpolated into the script. Legacy PDB can't represent every structure
350
+ * (>99,999 atoms, multi-character chain IDs), so a per-file failure doesn't fail
351
+ * an otherwise valid prediction: it is recorded in `moldesk-pdb-export-errors.json`
352
+ * (a declared, checksummed output) and the mmCIF remains the primary result.
353
+ */
354
+ const PDB_EXPORT_SCRIPT = `
355
+ import json, sys
356
+ import biotite.structure.io.pdb as pdb
357
+ import biotite.structure.io.pdbx as pdbx
358
+ errors = {}
359
+ for cif in sys.argv[1:]:
360
+ try:
361
+ atoms = pdbx.get_structure(pdbx.CIFFile.read(cif), model=1, extra_fields=["b_factor"], include_bonds=True)
362
+ out = pdb.PDBFile()
363
+ out.set_structure(atoms)
364
+ out.write(cif[:-len(".cif")] + ".pdb")
365
+ except Exception as exc:
366
+ errors[cif] = f"{type(exc).__name__}: {exc}"
367
+ print(json.dumps(errors))
368
+ `.trim();
369
+ async function exportPdb(context, cifPaths) {
370
+ if (cifPaths.length === 0)
371
+ return;
372
+ const runner = context.runner ?? runCommand;
373
+ const result = await runner(context.runtimeExecutable, ["-c", PDB_EXPORT_SCRIPT, ...cifPaths], { timeoutMs: 10 * 60_000 });
374
+ let errors;
375
+ try {
376
+ errors = result.code === 0 ? JSON.parse(result.stdout.trim().split("\n").pop() ?? "{}") : {};
377
+ }
378
+ catch {
379
+ errors = {};
380
+ }
381
+ if (result.code !== 0) {
382
+ for (const cif of cifPaths)
383
+ errors[cif] = `PDB export process failed (exit ${result.code}): ${result.stderr.trim().slice(-500)}`;
384
+ }
385
+ if (Object.keys(errors).length === 0)
386
+ return;
387
+ const relative = Object.fromEntries(Object.entries(errors).map(([cif, message]) => [path.relative(context.outputDir, cif), message]));
388
+ fs.writeFileSync(path.join(context.outputDir, PDB_EXPORT_ERRORS_FILE), `${JSON.stringify(relative, null, 2)}\n`, { flag: "wx" });
389
+ }
390
+ export const openddeAdapter = {
391
+ modelName: "opendde",
392
+ params: PARAMS,
393
+ async validateInput(inputPath) {
394
+ if (!fs.existsSync(inputPath)) {
395
+ throw invalidInput(`Input file not found: ${inputPath}.`, "Provide a path to an existing .json job file.");
396
+ }
397
+ if (path.extname(inputPath).toLowerCase() !== ".json") {
398
+ throw invalidInput(`OpenDDE requires a .json job file, got "${path.extname(inputPath) || "(no extension)"}".`, "See examples/opendde/tiny.json.");
399
+ }
400
+ const jobs = parseJobs(inputPath);
401
+ const jobDir = path.dirname(path.resolve(inputPath));
402
+ for (const ref of collectFileReferences(jobs))
403
+ checkReference(jobDir, ref);
404
+ },
405
+ async resolveCompanionInputs(inputPath) {
406
+ return collectFileReferences(parseJobs(inputPath)).map((ref) => ({ id: ref.id, sourcePath: ref.sourcePath }));
407
+ },
408
+ resolveParamDefaults(context) {
409
+ const platform = context.installed.runtime.python?.platform;
410
+ if (platform === "darwin-arm64")
411
+ return { device: "mps" };
412
+ if (platform === "linux-x64")
413
+ return { device: "cuda" };
414
+ return {};
415
+ },
416
+ async installPlan(_context) {
417
+ return {
418
+ steps: [
419
+ { id: "source", description: "Fetch the pinned OpenDDE v1.1.1 source revision" },
420
+ { id: "python", description: "Create an isolated managed Python 3.11 environment" },
421
+ { id: "dependencies", description: "Install hash-pinned opendde==1.1.1 and torch==2.7.1 (MPS build on Apple Silicon; cu126 + cuEquivariance on Linux/NVIDIA)" },
422
+ { id: "assets", description: "Populate and checksum-verify the opendde_v1 checkpoint and CCD runtime files" },
423
+ { id: "verify", description: "Verify asset digests, imports, the CLI, and the accelerator (MPS/CUDA)" },
424
+ ],
425
+ };
426
+ },
427
+ async command(context) {
428
+ const jobs = parseJobs(context.inputPath);
429
+ const params = context.params;
430
+ checkParams(params, jobs);
431
+ const device = resolveDevice(params.device, context.platform);
432
+ const refs = collectFileReferences(jobs);
433
+ const jobPath = refs.length > 0 ? writeResolvedJob(context, refs) : context.inputPath;
434
+ const rootDir = path.join(context.assetsDir, "opendde-root");
435
+ const args = [
436
+ "pred",
437
+ "-i", jobPath,
438
+ "-o", context.outputDir,
439
+ "-n", "opendde_v1",
440
+ "--load_checkpoint_path", path.join(rootDir, "checkpoint", "opendde.pt"),
441
+ "--device", device,
442
+ "--dtype", "fp32",
443
+ "--sample", String(params.samples ?? 1),
444
+ "--step", String(params.steps ?? 200),
445
+ "--cycle", String(params.cycles ?? 10),
446
+ "--use_msa", String(params.use_msa === true),
447
+ "--use_template", "false",
448
+ "--use_rna_msa", String(params.use_rna_msa === true),
449
+ "--deterministic", String(params.deterministic === true),
450
+ ];
451
+ if (params.seed !== undefined)
452
+ args.push("--seeds", String(params.seed));
453
+ return {
454
+ executable: openddeExecutable(context.runtimeExecutable),
455
+ args,
456
+ env: {
457
+ OPENDDE_ROOT_DIR: rootDir,
458
+ OPENDDE_DEPENDENCY_URL: DOWNLOADS_DISABLED_URL,
459
+ OPENDDE_COMMON_URL: DOWNLOADS_DISABLED_URL,
460
+ OPENDDE_SEARCH_DATABASE_URL: DOWNLOADS_DISABLED_URL,
461
+ },
462
+ };
463
+ },
464
+ async collectOutputs(context) {
465
+ const jobs = parseJobs(context.inputPath);
466
+ const upstreamOutputs = OUTPUTS.filter((o) => o.id !== "structures_pdb" && o.id !== "pdb_export_errors");
467
+ const collected = collectGlobOutputs(context.outputDir, upstreamOutputs);
468
+ validateJobOutputs(context.outputDir, jobs, collected);
469
+ await exportPdb(context, collected.filter((o) => o.id === "structures").map((o) => o.path));
470
+ return collectGlobOutputs(context.outputDir, OUTPUTS);
471
+ },
472
+ async verifyInstallation(context) {
473
+ const venvBin = path.join(context.modelDir, ".venv", "bin");
474
+ const python = path.join(venvBin, "python");
475
+ const cli = path.join(venvBin, "opendde");
476
+ const rootDir = path.join(context.assetsDir, "opendde-root");
477
+ const missing = [python, cli, ...MANAGED_ASSETS.map((a) => path.join(rootDir, a.relPath))].filter((file) => !fs.existsSync(file));
478
+ if (missing.length > 0)
479
+ return { passed: false, output: `Missing ${missing.join(", ")}` };
480
+ for (const asset of MANAGED_ASSETS) {
481
+ const file = path.join(rootDir, asset.relPath);
482
+ const size = fs.statSync(file).size;
483
+ if (size !== asset.sizeBytes)
484
+ return { passed: false, output: `${file}: expected ${asset.sizeBytes} bytes, got ${size}` };
485
+ }
486
+ const platform = context.platform ?? detectInstalledPlatform(context.modelDir);
487
+ if (platform !== "darwin-arm64" && platform !== "linux-x64") {
488
+ return { passed: false, output: "Cannot determine the installed OpenDDE platform to select an accelerator probe." };
489
+ }
490
+ const runner = context.runner ?? runCommand;
491
+ const probe = await runner(python, ["-c", platform === "darwin-arm64" ? MPS_PROBE : CUDA_PROBE], { timeoutMs: 120_000 });
492
+ if (probe.code !== 0)
493
+ return { passed: false, output: (probe.stderr || probe.stdout).trim() };
494
+ const help = await runner(cli, ["pred", "--help"], { timeoutMs: 120_000 });
495
+ const expectedDevice = platform === "darwin-arm64" ? "mps" : "cuda";
496
+ const missingFlags = ["--device", "--load_checkpoint_path", "--use_msa", "--seeds", expectedDevice].filter((flag) => !help.stdout.includes(flag));
497
+ if (help.code !== 0 || missingFlags.length > 0) {
498
+ return { passed: false, output: `Installed opendde CLI did not advertise ${missingFlags.join(", ") || "a usable pred command"}: ${(help.stdout || help.stderr).trim()}` };
499
+ }
500
+ // Digest last (it reads ~3.3 GB): upstream's own runtime check is size-only,
501
+ // so this is where content integrity of the installed files is established.
502
+ for (const asset of MANAGED_ASSETS) {
503
+ const file = path.join(rootDir, asset.relPath);
504
+ const digest = await sha256File(file);
505
+ if (digest !== asset.sha256)
506
+ return { passed: false, output: `${file}: expected sha256 ${asset.sha256}, got ${digest}` };
507
+ }
508
+ return { passed: true, output: probe.stdout.trim() };
509
+ },
510
+ };
511
+ /** Best-effort read of the installed runtime's platform from `installation.json` when
512
+ * `context.platform` isn't supplied; never guessed from `process.platform`. */
513
+ function detectInstalledPlatform(modelDir) {
514
+ try {
515
+ const raw = JSON.parse(fs.readFileSync(path.join(modelDir, "installation.json"), "utf8"));
516
+ return raw.runtime?.python?.platform;
517
+ }
518
+ catch {
519
+ return undefined;
520
+ }
521
+ }
522
+ const VERSION_ASSERTIONS = `
523
+ from importlib.metadata import version
524
+ import torch, opendde, runner.batch_inference
525
+ opendde_version = version("opendde")
526
+ assert opendde_version == "${PINNED_OPENDDE_VERSION}", f"expected pinned opendde==${PINNED_OPENDDE_VERSION}, got {opendde_version}"
527
+ assert torch.__version__.split("+")[0] == "${PINNED_TORCH_VERSION}", f"expected torch ${PINNED_TORCH_VERSION}, got {torch.__version__}"
528
+ `.trim();
529
+ const MPS_PROBE = `
530
+ import platform
531
+ ${VERSION_ASSERTIONS}
532
+ assert platform.system() == "Darwin", f"expected Darwin, got {platform.system()}"
533
+ assert platform.machine() == "arm64", f"expected arm64, got {platform.machine()}"
534
+ assert torch.backends.mps.is_built(), "torch was not built with MPS support"
535
+ assert torch.backends.mps.is_available(), "MPS backend is not available on this host"
536
+ x = torch.arange(4, dtype=torch.float32, device="mps")
537
+ y = (x @ x).item()
538
+ assert y == 14.0, f"unexpected MPS tensor op result: {y}"
539
+ print(f"mps ok: torch={torch.__version__} opendde={opendde_version} op={y}")
540
+ `.trim();
541
+ const CUDA_PROBE = `
542
+ ${VERSION_ASSERTIONS}
543
+ import cuequivariance_torch
544
+ assert torch.version.cuda is not None, "torch was not built with CUDA support (torch.version.cuda is None)"
545
+ assert torch.cuda.is_available(), "torch.cuda.is_available() is False"
546
+ assert torch.cuda.device_count() >= 1, "no CUDA devices visible"
547
+ x = torch.arange(4, dtype=torch.float32, device="cuda")
548
+ y = (x @ x).item()
549
+ assert y == 14.0, f"unexpected CUDA tensor op result: {y}"
550
+ print(f"cuda ok: torch={torch.__version__} cuda_build={torch.version.cuda} devices={torch.cuda.device_count()} device0={torch.cuda.get_device_name(0)} opendde={opendde_version}")
551
+ `.trim();
@@ -0,0 +1 @@
1
+ export {};