@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.
- package/LICENSE +21 -0
- package/dist/bindcraft2/index.d.ts +2 -0
- package/dist/bindcraft2/index.js +348 -0
- package/dist/bindcraft2/index.test.d.ts +1 -0
- package/dist/bindcraft2/index.test.js +232 -0
- package/dist/boltz/index.d.ts +2 -0
- package/dist/boltz/index.js +285 -0
- package/dist/boltz/index.test.d.ts +1 -0
- package/dist/boltz/index.test.js +368 -0
- package/dist/catalog.d.ts +6 -0
- package/dist/catalog.js +37 -0
- package/dist/catalog.test.d.ts +1 -0
- package/dist/catalog.test.js +33 -0
- package/dist/diffdock/index.d.ts +2 -0
- package/dist/diffdock/index.js +470 -0
- package/dist/diffdock/index.test.d.ts +1 -0
- package/dist/diffdock/index.test.js +595 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +2 -0
- package/dist/ligandmpnn/index.d.ts +2 -0
- package/dist/ligandmpnn/index.js +80 -0
- package/dist/ligandmpnn/index.test.d.ts +1 -0
- package/dist/ligandmpnn/index.test.js +85 -0
- package/dist/opendde/index.d.ts +2 -0
- package/dist/opendde/index.js +551 -0
- package/dist/opendde/index.test.d.ts +1 -0
- package/dist/opendde/index.test.js +577 -0
- package/dist/outputs.d.ts +8 -0
- package/dist/outputs.js +79 -0
- package/dist/outputs.test.d.ts +1 -0
- package/dist/outputs.test.js +69 -0
- package/dist/params.d.ts +13 -0
- package/dist/params.js +69 -0
- package/dist/params.test.d.ts +1 -0
- package/dist/params.test.js +41 -0
- package/dist/planned.d.ts +1 -0
- package/dist/planned.js +9 -0
- package/dist/proteinmpnn/index.d.ts +2 -0
- package/dist/proteinmpnn/index.js +84 -0
- package/dist/proteinmpnn/index.test.d.ts +1 -0
- package/dist/proteinmpnn/index.test.js +101 -0
- package/package.json +32 -3
- package/README.md +0 -3
package/dist/catalog.js
ADDED
|
@@ -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,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 {};
|