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 = ` + + Biomethods Provenance Assistant + Synthetic AI peer-review aid for specimen, cell-line, and reagent traceability + + + Author Response + ${decisionCounts['hold-for-author-response'] || 0} + + + + Provenance Review + ${decisionCounts['needs-provenance-review'] || 0} + + + + Ready + ${decisionCounts['ready-for-review'] || 0} + + Checks: specimen source, biological context, cell-line authentication, mycoplasma, RRID, lot traceability + Reviewer findings: ${findings}. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No patient data, private lab data, credentials, external APIs, or network calls. + +`; + +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/); +});