@moldesk/adapters 0.0.3 → 0.0.4

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.
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { MoldeskError } from "@moldesk/registry";
4
4
  import { runCommand } from "@moldesk/runtime";
5
5
  import { collectGlobOutputs } from "../outputs.js";
6
+ import { describeBindCraft2Visualization } from "./visualization.js";
6
7
  const MODALITIES = ["binder", "peptide", "cyclic_peptide", "VHH", "ARP"];
7
8
  const RESOLVED = "moldesk-campaign.json";
8
9
  const SUMMARY = "moldesk-summary.json";
@@ -329,6 +330,7 @@ export const bindcraft2Adapter = {
329
330
  fs.writeFileSync(path.join(context.outputDir, SUMMARY), `${JSON.stringify(summary, null, 2)}\n`, { flag: "wx" });
330
331
  return collectGlobOutputs(context.outputDir, OUTPUTS);
331
332
  },
333
+ async describeVisualization(context, outputs) { return describeBindCraft2Visualization(context.inputPath, outputs); },
332
334
  async verifyInstallation(context) {
333
335
  if (context.platform !== "linux-x64")
334
336
  return { passed: false, output: "BindCraft2 supports Linux x64/NVIDIA only." };
@@ -0,0 +1,8 @@
1
+ import { type ReportDescription } from "@moldesk/visualizer/contract";
2
+ import type { CollectedOutput } from "../index.js";
3
+ /**
4
+ * Accepted/ranked designs first (rank taken from the ranked table, never invented), then
5
+ * refolded structures, then raw trajectories. A lower tier is included only as a whole and
6
+ * only if every scene fits the report limit, so no tier is ever cut off partway.
7
+ */
8
+ export declare function describeBindCraft2Visualization(inputPath: string, outputs: readonly CollectedOutput[]): ReportDescription | undefined;
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MoldeskError } from "@moldesk/registry";
4
+ import { csvRecords, parseCsv } from "@moldesk/visualizer/csv";
5
+ import { REPORT_LIMITS } from "@moldesk/visualizer/contract";
6
+ import { ReportBuilder } from "../visualization.js";
7
+ function readBounded(file) {
8
+ if (fs.statSync(file).size > REPORT_LIMITS.maxResourceBytes) {
9
+ throw new MoldeskError({ code: "VISUALIZATION_SOURCE_TOO_LARGE", message: `${path.basename(file)} is too large to show.`, remediation: "Use --no-visualize." });
10
+ }
11
+ return fs.readFileSync(file, "utf8");
12
+ }
13
+ /**
14
+ * The accepted structure for a ranked design is `<design>.cif`, or `<design>_*.cif` other
15
+ * than `<design>_monomer.cif` — the same rule the adapter's result probe enforces. Never
16
+ * guessed: more than one candidate is an error.
17
+ */
18
+ function structureFor(design, accepted) {
19
+ const exact = accepted.find((o) => path.basename(o.path) === `${design}.cif`);
20
+ if (exact)
21
+ return exact;
22
+ const candidates = accepted.filter((o) => {
23
+ const name = path.basename(o.path);
24
+ return name.startsWith(`${design}_`) && name.endsWith(".cif") && name !== `${design}_monomer.cif`;
25
+ });
26
+ if (candidates.length !== 1) {
27
+ throw new MoldeskError({
28
+ code: "VISUALIZATION_AMBIGUOUS_STRUCTURE",
29
+ message: `${candidates.length === 0 ? "No" : "More than one"} accepted structure matches design "${design}".`,
30
+ remediation: "Use --no-visualize.",
31
+ });
32
+ }
33
+ return candidates[0];
34
+ }
35
+ /**
36
+ * Accepted/ranked designs first (rank taken from the ranked table, never invented), then
37
+ * refolded structures, then raw trajectories. A lower tier is included only as a whole and
38
+ * only if every scene fits the report limit, so no tier is ever cut off partway.
39
+ */
40
+ export function describeBindCraft2Visualization(inputPath, outputs) {
41
+ const of = (id) => outputs.filter((o) => o.id === id).sort((a, b) => a.path.localeCompare(b.path));
42
+ const ranked = of("ranked")[0];
43
+ const accepted = of("accepted_structures");
44
+ const refolded = of("refolded_structures");
45
+ const trajectories = of("trajectory_structures");
46
+ if (accepted.length + refolded.length + trajectories.length === 0)
47
+ return undefined;
48
+ const builder = new ReportBuilder();
49
+ const cif = (o) => builder.resource({ kind: "output", path: o.path }, "mmcif", path.basename(o.path));
50
+ const addScene = (title, o, rank) => builder.scene({
51
+ id: `scene-${builder.sceneCount + 1}`,
52
+ title,
53
+ ...(rank === undefined ? {} : { rank }),
54
+ structures: [{ id: "structure-1", resourceId: cif(o) }],
55
+ });
56
+ if (ranked) {
57
+ const rows = csvRecords(parseCsv(readBounded(ranked.path)));
58
+ const ordered = rows
59
+ .map((row) => ({ design: row["design"], rank: Number(row["rank"]) }))
60
+ .sort((a, b) => a.rank - b.rank);
61
+ for (const { design, rank } of ordered) {
62
+ if (!design || !Number.isInteger(rank) || rank < 1) {
63
+ throw new MoldeskError({ code: "INVALID_REPORT", message: "The ranked table has an invalid design or rank.", remediation: "Use --no-visualize." });
64
+ }
65
+ addScene(`#${rank} · ${design}`, structureFor(design, accepted), rank);
66
+ }
67
+ }
68
+ const room = () => REPORT_LIMITS.maxScenes - builder.sceneCount;
69
+ if (refolded.length <= room())
70
+ for (const o of refolded)
71
+ addScene(`Refolded · ${path.basename(o.path, ".cif")}`, o);
72
+ if (trajectories.length <= room()) {
73
+ for (const o of trajectories)
74
+ addScene(`Trajectory · ${path.basename(path.dirname(o.path))} / ${path.basename(o.path, ".cif")}`, o);
75
+ }
76
+ const table = (title, id) => {
77
+ const o = of(id)[0];
78
+ if (o)
79
+ builder.table(title, builder.resource({ kind: "output", path: o.path }, "csv", path.basename(o.path)));
80
+ };
81
+ table("Ranked designs", "ranked");
82
+ table("Refolded designs", "refolded");
83
+ table("Trajectories", "trajectories");
84
+ return builder.build(`BindCraft2 — ${path.basename(inputPath)}`);
85
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { validateDescription } from "@moldesk/visualizer";
6
+ import { describeBindCraft2Visualization } from "./visualization.js";
7
+ let out;
8
+ beforeEach(() => {
9
+ out = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-bc2-viz-"));
10
+ });
11
+ afterEach(() => fs.rmSync(out, { recursive: true, force: true }));
12
+ const file = (rel, content = "x") => {
13
+ const p = path.join(out, rel);
14
+ fs.mkdirSync(path.dirname(p), { recursive: true });
15
+ fs.writeFileSync(p, content);
16
+ return p;
17
+ };
18
+ const o = (id, rel, content) => ({ id, path: file(rel, content) });
19
+ const titles = (d) => d.scenes.map((s) => s.title);
20
+ describe("describeBindCraft2Visualization", () => {
21
+ it("returns undefined when there is no eligible structure", () => {
22
+ expect(describeBindCraft2Visualization("/in/c.json", [o("campaign", "moldesk-campaign.json")])).toBeUndefined();
23
+ });
24
+ it("orders accepted designs by the rank in the ranked table, with quoted cells, and sets verified ranks", () => {
25
+ const d = describeBindCraft2Visualization("/in/c.json", [
26
+ o("ranked", "3_Ranked/!_Ranked.csv", 'design,rank,sequence\nd_b,2,"AAA,BBB"\nd_a,1,CCC\n'),
27
+ o("accepted_structures", "3_Ranked/d_b.cif"),
28
+ o("accepted_structures", "3_Ranked/d_a.cif"),
29
+ o("accepted_structures", "3_Ranked/d_a_monomer.cif"),
30
+ ]);
31
+ expect(titles(d)).toEqual(["#1 · d_a", "#2 · d_b"]);
32
+ expect(d.scenes.map((s) => s.rank)).toEqual([1, 2]);
33
+ expect(d.resources.some((r) => r.source.path.endsWith("_monomer.cif"))).toBe(false);
34
+ expect(d.panels.some((p) => p.kind === "table" && p.title === "Ranked designs")).toBe(true);
35
+ validateDescription(d);
36
+ });
37
+ it("uses the single non-monomer <design>_*.cif and refuses ambiguous matches", () => {
38
+ const ranked = o("ranked", "3_Ranked/!_Ranked.csv", "design,rank\nd,1\n");
39
+ const ok = describeBindCraft2Visualization("/in/c.json", [ranked, o("accepted_structures", "3_Ranked/d_relaxed.cif"), o("accepted_structures", "3_Ranked/d_monomer.cif")]);
40
+ expect(ok.resources.find((r) => r.format === "mmcif").source.path).toMatch(/d_relaxed\.cif$/);
41
+ expect(() => describeBindCraft2Visualization("/in/c.json", [ranked, o("accepted_structures", "3_Ranked/d_a.cif"), o("accepted_structures", "3_Ranked/d_b.cif")])).toThrow(/More than one/);
42
+ expect(() => describeBindCraft2Visualization("/in/c.json", [ranked, o("accepted_structures", "3_Ranked/other.cif")])).toThrow(/No accepted/);
43
+ });
44
+ it("rejects an invalid rank instead of inventing one", () => {
45
+ expect(() => describeBindCraft2Visualization("/in/c.json", [o("ranked", "3_Ranked/!_Ranked.csv", "design,rank\nd,first\n"), o("accepted_structures", "3_Ranked/d.cif")])).toThrow(/invalid design or rank/);
46
+ });
47
+ it("falls back to refolded then trajectories (unranked) when nothing was accepted", () => {
48
+ const d = describeBindCraft2Visualization("/in/c.json", [
49
+ o("trajectory_structures", "1_Trajectories/t1/a.cif"),
50
+ o("refolded_structures", "2_Refolded/r1.cif"),
51
+ o("trajectories", "1_Trajectories/!_Trajectories.csv", "design,trajectory\na,1\n"),
52
+ ]);
53
+ expect(titles(d)).toEqual(["Refolded · r1", "Trajectory · t1 / a"]);
54
+ expect(d.scenes.every((s) => s.rank === undefined)).toBe(true);
55
+ validateDescription(d);
56
+ });
57
+ it("includes a lower tier only whole: an oversized trajectory set is left out, ranked stays", () => {
58
+ const outputs = [
59
+ o("ranked", "3_Ranked/!_Ranked.csv", "design,rank\nd,1\n"),
60
+ o("accepted_structures", "3_Ranked/d.cif"),
61
+ ];
62
+ for (let i = 0; i < 300; i++)
63
+ outputs.push({ id: "trajectory_structures", path: path.join(out, `1_Trajectories/t${i}/x.cif`) });
64
+ const d = describeBindCraft2Visualization("/in/c.json", outputs);
65
+ expect(titles(d)).toEqual(["#1 · d"]);
66
+ validateDescription(d);
67
+ });
68
+ });
@@ -4,6 +4,7 @@ import { MoldeskError } from "@moldesk/registry";
4
4
  import { runCommand } from "@moldesk/runtime";
5
5
  import { parse as parseYaml, YAMLParseError } from "yaml";
6
6
  import { collectGlobOutputs } from "../outputs.js";
7
+ import { describeBoltzVisualization } from "./visualization.js";
7
8
  /**
8
9
  * Boltz's CLI writes `<out_dir>/boltz_results_<input_stem>/predictions/<record_id>/…`
9
10
  * (verified against both pinned source revisions' `src/boltz/main.py` and
@@ -186,6 +187,9 @@ export const boltzAdapter = {
186
187
  async collectOutputs(context) {
187
188
  return collectGlobOutputs(context.outputDir, OUTPUTS);
188
189
  },
190
+ async describeVisualization(context, outputs) {
191
+ return describeBoltzVisualization(context.inputPath, outputs);
192
+ },
189
193
  async verifyInstallation(context) {
190
194
  const venvBin = path.join(context.modelDir, ".venv", "bin");
191
195
  const python = path.join(venvBin, "python");
@@ -0,0 +1,9 @@
1
+ import type { ReportDescription } from "@moldesk/visualizer/contract";
2
+ import type { CollectedOutput } from "../index.js";
3
+ /**
4
+ * One scene per predicted structure. Boltz's `_model_<n>` is an identifier (it is not
5
+ * ranked by confidence), so it only orders scenes deterministically; titles are
6
+ * "Prediction 1…", and no `rank` is set. Confidence/affinity files are attached only by
7
+ * exact name next to the structure — never by position in a sorted list.
8
+ */
9
+ export declare function describeBoltzVisualization(inputPath: string, outputs: readonly CollectedOutput[]): ReportDescription | undefined;
@@ -0,0 +1,52 @@
1
+ import path from "node:path";
2
+ import { ReportBuilder } from "../visualization.js";
3
+ const STRUCTURE = /^(.+)_model_(\d+)\.cif$/;
4
+ /**
5
+ * One scene per predicted structure. Boltz's `_model_<n>` is an identifier (it is not
6
+ * ranked by confidence), so it only orders scenes deterministically; titles are
7
+ * "Prediction 1…", and no `rank` is set. Confidence/affinity files are attached only by
8
+ * exact name next to the structure — never by position in a sorted list.
9
+ */
10
+ export function describeBoltzVisualization(inputPath, outputs) {
11
+ const exact = (id) => new Map(outputs.filter((o) => o.id === id).map((o) => [o.path, o]));
12
+ const confidence = exact("confidence");
13
+ const affinity = exact("affinity");
14
+ const predictions = [];
15
+ for (const output of outputs) {
16
+ if (output.id !== "structures")
17
+ continue;
18
+ const match = STRUCTURE.exec(path.basename(output.path));
19
+ if (!match)
20
+ continue;
21
+ predictions.push({ path: output.path, dir: path.dirname(output.path), record: match[1], model: Number(match[2]) });
22
+ }
23
+ if (predictions.length === 0)
24
+ return undefined;
25
+ predictions.sort((a, b) => a.dir.localeCompare(b.dir) || a.model - b.model);
26
+ const records = new Set(predictions.map((p) => p.dir));
27
+ const builder = new ReportBuilder();
28
+ const positionInRecord = new Map();
29
+ const affinityDone = new Set();
30
+ for (const p of predictions) {
31
+ const k = (positionInRecord.get(p.dir) ?? 0) + 1;
32
+ positionInRecord.set(p.dir, k);
33
+ const title = records.size > 1 ? `${p.record} — Prediction ${k}` : `Prediction ${k}`;
34
+ const sceneId = `scene-${builder.sceneCount + 1}`;
35
+ builder.scene({
36
+ id: sceneId,
37
+ title,
38
+ structures: [
39
+ { id: "structure-1", resourceId: builder.resource({ kind: "output", path: p.path }, "mmcif", path.basename(p.path)) },
40
+ ],
41
+ });
42
+ const conf = confidence.get(path.join(p.dir, `confidence_${p.record}_model_${p.model}.json`));
43
+ if (conf)
44
+ builder.table(`Confidence — ${title}`, builder.resource({ kind: "output", path: conf.path }, "json", path.basename(conf.path)));
45
+ const aff = affinity.get(path.join(p.dir, `affinity_${p.record}.json`));
46
+ if (aff && !affinityDone.has(aff.path)) {
47
+ affinityDone.add(aff.path);
48
+ builder.table(`Affinity — ${p.record}`, builder.resource({ kind: "output", path: aff.path }, "json", path.basename(aff.path)));
49
+ }
50
+ }
51
+ return builder.build(`Boltz — ${path.basename(inputPath)}`);
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import path from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { validateDescription } from "@moldesk/visualizer";
4
+ import { describeBoltzVisualization } from "./visualization.js";
5
+ const out = "/run/output";
6
+ const dir = (record) => path.join(out, "boltz_results_job", "predictions", record);
7
+ const cif = (record, n) => ({ id: "structures", path: path.join(dir(record), `${record}_model_${n}.cif`) });
8
+ const conf = (record, n) => ({ id: "confidence", path: path.join(dir(record), `confidence_${record}_model_${n}.json`) });
9
+ const aff = (record) => ({ id: "affinity", path: path.join(dir(record), `affinity_${record}.json`) });
10
+ const resource = (d, id) => d.resources.find((r) => r.id === id);
11
+ describe("describeBoltzVisualization", () => {
12
+ it("returns undefined when there are no structures", () => {
13
+ expect(describeBoltzVisualization("/in/job.yaml", [conf("a", 0)])).toBeUndefined();
14
+ });
15
+ it("makes one scene per model, ordered by model index, without inventing ranks", () => {
16
+ // Collected order deliberately scrambled: association must not depend on it.
17
+ const d = describeBoltzVisualization("/in/job.yaml", [cif("a", 2), cif("a", 0), cif("a", 10), conf("a", 10), conf("a", 0), conf("a", 2)]);
18
+ expect(d.scenes.map((s) => s.title)).toEqual(["Prediction 1", "Prediction 2", "Prediction 3"]);
19
+ expect(d.scenes.every((s) => s.rank === undefined)).toBe(true);
20
+ expect(d.scenes.map((s) => resource(d, s.structures[0].resourceId).source.path)).toEqual([
21
+ cif("a", 0).path,
22
+ cif("a", 2).path,
23
+ cif("a", 10).path,
24
+ ]);
25
+ expect(d.title).toBe("Boltz — job.yaml");
26
+ validateDescription(d);
27
+ });
28
+ it("pairs each structure only with its own confidence file", () => {
29
+ const d = describeBoltzVisualization("/in/job.yaml", [cif("a", 0), cif("a", 1), conf("a", 1)]);
30
+ const tables = d.panels.filter((p) => p.kind === "table");
31
+ expect(tables).toHaveLength(1);
32
+ expect(tables[0].kind === "table" && tables[0].title).toBe("Confidence — Prediction 2");
33
+ const t = tables[0];
34
+ expect(t.kind === "table" && resource(d, t.dataset.resourceId).source.path).toBe(conf("a", 1).path);
35
+ });
36
+ it("does not attach a confidence file from a different record", () => {
37
+ const d = describeBoltzVisualization("/in/job.yaml", [cif("a", 0), conf("b", 0)]);
38
+ expect(d.panels.filter((p) => p.kind === "table")).toEqual([]);
39
+ });
40
+ it("labels records and attaches affinity once per record", () => {
41
+ const d = describeBoltzVisualization("/in/job.yaml", [cif("a", 0), cif("a", 1), cif("b", 0), aff("a")]);
42
+ expect(d.scenes.map((s) => s.title)).toEqual(["a — Prediction 1", "a — Prediction 2", "b — Prediction 1"]);
43
+ const titles = d.panels.filter((p) => p.kind === "table").map((p) => p.kind === "table" && p.title);
44
+ expect(titles).toEqual(["Affinity — a"]);
45
+ validateDescription(d);
46
+ });
47
+ it("ignores .cif files that are not model predictions", () => {
48
+ const stray = { id: "structures", path: path.join(dir("a"), "ccd.cif") };
49
+ expect(describeBoltzVisualization("/in/job.yaml", [stray])).toBeUndefined();
50
+ });
51
+ });
@@ -4,6 +4,7 @@ import { MoldeskError } from "@moldesk/registry";
4
4
  import { runCommand } from "@moldesk/runtime";
5
5
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6
6
  import { collectGlobOutputs } from "../outputs.js";
7
+ import { describeDiffDockVisualization } from "./visualization.js";
7
8
  /**
8
9
  * DiffDock-L's `inference.py` (gcorso/DiffDock @ 85c49b60d3e0b0182a59ee43a34a6d7036981284)
9
10
  * writes, per complex, `<out_dir>/<complex_name>/rank1.sdf` (the single top pose,
@@ -418,6 +419,9 @@ export const diffdockAdapter = {
418
419
  const summaryOutputs = collectGlobOutputs(context.outputDir, OUTPUTS.filter((o) => o.id === "poses_summary"));
419
420
  return [...poseOutputs, ...summaryOutputs];
420
421
  },
422
+ async describeVisualization(context, outputs) {
423
+ return describeDiffDockVisualization(context, outputs);
424
+ },
421
425
  async verifyInstallation(context) {
422
426
  const python = path.join(context.modelDir, ".venv", "bin", "python");
423
427
  const sourceDir = path.join(context.modelDir, "source");
@@ -0,0 +1,10 @@
1
+ import type { ReportDescription } from "@moldesk/visualizer/contract";
2
+ import type { CollectedOutput, RunContext } from "../index.js";
3
+ /**
4
+ * Receptor + each ranked pose in one scene. DiffDock writes poses in the coordinate frame
5
+ * of the receptor it was given, so no transform is applied; nothing is aligned. Rank and
6
+ * confidence come from the adapter's own pose summary. `rank1.sdf` duplicates the top
7
+ * `rank1_confidence*.sdf`, so only the ranked files become scenes. A missing receptor is an
8
+ * error, never a ligand-only report.
9
+ */
10
+ export declare function describeDiffDockVisualization(context: Pick<RunContext, "inputPath" | "companionInputs">, outputs: readonly CollectedOutput[]): ReportDescription | undefined;
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MoldeskError } from "@moldesk/registry";
4
+ import { ReportBuilder } from "../visualization.js";
5
+ const RECEPTOR_FORMATS = { ".pdb": "pdb", ".cif": "mmcif", ".mmcif": "mmcif" };
6
+ function missingReceptor(message) {
7
+ return new MoldeskError({
8
+ code: "VISUALIZATION_RECEPTOR_MISSING",
9
+ message,
10
+ remediation: "The docking results are unaffected. A ligand shown without its receptor would misrepresent the result, so no report was created.",
11
+ });
12
+ }
13
+ /**
14
+ * Receptor + each ranked pose in one scene. DiffDock writes poses in the coordinate frame
15
+ * of the receptor it was given, so no transform is applied; nothing is aligned. Rank and
16
+ * confidence come from the adapter's own pose summary. `rank1.sdf` duplicates the top
17
+ * `rank1_confidence*.sdf`, so only the ranked files become scenes. A missing receptor is an
18
+ * error, never a ligand-only report.
19
+ */
20
+ export function describeDiffDockVisualization(context, outputs) {
21
+ const summaryOutput = outputs.find((o) => o.id === "poses_summary");
22
+ const poseFiles = outputs.filter((o) => o.id === "ranked_poses");
23
+ if (!summaryOutput || poseFiles.length === 0)
24
+ return undefined;
25
+ const receptorPath = context.companionInputs?.find((c) => c.id === "protein")?.path;
26
+ if (!receptorPath)
27
+ throw missingReceptor("The staged receptor for this docking run is not available.");
28
+ const receptorFormat = RECEPTOR_FORMATS[path.extname(receptorPath).toLowerCase()];
29
+ if (!receptorFormat)
30
+ throw missingReceptor(`The receptor ${path.basename(receptorPath)} is not a PDB or mmCIF file.`);
31
+ const summary = JSON.parse(fs.readFileSync(summaryOutput.path, "utf8"));
32
+ const builder = new ReportBuilder();
33
+ const receptor = builder.resource({ kind: "input", path: receptorPath }, receptorFormat, path.basename(receptorPath));
34
+ for (const pose of [...summary.poses].sort((a, b) => a.rank - b.rank)) {
35
+ const file = poseFiles.find((o) => path.basename(o.path) === pose.sdfFile && path.basename(path.dirname(o.path)) === summary.complexName);
36
+ if (!file) {
37
+ throw new MoldeskError({ code: "VISUALIZATION_POSE_MISSING", message: `Pose ${pose.rank} is in the summary but its file was not collected.`, remediation: "Use --no-visualize." });
38
+ }
39
+ builder.scene({
40
+ id: `scene-${builder.sceneCount + 1}`,
41
+ title: `Pose ${pose.rank} (confidence ${pose.confidence})`,
42
+ rank: pose.rank,
43
+ structures: [
44
+ { id: "receptor", resourceId: receptor },
45
+ { id: "pose", resourceId: builder.resource({ kind: "output", path: file.path }, "sdf", pose.sdfFile) },
46
+ ],
47
+ components: [
48
+ { id: "receptor", structureId: "receptor", selection: { kind: "all" }, role: "receptor", label: "Receptor", representation: "cartoon" },
49
+ { id: "pose", structureId: "pose", selection: { kind: "all" }, role: "ligand", label: "Ligand pose", representation: "sticks" },
50
+ ],
51
+ });
52
+ }
53
+ builder.table("Poses", builder.resource({ kind: "output", path: summaryOutput.path }, "json", path.basename(summaryOutput.path)), [
54
+ { field: "rank", label: "Rank" },
55
+ { field: "confidence", label: "Confidence" },
56
+ { field: "sdfFile", label: "File" },
57
+ { field: "atomCount", label: "Atoms" },
58
+ ], "poses");
59
+ return builder.build(`DiffDock — ${path.basename(context.inputPath)}`);
60
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { validateDescription } from "@moldesk/visualizer";
6
+ import { describeDiffDockVisualization } from "./visualization.js";
7
+ let dir;
8
+ beforeEach(() => {
9
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk-dd-viz-"));
10
+ });
11
+ afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
12
+ const out = () => path.join(dir, "run", "output");
13
+ const pose = (name) => ({ id: "ranked_poses", path: path.join(out(), "cplx", name) });
14
+ const top = () => ({ id: "top_pose", path: path.join(out(), "cplx", "rank1.sdf") });
15
+ function summary(poses) {
16
+ fs.mkdirSync(out(), { recursive: true });
17
+ const p = path.join(out(), "poses_summary.json");
18
+ fs.writeFileSync(p, JSON.stringify({ complexName: "cplx", topPoseFile: "rank1.sdf", poses }));
19
+ return { id: "poses_summary", path: p };
20
+ }
21
+ const POSES = [
22
+ { rank: 2, confidence: -1.23, sdfFile: "rank2_confidence-1.23.sdf" },
23
+ { rank: 1, confidence: 0.85, sdfFile: "rank1_confidence0.85.sdf" },
24
+ ];
25
+ const receptorOf = () => ({ id: "protein", path: path.join(dir, "run", "input", "protein", "receptor.pdb") });
26
+ describe("describeDiffDockVisualization", () => {
27
+ const outputs = () => [top(), pose("rank2_confidence-1.23.sdf"), pose("rank1_confidence0.85.sdf"), summary(POSES)];
28
+ it("puts the staged receptor and each ranked pose in one scene, sharing the receptor resource", () => {
29
+ const d = describeDiffDockVisualization({ inputPath: "/in/job.json", companionInputs: [receptorOf()] }, outputs());
30
+ expect(d.scenes.map((s) => [s.title, s.rank])).toEqual([
31
+ ["Pose 1 (confidence 0.85)", 1],
32
+ ["Pose 2 (confidence -1.23)", 2],
33
+ ]);
34
+ const receptors = d.resources.filter((r) => r.source.kind === "input");
35
+ expect(receptors).toHaveLength(1);
36
+ expect(receptors[0]).toMatchObject({ format: "pdb", source: { path: receptorOf().path } });
37
+ for (const s of d.scenes) {
38
+ expect(s.structures[0].resourceId).toBe(receptors[0].id);
39
+ expect(s.structures).toHaveLength(2);
40
+ expect(s.components?.map((c) => c.role)).toEqual(["receptor", "ligand"]);
41
+ }
42
+ validateDescription(d);
43
+ });
44
+ it("does not create a second scene for the rank1.sdf duplicate of the top pose", () => {
45
+ const d = describeDiffDockVisualization({ inputPath: "/in/job.json", companionInputs: [receptorOf()] }, outputs());
46
+ expect(d.resources.some((r) => r.source.path.endsWith(`${path.sep}rank1.sdf`))).toBe(false);
47
+ });
48
+ it("shows the pose summary as a table over its poses array", () => {
49
+ const d = describeDiffDockVisualization({ inputPath: "/in/job.json", companionInputs: [receptorOf()] }, outputs());
50
+ const table = d.panels.find((p) => p.kind === "table");
51
+ expect(table).toMatchObject({ title: "Poses", dataset: { rowsKey: "poses" } });
52
+ });
53
+ it.each([
54
+ ["no staged companions", undefined],
55
+ ["no protein companion", [{ id: "ligand", path: "/x/ligand.sdf" }]],
56
+ ["a receptor that is not PDB/mmCIF", [{ id: "protein", path: "/x/receptor.mol2" }]],
57
+ ])("refuses to describe a docking result with %s (never ligand-only)", (_name, companionInputs) => {
58
+ expect(() => describeDiffDockVisualization({ inputPath: "/in/job.json", companionInputs }, outputs())).toThrowError(expect.objectContaining({ code: "VISUALIZATION_RECEPTOR_MISSING" }));
59
+ });
60
+ it("accepts an mmCIF receptor", () => {
61
+ const d = describeDiffDockVisualization({ inputPath: "/in/job.json", companionInputs: [{ id: "protein", path: "/x/r.cif" }] }, outputs());
62
+ expect(d.resources.find((r) => r.source.kind === "input").format).toBe("mmcif");
63
+ });
64
+ it("errors when a summarized pose file was not collected, and returns undefined with no poses", () => {
65
+ expect(() => describeDiffDockVisualization({ inputPath: "/in/job.json", companionInputs: [receptorOf()] }, [pose("rank1_confidence0.85.sdf"), summary(POSES)])).toThrowError(expect.objectContaining({ code: "VISUALIZATION_POSE_MISSING" }));
66
+ expect(describeDiffDockVisualization({ inputPath: "/in/job.json", companionInputs: [receptorOf()] }, [])).toBeUndefined();
67
+ });
68
+ });
package/dist/index.d.ts CHANGED
@@ -78,6 +78,8 @@ export interface CollectedOutput {
78
78
  id: string;
79
79
  path: string;
80
80
  }
81
+ /** Type-only: the adapter never loads the visualizer's Node or browser code. */
82
+ export type ReportDescription = import("@moldesk/visualizer/contract").ReportDescription;
81
83
  export interface VerificationResult {
82
84
  passed: boolean;
83
85
  output?: string;
@@ -116,6 +118,18 @@ export interface ModelAdapterDefinition {
116
118
  * `runtime` only provide generic, validated staging.
117
119
  */
118
120
  resolveCompanionInputs?(inputPath: string): Promise<import("@moldesk/runtime").CompanionInputRef[]>;
121
+ /**
122
+ * Optional hook describing what a successful run produced, for the offline molecular
123
+ * report. Returns references to files (exact collected output paths, or staged input
124
+ * paths) plus scenes/panels — never bytes, HTML, or arbitrary paths; core resolves,
125
+ * verifies and embeds them. `undefined` means no report applies (not a failure);
126
+ * throwing a `MoldeskError` surfaces as a nonfatal warning on the run. Scientific
127
+ * associations (which confidence file belongs to which structure, what a rank means)
128
+ * belong here, matched against exact collected paths — never guessed by filename
129
+ * extension or global sort order.
130
+ */
131
+ describeVisualization?(context: RunContext, outputs: readonly CollectedOutput[]): Promise<ReportDescription | undefined>;
119
132
  }
120
133
  export { adapterCatalog, getAdapter, hasAdapter, validateAdapterCatalog } from "./catalog.js";
134
+ export { ReportBuilder } from "./visualization.js";
121
135
  export { validateParams, type ParamValidationResult } from "./params.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { adapterCatalog, getAdapter, hasAdapter, validateAdapterCatalog } from "./catalog.js";
2
+ export { ReportBuilder } from "./visualization.js";
2
3
  export { validateParams } from "./params.js";
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { MoldeskError } from "@moldesk/registry";
5
5
  import { runCommand } from "@moldesk/runtime";
6
6
  import { collectGlobOutputs } from "../outputs.js";
7
+ import { describeOpenDdeVisualization } from "./visualization.js";
7
8
  /**
8
9
  * Adapter-written copy of the job with every companion reference rewritten to its
9
10
  * staged path under the run's `input/` dir. Only written when the job references
@@ -469,6 +470,9 @@ export const openddeAdapter = {
469
470
  await exportPdb(context, collected.filter((o) => o.id === "structures").map((o) => o.path));
470
471
  return collectGlobOutputs(context.outputDir, OUTPUTS);
471
472
  },
473
+ async describeVisualization(context, outputs) {
474
+ return describeOpenDdeVisualization(context.inputPath, outputs);
475
+ },
472
476
  async verifyInstallation(context) {
473
477
  const venvBin = path.join(context.modelDir, ".venv", "bin");
474
478
  const python = path.join(venvBin, "python");
@@ -0,0 +1,8 @@
1
+ import type { ReportDescription } from "@moldesk/visualizer/contract";
2
+ import type { CollectedOutput } from "../index.js";
3
+ /**
4
+ * One scene per (job, seed, sample) mmCIF. The adapter-written `.pdb` files are the same
5
+ * prediction in another format, so they are never scenes. Summary confidence is matched
6
+ * by exact job/seed/sample file name; the per-atom `full_data` files are not shown.
7
+ */
8
+ export declare function describeOpenDdeVisualization(inputPath: string, outputs: readonly CollectedOutput[]): ReportDescription | undefined;
@@ -0,0 +1,53 @@
1
+ import path from "node:path";
2
+ import { ReportBuilder } from "../visualization.js";
3
+ const STRUCTURE = /^(.+)_sample_(\d+)\.cif$/;
4
+ const SEED_DIR = /^seed_(\d+)$/;
5
+ /**
6
+ * One scene per (job, seed, sample) mmCIF. The adapter-written `.pdb` files are the same
7
+ * prediction in another format, so they are never scenes. Summary confidence is matched
8
+ * by exact job/seed/sample file name; the per-atom `full_data` files are not shown.
9
+ */
10
+ export function describeOpenDdeVisualization(inputPath, outputs) {
11
+ const summaries = new Map(outputs.filter((o) => o.id === "confidence").map((o) => [o.path, o]));
12
+ const predictions = [];
13
+ for (const output of outputs) {
14
+ if (output.id !== "structures")
15
+ continue;
16
+ const name = STRUCTURE.exec(path.basename(output.path));
17
+ const dir = path.dirname(output.path);
18
+ const seed = SEED_DIR.exec(path.basename(path.dirname(dir)));
19
+ if (!name || !seed)
20
+ continue;
21
+ predictions.push({
22
+ path: output.path,
23
+ dir,
24
+ job: name[1],
25
+ seed: Number(seed[1]),
26
+ sample: Number(name[2]),
27
+ });
28
+ }
29
+ if (predictions.length === 0)
30
+ return undefined;
31
+ predictions.sort((a, b) => a.job.localeCompare(b.job) || a.seed - b.seed || a.sample - b.sample);
32
+ const jobs = new Set(predictions.map((p) => p.job));
33
+ const builder = new ReportBuilder();
34
+ const positionInJob = new Map();
35
+ for (const p of predictions) {
36
+ const k = (positionInJob.get(p.job) ?? 0) + 1;
37
+ positionInJob.set(p.job, k);
38
+ const label = `Prediction ${k} (seed ${p.seed}, sample ${p.sample})`;
39
+ const title = jobs.size > 1 ? `${p.job} — ${label}` : label;
40
+ builder.scene({
41
+ id: `scene-${builder.sceneCount + 1}`,
42
+ title,
43
+ structures: [
44
+ { id: "structure-1", resourceId: builder.resource({ kind: "output", path: p.path }, "mmcif", path.basename(p.path)) },
45
+ ],
46
+ });
47
+ const summary = summaries.get(path.join(p.dir, `${p.job}_summary_confidence_sample_${p.sample}.json`));
48
+ if (summary) {
49
+ builder.table(`Confidence — ${title}`, builder.resource({ kind: "output", path: summary.path }, "json", path.basename(summary.path)));
50
+ }
51
+ }
52
+ return builder.build(`OpenDDE — ${path.basename(inputPath)}`);
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { validateDescription } from "@moldesk/visualizer";
4
+ import { describeOpenDdeVisualization } from "./visualization.js";
5
+ const out = "/run/output";
6
+ const pred = (job, seed) => path.join(out, job, `seed_${seed}`, "predictions");
7
+ const cif = (job, seed, n) => ({ id: "structures", path: path.join(pred(job, seed), `${job}_sample_${n}.cif`) });
8
+ const pdb = (job, seed, n) => ({ id: "structures_pdb", path: path.join(pred(job, seed), `${job}_sample_${n}.pdb`) });
9
+ const summary = (job, seed, n) => ({ id: "confidence", path: path.join(pred(job, seed), `${job}_summary_confidence_sample_${n}.json`) });
10
+ const atoms = (job, seed, n) => ({ id: "atom_confidence", path: path.join(pred(job, seed), `${job}_full_data_sample_${n}.json`) });
11
+ describe("describeOpenDdeVisualization", () => {
12
+ it("returns undefined without structures", () => {
13
+ expect(describeOpenDdeVisualization("/in/j.json", [summary("t", 1, 0)])).toBeUndefined();
14
+ });
15
+ it("treats the exported PDB as the same prediction, not another scene", () => {
16
+ const d = describeOpenDdeVisualization("/in/j.json", [cif("t", 101, 0), pdb("t", 101, 0), summary("t", 101, 0), atoms("t", 101, 0)]);
17
+ expect(d.scenes).toHaveLength(1);
18
+ expect(d.resources.filter((r) => r.format === "mmcif")).toHaveLength(1);
19
+ expect(d.resources.some((r) => r.source.path.endsWith(".pdb"))).toBe(false);
20
+ expect(d.resources.some((r) => r.source.path.includes("full_data"))).toBe(false);
21
+ validateDescription(d);
22
+ });
23
+ it("orders by seed then sample numerically and matches confidence by exact job/seed/sample", () => {
24
+ const d = describeOpenDdeVisualization("/in/j.json", [
25
+ cif("t", 202, 0), cif("t", 101, 10), cif("t", 101, 2),
26
+ summary("t", 101, 2), summary("t", 202, 0),
27
+ ]);
28
+ expect(d.scenes.map((s) => s.title)).toEqual([
29
+ "Prediction 1 (seed 101, sample 2)",
30
+ "Prediction 2 (seed 101, sample 10)",
31
+ "Prediction 3 (seed 202, sample 0)",
32
+ ]);
33
+ const tables = d.panels.flatMap((p) => (p.kind === "table" ? [p] : []));
34
+ expect(tables.map((t) => t.title)).toEqual([
35
+ "Confidence — Prediction 1 (seed 101, sample 2)",
36
+ "Confidence — Prediction 3 (seed 202, sample 0)",
37
+ ]);
38
+ expect(d.scenes.every((s) => s.rank === undefined)).toBe(true);
39
+ });
40
+ it("prefixes the job name when several jobs ran, and skips non-prediction layouts", () => {
41
+ const stray = { id: "structures", path: path.join(out, "t", "not_seed", "predictions", "t_sample_0.cif") };
42
+ const d = describeOpenDdeVisualization("/in/j.json", [cif("a", 1, 0), cif("b", 1, 0), stray]);
43
+ expect(d.scenes.map((s) => s.title)).toEqual(["a — Prediction 1 (seed 1, sample 0)", "b — Prediction 1 (seed 1, sample 0)"]);
44
+ });
45
+ });
@@ -0,0 +1,22 @@
1
+ import type { ReportDescription, ResourceFormat, Scene, SourceRef } from "@moldesk/visualizer/contract";
2
+ /**
3
+ * Small helper for adapters' `describeVisualization`: assigns report-local resource ids,
4
+ * reuses one resource when several scenes reference the same file, and puts the structure
5
+ * panel first. Holds descriptions only — file reads and verification happen in core.
6
+ */
7
+ export declare class ReportBuilder {
8
+ private readonly resources;
9
+ private readonly byKey;
10
+ private readonly scenes;
11
+ private readonly panels;
12
+ /** Adds (or reuses) a resource for an exact collected output or staged input path. */
13
+ resource(source: SourceRef, format: ResourceFormat, label: string): string;
14
+ scene(scene: Scene): void;
15
+ /** A table over one JSON/CSV resource; empty `columns` shows every field present in the data. */
16
+ table(title: string, resourceId: string, columns?: Array<{
17
+ field: string;
18
+ label: string;
19
+ }>, rowsKey?: string): void;
20
+ get sceneCount(): number;
21
+ build(title: string): ReportDescription;
22
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Small helper for adapters' `describeVisualization`: assigns report-local resource ids,
3
+ * reuses one resource when several scenes reference the same file, and puts the structure
4
+ * panel first. Holds descriptions only — file reads and verification happen in core.
5
+ */
6
+ export class ReportBuilder {
7
+ resources = [];
8
+ byKey = new Map();
9
+ scenes = [];
10
+ panels = [];
11
+ /** Adds (or reuses) a resource for an exact collected output or staged input path. */
12
+ resource(source, format, label) {
13
+ const key = `${source.kind}\0${source.path}`;
14
+ const existing = this.byKey.get(key);
15
+ if (existing)
16
+ return existing;
17
+ const id = `r${this.resources.length + 1}`;
18
+ this.resources.push({ id, source, format, label });
19
+ this.byKey.set(key, id);
20
+ return id;
21
+ }
22
+ scene(scene) {
23
+ this.scenes.push(scene);
24
+ }
25
+ /** A table over one JSON/CSV resource; empty `columns` shows every field present in the data. */
26
+ table(title, resourceId, columns = [], rowsKey) {
27
+ this.panels.push({
28
+ id: `table-${this.panels.length + 1}`,
29
+ kind: "table",
30
+ title,
31
+ dataset: { resourceId, ...(rowsKey === undefined ? {} : { rowsKey }) },
32
+ columns,
33
+ });
34
+ }
35
+ get sceneCount() {
36
+ return this.scenes.length;
37
+ }
38
+ build(title) {
39
+ const panels = this.scenes.length > 0
40
+ ? [{ id: "structures", kind: "structure", sceneIds: this.scenes.map((s) => s.id) }, ...this.panels]
41
+ : this.panels;
42
+ return { title, resources: this.resources, scenes: this.scenes, panels };
43
+ }
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getAdapter } from "./index.js";
3
+ import { ReportBuilder } from "./visualization.js";
4
+ describe("adapter visualization hooks", () => {
5
+ it.each(["boltz", "opendde", "bindcraft2", "diffdock"])("%s describes its structure outputs", (name) => {
6
+ expect(typeof getAdapter(name)?.describeVisualization).toBe("function");
7
+ });
8
+ it.each(["proteinmpnn", "ligandmpnn"])("%s produces sequences, not coordinates, so it has no automatic report", (name) => {
9
+ expect(getAdapter(name)?.describeVisualization).toBeUndefined();
10
+ });
11
+ });
12
+ describe("ReportBuilder", () => {
13
+ it("reuses one resource for the same file and puts the structure panel first", () => {
14
+ const b = new ReportBuilder();
15
+ const a = b.resource({ kind: "input", path: "/r.pdb" }, "pdb", "r.pdb");
16
+ expect(b.resource({ kind: "input", path: "/r.pdb" }, "pdb", "r.pdb")).toBe(a);
17
+ // Same path, different kind is a different source.
18
+ expect(b.resource({ kind: "output", path: "/r.pdb" }, "pdb", "r.pdb")).not.toBe(a);
19
+ b.scene({ id: "s1", title: "S", structures: [{ id: "x", resourceId: a }] });
20
+ b.table("T", a);
21
+ const d = b.build("Title");
22
+ expect(d.panels.map((p) => p.kind)).toEqual(["structure", "table"]);
23
+ expect(d.resources).toHaveLength(2);
24
+ });
25
+ it("builds a chart-only description without a structure panel", () => {
26
+ const b = new ReportBuilder();
27
+ b.table("T", b.resource({ kind: "output", path: "/a.json" }, "json", "a.json"), [], "rows");
28
+ const d = b.build("t");
29
+ expect(d.scenes).toEqual([]);
30
+ expect(d.panels).toEqual([{ id: "table-1", kind: "table", title: "T", dataset: { resourceId: "r1", rowsKey: "rows" }, columns: [] }]);
31
+ });
32
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moldesk/adapters",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Thin model-specific adapters for MoleculeDesk (translation/verification only)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,8 +20,9 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "yaml": "^2.5.1",
23
- "@moldesk/registry": "0.0.3",
24
- "@moldesk/runtime": "0.0.3"
23
+ "@moldesk/registry": "0.0.4",
24
+ "@moldesk/visualizer": "0.0.4",
25
+ "@moldesk/runtime": "0.0.4"
25
26
  },
26
27
  "devDependencies": {
27
28
  "typescript": "^5.6.3",