@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/outputs.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { MoldeskError } from "@moldesk/registry";
|
|
4
|
+
function segmentPattern(segment) {
|
|
5
|
+
const escaped = segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
6
|
+
return new RegExp(`^${escaped}$`);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Matches a manifest output glob (segment-wise `*` wildcards, e.g. "seqs/*.fa")
|
|
10
|
+
* against files under `root`. Uses `lstat`, never `stat`, at every level — a
|
|
11
|
+
* symlink (whether an intermediate directory-like entry or the final matched
|
|
12
|
+
* file) is never followed or trusted as a legitimate output. A model process
|
|
13
|
+
* could in principle write a symlink pointing outside `outputDir`; collecting
|
|
14
|
+
* and checksumming whatever that symlink resolves to would silently break
|
|
15
|
+
* output confinement, so any symlink is simply excluded from matching rather
|
|
16
|
+
* than resolved.
|
|
17
|
+
*/
|
|
18
|
+
function matchGlob(root, glob) {
|
|
19
|
+
let candidates = [root];
|
|
20
|
+
for (const segment of glob.split("/")) {
|
|
21
|
+
const pattern = segmentPattern(segment);
|
|
22
|
+
const next = [];
|
|
23
|
+
for (const dir of candidates) {
|
|
24
|
+
// A prior segment's `*` wildcard matches any entry name, files included —
|
|
25
|
+
// only descend into it here if it's actually a real (non-symlink)
|
|
26
|
+
// directory. Without this check, a stray file (or a directory-symlink)
|
|
27
|
+
// sitting alongside the expected subdirectory would make `readdirSync`
|
|
28
|
+
// throw ENOTDIR instead of simply not matching.
|
|
29
|
+
let stat;
|
|
30
|
+
try {
|
|
31
|
+
stat = fs.lstatSync(dir);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!stat.isDirectory())
|
|
37
|
+
continue;
|
|
38
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
39
|
+
if (pattern.test(entry))
|
|
40
|
+
next.push(path.join(dir, entry));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
candidates = next;
|
|
44
|
+
}
|
|
45
|
+
return candidates
|
|
46
|
+
.filter((entry) => {
|
|
47
|
+
let stat;
|
|
48
|
+
try {
|
|
49
|
+
stat = fs.lstatSync(entry);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return stat.isFile();
|
|
55
|
+
})
|
|
56
|
+
.sort();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolves manifest-declared output globs against a run's output directory.
|
|
60
|
+
* Throws `MISSING_REQUIRED_OUTPUT` if any `required` output has no matches —
|
|
61
|
+
* a nonzero exit code alone is not sufficient (spec §4.4).
|
|
62
|
+
*/
|
|
63
|
+
export function collectGlobOutputs(outputDir, outputs) {
|
|
64
|
+
const collected = [];
|
|
65
|
+
for (const output of outputs) {
|
|
66
|
+
const matches = matchGlob(outputDir, output.glob);
|
|
67
|
+
if (matches.length === 0 && output.required) {
|
|
68
|
+
throw new MoldeskError({
|
|
69
|
+
code: "MISSING_REQUIRED_OUTPUT",
|
|
70
|
+
message: `Required output "${output.id}" (glob "${output.glob}") produced no files in ${outputDir}.`,
|
|
71
|
+
remediation: "Check stdout.log/stderr.log in the run directory for the underlying failure.",
|
|
72
|
+
details: { id: output.id, glob: output.glob, outputDir },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
for (const match of matches)
|
|
76
|
+
collected.push({ id: output.id, path: match });
|
|
77
|
+
}
|
|
78
|
+
return collected;
|
|
79
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { collectGlobOutputs } from "./outputs.js";
|
|
6
|
+
const dirs = [];
|
|
7
|
+
function tempDir() {
|
|
8
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-outputs-test-"));
|
|
9
|
+
dirs.push(dir);
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const dir of dirs.splice(0))
|
|
14
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
describe("collectGlobOutputs", () => {
|
|
17
|
+
it("matches a single-segment glob", () => {
|
|
18
|
+
const outputDir = tempDir();
|
|
19
|
+
fs.writeFileSync(path.join(outputDir, "summary.json"), "{}");
|
|
20
|
+
const outputs = collectGlobOutputs(outputDir, [{ id: "summary", glob: "summary.json", required: true }]);
|
|
21
|
+
expect(outputs).toEqual([{ id: "summary", path: path.join(outputDir, "summary.json") }]);
|
|
22
|
+
});
|
|
23
|
+
it("matches every file for a multi-match glob, one CollectedOutput per file", () => {
|
|
24
|
+
const outputDir = tempDir();
|
|
25
|
+
const complexDir = path.join(outputDir, "complex_0");
|
|
26
|
+
fs.mkdirSync(complexDir, { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(complexDir, "rank1_confidence0.85.sdf"), "");
|
|
28
|
+
fs.writeFileSync(path.join(complexDir, "rank2_confidence-1.23.sdf"), "");
|
|
29
|
+
const outputs = collectGlobOutputs(outputDir, [{ id: "ranked_poses", glob: "*/rank*_confidence*.sdf", required: true }]);
|
|
30
|
+
expect(outputs.map((o) => o.id)).toEqual(["ranked_poses", "ranked_poses"]);
|
|
31
|
+
});
|
|
32
|
+
it("does not throw ENOTDIR when a stray file sits alongside the expected subdirectory at a wildcard-matched level", () => {
|
|
33
|
+
const outputDir = tempDir();
|
|
34
|
+
const complexDir = path.join(outputDir, "complex_0");
|
|
35
|
+
fs.mkdirSync(complexDir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(complexDir, "rank1.sdf"), "");
|
|
37
|
+
// A sidecar file directly under outputDir — the `*` in `*/rank1.sdf` also
|
|
38
|
+
// matches this filename, and a naive matcher would try to readdir it.
|
|
39
|
+
fs.writeFileSync(path.join(outputDir, "poses_summary.json"), "{}");
|
|
40
|
+
const outputs = collectGlobOutputs(outputDir, [{ id: "top_pose", glob: "*/rank1.sdf", required: true }]);
|
|
41
|
+
expect(outputs).toEqual([{ id: "top_pose", path: path.join(complexDir, "rank1.sdf") }]);
|
|
42
|
+
});
|
|
43
|
+
it("throws MISSING_REQUIRED_OUTPUT for a required output with no matches, even when an unrelated stray file exists", () => {
|
|
44
|
+
const outputDir = tempDir();
|
|
45
|
+
fs.writeFileSync(path.join(outputDir, "some-unrelated-file.txt"), "not a pose");
|
|
46
|
+
expect(() => collectGlobOutputs(outputDir, [{ id: "top_pose", glob: "*/rank1.sdf", required: true }])).toThrowError(expect.objectContaining({ code: "MISSING_REQUIRED_OUTPUT" }));
|
|
47
|
+
});
|
|
48
|
+
it("does not throw for a non-required output with no matches", () => {
|
|
49
|
+
const outputDir = tempDir();
|
|
50
|
+
const outputs = collectGlobOutputs(outputDir, [{ id: "optional", glob: "optional.json", required: false }]);
|
|
51
|
+
expect(outputs).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
it("rejects a symlinked output file pointing outside outputDir instead of following it", () => {
|
|
54
|
+
const outputDir = tempDir();
|
|
55
|
+
const outsideDir = tempDir();
|
|
56
|
+
const secretPath = path.join(outsideDir, "secret.sdf");
|
|
57
|
+
fs.writeFileSync(secretPath, "secret");
|
|
58
|
+
fs.symlinkSync(secretPath, path.join(outputDir, "rank1.sdf"));
|
|
59
|
+
expect(() => collectGlobOutputs(outputDir, [{ id: "top_pose", glob: "rank1.sdf", required: true }])).toThrowError(expect.objectContaining({ code: "MISSING_REQUIRED_OUTPUT" }));
|
|
60
|
+
});
|
|
61
|
+
it("rejects a symlinked directory reached through a wildcard segment instead of descending into it", () => {
|
|
62
|
+
const outputDir = tempDir();
|
|
63
|
+
const outsideDir = tempDir();
|
|
64
|
+
fs.mkdirSync(path.join(outsideDir, "escaped"), { recursive: true });
|
|
65
|
+
fs.writeFileSync(path.join(outsideDir, "escaped", "rank1.sdf"), "outside content");
|
|
66
|
+
fs.symlinkSync(path.join(outsideDir, "escaped"), path.join(outputDir, "complex_0"));
|
|
67
|
+
expect(() => collectGlobOutputs(outputDir, [{ id: "top_pose", glob: "*/rank1.sdf", required: true }])).toThrowError(expect.objectContaining({ code: "MISSING_REQUIRED_OUTPUT" }));
|
|
68
|
+
});
|
|
69
|
+
});
|
package/dist/params.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ParamDescriptor } from "./index.js";
|
|
2
|
+
export interface ParamValidationResult {
|
|
3
|
+
effective: Record<string, unknown>;
|
|
4
|
+
errors: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Validates and normalizes `--param key=value` input against an adapter's
|
|
8
|
+
* declared params. Rejects unknown keys, coerces by declared type, enforces
|
|
9
|
+
* min/max/enum, and fills defaults for missing non-required params.
|
|
10
|
+
* Collects every error rather than stopping at the first, so the CLI can
|
|
11
|
+
* report all problems in one pass.
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateParams(descriptors: ParamDescriptor[], supplied: Record<string, string>): ParamValidationResult;
|
package/dist/params.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function coerce(descriptor, raw, errors) {
|
|
2
|
+
switch (descriptor.type) {
|
|
3
|
+
case "string":
|
|
4
|
+
if (descriptor.enum && !descriptor.enum.includes(raw)) {
|
|
5
|
+
errors.push(`--param ${descriptor.name}: "${raw}" is not one of ${descriptor.enum.join(", ")}.`);
|
|
6
|
+
}
|
|
7
|
+
return raw;
|
|
8
|
+
case "number": {
|
|
9
|
+
const value = Number(raw);
|
|
10
|
+
if (!Number.isFinite(value)) {
|
|
11
|
+
errors.push(`--param ${descriptor.name}: "${raw}" is not a number.`);
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
if (descriptor.min !== undefined && value < descriptor.min) {
|
|
15
|
+
errors.push(`--param ${descriptor.name}: ${value} is below the minimum of ${descriptor.min}.`);
|
|
16
|
+
}
|
|
17
|
+
if (descriptor.max !== undefined && value > descriptor.max) {
|
|
18
|
+
errors.push(`--param ${descriptor.name}: ${value} is above the maximum of ${descriptor.max}.`);
|
|
19
|
+
}
|
|
20
|
+
if (descriptor.enum && !descriptor.enum.includes(value)) {
|
|
21
|
+
errors.push(`--param ${descriptor.name}: ${value} is not one of ${descriptor.enum.join(", ")}.`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
case "boolean": {
|
|
26
|
+
if (raw === "true" || raw === "1")
|
|
27
|
+
return true;
|
|
28
|
+
if (raw === "false" || raw === "0")
|
|
29
|
+
return false;
|
|
30
|
+
errors.push(`--param ${descriptor.name}: "${raw}" is not a boolean (use true/false).`);
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
default:
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validates and normalizes `--param key=value` input against an adapter's
|
|
39
|
+
* declared params. Rejects unknown keys, coerces by declared type, enforces
|
|
40
|
+
* min/max/enum, and fills defaults for missing non-required params.
|
|
41
|
+
* Collects every error rather than stopping at the first, so the CLI can
|
|
42
|
+
* report all problems in one pass.
|
|
43
|
+
*/
|
|
44
|
+
export function validateParams(descriptors, supplied) {
|
|
45
|
+
const errors = [];
|
|
46
|
+
const byName = new Map(descriptors.map((descriptor) => [descriptor.name, descriptor]));
|
|
47
|
+
for (const key of Object.keys(supplied)) {
|
|
48
|
+
if (!byName.has(key)) {
|
|
49
|
+
errors.push(`Unknown parameter "${key}". Accepted parameters: ${descriptors.map((d) => d.name).join(", ") || "(none)"}.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const effective = {};
|
|
53
|
+
for (const descriptor of descriptors) {
|
|
54
|
+
const raw = supplied[descriptor.name];
|
|
55
|
+
if (raw === undefined) {
|
|
56
|
+
if (descriptor.required) {
|
|
57
|
+
errors.push(`Missing required parameter "${descriptor.name}".`);
|
|
58
|
+
}
|
|
59
|
+
else if (descriptor.default !== undefined) {
|
|
60
|
+
effective[descriptor.name] = descriptor.default;
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const value = coerce(descriptor, raw, errors);
|
|
65
|
+
if (value !== undefined)
|
|
66
|
+
effective[descriptor.name] = value;
|
|
67
|
+
}
|
|
68
|
+
return { effective, errors };
|
|
69
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { validateParams } from "./params.js";
|
|
3
|
+
const descriptors = [
|
|
4
|
+
{ name: "count", type: "number", default: 1, min: 1, max: 10 },
|
|
5
|
+
{ name: "mode", type: "string", enum: ["fast", "slow"], required: true },
|
|
6
|
+
{ name: "verbose", type: "boolean", default: false },
|
|
7
|
+
];
|
|
8
|
+
describe("validateParams", () => {
|
|
9
|
+
it("fills defaults for missing non-required params", () => {
|
|
10
|
+
const { effective, errors } = validateParams(descriptors, { mode: "fast" });
|
|
11
|
+
expect(errors).toEqual([]);
|
|
12
|
+
expect(effective).toEqual({ count: 1, mode: "fast", verbose: false });
|
|
13
|
+
});
|
|
14
|
+
it("coerces declared types", () => {
|
|
15
|
+
const { effective, errors } = validateParams(descriptors, { count: "5", mode: "slow", verbose: "true" });
|
|
16
|
+
expect(errors).toEqual([]);
|
|
17
|
+
expect(effective).toEqual({ count: 5, mode: "slow", verbose: true });
|
|
18
|
+
});
|
|
19
|
+
it("rejects an unknown parameter", () => {
|
|
20
|
+
const { errors } = validateParams(descriptors, { mode: "fast", bogus: "1" });
|
|
21
|
+
expect(errors.some((e) => e.includes('Unknown parameter "bogus"'))).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it("reports a missing required parameter", () => {
|
|
24
|
+
const { errors } = validateParams(descriptors, {});
|
|
25
|
+
expect(errors.some((e) => e.includes('Missing required parameter "mode"'))).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it("enforces min/max/enum and collects every error in one pass", () => {
|
|
28
|
+
const { errors } = validateParams(descriptors, { count: "999", mode: "medium" });
|
|
29
|
+
expect(errors.length).toBeGreaterThanOrEqual(2);
|
|
30
|
+
expect(errors.some((e) => e.includes("above the maximum"))).toBe(true);
|
|
31
|
+
expect(errors.some((e) => e.includes("is not one of"))).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it("rejects a non-numeric value for a number param", () => {
|
|
34
|
+
const { errors } = validateParams(descriptors, { count: "abc", mode: "fast" });
|
|
35
|
+
expect(errors.some((e) => e.includes("is not a number"))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it("rejects a non-boolean value for a boolean param", () => {
|
|
38
|
+
const { errors } = validateParams(descriptors, { mode: "fast", verbose: "maybe" });
|
|
39
|
+
expect(errors.some((e) => e.includes("is not a boolean"))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function plannedAdapterOperation(modelName: string, operation: string): never;
|
package/dist/planned.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { MoldeskError } from "@moldesk/registry";
|
|
2
|
+
export function plannedAdapterOperation(modelName, operation) {
|
|
3
|
+
throw new MoldeskError({
|
|
4
|
+
code: "ADAPTER_NOT_IMPLEMENTED",
|
|
5
|
+
message: `Model adapter "${modelName}" cannot ${operation} yet.`,
|
|
6
|
+
remediation: `The model is listed as planned. Wait until its install/run vertical slice is verified and marked available.`,
|
|
7
|
+
details: { modelName, operation },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { collectGlobOutputs } from "../outputs.js";
|
|
6
|
+
/** Pinned to the manifest's modelVersion; protein_mpnn_run.py loads `<path_to_model_weights>/<model_name>.pt`. */
|
|
7
|
+
const MODEL_NAME = "v_48_020";
|
|
8
|
+
/** protein_mpnn_run.py writes `<out_folder>/seqs/<pdb_name>.fa` (verified against the pinned source revision). */
|
|
9
|
+
const OUTPUTS = [{ id: "sequences", glob: "seqs/*.fa", required: true }];
|
|
10
|
+
const PARAMS = [
|
|
11
|
+
{ name: "num_seq_per_target", type: "number", default: 1, min: 1, description: "Number of sequences to generate per target" },
|
|
12
|
+
// Upstream parses this as a space-separated string of temperatures, not a single float.
|
|
13
|
+
{ name: "sampling_temp", type: "string", default: "0.1", description: "Sampling temperature(s), e.g. \"0.1\" or \"0.1 0.2\"" },
|
|
14
|
+
{ name: "seed", type: "number", default: 0, description: "Random seed; 0 picks a random seed" },
|
|
15
|
+
{ name: "batch_size", type: "number", default: 1, min: 1, description: "Batch size" },
|
|
16
|
+
{ name: "ca_only", type: "boolean", default: false, description: "Parse CA-only structures and use CA-only models" },
|
|
17
|
+
];
|
|
18
|
+
export const proteinmpnnAdapter = {
|
|
19
|
+
modelName: "proteinmpnn",
|
|
20
|
+
params: PARAMS,
|
|
21
|
+
async validateInput(inputPath) {
|
|
22
|
+
if (!fs.existsSync(inputPath)) {
|
|
23
|
+
throw new MoldeskError({
|
|
24
|
+
code: "INVALID_RUN_INPUT",
|
|
25
|
+
message: `Input file not found: ${inputPath}.`,
|
|
26
|
+
remediation: "Provide a path to an existing .pdb file.",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (path.extname(inputPath).toLowerCase() !== ".pdb") {
|
|
30
|
+
throw new MoldeskError({
|
|
31
|
+
code: "INVALID_RUN_INPUT",
|
|
32
|
+
message: `ProteinMPNN requires a .pdb input file, got "${path.extname(inputPath) || "(no extension)"}".`,
|
|
33
|
+
remediation: "Provide a .pdb structure file.",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
async installPlan(_context) {
|
|
38
|
+
return {
|
|
39
|
+
steps: [
|
|
40
|
+
{ id: "source", description: "Fetch the pinned ProteinMPNN source revision" },
|
|
41
|
+
{ id: "checkpoint", description: "Download and checksum the ProteinMPNN checkpoint" },
|
|
42
|
+
{ id: "python", description: "Create an isolated managed Python 3.11 environment" },
|
|
43
|
+
{ id: "dependencies", description: "Install pinned Python dependencies" },
|
|
44
|
+
{ id: "verify", description: "Verify source, checkpoint, and Python imports" },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
async command(context) {
|
|
49
|
+
const params = context.params;
|
|
50
|
+
const args = [
|
|
51
|
+
path.join(context.modelDir, "source", "protein_mpnn_run.py"),
|
|
52
|
+
"--pdb_path", context.inputPath,
|
|
53
|
+
"--out_folder", context.outputDir,
|
|
54
|
+
"--path_to_model_weights", path.join(context.assetsDir, "vanilla_model_weights"),
|
|
55
|
+
"--model_name", MODEL_NAME,
|
|
56
|
+
];
|
|
57
|
+
if (params.num_seq_per_target !== undefined)
|
|
58
|
+
args.push("--num_seq_per_target", String(params.num_seq_per_target));
|
|
59
|
+
if (params.sampling_temp !== undefined)
|
|
60
|
+
args.push("--sampling_temp", String(params.sampling_temp));
|
|
61
|
+
if (params.seed !== undefined)
|
|
62
|
+
args.push("--seed", String(params.seed));
|
|
63
|
+
if (params.batch_size !== undefined)
|
|
64
|
+
args.push("--batch_size", String(params.batch_size));
|
|
65
|
+
if (params.ca_only === true)
|
|
66
|
+
args.push("--ca_only");
|
|
67
|
+
return { executable: context.runtimeExecutable, args };
|
|
68
|
+
},
|
|
69
|
+
async collectOutputs(context) {
|
|
70
|
+
return collectGlobOutputs(context.outputDir, OUTPUTS);
|
|
71
|
+
},
|
|
72
|
+
async verifyInstallation(context) {
|
|
73
|
+
const requiredFiles = [
|
|
74
|
+
path.join(context.modelDir, "source", "protein_mpnn_run.py"),
|
|
75
|
+
path.join(context.assetsDir, "vanilla_model_weights", "v_48_020.pt"),
|
|
76
|
+
];
|
|
77
|
+
const missing = requiredFiles.filter((file) => !fs.existsSync(file));
|
|
78
|
+
if (missing.length > 0)
|
|
79
|
+
return { passed: false, output: `Missing ${missing.join(", ")}` };
|
|
80
|
+
const python = path.join(context.modelDir, ".venv", "bin", "python");
|
|
81
|
+
const result = await (context.runner ?? runCommand)(python, ["-c", "import numpy, torch"], { timeoutMs: 120_000 });
|
|
82
|
+
return { passed: result.code === 0, output: (result.code === 0 ? result.stdout : result.stderr).trim() };
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { proteinmpnnAdapter } from "./index.js";
|
|
6
|
+
const dirs = [];
|
|
7
|
+
function tempDir() {
|
|
8
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-proteinmpnn-test-"));
|
|
9
|
+
dirs.push(dir);
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const dir of dirs.splice(0))
|
|
14
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
describe("proteinmpnnAdapter.validateInput", () => {
|
|
17
|
+
it("accepts an existing .pdb file", async () => {
|
|
18
|
+
const dir = tempDir();
|
|
19
|
+
const input = path.join(dir, "structure.pdb");
|
|
20
|
+
fs.writeFileSync(input, "ATOM\n");
|
|
21
|
+
await expect(proteinmpnnAdapter.validateInput(input)).resolves.toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
it("rejects a missing file", async () => {
|
|
24
|
+
await expect(proteinmpnnAdapter.validateInput(path.join(tempDir(), "missing.pdb"))).rejects.toMatchObject({
|
|
25
|
+
code: "INVALID_RUN_INPUT",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it("rejects a non-.pdb extension", async () => {
|
|
29
|
+
const dir = tempDir();
|
|
30
|
+
const input = path.join(dir, "structure.txt");
|
|
31
|
+
fs.writeFileSync(input, "ATOM\n");
|
|
32
|
+
await expect(proteinmpnnAdapter.validateInput(input)).rejects.toMatchObject({ code: "INVALID_RUN_INPUT" });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("proteinmpnnAdapter.command", () => {
|
|
36
|
+
it("builds the expected executable/args shape", async () => {
|
|
37
|
+
const spec = await proteinmpnnAdapter.command({
|
|
38
|
+
manifestName: "proteinmpnn",
|
|
39
|
+
inputPath: "/run/input/structure.pdb",
|
|
40
|
+
outputDir: "/run/output",
|
|
41
|
+
modelDir: "/models/proteinmpnn",
|
|
42
|
+
assetsDir: "/models/proteinmpnn/assets",
|
|
43
|
+
params: { num_seq_per_target: 2, sampling_temp: "0.1", seed: 0, batch_size: 1, ca_only: true },
|
|
44
|
+
runtimeExecutable: "/models/proteinmpnn/.venv/bin/python",
|
|
45
|
+
});
|
|
46
|
+
expect(spec.executable).toBe("/models/proteinmpnn/.venv/bin/python");
|
|
47
|
+
expect(spec.args).toContain("--pdb_path");
|
|
48
|
+
expect(spec.args).toContain("/run/input/structure.pdb");
|
|
49
|
+
expect(spec.args).toContain("--out_folder");
|
|
50
|
+
expect(spec.args).toContain("/run/output");
|
|
51
|
+
expect(spec.args).toContain("--path_to_model_weights");
|
|
52
|
+
expect(spec.args).toContain(path.join("/models/proteinmpnn/assets", "vanilla_model_weights"));
|
|
53
|
+
expect(spec.args).toContain("--num_seq_per_target");
|
|
54
|
+
expect(spec.args).toContain("2");
|
|
55
|
+
expect(spec.args).toContain("--ca_only");
|
|
56
|
+
});
|
|
57
|
+
it("omits --ca_only when false", async () => {
|
|
58
|
+
const spec = await proteinmpnnAdapter.command({
|
|
59
|
+
manifestName: "proteinmpnn",
|
|
60
|
+
inputPath: "/run/input/structure.pdb",
|
|
61
|
+
outputDir: "/run/output",
|
|
62
|
+
modelDir: "/models/proteinmpnn",
|
|
63
|
+
assetsDir: "/models/proteinmpnn/assets",
|
|
64
|
+
params: { num_seq_per_target: 1, sampling_temp: "0.1", seed: 0, batch_size: 1, ca_only: false },
|
|
65
|
+
runtimeExecutable: "/models/proteinmpnn/.venv/bin/python",
|
|
66
|
+
});
|
|
67
|
+
expect(spec.args).not.toContain("--ca_only");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe("proteinmpnnAdapter.collectOutputs", () => {
|
|
71
|
+
it("collects generated .fa files from the seqs/ subdirectory", async () => {
|
|
72
|
+
const dir = tempDir();
|
|
73
|
+
const outputDir = path.join(dir, "output");
|
|
74
|
+
fs.mkdirSync(path.join(outputDir, "seqs"), { recursive: true });
|
|
75
|
+
fs.writeFileSync(path.join(outputDir, "seqs", "structure.fa"), ">structure\nMKV\n");
|
|
76
|
+
const outputs = await proteinmpnnAdapter.collectOutputs({
|
|
77
|
+
manifestName: "proteinmpnn",
|
|
78
|
+
inputPath: path.join(dir, "input", "structure.pdb"),
|
|
79
|
+
outputDir,
|
|
80
|
+
modelDir: dir,
|
|
81
|
+
assetsDir: path.join(dir, "assets"),
|
|
82
|
+
params: {},
|
|
83
|
+
runtimeExecutable: "python",
|
|
84
|
+
});
|
|
85
|
+
expect(outputs).toEqual([{ id: "sequences", path: path.join(outputDir, "seqs", "structure.fa") }]);
|
|
86
|
+
});
|
|
87
|
+
it("throws MISSING_REQUIRED_OUTPUT when no .fa files were produced", async () => {
|
|
88
|
+
const dir = tempDir();
|
|
89
|
+
const outputDir = path.join(dir, "output");
|
|
90
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
91
|
+
await expect(proteinmpnnAdapter.collectOutputs({
|
|
92
|
+
manifestName: "proteinmpnn",
|
|
93
|
+
inputPath: path.join(dir, "input", "structure.pdb"),
|
|
94
|
+
outputDir,
|
|
95
|
+
modelDir: dir,
|
|
96
|
+
assetsDir: path.join(dir, "assets"),
|
|
97
|
+
params: {},
|
|
98
|
+
runtimeExecutable: "python",
|
|
99
|
+
})).rejects.toMatchObject({ code: "MISSING_REQUIRED_OUTPUT" });
|
|
100
|
+
});
|
|
101
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moldesk/adapters",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Thin model-specific adapters for MoleculeDesk (translation/verification only)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/pooriyapfn/moldesk.git",
|
|
10
|
+
"directory": "packages/adapters"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"yaml": "^2.5.1",
|
|
23
|
+
"@moldesk/registry": "0.0.3",
|
|
24
|
+
"@moldesk/runtime": "0.0.3"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.6.3",
|
|
28
|
+
"vitest": "^3.0.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json && node scripts/validate-catalog.mjs",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
}
|
|
6
35
|
}
|
package/README.md
DELETED