From 7efa51c21ada21aed8787bbe234a0da9fc2067dd Mon Sep 17 00:00:00 2001
From: "tho.nguyen" <91511523+haki203@users.noreply.github.com>
Date: Fri, 22 May 2026 21:37:36 +0700
Subject: [PATCH] Add biomethods provenance assistant
---
biomethods-provenance-assistant/README.md | 31 ++
biomethods-provenance-assistant/demo.js | 131 +++++++
biomethods-provenance-assistant/index.js | 341 ++++++++++++++++++
.../reports/biomethods-provenance-packet.json | 148 ++++++++
.../reports/demo.mp4 | Bin 0 -> 8481 bytes
.../reports/reviewer-report.md | 74 ++++
.../reports/summary.svg | 23 ++
.../sample-data.js | 106 ++++++
biomethods-provenance-assistant/test.js | 160 ++++++++
9 files changed, 1014 insertions(+)
create mode 100644 biomethods-provenance-assistant/README.md
create mode 100644 biomethods-provenance-assistant/demo.js
create mode 100644 biomethods-provenance-assistant/index.js
create mode 100644 biomethods-provenance-assistant/reports/biomethods-provenance-packet.json
create mode 100644 biomethods-provenance-assistant/reports/demo.mp4
create mode 100644 biomethods-provenance-assistant/reports/reviewer-report.md
create mode 100644 biomethods-provenance-assistant/reports/summary.svg
create mode 100644 biomethods-provenance-assistant/sample-data.js
create mode 100644 biomethods-provenance-assistant/test.js
diff --git a/biomethods-provenance-assistant/README.md b/biomethods-provenance-assistant/README.md
new file mode 100644
index 00000000..5727c8ff
--- /dev/null
+++ b/biomethods-provenance-assistant/README.md
@@ -0,0 +1,31 @@
+# Biomethods Provenance Assistant
+
+Self-contained AI-assisted research tools slice for issue #13. It gives peer reviewers a deterministic biology methods provenance packet before manuscript review.
+
+## What It Checks
+
+- Biospecimen source, organism, strain, sex, and age context.
+- Cell-line source, authentication method/date, and mycoplasma test evidence.
+- Critical reagent vendor, catalog, lot, and antibody RRID traceability.
+- Reagent batch conflicts across method sections that need author explanation.
+
+## Outputs
+
+- `reports/biomethods-provenance-packet.json`: structured reviewer decisions and findings.
+- `reports/reviewer-report.md`: readable reviewer report for each synthetic scenario.
+- `reports/summary.svg`: visual decision summary.
+- `reports/demo.mp4`: short demo artifact for Algora review.
+
+## Local Verification
+
+```bash
+node --test biomethods-provenance-assistant/test.js
+node biomethods-provenance-assistant/demo.js
+node --check biomethods-provenance-assistant/index.js
+node --check biomethods-provenance-assistant/sample-data.js
+node --check biomethods-provenance-assistant/demo.js
+node --check biomethods-provenance-assistant/test.js
+```
+
+The module is dependency-free, uses synthetic data only, and makes no network calls.
+Set `FFMPEG_PATH` to an ffmpeg binary before running `demo.js` if regenerating `reports/demo.mp4`.
diff --git a/biomethods-provenance-assistant/demo.js b/biomethods-provenance-assistant/demo.js
new file mode 100644
index 00000000..539be06d
--- /dev/null
+++ b/biomethods-provenance-assistant/demo.js
@@ -0,0 +1,131 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const {spawnSync} = require('node:child_process');
+
+const {
+ evaluateBiomethodsProvenance,
+ buildReviewerReport,
+} = require('./index');
+const {scenarios} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+const framesDir = path.join(reportsDir, 'frames');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const evaluations = scenarios.map((scenario) => ({
+ scenario: scenario.name,
+ ...evaluateBiomethodsProvenance(scenario),
+}));
+
+const packetJson = JSON.stringify(evaluations, null, 2);
+const reviewerReport = evaluations.map(buildReviewerReport).join('\n---\n');
+const decisionCounts = evaluations.reduce((counts, item) => {
+ counts[item.decision] = (counts[item.decision] || 0) + 1;
+ return counts;
+}, {});
+const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0);
+
+const svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'biomethods-provenance-packet.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'reviewer-report.md'), reviewerReport);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) {
+ const [r, g, b] = color;
+ const x1 = Math.min(width, x0 + rectWidth);
+ const y1 = Math.min(Math.floor(buffer.length / (width * 3)), y0 + rectHeight);
+ for (let y = Math.max(0, y0); y < y1; y += 1) {
+ for (let x = Math.max(0, x0); x < x1; x += 1) {
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+ }
+ }
+}
+
+function writePpmFrame(filePath, frameIndex, frameCount) {
+ const width = 640;
+ const height = 360;
+ const buffer = Buffer.alloc(width * height * 3, 18);
+ for (let i = 0; i < width * height; i += 1) {
+ buffer[i * 3] = 16;
+ buffer[i * 3 + 1] = 24;
+ buffer[i * 3 + 2] = 32;
+ }
+ const progress = frameIndex / Math.max(1, frameCount - 1);
+ fillRect(buffer, width, 42, 54, 556, 42, [248, 250, 252]);
+ fillRect(buffer, width, 42, 118, 160, 108, [127, 29, 29]);
+ fillRect(buffer, width, 240, 118, 160, 108, [146, 64, 14]);
+ fillRect(buffer, width, 438, 118, 160, 108, [6, 95, 70]);
+ fillRect(buffer, width, 42, 258, Math.round(556 * progress), 26, [185, 230, 255]);
+ fillRect(buffer, width, 42 + Math.round(480 * progress), 300, 72, 28, [248, 250, 252]);
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
+ fs.writeFileSync(filePath, Buffer.concat([header, buffer]));
+}
+
+function createDemoVideo() {
+ const ffmpegPath = process.env.FFMPEG_PATH;
+ if (!ffmpegPath) {
+ console.log('FFMPEG_PATH not set; skipped MP4 generation.');
+ return;
+ }
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ fs.mkdirSync(framesDir, {recursive: true});
+ const frameCount = 72;
+ for (let index = 0; index < frameCount; index += 1) {
+ writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount);
+ }
+
+ const output = path.join(reportsDir, 'demo.mp4');
+ const result = spawnSync(ffmpegPath, [
+ '-y',
+ '-framerate',
+ '24',
+ '-i',
+ path.join(framesDir, 'frame-%03d.ppm'),
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ '-movflags',
+ '+faststart',
+ output,
+ ], {encoding: 'utf8'});
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ if (result.status !== 0) {
+ throw new Error(result.stderr || 'ffmpeg failed to generate demo.mp4');
+ }
+}
+
+createDemoVideo();
+
+console.log(`Wrote ${evaluations.length} biomethods evaluations to ${reportsDir}`);
+console.log(`Decision counts: ${JSON.stringify(decisionCounts)}`);
+console.log(`Reviewer findings: ${findings}`);
diff --git a/biomethods-provenance-assistant/index.js b/biomethods-provenance-assistant/index.js
new file mode 100644
index 00000000..4a1d5298
--- /dev/null
+++ b/biomethods-provenance-assistant/index.js
@@ -0,0 +1,341 @@
+function list(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function hasText(value) {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+function fullDaysBetween(older, newer) {
+ const olderTime = Date.parse(older);
+ const newerTime = Date.parse(newer);
+ if (!Number.isFinite(olderTime) || !Number.isFinite(newerTime)) {
+ return Infinity;
+ }
+ return Math.floor((newerTime - olderTime) / 86400000);
+}
+
+function reviewerAction(type, target, reason) {
+ return {type, target, reason};
+}
+
+function addFinding(findings, requiredActions, finding, action) {
+ findings.push(finding);
+ if (action) {
+ requiredActions.push(action);
+ }
+}
+
+function reagentKey(reagent) {
+ return [
+ reagent.vendor || 'unknown-vendor',
+ reagent.catalogNumber || reagent.name || reagent.id || 'unknown-reagent',
+ ].join('::').toLowerCase();
+}
+
+function countSeverities(findings) {
+ return findings.reduce((counts, finding) => {
+ counts[finding.severity] = (counts[finding.severity] || 0) + 1;
+ return counts;
+ }, {critical: 0, major: 0, minor: 0});
+}
+
+function evaluateSpecimen(specimen, findings, requiredActions) {
+ const target = specimen.id || specimen.label || 'specimen';
+
+ if (!hasText(specimen.source)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-specimen-source',
+ severity: 'critical',
+ target,
+ message: `${target} is missing source, accession, facility, or cohort provenance`,
+ },
+ reviewerAction(
+ 'request_specimen_source',
+ target,
+ 'reviewers need specimen origin provenance before trusting biomethods claims'
+ )
+ );
+ }
+
+ const missingContext = ['organism', 'strain', 'sex', 'age']
+ .filter((field) => !hasText(specimen[field]));
+ if (missingContext.length > 0) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-specimen-context',
+ severity: 'major',
+ target,
+ missingContext,
+ message: `${target} lacks biological context fields: ${missingContext.join(', ')}`,
+ },
+ reviewerAction(
+ 'request_specimen_context',
+ target,
+ 'organism, strain, sex, and age context are needed for biology peer-review templates'
+ )
+ );
+ }
+}
+
+function evaluateCellLine(cellLine, generatedAt, findings, requiredActions) {
+ const target = cellLine.id || cellLine.name || 'cell-line';
+ if (!hasText(cellLine.source)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-cell-line-source',
+ severity: 'major',
+ target,
+ message: `${target} is missing source or repository provenance`,
+ },
+ reviewerAction(
+ 'record_cell_line_source',
+ target,
+ 'cell-line source must be traceable for reproducibility and contamination checks'
+ )
+ );
+ }
+
+ if (!hasText(cellLine.authenticatedAt) || !hasText(cellLine.authenticationMethod)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-cell-line-authentication',
+ severity: 'critical',
+ target,
+ message: `${target} lacks authentication date or method evidence`,
+ },
+ reviewerAction(
+ 'provide_cell_line_authentication',
+ target,
+ 'cell-line identity needs authentication evidence before review release'
+ )
+ );
+ } else if (fullDaysBetween(cellLine.authenticatedAt, generatedAt) > 365) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'stale-cell-line-authentication',
+ severity: 'critical',
+ target,
+ authenticatedAt: cellLine.authenticatedAt,
+ message: `${target} authentication evidence is older than 365 days`,
+ },
+ reviewerAction(
+ 'refresh_cell_line_authentication',
+ target,
+ 'stale identity evidence can invalidate downstream biological conclusions'
+ )
+ );
+ }
+
+ if (!hasText(cellLine.mycoplasmaTestedAt)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-mycoplasma-evidence',
+ severity: 'major',
+ target,
+ message: `${target} lacks mycoplasma test evidence`,
+ },
+ reviewerAction(
+ 'provide_mycoplasma_test',
+ target,
+ 'cell-culture studies need contamination-screening evidence'
+ )
+ );
+ }
+}
+
+function evaluateReagent(reagent, findings, requiredActions) {
+ const target = reagent.id || reagent.name || 'reagent';
+ if (!hasText(reagent.vendor) || !hasText(reagent.catalogNumber)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-reagent-identifier',
+ severity: 'major',
+ target,
+ message: `${target} lacks vendor or catalog-number traceability`,
+ },
+ reviewerAction(
+ 'record_reagent_identifier',
+ target,
+ 'critical reagents need vendor and catalog identifiers for repeatability'
+ )
+ );
+ }
+
+ if (!hasText(reagent.lot)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-reagent-lot',
+ severity: 'major',
+ target,
+ message: `${target} lacks batch or lot evidence`,
+ },
+ reviewerAction(
+ 'record_reagent_lot',
+ target,
+ 'batch-sensitive assays need lot traceability before peer review'
+ )
+ );
+ }
+
+ if (String(reagent.type || '').toLowerCase() === 'antibody' && !hasText(reagent.rrid)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-antibody-rrid',
+ severity: 'major',
+ target,
+ message: `${target} is an antibody without RRID evidence`,
+ },
+ reviewerAction(
+ 'record_antibody_rrid',
+ target,
+ 'antibody-based claims need RRID evidence for reviewer verification'
+ )
+ );
+ }
+}
+
+function evaluateBatchConflicts(reagents, findings, requiredActions) {
+ const groups = new Map();
+ for (const reagent of reagents) {
+ if (!hasText(reagent.lot) || !hasText(reagent.vendor) || !hasText(reagent.catalogNumber)) {
+ continue;
+ }
+ const key = reagentKey(reagent);
+ if (!groups.has(key)) {
+ groups.set(key, []);
+ }
+ groups.get(key).push(reagent);
+ }
+
+ for (const entries of groups.values()) {
+ const lots = [...new Set(entries.map((entry) => entry.lot))].sort();
+ if (lots.length < 2) {
+ continue;
+ }
+ const target = entries[0].catalogNumber || entries[0].name;
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'reagent-batch-conflict',
+ severity: 'major',
+ target,
+ lots,
+ methodSections: entries.map((entry) => entry.methodSection).filter(hasText),
+ message: `${target} appears with multiple lots without batch-effect notes`,
+ },
+ reviewerAction(
+ 'explain_reagent_batch_handling',
+ target,
+ 'multi-lot reagents need batch-effect handling before claims are compared'
+ )
+ );
+ }
+}
+
+function decide(findings) {
+ if (findings.some((finding) => finding.severity === 'critical')) {
+ return 'hold-for-author-response';
+ }
+ if (findings.some((finding) => finding.severity === 'major')) {
+ return 'needs-provenance-review';
+ }
+ return 'ready-for-review';
+}
+
+function evaluateBiomethodsProvenance(input) {
+ const findings = [];
+ const requiredActions = [];
+ const generatedAt = input.generatedAt || new Date(0).toISOString();
+ const specimens = list(input.specimens);
+ const cellLines = list(input.cellLines);
+ const reagents = list(input.reagents);
+
+ for (const specimen of specimens) {
+ evaluateSpecimen(specimen, findings, requiredActions);
+ }
+ for (const cellLine of cellLines) {
+ evaluateCellLine(cellLine, generatedAt, findings, requiredActions);
+ }
+ for (const reagent of reagents) {
+ evaluateReagent(reagent, findings, requiredActions);
+ }
+ evaluateBatchConflicts(reagents, findings, requiredActions);
+
+ const summary = {
+ ...countSeverities(findings),
+ specimens: specimens.length,
+ cellLines: cellLines.length,
+ reagents: reagents.length,
+ findings: findings.length,
+ };
+
+ return {
+ manuscriptId: input.manuscriptId || 'unknown-manuscript',
+ generatedAt,
+ decision: decide(findings),
+ summary,
+ findings,
+ requiredActions,
+ };
+}
+
+function buildReviewerReport(result) {
+ const lines = [
+ '# Biomethods Provenance Assistant',
+ '',
+ `Manuscript: ${result.manuscriptId}`,
+ `Decision: ${result.decision}`,
+ `Generated: ${result.generatedAt}`,
+ '',
+ '## Summary',
+ '',
+ `- Critical findings: ${result.summary.critical || 0}`,
+ `- Major findings: ${result.summary.major || 0}`,
+ `- Minor findings: ${result.summary.minor || 0}`,
+ `- Specimens checked: ${result.summary.specimens || 0}`,
+ `- Cell lines checked: ${result.summary.cellLines || 0}`,
+ `- Reagents checked: ${result.summary.reagents || 0}`,
+ '',
+ ];
+
+ if (result.findings.length === 0) {
+ lines.push('No blocking biomethods provenance gaps detected.');
+ } else {
+ lines.push('## Findings', '');
+ for (const finding of result.findings) {
+ lines.push(`- **${finding.severity}** ${finding.type}: ${finding.message}`);
+ }
+ lines.push('', '## Required Actions', '');
+ for (const action of result.requiredActions) {
+ lines.push(`- ${action.type} (${action.target}): ${action.reason}`);
+ }
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+module.exports = {
+ evaluateBiomethodsProvenance,
+ buildReviewerReport,
+};
diff --git a/biomethods-provenance-assistant/reports/biomethods-provenance-packet.json b/biomethods-provenance-assistant/reports/biomethods-provenance-packet.json
new file mode 100644
index 00000000..6ffbe863
--- /dev/null
+++ b/biomethods-provenance-assistant/reports/biomethods-provenance-packet.json
@@ -0,0 +1,148 @@
+[
+ {
+ "scenario": "author-response-needed",
+ "manuscriptId": "ms-organoid-drug-response",
+ "generatedAt": "2026-05-22T14:00:00Z",
+ "decision": "hold-for-author-response",
+ "summary": {
+ "critical": 2,
+ "major": 4,
+ "minor": 0,
+ "specimens": 1,
+ "cellLines": 1,
+ "reagents": 1,
+ "findings": 6
+ },
+ "findings": [
+ {
+ "type": "missing-specimen-source",
+ "severity": "critical",
+ "target": "spec-1",
+ "message": "spec-1 is missing source, accession, facility, or cohort provenance"
+ },
+ {
+ "type": "missing-specimen-context",
+ "severity": "major",
+ "target": "spec-1",
+ "missingContext": [
+ "strain",
+ "sex",
+ "age"
+ ],
+ "message": "spec-1 lacks biological context fields: strain, sex, age"
+ },
+ {
+ "type": "stale-cell-line-authentication",
+ "severity": "critical",
+ "target": "cl-hek293t",
+ "authenticatedAt": "2024-01-01T00:00:00Z",
+ "message": "cl-hek293t authentication evidence is older than 365 days"
+ },
+ {
+ "type": "missing-mycoplasma-evidence",
+ "severity": "major",
+ "target": "cl-hek293t",
+ "message": "cl-hek293t lacks mycoplasma test evidence"
+ },
+ {
+ "type": "missing-reagent-lot",
+ "severity": "major",
+ "target": "ab-gapdh",
+ "message": "ab-gapdh lacks batch or lot evidence"
+ },
+ {
+ "type": "missing-antibody-rrid",
+ "severity": "major",
+ "target": "ab-gapdh",
+ "message": "ab-gapdh is an antibody without RRID evidence"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "request_specimen_source",
+ "target": "spec-1",
+ "reason": "reviewers need specimen origin provenance before trusting biomethods claims"
+ },
+ {
+ "type": "request_specimen_context",
+ "target": "spec-1",
+ "reason": "organism, strain, sex, and age context are needed for biology peer-review templates"
+ },
+ {
+ "type": "refresh_cell_line_authentication",
+ "target": "cl-hek293t",
+ "reason": "stale identity evidence can invalidate downstream biological conclusions"
+ },
+ {
+ "type": "provide_mycoplasma_test",
+ "target": "cl-hek293t",
+ "reason": "cell-culture studies need contamination-screening evidence"
+ },
+ {
+ "type": "record_reagent_lot",
+ "target": "ab-gapdh",
+ "reason": "batch-sensitive assays need lot traceability before peer review"
+ },
+ {
+ "type": "record_antibody_rrid",
+ "target": "ab-gapdh",
+ "reason": "antibody-based claims need RRID evidence for reviewer verification"
+ }
+ ]
+ },
+ {
+ "scenario": "batch-review-needed",
+ "manuscriptId": "ms-batch-sensitive-assay",
+ "generatedAt": "2026-05-22T14:00:00Z",
+ "decision": "needs-provenance-review",
+ "summary": {
+ "critical": 0,
+ "major": 1,
+ "minor": 0,
+ "specimens": 0,
+ "cellLines": 0,
+ "reagents": 2,
+ "findings": 1
+ },
+ "findings": [
+ {
+ "type": "reagent-batch-conflict",
+ "severity": "major",
+ "target": "ELISA-IL6",
+ "lots": [
+ "lot-a",
+ "lot-b"
+ ],
+ "methodSections": [
+ "Figure 2",
+ "Figure 4"
+ ],
+ "message": "ELISA-IL6 appears with multiple lots without batch-effect notes"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "explain_reagent_batch_handling",
+ "target": "ELISA-IL6",
+ "reason": "multi-lot reagents need batch-effect handling before claims are compared"
+ }
+ ]
+ },
+ {
+ "scenario": "ready-for-review",
+ "manuscriptId": "ms-complete-biomethods",
+ "generatedAt": "2026-05-22T14:00:00Z",
+ "decision": "ready-for-review",
+ "summary": {
+ "critical": 0,
+ "major": 0,
+ "minor": 0,
+ "specimens": 1,
+ "cellLines": 1,
+ "reagents": 1,
+ "findings": 0
+ },
+ "findings": [],
+ "requiredActions": []
+ }
+]
diff --git a/biomethods-provenance-assistant/reports/demo.mp4 b/biomethods-provenance-assistant/reports/demo.mp4
new file mode 100644
index 0000000000000000000000000000000000000000..14db5cf4268f5258b244ac0509b9bf71c178ba3c
GIT binary patch
literal 8481
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
m2`5
+
+ Biomethods Provenance Assistant
+ Synthetic AI peer-review aid for specimen, cell-line, and reagent traceability
+
+
+ Author Response
+ 1
+
+
+
+ Provenance Review
+ 1
+
+
+
+ Ready
+ 1
+
+ Checks: specimen source, biological context, cell-line authentication, mycoplasma, RRID, lot traceability
+ Reviewer findings: 7. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact.
+ Synthetic data only. No patient data, private lab data, credentials, external APIs, or network calls.
+
diff --git a/biomethods-provenance-assistant/sample-data.js b/biomethods-provenance-assistant/sample-data.js
new file mode 100644
index 00000000..0a4fd260
--- /dev/null
+++ b/biomethods-provenance-assistant/sample-data.js
@@ -0,0 +1,106 @@
+const scenarios = [
+ {
+ name: 'author-response-needed',
+ manuscriptId: 'ms-organoid-drug-response',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [
+ {
+ id: 'spec-1',
+ label: 'patient-derived pancreatic organoid line',
+ organism: 'Homo sapiens',
+ tissue: 'pancreatic tumor',
+ source: '',
+ strain: '',
+ sex: '',
+ age: '',
+ },
+ ],
+ cellLines: [
+ {
+ id: 'cl-hek293t',
+ name: 'HEK293T',
+ source: 'ATCC',
+ authenticatedAt: '2024-01-01T00:00:00Z',
+ authenticationMethod: 'STR',
+ mycoplasmaTestedAt: '',
+ },
+ ],
+ reagents: [
+ {
+ id: 'ab-gapdh',
+ name: 'anti-GAPDH antibody',
+ type: 'antibody',
+ vendor: 'ExampleBio',
+ catalogNumber: 'EB-100',
+ lot: '',
+ rrid: '',
+ },
+ ],
+ },
+ {
+ name: 'batch-review-needed',
+ manuscriptId: 'ms-batch-sensitive-assay',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [],
+ cellLines: [],
+ reagents: [
+ {
+ id: 'cytokine-a',
+ name: 'IL-6 ELISA kit',
+ type: 'assay-kit',
+ vendor: 'ExampleBio',
+ catalogNumber: 'ELISA-IL6',
+ lot: 'lot-a',
+ methodSection: 'Figure 2',
+ },
+ {
+ id: 'cytokine-b',
+ name: 'IL-6 ELISA kit',
+ type: 'assay-kit',
+ vendor: 'ExampleBio',
+ catalogNumber: 'ELISA-IL6',
+ lot: 'lot-b',
+ methodSection: 'Figure 4',
+ },
+ ],
+ },
+ {
+ name: 'ready-for-review',
+ manuscriptId: 'ms-complete-biomethods',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [
+ {
+ id: 'mouse-cohort-a',
+ label: 'treated mouse cohort',
+ organism: 'Mus musculus',
+ strain: 'C57BL/6J',
+ sex: 'female',
+ age: '10 weeks',
+ source: 'Institutional animal facility',
+ },
+ ],
+ cellLines: [
+ {
+ id: 'cl-a549',
+ name: 'A549',
+ source: 'ATCC',
+ authenticatedAt: '2026-02-12T00:00:00Z',
+ authenticationMethod: 'STR',
+ mycoplasmaTestedAt: '2026-04-01T00:00:00Z',
+ },
+ ],
+ reagents: [
+ {
+ id: 'ab-actin',
+ name: 'anti-beta-actin antibody',
+ type: 'antibody',
+ vendor: 'ExampleBio',
+ catalogNumber: 'EB-200',
+ lot: 'lot-42',
+ rrid: 'AB_123456',
+ },
+ ],
+ },
+];
+
+module.exports = {scenarios};
diff --git a/biomethods-provenance-assistant/test.js b/biomethods-provenance-assistant/test.js
new file mode 100644
index 00000000..c92828a0
--- /dev/null
+++ b/biomethods-provenance-assistant/test.js
@@ -0,0 +1,160 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluateBiomethodsProvenance,
+ buildReviewerReport,
+} = require('./index');
+
+test('holds manuscript when biospecimen source and biological context are incomplete', () => {
+ const result = evaluateBiomethodsProvenance({
+ manuscriptId: 'ms-organoid-drug-response',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [
+ {
+ id: 'spec-1',
+ label: 'patient-derived organoid line',
+ organism: 'Homo sapiens',
+ tissue: 'pancreatic tumor',
+ source: '',
+ strain: '',
+ sex: '',
+ age: '',
+ },
+ ],
+ cellLines: [],
+ reagents: [],
+ });
+
+ assert.equal(result.decision, 'hold-for-author-response');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ [
+ 'missing-specimen-source',
+ 'missing-specimen-context',
+ ]
+ );
+ assert.equal(result.requiredActions.length, 2);
+});
+
+test('flags stale cell-line authentication and reagent traceability gaps', () => {
+ const result = evaluateBiomethodsProvenance({
+ manuscriptId: 'ms-immunoblot-screen',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [],
+ cellLines: [
+ {
+ id: 'cl-hek293t',
+ name: 'HEK293T',
+ source: 'ATCC',
+ authenticatedAt: '2024-01-01T00:00:00Z',
+ authenticationMethod: 'STR',
+ mycoplasmaTestedAt: '',
+ },
+ ],
+ reagents: [
+ {
+ id: 'ab-gapdh',
+ name: 'anti-GAPDH antibody',
+ type: 'antibody',
+ vendor: 'ExampleBio',
+ catalogNumber: 'EB-100',
+ lot: '',
+ rrid: '',
+ },
+ ],
+ });
+
+ assert.equal(result.decision, 'hold-for-author-response');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ [
+ 'stale-cell-line-authentication',
+ 'missing-mycoplasma-evidence',
+ 'missing-reagent-lot',
+ 'missing-antibody-rrid',
+ ]
+ );
+ assert.equal(result.summary.critical, 1);
+ assert.equal(result.summary.major, 3);
+});
+
+test('detects reagent batch conflicts across methods', () => {
+ const result = evaluateBiomethodsProvenance({
+ manuscriptId: 'ms-batch-sensitive-assay',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [],
+ cellLines: [],
+ reagents: [
+ {
+ id: 'cytokine-a',
+ name: 'IL-6 ELISA kit',
+ type: 'assay-kit',
+ vendor: 'ExampleBio',
+ catalogNumber: 'ELISA-IL6',
+ lot: 'lot-a',
+ methodSection: 'Figure 2',
+ },
+ {
+ id: 'cytokine-b',
+ name: 'IL-6 ELISA kit',
+ type: 'assay-kit',
+ vendor: 'ExampleBio',
+ catalogNumber: 'ELISA-IL6',
+ lot: 'lot-b',
+ methodSection: 'Figure 4',
+ },
+ ],
+ });
+
+ assert.equal(result.decision, 'needs-provenance-review');
+ assert.equal(result.findings.length, 1);
+ assert.equal(result.findings[0].type, 'reagent-batch-conflict');
+ assert.deepEqual(result.findings[0].lots, ['lot-a', 'lot-b']);
+});
+
+test('builds ready reviewer report when biomethods provenance is complete', () => {
+ const result = evaluateBiomethodsProvenance({
+ manuscriptId: 'ms-complete-biomethods',
+ generatedAt: '2026-05-22T14:00:00Z',
+ specimens: [
+ {
+ id: 'mouse-cohort-a',
+ label: 'treated mouse cohort',
+ organism: 'Mus musculus',
+ strain: 'C57BL/6J',
+ sex: 'female',
+ age: '10 weeks',
+ source: 'Institutional animal facility',
+ },
+ ],
+ cellLines: [
+ {
+ id: 'cl-a549',
+ name: 'A549',
+ source: 'ATCC',
+ authenticatedAt: '2026-02-12T00:00:00Z',
+ authenticationMethod: 'STR',
+ mycoplasmaTestedAt: '2026-04-01T00:00:00Z',
+ },
+ ],
+ reagents: [
+ {
+ id: 'ab-actin',
+ name: 'anti-beta-actin antibody',
+ type: 'antibody',
+ vendor: 'ExampleBio',
+ catalogNumber: 'EB-200',
+ lot: 'lot-42',
+ rrid: 'AB_123456',
+ },
+ ],
+ });
+ const report = buildReviewerReport(result);
+
+ assert.equal(result.decision, 'ready-for-review');
+ assert.equal(result.findings.length, 0);
+ assert.match(report, /Biomethods Provenance Assistant/);
+ assert.match(report, /ready-for-review/);
+ assert.match(report, /No blocking biomethods provenance gaps detected/);
+});