diff --git a/research-disclosure-assistant/README.md b/research-disclosure-assistant/README.md
new file mode 100644
index 00000000..e3efcc40
--- /dev/null
+++ b/research-disclosure-assistant/README.md
@@ -0,0 +1,17 @@
+# Research Disclosure Assistant
+
+Self-contained SCIBASE AI-Assisted Research Tools slice for issue #13. The assistant checks funding statements, sponsor role clarity, competing-interest disclosures, author contribution roles, and corresponding-author accountability before AI peer-review aid output is trusted.
+
+## Why this slice is distinct
+
+Existing #13 submissions cover broad AI tool suites, evidence-grounded summaries, citation metadata/context/style/diversity/retraction/venue checks, methods reproducibility, protocol deviation, statistical power, multiple-comparison control, terminology/unit consistency, contradictory evidence, novelty overlap, and reporting-guideline checklists. This module focuses only on disclosure completeness and accountability.
+
+## Run
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+Demo artifacts are written to `reports/`, including JSON, Markdown, SVG, GIF, and MP4 files.
diff --git a/research-disclosure-assistant/demo.js b/research-disclosure-assistant/demo.js
new file mode 100644
index 00000000..d0571320
--- /dev/null
+++ b/research-disclosure-assistant/demo.js
@@ -0,0 +1,59 @@
+const fs = require("fs");
+const path = require("path");
+
+const { assessResearchDisclosures } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+function markdownReport(name, report) {
+ const findings = report.findings.length
+ ? report.findings
+ .map((item) => `- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`)
+ .join("\n")
+ : "- No disclosure findings.";
+ return `# ${report.title}
+
+Scenario: ${name}
+
+Decision: ${report.decision.toUpperCase()}
+
+Reviewed ${report.summary.authorsReviewed} authors, ${report.summary.fundersReviewed} funders, and ${report.summary.disclosuresReviewed} disclosure records.
+
+## Findings
+
+${findings}
+
+## Release Criteria
+
+${report.releaseCriteria.map((item) => `- ${item}`).join("\n")}
+`;
+}
+
+function svgReport(report) {
+ const color = report.decision === "hold" ? "#dc2626" : report.decision === "revise" ? "#d97706" : "#16a34a";
+ return ``;
+}
+
+for (const [name, packet] of [
+ ["clean-packet", cleanPacket],
+ ["risky-packet", riskyPacket],
+]) {
+ const report = assessResearchDisclosures(packet);
+ fs.writeFileSync(path.join(reportsDir, `${name}.json`), JSON.stringify(report, null, 2));
+ fs.writeFileSync(path.join(reportsDir, `${name}.md`), markdownReport(name, report));
+ fs.writeFileSync(path.join(reportsDir, `${name}.svg`), svgReport(report));
+ console.log(`${name}: ${report.decision} (${report.summary.findings} findings)`);
+}
diff --git a/research-disclosure-assistant/demo_video.py b/research-disclosure-assistant/demo_video.py
new file mode 100644
index 00000000..5ef1196d
--- /dev/null
+++ b/research-disclosure-assistant/demo_video.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+import imageio.v3 as iio
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+ROOT = Path(__file__).resolve().parent
+REPORTS = ROOT / "reports"
+REPORTS.mkdir(exist_ok=True)
+
+
+def font(size):
+ for name in ("arial.ttf", "segoeui.ttf"):
+ try:
+ return ImageFont.truetype(name, size)
+ except OSError:
+ pass
+ return ImageFont.load_default()
+
+
+slides = [
+ ("Research Disclosure Assistant", "AI-Assisted Research Tools #13"),
+ ("Checks", "funding statements + sponsor role clarity"),
+ ("Checks", "COI declarations + industry relationships"),
+ ("Decision", "hold AI review output until disclosures are complete"),
+]
+
+frames = []
+for index, (title, subtitle) in enumerate(slides, start=1):
+ image = Image.new("RGB", (960, 544), "#102033")
+ draw = ImageDraw.Draw(image)
+ draw.rectangle((46, 54, 914, 490), outline="#facc15", width=3)
+ draw.text((82, 124), title, fill="#f8fafc", font=font(40))
+ draw.text((82, 206), subtitle, fill="#fef9c3", font=font(26))
+ draw.rectangle((82, 326, 770, 382), fill="#854d0e")
+ draw.text((104, 342), "disclosure gaps block trusted peer-review output", fill="#fffbeb", font=font(22))
+ draw.text((82, 438), f"Slide {index}/4 - synthetic reviewer artifact", fill="#cbd5e1", font=font(20))
+ frames.extend([image] * 14)
+
+gif_path = REPORTS / "demo.gif"
+mp4_path = REPORTS / "demo.mp4"
+frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0)
+iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264")
+print(f"wrote {gif_path}")
+print(f"wrote {mp4_path}")
diff --git a/research-disclosure-assistant/index.js b/research-disclosure-assistant/index.js
new file mode 100644
index 00000000..934a199b
--- /dev/null
+++ b/research-disclosure-assistant/index.js
@@ -0,0 +1,231 @@
+const HIGH = "high";
+const MEDIUM = "medium";
+const LOW = "low";
+
+const REQUIRED_CONTRIBUTIONS = new Set([
+ "Conceptualization",
+ "Methodology",
+ "Software",
+ "Validation",
+ "Formal analysis",
+ "Data curation",
+ "Writing - original draft",
+ "Writing - review and editing",
+]);
+
+function requireString(value, field) {
+ if (typeof value !== "string" || value.trim() === "") {
+ throw new TypeError(`${field} must be a non-empty string`);
+ }
+ return value.trim();
+}
+
+function asArray(value, field) {
+ if (!Array.isArray(value)) {
+ throw new TypeError(`${field} must be an array`);
+ }
+ return value;
+}
+
+function normalizePacket(raw) {
+ return {
+ manuscriptId: requireString(raw.manuscriptId, "manuscriptId"),
+ title: requireString(raw.title, "title"),
+ fundingStatement: {
+ funders: asArray(raw.fundingStatement?.funders || [], "fundingStatement.funders").map(String),
+ grantIds: asArray(raw.fundingStatement?.grantIds || [], "fundingStatement.grantIds").map(String),
+ sponsorRole: String(raw.fundingStatement?.sponsorRole || "").trim(),
+ },
+ competingInterests: asArray(raw.competingInterests || [], "competingInterests").map((item) => ({
+ authorId: requireString(item.authorId, "competingInterests.authorId"),
+ statement: String(item.statement || "").trim(),
+ })),
+ acknowledgements: asArray(raw.acknowledgements || [], "acknowledgements").map(String),
+ authors: asArray(raw.authors || [], "authors").map((author) => ({
+ id: requireString(author.id, "author.id"),
+ name: requireString(author.name, "author.name"),
+ corresponding: Boolean(author.corresponding),
+ contributions: asArray(author.contributions || [], "author.contributions").map(String),
+ affiliations: asArray(author.affiliations || [], "author.affiliations").map(String),
+ industryRelationships: asArray(author.industryRelationships || [], "author.industryRelationships").map(String),
+ })),
+ };
+}
+
+function finding(code, severity, message, remediation, subject = "manuscript") {
+ return { code, severity, subject, message, remediation };
+}
+
+function hasNoConflictStatement(statement) {
+ return /no competing|none declared|no conflict/i.test(statement);
+}
+
+function relationshipDisclosed(relationship, statement) {
+ return statement.toLowerCase().includes(relationship.toLowerCase());
+}
+
+function assessResearchDisclosures(rawPacket) {
+ const packet = normalizePacket(rawPacket);
+ const findings = [];
+ const disclosures = new Map(packet.competingInterests.map((item) => [item.authorId, item.statement]));
+ const authorIds = new Set(packet.authors.map((author) => author.id));
+ const acknowledgementText = packet.acknowledgements.join(" ").toLowerCase();
+
+ if (packet.fundingStatement.funders.length === 0) {
+ findings.push(
+ finding(
+ "MISSING_FUNDER_STATEMENT",
+ HIGH,
+ "No funder is listed for the manuscript.",
+ "Add an explicit funding or no-funding statement before AI peer-review output is trusted."
+ )
+ );
+ }
+
+ if (packet.fundingStatement.funders.length > 0 && packet.fundingStatement.grantIds.length === 0) {
+ findings.push(
+ finding(
+ "MISSING_GRANT_IDENTIFIERS",
+ MEDIUM,
+ "Funding is declared without grant, award, or contract identifiers.",
+ "Add grant identifiers or document why the funder did not issue one."
+ )
+ );
+ }
+
+ if (packet.fundingStatement.funders.length > 0 && !packet.fundingStatement.sponsorRole) {
+ findings.push(
+ finding(
+ "SPONSOR_ROLE_UNCLEAR",
+ HIGH,
+ "Sponsor role in study design, analysis, writing, or publication decision is not stated.",
+ "State the sponsor role explicitly, including whether the funder reviewed or influenced the manuscript."
+ )
+ );
+ }
+
+ for (const author of packet.authors) {
+ const statement = disclosures.get(author.id) || "";
+ if (!statement) {
+ findings.push(
+ finding(
+ "MISSING_COMPETING_INTEREST",
+ HIGH,
+ `${author.name} has no competing-interest statement.`,
+ "Add a competing-interest declaration for every listed author.",
+ author.id
+ )
+ );
+ }
+
+ for (const relationship of author.industryRelationships) {
+ if (hasNoConflictStatement(statement) || !relationshipDisclosed(relationship, statement)) {
+ findings.push(
+ finding(
+ "UNDISCLOSED_INDUSTRY_RELATIONSHIP",
+ HIGH,
+ `${author.name} lists an industry relationship with ${relationship}, but the conflict statement does not disclose it.`,
+ "Revise the competing-interest declaration or remove/justify the relationship record.",
+ author.id
+ )
+ );
+ }
+ }
+
+ if (author.contributions.length === 0) {
+ findings.push(
+ finding(
+ "MISSING_AUTHOR_CONTRIBUTIONS",
+ MEDIUM,
+ `${author.name} has no CRediT-style contributions listed.`,
+ "Add at least one contributor role for every author before reviewer packets are released.",
+ author.id
+ )
+ );
+ }
+
+ const unrecognized = author.contributions.filter((role) => !REQUIRED_CONTRIBUTIONS.has(role));
+ if (unrecognized.length > 0) {
+ findings.push(
+ finding(
+ "UNRECOGNIZED_CONTRIBUTION_ROLE",
+ LOW,
+ `${author.name} has non-standard contribution roles: ${unrecognized.join(", ")}.`,
+ "Map non-standard roles to the configured contribution taxonomy or document the extension.",
+ author.id
+ )
+ );
+ }
+ }
+
+ for (const disclosure of packet.competingInterests) {
+ if (!authorIds.has(disclosure.authorId)) {
+ findings.push(
+ finding(
+ "ORPHAN_COMPETING_INTEREST",
+ MEDIUM,
+ `Competing-interest statement references unknown author ${disclosure.authorId}.`,
+ "Retarget or remove orphan disclosure records before export.",
+ disclosure.authorId
+ )
+ );
+ }
+ }
+
+ for (const funder of packet.fundingStatement.funders) {
+ const namedInAcknowledgements = acknowledgementText.includes(funder.toLowerCase());
+ if (namedInAcknowledgements && !packet.fundingStatement.sponsorRole) {
+ findings.push(
+ finding(
+ "ACKNOWLEDGED_SPONSOR_ROLE_MISMATCH",
+ MEDIUM,
+ `${funder} is acknowledged in the manuscript but sponsor role is absent from the funding statement.`,
+ "Reconcile acknowledgements with funding and sponsor-role disclosures."
+ )
+ );
+ }
+ }
+
+ const hasCorrespondingAuthor = packet.authors.some((author) => author.corresponding);
+ if (!hasCorrespondingAuthor) {
+ findings.push(
+ finding(
+ "MISSING_CORRESPONDING_AUTHOR",
+ HIGH,
+ "No corresponding author is marked for accountability and disclosure follow-up.",
+ "Mark one or more corresponding authors before submission or AI-generated review packets proceed."
+ )
+ );
+ }
+
+ const high = findings.filter((item) => item.severity === HIGH).length;
+ const medium = findings.filter((item) => item.severity === MEDIUM).length;
+ const decision = high > 0 ? "hold" : medium > 0 ? "revise" : "release";
+
+ return {
+ manuscriptId: packet.manuscriptId,
+ title: packet.title,
+ decision,
+ summary: {
+ authorsReviewed: packet.authors.length,
+ fundersReviewed: packet.fundingStatement.funders.length,
+ disclosuresReviewed: packet.competingInterests.length,
+ findings: findings.length,
+ high,
+ medium,
+ low: findings.filter((item) => item.severity === LOW).length,
+ },
+ findings,
+ releaseCriteria: [
+ "Funder and grant statements are explicit before AI peer-review aid output is trusted.",
+ "Sponsor role in design, analysis, writing, review, and publication decision is disclosed.",
+ "Every author has a competing-interest statement matching known relationships.",
+ "CRediT-style author contributions and corresponding-author accountability are present.",
+ ],
+ };
+}
+
+module.exports = {
+ assessResearchDisclosures,
+ normalizePacket,
+};
diff --git a/research-disclosure-assistant/package.json b/research-disclosure-assistant/package.json
new file mode 100644
index 00000000..c54c0dd4
--- /dev/null
+++ b/research-disclosure-assistant/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "research-disclosure-assistant",
+ "version": "1.0.0",
+ "description": "Funding, conflict-of-interest, and author contribution assistant for SCIBASE issue #13",
+ "main": "index.js",
+ "type": "commonjs",
+ "scripts": {
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "python demo_video.py"
+ },
+ "license": "MIT"
+}
diff --git a/research-disclosure-assistant/reports/clean-packet.json b/research-disclosure-assistant/reports/clean-packet.json
new file mode 100644
index 00000000..dc6bb73b
--- /dev/null
+++ b/research-disclosure-assistant/reports/clean-packet.json
@@ -0,0 +1,21 @@
+{
+ "manuscriptId": "peer-review-disclosure-001",
+ "title": "AI-assisted polymer screening",
+ "decision": "release",
+ "summary": {
+ "authorsReviewed": 3,
+ "fundersReviewed": 2,
+ "disclosuresReviewed": 3,
+ "findings": 0,
+ "high": 0,
+ "medium": 0,
+ "low": 0
+ },
+ "findings": [],
+ "releaseCriteria": [
+ "Funder and grant statements are explicit before AI peer-review aid output is trusted.",
+ "Sponsor role in design, analysis, writing, review, and publication decision is disclosed.",
+ "Every author has a competing-interest statement matching known relationships.",
+ "CRediT-style author contributions and corresponding-author accountability are present."
+ ]
+}
\ No newline at end of file
diff --git a/research-disclosure-assistant/reports/clean-packet.md b/research-disclosure-assistant/reports/clean-packet.md
new file mode 100644
index 00000000..afeeb079
--- /dev/null
+++ b/research-disclosure-assistant/reports/clean-packet.md
@@ -0,0 +1,18 @@
+# AI-assisted polymer screening
+
+Scenario: clean-packet
+
+Decision: RELEASE
+
+Reviewed 3 authors, 2 funders, and 3 disclosure records.
+
+## Findings
+
+- No disclosure findings.
+
+## Release Criteria
+
+- Funder and grant statements are explicit before AI peer-review aid output is trusted.
+- Sponsor role in design, analysis, writing, review, and publication decision is disclosed.
+- Every author has a competing-interest statement matching known relationships.
+- CRediT-style author contributions and corresponding-author accountability are present.
diff --git a/research-disclosure-assistant/reports/clean-packet.svg b/research-disclosure-assistant/reports/clean-packet.svg
new file mode 100644
index 00000000..7c555c7f
--- /dev/null
+++ b/research-disclosure-assistant/reports/clean-packet.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/research-disclosure-assistant/reports/demo.gif b/research-disclosure-assistant/reports/demo.gif
new file mode 100644
index 00000000..4adab082
Binary files /dev/null and b/research-disclosure-assistant/reports/demo.gif differ
diff --git a/research-disclosure-assistant/reports/demo.mp4 b/research-disclosure-assistant/reports/demo.mp4
new file mode 100644
index 00000000..0440ffa3
Binary files /dev/null and b/research-disclosure-assistant/reports/demo.mp4 differ
diff --git a/research-disclosure-assistant/reports/risky-packet.json b/research-disclosure-assistant/reports/risky-packet.json
new file mode 100644
index 00000000..8ae87e91
--- /dev/null
+++ b/research-disclosure-assistant/reports/risky-packet.json
@@ -0,0 +1,71 @@
+{
+ "manuscriptId": "peer-review-disclosure-002",
+ "title": "Closed-loop catalyst ranking",
+ "decision": "hold",
+ "summary": {
+ "authorsReviewed": 2,
+ "fundersReviewed": 1,
+ "disclosuresReviewed": 2,
+ "findings": 7,
+ "high": 4,
+ "medium": 3,
+ "low": 0
+ },
+ "findings": [
+ {
+ "code": "MISSING_GRANT_IDENTIFIERS",
+ "severity": "medium",
+ "subject": "manuscript",
+ "message": "Funding is declared without grant, award, or contract identifiers.",
+ "remediation": "Add grant identifiers or document why the funder did not issue one."
+ },
+ {
+ "code": "SPONSOR_ROLE_UNCLEAR",
+ "severity": "high",
+ "subject": "manuscript",
+ "message": "Sponsor role in study design, analysis, writing, or publication decision is not stated.",
+ "remediation": "State the sponsor role explicitly, including whether the funder reviewed or influenced the manuscript."
+ },
+ {
+ "code": "UNDISCLOSED_INDUSTRY_RELATIONSHIP",
+ "severity": "high",
+ "subject": "a1",
+ "message": "Ari Chen lists an industry relationship with NanoCatalyst Inc., but the conflict statement does not disclose it.",
+ "remediation": "Revise the competing-interest declaration or remove/justify the relationship record."
+ },
+ {
+ "code": "MISSING_COMPETING_INTEREST",
+ "severity": "high",
+ "subject": "a2",
+ "message": "Bea Singh has no competing-interest statement.",
+ "remediation": "Add a competing-interest declaration for every listed author."
+ },
+ {
+ "code": "MISSING_AUTHOR_CONTRIBUTIONS",
+ "severity": "medium",
+ "subject": "a2",
+ "message": "Bea Singh has no CRediT-style contributions listed.",
+ "remediation": "Add at least one contributor role for every author before reviewer packets are released."
+ },
+ {
+ "code": "ACKNOWLEDGED_SPONSOR_ROLE_MISMATCH",
+ "severity": "medium",
+ "subject": "manuscript",
+ "message": "NanoCatalyst Inc. is acknowledged in the manuscript but sponsor role is absent from the funding statement.",
+ "remediation": "Reconcile acknowledgements with funding and sponsor-role disclosures."
+ },
+ {
+ "code": "MISSING_CORRESPONDING_AUTHOR",
+ "severity": "high",
+ "subject": "manuscript",
+ "message": "No corresponding author is marked for accountability and disclosure follow-up.",
+ "remediation": "Mark one or more corresponding authors before submission or AI-generated review packets proceed."
+ }
+ ],
+ "releaseCriteria": [
+ "Funder and grant statements are explicit before AI peer-review aid output is trusted.",
+ "Sponsor role in design, analysis, writing, review, and publication decision is disclosed.",
+ "Every author has a competing-interest statement matching known relationships.",
+ "CRediT-style author contributions and corresponding-author accountability are present."
+ ]
+}
\ No newline at end of file
diff --git a/research-disclosure-assistant/reports/risky-packet.md b/research-disclosure-assistant/reports/risky-packet.md
new file mode 100644
index 00000000..97868e79
--- /dev/null
+++ b/research-disclosure-assistant/reports/risky-packet.md
@@ -0,0 +1,24 @@
+# Closed-loop catalyst ranking
+
+Scenario: risky-packet
+
+Decision: HOLD
+
+Reviewed 2 authors, 1 funders, and 2 disclosure records.
+
+## Findings
+
+- MEDIUM MISSING_GRANT_IDENTIFIERS: Funding is declared without grant, award, or contract identifiers.
+- HIGH SPONSOR_ROLE_UNCLEAR: Sponsor role in study design, analysis, writing, or publication decision is not stated.
+- HIGH UNDISCLOSED_INDUSTRY_RELATIONSHIP: Ari Chen lists an industry relationship with NanoCatalyst Inc., but the conflict statement does not disclose it.
+- HIGH MISSING_COMPETING_INTEREST: Bea Singh has no competing-interest statement.
+- MEDIUM MISSING_AUTHOR_CONTRIBUTIONS: Bea Singh has no CRediT-style contributions listed.
+- MEDIUM ACKNOWLEDGED_SPONSOR_ROLE_MISMATCH: NanoCatalyst Inc. is acknowledged in the manuscript but sponsor role is absent from the funding statement.
+- HIGH MISSING_CORRESPONDING_AUTHOR: No corresponding author is marked for accountability and disclosure follow-up.
+
+## Release Criteria
+
+- Funder and grant statements are explicit before AI peer-review aid output is trusted.
+- Sponsor role in design, analysis, writing, review, and publication decision is disclosed.
+- Every author has a competing-interest statement matching known relationships.
+- CRediT-style author contributions and corresponding-author accountability are present.
diff --git a/research-disclosure-assistant/reports/risky-packet.svg b/research-disclosure-assistant/reports/risky-packet.svg
new file mode 100644
index 00000000..05cdd359
--- /dev/null
+++ b/research-disclosure-assistant/reports/risky-packet.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/research-disclosure-assistant/requirements-map.md b/research-disclosure-assistant/requirements-map.md
new file mode 100644
index 00000000..ddc7fdfe
--- /dev/null
+++ b/research-disclosure-assistant/requirements-map.md
@@ -0,0 +1,11 @@
+# Requirements Map
+
+Issue #13 asks for AI-assisted research tools, including a peer-review aid that checks technical and compliance issues before sharing or submission.
+
+| Issue capability | This implementation |
+| --- | --- |
+| AI Peer Review Aid | Produces deterministic disclosure diagnostics for manuscript peer-review packets. |
+| Compliance checks | Checks funder statements, sponsor-role clarity, competing interests, author contributions, and accountability gaps. |
+| Custom reviewer diagnostics | Emits finding codes, severities, affected subjects, and remediation guidance. |
+| Research quality workflow | Uses `release`, `revise`, or `hold` decisions before AI review output is trusted. |
+| Safe MVP behavior | Uses synthetic packets only, with no credentials, external AI APIs, private manuscripts, or live compliance systems. |
diff --git a/research-disclosure-assistant/sample-data.js b/research-disclosure-assistant/sample-data.js
new file mode 100644
index 00000000..98052825
--- /dev/null
+++ b/research-disclosure-assistant/sample-data.js
@@ -0,0 +1,82 @@
+const cleanPacket = {
+ manuscriptId: "peer-review-disclosure-001",
+ title: "AI-assisted polymer screening",
+ fundingStatement: {
+ funders: ["National Science Fund", "Open Materials Institute"],
+ grantIds: ["NSF-2244", "OMI-18"],
+ sponsorRole: "Funders had no role in study design, analysis, manuscript preparation, or publication decision.",
+ },
+ competingInterests: [
+ { authorId: "a1", statement: "No competing interests declared." },
+ { authorId: "a2", statement: "No competing interests declared." },
+ { authorId: "a3", statement: "Consulting fees from NanoCatalyst Inc.; unrelated to this work." },
+ ],
+ acknowledgements: ["NanoCatalyst Inc. provided access to a public benchmark dataset."],
+ authors: [
+ {
+ id: "a1",
+ name: "Ari Chen",
+ corresponding: true,
+ contributions: ["Conceptualization", "Methodology", "Writing - original draft"],
+ affiliations: ["Open Materials Institute"],
+ industryRelationships: [],
+ },
+ {
+ id: "a2",
+ name: "Bea Singh",
+ corresponding: false,
+ contributions: ["Software", "Validation", "Formal analysis"],
+ affiliations: ["State University"],
+ industryRelationships: [],
+ },
+ {
+ id: "a3",
+ name: "Cam Reed",
+ corresponding: false,
+ contributions: ["Data curation", "Writing - review and editing"],
+ affiliations: ["State University"],
+ industryRelationships: ["NanoCatalyst Inc."],
+ },
+ ],
+};
+
+const riskyPacket = {
+ manuscriptId: "peer-review-disclosure-002",
+ title: "Closed-loop catalyst ranking",
+ fundingStatement: {
+ funders: ["NanoCatalyst Inc."],
+ grantIds: [],
+ sponsorRole: "",
+ },
+ competingInterests: [
+ { authorId: "a1", statement: "No competing interests declared." },
+ { authorId: "a2", statement: "" },
+ ],
+ acknowledgements: [
+ "NanoCatalyst Inc. supplied proprietary screening data and reviewed the manuscript.",
+ "Dana Fox helped prepare the final figures.",
+ ],
+ authors: [
+ {
+ id: "a1",
+ name: "Ari Chen",
+ corresponding: false,
+ contributions: ["Conceptualization"],
+ affiliations: ["Open Materials Institute"],
+ industryRelationships: ["NanoCatalyst Inc."],
+ },
+ {
+ id: "a2",
+ name: "Bea Singh",
+ corresponding: false,
+ contributions: [],
+ affiliations: ["State University"],
+ industryRelationships: [],
+ },
+ ],
+};
+
+module.exports = {
+ cleanPacket,
+ riskyPacket,
+};
diff --git a/research-disclosure-assistant/test.js b/research-disclosure-assistant/test.js
new file mode 100644
index 00000000..afd5b9b5
--- /dev/null
+++ b/research-disclosure-assistant/test.js
@@ -0,0 +1,46 @@
+const assert = require("assert");
+
+const { assessResearchDisclosures, normalizePacket } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const clean = assessResearchDisclosures(cleanPacket);
+assert.strictEqual(clean.decision, "release");
+assert.strictEqual(clean.summary.findings, 0);
+
+const risky = assessResearchDisclosures(riskyPacket);
+assert.strictEqual(risky.decision, "hold");
+for (const code of [
+ "MISSING_GRANT_IDENTIFIERS",
+ "SPONSOR_ROLE_UNCLEAR",
+ "UNDISCLOSED_INDUSTRY_RELATIONSHIP",
+ "MISSING_COMPETING_INTEREST",
+ "MISSING_AUTHOR_CONTRIBUTIONS",
+ "ACKNOWLEDGED_SPONSOR_ROLE_MISMATCH",
+ "MISSING_CORRESPONDING_AUTHOR",
+]) {
+ assert(risky.findings.some((finding) => finding.code === code), `missing ${code}`);
+}
+
+const noFunding = assessResearchDisclosures({
+ ...cleanPacket,
+ fundingStatement: { funders: [], grantIds: [], sponsorRole: "" },
+});
+assert.strictEqual(noFunding.decision, "hold");
+assert(noFunding.findings.some((finding) => finding.code === "MISSING_FUNDER_STATEMENT"));
+
+const orphanDisclosure = assessResearchDisclosures({
+ ...cleanPacket,
+ competingInterests: [
+ ...cleanPacket.competingInterests,
+ { authorId: "ghost", statement: "No competing interests declared." },
+ ],
+});
+assert.strictEqual(orphanDisclosure.decision, "revise");
+assert(orphanDisclosure.findings.some((finding) => finding.code === "ORPHAN_COMPETING_INTEREST"));
+
+assert.throws(
+ () => normalizePacket({ ...cleanPacket, manuscriptId: "" }),
+ /manuscriptId must be a non-empty string/
+);
+
+console.log("research disclosure assistant tests passed");