@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,37 @@
1
+ import { proteinmpnnAdapter } from "./proteinmpnn/index.js";
2
+ import { ligandmpnnAdapter } from "./ligandmpnn/index.js";
3
+ import { boltzAdapter } from "./boltz/index.js";
4
+ import { diffdockAdapter } from "./diffdock/index.js";
5
+ import { openddeAdapter } from "./opendde/index.js";
6
+ import { bindcraft2Adapter } from "./bindcraft2/index.js";
7
+ export const adapterCatalog = {
8
+ bindcraft2: bindcraft2Adapter,
9
+ proteinmpnn: proteinmpnnAdapter,
10
+ ligandmpnn: ligandmpnnAdapter,
11
+ boltz: boltzAdapter,
12
+ diffdock: diffdockAdapter,
13
+ opendde: openddeAdapter,
14
+ };
15
+ export function getAdapter(modelName) {
16
+ return adapterCatalog[modelName];
17
+ }
18
+ export function hasAdapter(modelName) {
19
+ return modelName in adapterCatalog;
20
+ }
21
+ export function validateAdapterCatalog(manifests, catalog = adapterCatalog) {
22
+ const manifestsByName = new Map(manifests.map((manifest) => [manifest.name, manifest]));
23
+ const errors = [];
24
+ for (const manifest of manifests) {
25
+ if (manifest.status === "available" && !(manifest.name in catalog)) {
26
+ errors.push(`Available model "${manifest.name}" has no compiled adapter registration.`);
27
+ }
28
+ }
29
+ for (const [name, adapter] of Object.entries(catalog)) {
30
+ if (!manifestsByName.has(name))
31
+ errors.push(`Adapter "${name}" has no registry manifest.`);
32
+ if (adapter.modelName !== name) {
33
+ errors.push(`Adapter catalog key "${name}" does not match adapter.modelName "${adapter.modelName}".`);
34
+ }
35
+ }
36
+ return errors;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { adapterCatalog, getAdapter, validateAdapterCatalog } from "./catalog.js";
6
+ describe("adapterCatalog", () => {
7
+ it("matches source model directories without a hard-coded name list", () => {
8
+ const modelsDir = fileURLToPath(new URL("../../../models", import.meta.url));
9
+ const manifestNames = fs
10
+ .readdirSync(modelsDir, { withFileTypes: true })
11
+ .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(modelsDir, entry.name, "manifest.yaml")))
12
+ .map((entry) => entry.name)
13
+ .sort();
14
+ const adapterNames = Object.keys(adapterCatalog).sort();
15
+ expect(adapterNames).toEqual(manifestNames);
16
+ for (const [name, adapter] of Object.entries(adapterCatalog))
17
+ expect(adapter.modelName).toBe(name);
18
+ });
19
+ it("has unique model names", () => {
20
+ const names = Object.keys(adapterCatalog);
21
+ expect(new Set(names).size).toBe(names.length);
22
+ });
23
+ it("rejects an available manifest without a compiled adapter", () => {
24
+ const errors = validateAdapterCatalog([{ name: "missing-model", status: "available" }], {});
25
+ expect(errors).toEqual([expect.stringContaining("missing-model")]);
26
+ });
27
+ it("makes installable adapters fail verification when files are absent", async () => {
28
+ const context = { manifestName: "proteinmpnn", modelDir: "/model", assetsDir: "/assets" };
29
+ const adapter = getAdapter("proteinmpnn");
30
+ expect(adapter).toBeDefined();
31
+ await expect(adapter.verifyInstallation(context)).resolves.toMatchObject({ passed: false });
32
+ });
33
+ });
@@ -0,0 +1,2 @@
1
+ import type { ModelAdapterDefinition } from "../index.js";
2
+ export declare const diffdockAdapter: ModelAdapterDefinition;
@@ -0,0 +1,470 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MoldeskError } from "@moldesk/registry";
4
+ import { runCommand } from "@moldesk/runtime";
5
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6
+ import { collectGlobOutputs } from "../outputs.js";
7
+ /**
8
+ * DiffDock-L's `inference.py` (gcorso/DiffDock @ 85c49b60d3e0b0182a59ee43a34a6d7036981284)
9
+ * writes, per complex, `<out_dir>/<complex_name>/rank1.sdf` (the single top pose,
10
+ * unsuffixed) plus `<out_dir>/<complex_name>/rank{N}_confidence{score:.2f}.sdf` for
11
+ * every sampled pose, already sorted by descending confidence — verified directly
12
+ * against the pinned revision's `main()`. `poses_summary` is adapter-authored (not
13
+ * upstream), synthesized in `collectOutputs` from those filenames, so a structured,
14
+ * checksummed rank/confidence record exists in `run.json` rather than only encoded
15
+ * in filenames.
16
+ */
17
+ const OUTPUTS = [
18
+ { id: "top_pose", glob: "*/rank1.sdf", required: true },
19
+ { id: "ranked_poses", glob: "*/rank*_confidence*.sdf", required: true },
20
+ { id: "poses_summary", glob: "poses_summary.json", required: true },
21
+ ];
22
+ /**
23
+ * The pinned CLI (`inference.py`) exposes no `--seed`/`--device`/`--cpu`/`--gpu`
24
+ * flag at all — device selection is upstream's own unconditional
25
+ * `torch.device('cuda' if torch.cuda.is_available() else 'cpu')`, and there is no
26
+ * documented CPU support surface for this milestone (CUDA is required end-to-end;
27
+ * see `models/diffdock/manifest.yaml`). `batch_size`/`samples_per_complex`/
28
+ * `inference_steps` are genuine argparse flags, verified against `get_parser()`.
29
+ */
30
+ const PARAMS = [
31
+ { name: "samples_per_complex", type: "number", default: 10, min: 1, description: "Number of docking poses to sample per complex" },
32
+ { name: "inference_steps", type: "number", default: 20, min: 1, description: "Number of denoising steps" },
33
+ { name: "batch_size", type: "number", default: 10, min: 1, description: "Batch size used while sampling" },
34
+ ];
35
+ function diffdockError(code, message, remediation, details) {
36
+ return new MoldeskError({ code, message, remediation, details });
37
+ }
38
+ function invalidInput(message, remediation, details) {
39
+ return diffdockError("INVALID_RUN_INPUT", message, remediation, details);
40
+ }
41
+ /** Parses and structurally validates a DiffDock-L MoleculeDesk job file. Does not
42
+ * touch the filesystem beyond `inputPath` itself — path resolution/sniffing of
43
+ * `proteinPath`/`ligand.path` happens separately in `validateInput`. */
44
+ function parseJob(inputPath) {
45
+ let text;
46
+ try {
47
+ text = fs.readFileSync(inputPath, "utf8");
48
+ }
49
+ catch {
50
+ throw invalidInput(`DiffDock-L job file not found: ${inputPath}.`, "Provide a path to an existing .json job file.");
51
+ }
52
+ let raw;
53
+ try {
54
+ raw = JSON.parse(text);
55
+ }
56
+ catch (error) {
57
+ throw invalidInput(`DiffDock-L job file is not valid JSON: ${inputPath}. ${error instanceof Error ? error.message : String(error)}`, "Fix the JSON syntax error and retry.");
58
+ }
59
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
60
+ throw invalidInput(`DiffDock-L job file must be a JSON object: ${inputPath}.`, "See examples/diffdock/job.json for the expected shape.");
61
+ }
62
+ const obj = raw;
63
+ if (typeof obj["proteinPath"] !== "string" || obj["proteinPath"].trim().length === 0) {
64
+ throw invalidInput(`DiffDock-L job file is missing a non-empty "proteinPath" string: ${inputPath}.`, "Add a top-level proteinPath pointing at a .pdb file.");
65
+ }
66
+ if (obj["jobName"] !== undefined && typeof obj["jobName"] !== "string") {
67
+ throw invalidInput(`DiffDock-L job file's "jobName" must be a string: ${inputPath}.`, "Remove jobName or provide a string value.");
68
+ }
69
+ const ligandRaw = obj["ligand"];
70
+ if (typeof ligandRaw !== "object" || ligandRaw === null || Array.isArray(ligandRaw)) {
71
+ throw invalidInput(`DiffDock-L job file is missing a "ligand" object: ${inputPath}.`, 'Add a top-level ligand object with exactly one of {"path": "..."} or {"smiles": "..."}.');
72
+ }
73
+ const ligandObj = ligandRaw;
74
+ const hasPath = typeof ligandObj["path"] === "string" && ligandObj["path"].trim().length > 0;
75
+ const hasSmiles = typeof ligandObj["smiles"] === "string" && ligandObj["smiles"].trim().length > 0;
76
+ if (hasPath === hasSmiles) {
77
+ throw invalidInput(`DiffDock-L job file's "ligand" must specify exactly one of "path" or "smiles": ${inputPath}.`, 'Provide either {"path": "ligand.sdf"} or {"smiles": "CCO"}, not both or neither.');
78
+ }
79
+ return {
80
+ jobName: obj["jobName"],
81
+ proteinPath: obj["proteinPath"],
82
+ ligand: hasPath ? { path: ligandObj["path"] } : { smiles: ligandObj["smiles"] },
83
+ };
84
+ }
85
+ const SAFE_JOB_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
86
+ /** DiffDock-L's own `inference.py` interpolates `--complex_name` directly into a
87
+ * filesystem path (`f'{args.out_dir}/{name}'`) with no sanitization at all — an
88
+ * unsanitized value could escape output confinement (e.g. `../../etc`). Never
89
+ * derived from anything but this explicit allowlist check. */
90
+ function sanitizeJobName(rawJobName) {
91
+ if (rawJobName === undefined)
92
+ return "complex_0";
93
+ if (!SAFE_JOB_NAME_PATTERN.test(rawJobName)) {
94
+ throw invalidInput(`DiffDock-L job file's "jobName" is not a safe identifier: ${JSON.stringify(rawJobName)}.`, "Use a plain identifier (letters, digits, '.', '_', '-'; no path separators, no leading '.', no \"..\") or omit jobName.");
95
+ }
96
+ return rawJobName;
97
+ }
98
+ function resolveJobRelative(jobDir, target) {
99
+ return path.isAbsolute(target) ? path.resolve(target) : path.resolve(jobDir, target);
100
+ }
101
+ function requireRegularFile(absPath, label) {
102
+ let stat;
103
+ try {
104
+ stat = fs.statSync(absPath);
105
+ }
106
+ catch {
107
+ throw invalidInput(`DiffDock-L job references a missing ${label} file: ${absPath}.`, "Check the referenced path exists and retry.");
108
+ }
109
+ if (!stat.isFile()) {
110
+ throw invalidInput(`DiffDock-L job's ${label} reference is not a regular file: ${absPath}.`, "Reference a regular file, not a directory — DiffDock-L jobs do not support directory inputs.");
111
+ }
112
+ }
113
+ function requireExtension(absPath, allowed, label) {
114
+ const ext = path.extname(absPath).toLowerCase();
115
+ if (!allowed.includes(ext)) {
116
+ throw invalidInput(`DiffDock-L job's ${label} reference has an unsupported extension "${ext || "(none)"}": ${absPath}.`, `Use one of: ${allowed.join(", ")}.`);
117
+ }
118
+ }
119
+ const PDB_RECORD_PREFIXES = ["HEADER", "ATOM", "HETATM", "CRYST1"];
120
+ /** Practical-level sniff, not a full PDB parser — enough to reject an obviously
121
+ * wrong file before a long install/run pipeline runs. */
122
+ function sniffPdb(absPath) {
123
+ const text = fs.readFileSync(absPath, "utf8");
124
+ if (text.trim().length === 0) {
125
+ throw invalidInput(`DiffDock-L protein PDB is empty: ${absPath}.`, "Provide a non-empty .pdb file.");
126
+ }
127
+ const hasRecord = text.split(/\r?\n/).some((line) => PDB_RECORD_PREFIXES.some((prefix) => line.startsWith(prefix)));
128
+ if (!hasRecord) {
129
+ throw invalidInput(`DiffDock-L protein file does not look like a PDB file (no HEADER/ATOM/HETATM/CRYST1 record found): ${absPath}.`, "Provide a real .pdb structure file.");
130
+ }
131
+ }
132
+ const SMILES_CHARSET_PATTERN = /^[A-Za-z0-9@+\-[\]().=#$:/\\%*]+$/;
133
+ /** Permissive charset/non-empty sniff, not a chemical validity check — full
134
+ * validation happens inside the real DiffDock-L run via RDKit. */
135
+ function sniffSmiles(value) {
136
+ const trimmed = value.trim();
137
+ if (trimmed.length === 0 || !SMILES_CHARSET_PATTERN.test(trimmed)) {
138
+ throw invalidInput(`DiffDock-L job's ligand SMILES looks invalid: ${JSON.stringify(value)}.`, "Provide a valid SMILES string, or reference a ligand file instead.");
139
+ }
140
+ }
141
+ function findCompanion(context, id) {
142
+ const found = context.companionInputs?.find((c) => c.id === id);
143
+ if (!found) {
144
+ throw diffdockError("COMPANION_INPUT_NOT_STAGED", `Expected a staged companion input "${id}" but none was found.`, "This is a MoleculeDesk bug — report it.", { id });
145
+ }
146
+ return found.path;
147
+ }
148
+ /**
149
+ * `inference.py`'s own config-merge loop unconditionally overwrites
150
+ * `args.__dict__[key]` for every key present in the `--config` YAML — including
151
+ * keys already set from explicit CLI flags (verified directly against
152
+ * `main()`'s `for key, value in config_dict.items(): arg_dict[key] = value`).
153
+ * That means passing `--model_dir`/`--samples_per_complex`/etc. on argv
154
+ * alongside the pinned source's own `default_inference_args.yaml` would be
155
+ * silently clobbered back to that file's baked-in `./workdir/v1.1/...` relative
156
+ * paths and defaults. To let MoleculeDesk's installed asset paths and adapter
157
+ * params actually take effect, this strips exactly the keys that must be
158
+ * CLI/param-controlled (`model_dir`, `confidence_model_dir`,
159
+ * `samples_per_complex`, `inference_steps`, and `actual_steps` — which would
160
+ * otherwise silently override `--inference_steps` per `main()`'s
161
+ * `args.actual_steps if args.actual_steps is not None else args.inference_steps`)
162
+ * from a copy written fresh into this run's own output directory. Every other
163
+ * tuned sampling hyperparameter (temp_*, sigma_schedule,
164
+ * initial_noise_std_proportion, old_filtering_model, ckpt filenames, …) is
165
+ * carried over byte-for-byte from the installed pinned source's own file, so
166
+ * model behavior never independently drifts from what upstream ships.
167
+ */
168
+ function buildRunScopedConfig(sourceDir, outputDir) {
169
+ const upstreamConfigPath = path.join(sourceDir, "default_inference_args.yaml");
170
+ let parsed;
171
+ try {
172
+ parsed = parseYaml(fs.readFileSync(upstreamConfigPath, "utf8"));
173
+ }
174
+ catch (error) {
175
+ throw diffdockError("DIFFDOCK_CONFIG_UNREADABLE", `Could not read the installed DiffDock-L source's default_inference_args.yaml: ${upstreamConfigPath}.`, "Reinstall diffdock (moldesk install diffdock --reinstall).", { cause: error instanceof Error ? error.message : String(error) });
176
+ }
177
+ const doc = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
178
+ ? { ...parsed }
179
+ : {};
180
+ for (const key of ["model_dir", "confidence_model_dir", "samples_per_complex", "inference_steps", "actual_steps"]) {
181
+ delete doc[key];
182
+ }
183
+ const runConfigPath = path.join(outputDir, "moldesk-inference-config.yaml");
184
+ fs.writeFileSync(runConfigPath, stringifyYaml(doc));
185
+ return runConfigPath;
186
+ }
187
+ /**
188
+ * Requires `sdfPath` to be a regular, non-empty file with a parseable,
189
+ * positive atom count (SDF V2000 counts line, 4th line / 0-indexed [3], first
190
+ * 3 columns). Throws rather than returning `undefined` on failure — the
191
+ * presence of a correctly-named `rank*.sdf` file is not by itself sufficient
192
+ * evidence of a real pose; an empty or malformed file must fail the run, not
193
+ * silently appear in `poses_summary.json` with a missing atom count.
194
+ */
195
+ function requireValidSdfPose(sdfPath, label) {
196
+ let stat;
197
+ try {
198
+ stat = fs.statSync(sdfPath);
199
+ }
200
+ catch {
201
+ throw diffdockError("INVALID_RUN_OUTPUT", `Expected pose file is missing: ${sdfPath} (${label}).`, "Check stdout.log/stderr.log in the run directory for the underlying failure.", { sdfPath, label });
202
+ }
203
+ if (!stat.isFile() || stat.size === 0) {
204
+ throw diffdockError("INVALID_RUN_OUTPUT", `Pose file "${label}" is empty or not a regular file: ${sdfPath}.`, "DiffDock-L produced an invalid pose; check stdout.log/stderr.log in the run directory.", { sdfPath, label });
205
+ }
206
+ const lines = fs.readFileSync(sdfPath, "utf8").split(/\r?\n/);
207
+ const countsLine = lines[3];
208
+ const match = countsLine ? /^\s*(\d+)/.exec(countsLine) : null;
209
+ const atomCount = match ? Number(match[1]) : 0;
210
+ if (!(atomCount > 0)) {
211
+ throw diffdockError("INVALID_RUN_OUTPUT", `Pose file "${label}" has no parseable positive atom count: ${sdfPath}.`, "DiffDock-L produced a malformed SDF pose; check stdout.log/stderr.log in the run directory.", { sdfPath, label });
212
+ }
213
+ return atomCount;
214
+ }
215
+ const RANKED_POSE_PATTERN = /^rank(\d+)_confidence(-?\d+(?:\.\d+)?)\.sdf$/;
216
+ /** Adapter-authored (not upstream) structured rank/confidence record, synthesized
217
+ * from the ranked-pose filenames DiffDock-L itself writes. Written into `outputDir`
218
+ * before `collectGlobOutputs` runs, so it becomes one of the declared, checksummed
219
+ * outputs rather than leaving rank/confidence association encoded only in filenames.
220
+ * Every pose file (including the unsuffixed top pose) is required to be a real,
221
+ * non-empty, parseable SDF — this throws (failing the whole run) rather than
222
+ * silently recording an invalid pose as if it were a success. */
223
+ function writePosesSummary(outputDir, complexName) {
224
+ const complexDir = path.join(outputDir, complexName);
225
+ if (!fs.existsSync(complexDir))
226
+ return; // collectGlobOutputs raises MISSING_REQUIRED_OUTPUT with a clear message.
227
+ const poses = fs
228
+ .readdirSync(complexDir)
229
+ .map((entry) => ({ entry, match: RANKED_POSE_PATTERN.exec(entry) }))
230
+ .filter((x) => x.match !== null)
231
+ .map(({ entry, match }) => ({
232
+ rank: Number(match[1]),
233
+ confidence: Number(match[2]),
234
+ sdfFile: entry,
235
+ atomCount: requireValidSdfPose(path.join(complexDir, entry), entry),
236
+ }))
237
+ .sort((a, b) => a.rank - b.rank);
238
+ const topPosePath = path.join(complexDir, "rank1.sdf");
239
+ const topPoseExists = fs.existsSync(topPosePath);
240
+ if (topPoseExists)
241
+ requireValidSdfPose(topPosePath, "rank1.sdf");
242
+ const summary = {
243
+ complexName,
244
+ topPoseFile: topPoseExists ? "rank1.sdf" : undefined,
245
+ poses,
246
+ };
247
+ fs.writeFileSync(path.join(outputDir, "poses_summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
248
+ }
249
+ /**
250
+ * Imports every pinned CUDA-extension package, then EXECUTES a minimal real
251
+ * operator from each of torch_scatter/torch_cluster/torch_sparse ON A CUDA
252
+ * TENSOR — not just imports them. DiffDock-L's own code genuinely calls into
253
+ * all three directly (verified by grepping the pinned revision's
254
+ * datasets/process_mols.py, models/aa_model.py, models/cg_model.py, and
255
+ * utils/training.py), so exercising the same package surface here catches
256
+ * ABI/kernel mismatches between the installed torch build and these
257
+ * CUDA-compiled extensions that an import alone cannot — some such mismatches
258
+ * only surface when a compiled kernel actually launches, not at import time.
259
+ * torch_spline_conv is imported (for completeness/ABI-linkage checking) but
260
+ * not exercised — DiffDock-L's own code never calls into it directly.
261
+ * `scatter_add`, `radius_graph`, and `coalesce`'s call signatures below are
262
+ * verified directly against the pinned versions' real source
263
+ * (rusty1s/pytorch_scatter@2.1.0, rusty1s/pytorch_cluster@1.6.0,
264
+ * rusty1s/pytorch_sparse@0.6.16) — not assumed. This exact probe has not
265
+ * itself been executed against real CUDA hardware (none was available); spot
266
+ * check it during the real live gate before trusting it unconditionally.
267
+ */
268
+ const ABI_AND_CUDA_PROBE = `
269
+ import torch, torch_geometric, torch_scatter, torch_cluster, torch_sparse, torch_spline_conv, e3nn, esm
270
+ from torch_geometric.utils import degree
271
+ assert torch.cuda.is_available(), "torch.cuda.is_available() is False"
272
+ assert torch.cuda.device_count() >= 1, "no CUDA devices visible"
273
+ assert torch.version.cuda is not None, "torch was not built with CUDA support (torch.version.cuda is None)"
274
+ device = torch.device("cuda")
275
+
276
+ idx = torch.tensor([0, 0, 1, 2], device=device)
277
+ src = torch.tensor([1.0, 2.0, 3.0, 4.0], device=device)
278
+ scattered = torch_scatter.scatter_add(src, idx)
279
+ assert scattered.shape[0] == 3, f"torch_scatter.scatter_add produced unexpected shape: {tuple(scattered.shape)}"
280
+
281
+ pos = torch.rand(10, 3, device=device)
282
+ batch = torch.zeros(10, dtype=torch.long, device=device)
283
+ edges = torch_cluster.radius_graph(pos, r=0.75, batch=batch)
284
+ assert edges.dtype == torch.long, f"torch_cluster.radius_graph returned unexpected dtype: {edges.dtype}"
285
+
286
+ sparse_index = torch.tensor([[0, 1], [1, 0]], dtype=torch.long, device=device)
287
+ sparse_value = torch.tensor([1.0, 2.0], device=device)
288
+ coalesced_index, coalesced_value = torch_sparse.coalesce(sparse_index, sparse_value, m=2, n=2)
289
+ assert coalesced_index.shape[0] == 2, f"torch_sparse.coalesce produced unexpected index shape: {tuple(coalesced_index.shape)}"
290
+
291
+ print(f"cuda ops ok: torch={torch.__version__} cuda_build={torch.version.cuda} devices={torch.cuda.device_count()} pyg={torch_geometric.__version__}")
292
+ `.trim();
293
+ /**
294
+ * `utils/so3.py` and `utils/torus.py` (both imported by `inference.py`) run heavy
295
+ * single-core precomputations the first time they are imported (the SO(3) and
296
+ * torus diffusion lookup tables) and cache the results as `.so3_*.npy`,
297
+ * `.p.npy` and `.score.npy` in the *current working directory* (upstream: "the
298
+ * precomputation is only run the first time the repository is run on a
299
+ * machine"). MoleculeDesk runs every job with a fresh per-run working
300
+ * directory, so without help each run would redo ~30 minutes of work. They are
301
+ * computed once at install time into `assets/so3-cache/` and symlinked into
302
+ * each run directory.
303
+ */
304
+ const SO3_CACHE_FILES = [
305
+ ".so3_omegas_array4.npy",
306
+ ".so3_cdf_vals4.npy",
307
+ ".so3_score_norms4.npy",
308
+ ".so3_exp_score_norms4.npy",
309
+ ".p.npy",
310
+ ".score.npy",
311
+ ];
312
+ const SO3_CACHE_TIMEOUT_MS = 90 * 60_000;
313
+ function so3CacheDir(assetsDir) {
314
+ return path.join(assetsDir, "so3-cache");
315
+ }
316
+ export const diffdockAdapter = {
317
+ modelName: "diffdock",
318
+ params: PARAMS,
319
+ async validateInput(inputPath) {
320
+ if (!fs.existsSync(inputPath)) {
321
+ throw invalidInput(`Input file not found: ${inputPath}.`, "Provide a path to an existing .json job file.");
322
+ }
323
+ if (path.extname(inputPath).toLowerCase() !== ".json") {
324
+ throw invalidInput(`DiffDock-L requires a .json job file, got "${path.extname(inputPath) || "(no extension)"}".`, "See examples/diffdock/job.json.");
325
+ }
326
+ const job = parseJob(inputPath);
327
+ sanitizeJobName(job.jobName);
328
+ const jobDir = path.dirname(inputPath);
329
+ const proteinAbs = resolveJobRelative(jobDir, job.proteinPath);
330
+ requireExtension(proteinAbs, [".pdb"], "protein");
331
+ requireRegularFile(proteinAbs, "protein");
332
+ sniffPdb(proteinAbs);
333
+ if (job.ligand.path !== undefined) {
334
+ const ligandAbs = resolveJobRelative(jobDir, job.ligand.path);
335
+ requireExtension(ligandAbs, [".sdf", ".mol2"], "ligand");
336
+ requireRegularFile(ligandAbs, "ligand");
337
+ }
338
+ else {
339
+ sniffSmiles(job.ligand.smiles);
340
+ }
341
+ },
342
+ async resolveCompanionInputs(inputPath) {
343
+ const job = parseJob(inputPath);
344
+ const refs = [{ id: "protein", sourcePath: job.proteinPath }];
345
+ if (job.ligand.path !== undefined)
346
+ refs.push({ id: "ligand", sourcePath: job.ligand.path });
347
+ return refs;
348
+ },
349
+ async installPlan(_context) {
350
+ return {
351
+ steps: [
352
+ { id: "source", description: "Fetch the pinned DiffDock-L source revision" },
353
+ { id: "python", description: "Create an isolated managed Python environment" },
354
+ { id: "dependencies", description: "Install pinned CUDA-specific PyTorch/PyTorch Geometric dependencies from their pinned wheel indexes" },
355
+ { id: "assets", description: "Populate and checksum-verify the score/confidence model weights and the ESM2 language-model checkpoint" },
356
+ { id: "verify", description: "Verify CLI help, pinned imports (ABI compatibility), installed weights, and CUDA availability" },
357
+ ],
358
+ };
359
+ },
360
+ async command(context) {
361
+ const job = parseJob(context.inputPath);
362
+ const complexName = sanitizeJobName(job.jobName);
363
+ const proteinPath = findCompanion(context, "protein");
364
+ const ligandArg = job.ligand.path !== undefined ? findCompanion(context, "ligand") : job.ligand.smiles;
365
+ const sourceDir = path.join(context.modelDir, "source");
366
+ const script = path.join(sourceDir, "inference.py");
367
+ const runConfig = buildRunScopedConfig(sourceDir, context.outputDir);
368
+ const scoreModelDir = path.join(context.assetsDir, "diffdock-weights", "score_model");
369
+ const confidenceModelDir = path.join(context.assetsDir, "diffdock-weights", "confidence_model");
370
+ const torchHome = path.join(context.assetsDir, "torch-home");
371
+ const params = context.params;
372
+ const args = [
373
+ script,
374
+ "--config", runConfig,
375
+ "--protein_path", proteinPath,
376
+ "--ligand_description", ligandArg,
377
+ "--complex_name", complexName,
378
+ "--out_dir", context.outputDir,
379
+ "--model_dir", scoreModelDir,
380
+ "--confidence_model_dir", confidenceModelDir,
381
+ ];
382
+ if (params.samples_per_complex !== undefined)
383
+ args.push("--samples_per_complex", String(params.samples_per_complex));
384
+ if (params.inference_steps !== undefined)
385
+ args.push("--inference_steps", String(params.inference_steps));
386
+ if (params.batch_size !== undefined)
387
+ args.push("--batch_size", String(params.batch_size));
388
+ // ESM2's own loader (`esm.pretrained.load_model_and_alphabet`, reached
389
+ // unconditionally on every run via InferenceDataset's `lm_embeddings=True`)
390
+ // downloads into torch.hub's cache dir under $TORCH_HOME/hub/checkpoints/ on
391
+ // first use. Redirected into this installation's assets dir (pre-populated
392
+ // and checksum-verified at install time — see manifest `assets:`) so no
393
+ // network access occurs during a run and nothing lands in an uncontrolled
394
+ // $HOME cache.
395
+ // Link the install-time SO(3) cache into this run's working directory (the
396
+ // run directory, parent of outputDir) so upstream finds it instead of
397
+ // recomputing — see SO3_CACHE_FILES.
398
+ const runDir = path.dirname(context.outputDir);
399
+ const cacheDir = so3CacheDir(context.assetsDir);
400
+ for (const name of SO3_CACHE_FILES) {
401
+ const cached = path.join(cacheDir, name);
402
+ const link = path.join(runDir, name);
403
+ if (fs.existsSync(cached) && !fs.existsSync(link))
404
+ fs.symlinkSync(cached, link);
405
+ }
406
+ return { executable: context.runtimeExecutable, args, env: { TORCH_HOME: torchHome } };
407
+ },
408
+ async collectOutputs(context) {
409
+ const job = parseJob(context.inputPath);
410
+ const complexName = sanitizeJobName(job.jobName);
411
+ // Collect the pose files BEFORE writing poses_summary.json: top_pose/
412
+ // ranked_poses glob on `*/...` (a single-segment wildcard matching any
413
+ // entry directly under outputDir), which would otherwise also match the
414
+ // freshly-written poses_summary.json file itself and try to `readdir` it
415
+ // as if it were a directory.
416
+ const poseOutputs = collectGlobOutputs(context.outputDir, OUTPUTS.filter((o) => o.id !== "poses_summary"));
417
+ writePosesSummary(context.outputDir, complexName);
418
+ const summaryOutputs = collectGlobOutputs(context.outputDir, OUTPUTS.filter((o) => o.id === "poses_summary"));
419
+ return [...poseOutputs, ...summaryOutputs];
420
+ },
421
+ async verifyInstallation(context) {
422
+ const python = path.join(context.modelDir, ".venv", "bin", "python");
423
+ const sourceDir = path.join(context.modelDir, "source");
424
+ const script = path.join(sourceDir, "inference.py");
425
+ const scoreModelDir = path.join(context.assetsDir, "diffdock-weights", "score_model");
426
+ const confidenceModelDir = path.join(context.assetsDir, "diffdock-weights", "confidence_model");
427
+ const esmCheckpointDir = path.join(context.assetsDir, "torch-home", "hub", "checkpoints");
428
+ // Checkpoints (DiffDock-L score/confidence weights and the ESM2 language
429
+ // model) are populated and checksum-verified at install time (manifest
430
+ // `assets:`), never lazily on first run — checked here so a run never
431
+ // silently re-triggers an unverified download.
432
+ const requiredFiles = [
433
+ python,
434
+ script,
435
+ path.join(scoreModelDir, "model_parameters.yml"),
436
+ path.join(scoreModelDir, "best_ema_inference_epoch_model.pt"),
437
+ path.join(confidenceModelDir, "model_parameters.yml"),
438
+ path.join(confidenceModelDir, "best_model_epoch75.pt"),
439
+ path.join(esmCheckpointDir, "esm2_t33_650M_UR50D.pt"),
440
+ path.join(esmCheckpointDir, "esm2_t33_650M_UR50D-contact-regression.pt"),
441
+ ];
442
+ const missing = requiredFiles.filter((file) => !fs.existsSync(file));
443
+ if (missing.length > 0)
444
+ return { passed: false, output: `Missing ${missing.join(", ")}` };
445
+ const runner = context.runner ?? runCommand;
446
+ // Import + ABI-mismatch check for the pinned Torch/PyTorch Geometric
447
+ // extension wheels, plus a mandatory CUDA probe — DiffDock-L is CUDA-only
448
+ // for this milestone (see manifest hardware.nvidiaGpu/cuda: required); an
449
+ // install where CUDA is unavailable must fail verification, not silently
450
+ // succeed and let a run fall back to CPU (`torch.device('cuda' if
451
+ // torch.cuda.is_available() else 'cpu')` is upstream's own selection logic).
452
+ const probe = await runner(python, ["-c", ABI_AND_CUDA_PROBE], { timeoutMs: 120_000 });
453
+ if (probe.code !== 0)
454
+ return { passed: false, output: probe.stderr.trim() };
455
+ // Build the one-time SO(3) cache here, in a stable directory (see SO3_CACHE_FILES).
456
+ const cacheDir = so3CacheDir(context.assetsDir);
457
+ fs.mkdirSync(cacheDir, { recursive: true });
458
+ const warm = await runner(python, ["-c", "import sys; sys.path.insert(0, sys.argv[1]); import utils.so3, utils.torus", sourceDir], { cwd: cacheDir, timeoutMs: SO3_CACHE_TIMEOUT_MS });
459
+ if (warm.code !== 0)
460
+ return { passed: false, output: `Failed to build DiffDock-L's SO(3)/torus cache: ${(warm.stderr || warm.stdout).trim().slice(-2000)}` };
461
+ const uncached = SO3_CACHE_FILES.filter((name) => !fs.existsSync(path.join(cacheDir, name)));
462
+ if (uncached.length > 0)
463
+ return { passed: false, output: `DiffDock-L's SO(3)/torus cache is incomplete, missing ${uncached.join(", ")}` };
464
+ const help = await runner(python, [script, "--help"], { cwd: cacheDir, timeoutMs: 120_000 });
465
+ if (help.code !== 0 || !help.stdout.includes("--protein_path")) {
466
+ return { passed: false, output: `Installed DiffDock-L CLI did not respond as expected to --help: ${(help.stdout || help.stderr).trim()}` };
467
+ }
468
+ return { passed: true, output: probe.stdout.trim() };
469
+ },
470
+ };
@@ -0,0 +1 @@
1
+ export {};