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 00000000..14db5cf4 Binary files /dev/null and b/biomethods-provenance-assistant/reports/demo.mp4 differ diff --git a/biomethods-provenance-assistant/reports/reviewer-report.md b/biomethods-provenance-assistant/reports/reviewer-report.md new file mode 100644 index 00000000..5861c18b --- /dev/null +++ b/biomethods-provenance-assistant/reports/reviewer-report.md @@ -0,0 +1,74 @@ +# Biomethods Provenance Assistant + +Manuscript: ms-organoid-drug-response +Decision: hold-for-author-response +Generated: 2026-05-22T14:00:00Z + +## Summary + +- Critical findings: 2 +- Major findings: 4 +- Minor findings: 0 +- Specimens checked: 1 +- Cell lines checked: 1 +- Reagents checked: 1 + +## Findings + +- **critical** missing-specimen-source: spec-1 is missing source, accession, facility, or cohort provenance +- **major** missing-specimen-context: spec-1 lacks biological context fields: strain, sex, age +- **critical** stale-cell-line-authentication: cl-hek293t authentication evidence is older than 365 days +- **major** missing-mycoplasma-evidence: cl-hek293t lacks mycoplasma test evidence +- **major** missing-reagent-lot: ab-gapdh lacks batch or lot evidence +- **major** missing-antibody-rrid: ab-gapdh is an antibody without RRID evidence + +## Required Actions + +- request_specimen_source (spec-1): reviewers need specimen origin provenance before trusting biomethods claims +- request_specimen_context (spec-1): organism, strain, sex, and age context are needed for biology peer-review templates +- refresh_cell_line_authentication (cl-hek293t): stale identity evidence can invalidate downstream biological conclusions +- provide_mycoplasma_test (cl-hek293t): cell-culture studies need contamination-screening evidence +- record_reagent_lot (ab-gapdh): batch-sensitive assays need lot traceability before peer review +- record_antibody_rrid (ab-gapdh): antibody-based claims need RRID evidence for reviewer verification + +--- +# Biomethods Provenance Assistant + +Manuscript: ms-batch-sensitive-assay +Decision: needs-provenance-review +Generated: 2026-05-22T14:00:00Z + +## Summary + +- Critical findings: 0 +- Major findings: 1 +- Minor findings: 0 +- Specimens checked: 0 +- Cell lines checked: 0 +- Reagents checked: 2 + +## Findings + +- **major** reagent-batch-conflict: ELISA-IL6 appears with multiple lots without batch-effect notes + +## Required Actions + +- explain_reagent_batch_handling (ELISA-IL6): multi-lot reagents need batch-effect handling before claims are compared + +--- +# Biomethods Provenance Assistant + +Manuscript: ms-complete-biomethods +Decision: ready-for-review +Generated: 2026-05-22T14:00:00Z + +## Summary + +- Critical findings: 0 +- Major findings: 0 +- Minor findings: 0 +- Specimens checked: 1 +- Cell lines checked: 1 +- Reagents checked: 1 + +No blocking biomethods provenance gaps detected. diff --git a/biomethods-provenance-assistant/reports/summary.svg b/biomethods-provenance-assistant/reports/summary.svg new file mode 100644 index 00000000..05a9aae5 --- /dev/null +++ b/biomethods-provenance-assistant/reports/summary.svg @@ -0,0 +1,23 @@ + + + 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/); +});