diff --git a/assay-control-calibration-assistant/README.md b/assay-control-calibration-assistant/README.md new file mode 100644 index 00000000..064c4168 --- /dev/null +++ b/assay-control-calibration-assistant/README.md @@ -0,0 +1,29 @@ +# Assay Control Calibration Assistant + +Self-contained AI-powered research assistant slice for issue #16. It gives reviewers a deterministic pre-submission packet for assay control coverage, standard curve readiness, calibration drift, and QC replicate variation. + +## What It Checks + +- Required positive, negative, blank, vehicle, and no-template control evidence. +- Failed control runs that should block release until repeated or explained. +- Calibration curve fit against manuscript-declared acceptance thresholds. +- Calibration drift above the accepted method or instrument threshold. +- QC replicate coefficient-of-variation issues before reviewer packets are shown. + +## Outputs + +- `reports/assay-control-calibration-packet.json`: structured reviewer decisions and findings. +- `reports/reviewer-packet.md`: readable reviewer report for each synthetic scenario. +- `reports/summary.svg`: visual summary of approve, response, and hold decisions. +- `reports/demo.mp4`: short demo artifact for Algora review. + +## Local Verification + +```bash +npm run check +npm test +npm run demo +npm run video +``` + +The module is dependency-free, uses synthetic data only, and makes no network calls. diff --git a/assay-control-calibration-assistant/demo.js b/assay-control-calibration-assistant/demo.js new file mode 100644 index 00000000..4bf0d043 --- /dev/null +++ b/assay-control-calibration-assistant/demo.js @@ -0,0 +1,59 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { + evaluateAssayControlCalibration, + buildReviewerPacket, +} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateAssayControlCalibration(scenario), +})); + +const reviewerPacket = evaluations.map(buildReviewerPacket).join('\n---\n'); +const packetJson = JSON.stringify(evaluations, null, 2); +const approved = evaluations.filter((item) => item.decision === 'approved').length; +const response = evaluations.filter((item) => item.decision === 'needs-author-response').length; +const hold = evaluations.filter((item) => item.decision === 'hold-for-review').length; +const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0); +const missingControls = evaluations.reduce((sum, item) => sum + item.summary.missingControls, 0); +const calibrationIssues = evaluations.reduce((sum, item) => sum + item.summary.calibrationIssues, 0); +const qcIssues = evaluations.reduce((sum, item) => sum + item.summary.qcIssues, 0); + +const svg = ` + + Assay Control Calibration Assistant + Synthetic reviewer packet for assay controls, standards, calibration drift, and QC replicates + + + Approved + ${approved} + + + + Author Response + ${response} + + + + Hold Review + ${hold} + + Findings: ${findings}. Missing controls: ${missingControls}. Calibration issues: ${calibrationIssues}. QC issues: ${qcIssues}. + Checks: positive/negative/blank controls, standard curve fit, calibration drift, QC replicate variation. + Synthetic data only. No private manuscripts, lab data, credentials, external APIs, or network calls. + +`; + +fs.writeFileSync(path.join(reportsDir, 'assay-control-calibration-packet.json'), `${packetJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'reviewer-packet.md'), reviewerPacket); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +console.log(`Wrote ${evaluations.length} assay-control evaluations to ${reportsDir}`); +console.log(`Decision counts: approved=${approved}, response=${response}, hold=${hold}`); +console.log(`Findings=${findings}, missingControls=${missingControls}, calibrationIssues=${calibrationIssues}, qcIssues=${qcIssues}`); diff --git a/assay-control-calibration-assistant/index.js b/assay-control-calibration-assistant/index.js new file mode 100644 index 00000000..11b9a7d1 --- /dev/null +++ b/assay-control-calibration-assistant/index.js @@ -0,0 +1,245 @@ +function normalizeList(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeControlType(value) { + return String(value || '').trim().toLowerCase(); +} + +function roundMetric(value) { + return Math.round(Number(value || 0) * 1000) / 1000; +} + +function severityCounts(findings) { + return findings.reduce((counts, finding) => { + counts[finding.severity] = (counts[finding.severity] || 0) + 1; + return counts; + }, {}); +} + +function reviewerAction(type, target, reason) { + return {type, target, reason}; +} + +function evaluateAssayControlCalibration(input) { + const assays = normalizeList(input.assays); + const findings = []; + const requiredActions = []; + let controlCount = 0; + + for (const assay of assays) { + const controls = normalizeList(assay.controls); + const requiredControls = normalizeList(assay.requiredControls).map(normalizeControlType); + const controlsByType = new Map(); + controlCount += controls.length; + + for (const control of controls) { + const type = normalizeControlType(control.type); + if (!controlsByType.has(type)) { + controlsByType.set(type, []); + } + controlsByType.get(type).push(control); + } + + for (const requiredType of requiredControls) { + if (!controlsByType.has(requiredType)) { + findings.push({ + type: 'missing-control', + severity: 'critical', + assayId: assay.id, + controlType: requiredType, + message: `${assay.id} lacks required ${requiredType} control evidence`, + }); + requiredActions.push(reviewerAction( + 'attach_control_evidence', + `${assay.id}:${requiredType}`, + 'reviewers need explicit control evidence before assay results are released' + )); + } + } + + for (const control of controls) { + if (normalizeControlType(control.status) && normalizeControlType(control.status) !== 'pass') { + findings.push({ + type: 'failed-control', + severity: 'major', + assayId: assay.id, + controlId: control.id || '', + controlType: normalizeControlType(control.type), + status: normalizeControlType(control.status), + message: `${assay.id} has failed ${control.type} control ${control.id || ''}`.trim(), + }); + requiredActions.push(reviewerAction( + 'repeat_or_explain_control', + `${assay.id}:${control.id || control.type}`, + 'failed control must be repeated or justified before reviewer approval' + )); + } + } + + const calibration = assay.calibration || null; + if (!calibration) { + findings.push({ + type: 'missing-calibration', + severity: 'critical', + assayId: assay.id, + message: `${assay.id} lacks linked calibration evidence`, + }); + requiredActions.push(reviewerAction( + 'attach_calibration_evidence', + assay.id, + 'assay packets need a calibration curve or standard evidence' + )); + } else { + const acceptedRange = normalizeList(calibration.acceptedRange); + const minimumFit = Number(acceptedRange[0] || 0); + const rSquared = Number(calibration.rSquared || 0); + if (minimumFit > 0 && rSquared < minimumFit) { + findings.push({ + type: 'weak-calibration-fit', + severity: 'major', + assayId: assay.id, + curveId: calibration.curveId || '', + observedRSquared: roundMetric(rSquared), + requiredMinimumRSquared: roundMetric(minimumFit), + message: `${assay.id} calibration fit ${roundMetric(rSquared)} is below ${roundMetric(minimumFit)}`, + }); + requiredActions.push(reviewerAction( + 'provide_standard_curve_evidence', + `${assay.id}:${calibration.curveId || 'calibration'}`, + 'weak standard curve fit needs source standards or recalibration evidence' + )); + } + + if (Number(calibration.standards || 0) > 0 && Number(calibration.standards || 0) < 4) { + findings.push({ + type: 'insufficient-calibration-standards', + severity: 'major', + assayId: assay.id, + curveId: calibration.curveId || '', + standards: Number(calibration.standards || 0), + message: `${assay.id} calibration has fewer than four standards`, + }); + requiredActions.push(reviewerAction( + 'add_calibration_standards', + `${assay.id}:${calibration.curveId || 'calibration'}`, + 'calibration curve needs enough standards to support reviewer confidence' + )); + } + + const driftPercent = Number(calibration.driftPercent || 0); + const maxDriftPercent = Number(calibration.maxDriftPercent || 0); + if (maxDriftPercent > 0 && driftPercent > maxDriftPercent) { + findings.push({ + type: 'calibration-drift', + severity: 'major', + assayId: assay.id, + curveId: calibration.curveId || '', + driftPercent: roundMetric(driftPercent), + maxDriftPercent: roundMetric(maxDriftPercent), + message: `${assay.id} calibration drift ${roundMetric(driftPercent)}% exceeds ${roundMetric(maxDriftPercent)}%`, + }); + requiredActions.push(reviewerAction( + 'rerun_or_explain_calibration', + `${assay.id}:${calibration.curveId || 'calibration'}`, + 'calibration drift must be rerun or explained before release' + )); + } + } + + for (const replicate of normalizeList(assay.qcReplicates)) { + const observed = Number(replicate.coefficientOfVariation || 0); + const maximum = Number(replicate.maxCoefficientOfVariation || 0); + if (maximum > 0 && observed > maximum) { + findings.push({ + type: 'high-qc-variation', + severity: 'major', + assayId: assay.id, + replicateId: replicate.id || '', + coefficientOfVariation: roundMetric(observed), + maxCoefficientOfVariation: roundMetric(maximum), + message: `${assay.id} QC replicate ${replicate.id || ''} CV ${roundMetric(observed)} exceeds ${roundMetric(maximum)}`.trim(), + }); + requiredActions.push(reviewerAction( + 'repeat_qc_or_explain_variation', + `${assay.id}:${replicate.id || 'qc-replicate'}`, + 'high replicate variation needs repeat evidence or reviewer-facing explanation' + )); + } + } + } + + const counts = severityCounts(findings); + const criticalCount = counts.critical || 0; + const majorCount = counts.major || 0; + const minorCount = counts.minor || 0; + const decision = criticalCount > 0 + ? 'hold-for-review' + : findings.length > 0 + ? 'needs-author-response' + : 'approved'; + const readinessScore = Math.max(0, 100 - criticalCount * 30 - majorCount * 15 - minorCount * 5); + + return { + manuscriptId: input.manuscriptId, + generatedAt: input.generatedAt, + decision, + readinessScore, + findings, + requiredActions, + summary: { + assayCount: assays.length, + controlCount, + missingControls: findings.filter((finding) => finding.type === 'missing-control').length, + failedControls: findings.filter((finding) => finding.type === 'failed-control').length, + calibrationIssues: findings.filter((finding) => [ + 'missing-calibration', + 'weak-calibration-fit', + 'insufficient-calibration-standards', + 'calibration-drift', + ].includes(finding.type)).length, + qcIssues: findings.filter((finding) => finding.type === 'high-qc-variation').length, + severityCounts: counts, + }, + }; +} + +function buildReviewerPacket(result) { + const lines = [ + '# Assay Control Calibration Assistant Report', + '', + `Manuscript: ${result.manuscriptId}`, + `Generated: ${result.generatedAt}`, + `Decision: ${result.decision}`, + `Readiness score: ${result.readinessScore}`, + '', + '## Packet Summary', + '', + `Assays: ${result.summary.assayCount}`, + `Controls: ${result.summary.controlCount}`, + `Missing controls: ${result.summary.missingControls}`, + `Failed controls: ${result.summary.failedControls}`, + `Calibration issues: ${result.summary.calibrationIssues}`, + `QC issues: ${result.summary.qcIssues}`, + `Findings: ${result.findings.length}`, + '', + '## Findings', + '', + ...(result.findings.length + ? result.findings.map((finding) => `- ${finding.severity}: ${finding.type} - ${finding.message}`) + : ['- None']), + '', + '## Required Actions', + '', + ...(result.requiredActions.length + ? result.requiredActions.map((action) => `- ${action.type}: ${action.target} (${action.reason})`) + : ['- None']), + '', + ]; + return lines.join('\n'); +} + +module.exports = { + evaluateAssayControlCalibration, + buildReviewerPacket, +}; diff --git a/assay-control-calibration-assistant/package.json b/assay-control-calibration-assistant/package.json new file mode 100644 index 00000000..a22a2af1 --- /dev/null +++ b/assay-control-calibration-assistant/package.json @@ -0,0 +1,12 @@ +{ + "name": "assay-control-calibration-assistant", + "version": "1.0.0", + "private": true, + "description": "Dependency-free assay control and calibration completeness assistant for SCIBASE issue #16.", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check render-video.js && node --check test.js", + "test": "node --test test.js", + "demo": "node demo.js", + "video": "node render-video.js" + } +} diff --git a/assay-control-calibration-assistant/render-video.js b/assay-control-calibration-assistant/render-video.js new file mode 100644 index 00000000..f911b9e3 --- /dev/null +++ b/assay-control-calibration-assistant/render-video.js @@ -0,0 +1,59 @@ +const path = require('node:path'); +const fs = require('node:fs'); +const {spawnSync} = require('node:child_process'); + +function resolveFfmpegPath() { + if (process.env.FFMPEG_PATH) { + return process.env.FFMPEG_PATH; + } + + try { + return require('ffmpeg-static'); + } catch (error) { + throw new Error('Set FFMPEG_PATH or install ffmpeg-static to render reports/demo.mp4'); + } +} + +const outputPath = path.join(__dirname, 'reports', 'demo.mp4'); +const windowsArial = 'C:/Windows/Fonts/arial.ttf'; +const font = fs.existsSync(windowsArial) ? 'fontfile=C\\\\:/Windows/Fonts/arial.ttf' : ''; +function drawText(options) { + return `drawtext=${[font, ...options].filter(Boolean).join(':')}`; +} + +const filter = [ + drawText(['fontcolor=white', 'fontsize=46', 'text=Assay Control Calibration Assistant', 'x=60', 'y=90']), + drawText(['fontcolor=0xBAE6FD', 'fontsize=28', 'text=Synthetic reviewer packet for controls calibration QC', 'x=60', 'y=150']), + drawText(['fontcolor=0xD1FAE5', 'fontsize=64', 'text=Approved 1', 'x=90', 'y=280']), + drawText(['fontcolor=0xFEF3C7', 'fontsize=64', 'text=Response 2', 'x=460', 'y=280']), + drawText(['fontcolor=0xFEE2E2', 'fontsize=64', 'text=Hold 1', 'x=880', 'y=280']), + drawText(['fontcolor=white', 'fontsize=28', 'text=Findings 7 Missing controls 3 Calibration issues 2 QC issues 1', 'x=60', 'y=520']), +].join(','); + +const ffmpegPath = resolveFfmpegPath(); +const result = spawnSync(ffmpegPath, [ + '-y', + '-v', + 'error', + '-f', + 'lavfi', + '-i', + 'color=c=0f172a:s=1280x720:d=4', + '-vf', + filter, + '-t', + '4', + '-pix_fmt', + 'yuv420p', + '-c:v', + 'libx264', + '-movflags', + '+faststart', + outputPath, +], {stdio: 'inherit'}); + +if (result.status !== 0) { + process.exit(result.status || 1); +} + +console.log(`Wrote ${outputPath}`); diff --git a/assay-control-calibration-assistant/reports/assay-control-calibration-packet.json b/assay-control-calibration-assistant/reports/assay-control-calibration-packet.json new file mode 100644 index 00000000..fb033f4a --- /dev/null +++ b/assay-control-calibration-assistant/reports/assay-control-calibration-packet.json @@ -0,0 +1,178 @@ +[ + { + "scenario": "missing-required-controls", + "manuscriptId": "ms-missing-controls", + "generatedAt": "2026-05-23T02:00:00Z", + "decision": "hold-for-review", + "readinessScore": 10, + "findings": [ + { + "type": "missing-control", + "severity": "critical", + "assayId": "elisa-cytokine-panel", + "controlType": "positive", + "message": "elisa-cytokine-panel lacks required positive control evidence" + }, + { + "type": "missing-control", + "severity": "critical", + "assayId": "elisa-cytokine-panel", + "controlType": "negative", + "message": "elisa-cytokine-panel lacks required negative control evidence" + }, + { + "type": "missing-control", + "severity": "critical", + "assayId": "elisa-cytokine-panel", + "controlType": "blank", + "message": "elisa-cytokine-panel lacks required blank control evidence" + } + ], + "requiredActions": [ + { + "type": "attach_control_evidence", + "target": "elisa-cytokine-panel:positive", + "reason": "reviewers need explicit control evidence before assay results are released" + }, + { + "type": "attach_control_evidence", + "target": "elisa-cytokine-panel:negative", + "reason": "reviewers need explicit control evidence before assay results are released" + }, + { + "type": "attach_control_evidence", + "target": "elisa-cytokine-panel:blank", + "reason": "reviewers need explicit control evidence before assay results are released" + } + ], + "summary": { + "assayCount": 1, + "controlCount": 1, + "missingControls": 3, + "failedControls": 0, + "calibrationIssues": 0, + "qcIssues": 0, + "severityCounts": { + "critical": 3 + } + } + }, + { + "scenario": "calibration-drift-response", + "manuscriptId": "ms-calibration-drift", + "generatedAt": "2026-05-23T02:00:00Z", + "decision": "needs-author-response", + "readinessScore": 70, + "findings": [ + { + "type": "weak-calibration-fit", + "severity": "major", + "assayId": "lcms-metabolomics", + "curveId": "std-curve-lcms-4", + "observedRSquared": 0.982, + "requiredMinimumRSquared": 0.995, + "message": "lcms-metabolomics calibration fit 0.982 is below 0.995" + }, + { + "type": "calibration-drift", + "severity": "major", + "assayId": "lcms-metabolomics", + "curveId": "std-curve-lcms-4", + "driftPercent": 8.4, + "maxDriftPercent": 5, + "message": "lcms-metabolomics calibration drift 8.4% exceeds 5%" + } + ], + "requiredActions": [ + { + "type": "provide_standard_curve_evidence", + "target": "lcms-metabolomics:std-curve-lcms-4", + "reason": "weak standard curve fit needs source standards or recalibration evidence" + }, + { + "type": "rerun_or_explain_calibration", + "target": "lcms-metabolomics:std-curve-lcms-4", + "reason": "calibration drift must be rerun or explained before release" + } + ], + "summary": { + "assayCount": 1, + "controlCount": 3, + "missingControls": 0, + "failedControls": 0, + "calibrationIssues": 2, + "qcIssues": 0, + "severityCounts": { + "major": 2 + } + } + }, + { + "scenario": "failed-control-and-qc-variation", + "manuscriptId": "ms-qc-variation", + "generatedAt": "2026-05-23T02:00:00Z", + "decision": "needs-author-response", + "readinessScore": 70, + "findings": [ + { + "type": "failed-control", + "severity": "major", + "assayId": "cell-viability-assay", + "controlId": "staurosporine", + "controlType": "positive", + "status": "fail", + "message": "cell-viability-assay has failed positive control staurosporine" + }, + { + "type": "high-qc-variation", + "severity": "major", + "assayId": "cell-viability-assay", + "replicateId": "qc-high", + "coefficientOfVariation": 0.22, + "maxCoefficientOfVariation": 0.15, + "message": "cell-viability-assay QC replicate qc-high CV 0.22 exceeds 0.15" + } + ], + "requiredActions": [ + { + "type": "repeat_or_explain_control", + "target": "cell-viability-assay:staurosporine", + "reason": "failed control must be repeated or justified before reviewer approval" + }, + { + "type": "repeat_qc_or_explain_variation", + "target": "cell-viability-assay:qc-high", + "reason": "high replicate variation needs repeat evidence or reviewer-facing explanation" + } + ], + "summary": { + "assayCount": 1, + "controlCount": 3, + "missingControls": 0, + "failedControls": 1, + "calibrationIssues": 0, + "qcIssues": 1, + "severityCounts": { + "major": 2 + } + } + }, + { + "scenario": "ready-assay-packet", + "manuscriptId": "ms-ready-assay-packet", + "generatedAt": "2026-05-23T02:00:00Z", + "decision": "approved", + "readinessScore": 100, + "findings": [], + "requiredActions": [], + "summary": { + "assayCount": 1, + "controlCount": 3, + "missingControls": 0, + "failedControls": 0, + "calibrationIssues": 0, + "qcIssues": 0, + "severityCounts": {} + } + } +] diff --git a/assay-control-calibration-assistant/reports/demo.mp4 b/assay-control-calibration-assistant/reports/demo.mp4 new file mode 100644 index 00000000..98e8b917 Binary files /dev/null and b/assay-control-calibration-assistant/reports/demo.mp4 differ diff --git a/assay-control-calibration-assistant/reports/reviewer-packet.md b/assay-control-calibration-assistant/reports/reviewer-packet.md new file mode 100644 index 00000000..9ab6566c --- /dev/null +++ b/assay-control-calibration-assistant/reports/reviewer-packet.md @@ -0,0 +1,110 @@ +# Assay Control Calibration Assistant Report + +Manuscript: ms-missing-controls +Generated: 2026-05-23T02:00:00Z +Decision: hold-for-review +Readiness score: 10 + +## Packet Summary + +Assays: 1 +Controls: 1 +Missing controls: 3 +Failed controls: 0 +Calibration issues: 0 +QC issues: 0 +Findings: 3 + +## Findings + +- critical: missing-control - elisa-cytokine-panel lacks required positive control evidence +- critical: missing-control - elisa-cytokine-panel lacks required negative control evidence +- critical: missing-control - elisa-cytokine-panel lacks required blank control evidence + +## Required Actions + +- attach_control_evidence: elisa-cytokine-panel:positive (reviewers need explicit control evidence before assay results are released) +- attach_control_evidence: elisa-cytokine-panel:negative (reviewers need explicit control evidence before assay results are released) +- attach_control_evidence: elisa-cytokine-panel:blank (reviewers need explicit control evidence before assay results are released) + +--- +# Assay Control Calibration Assistant Report + +Manuscript: ms-calibration-drift +Generated: 2026-05-23T02:00:00Z +Decision: needs-author-response +Readiness score: 70 + +## Packet Summary + +Assays: 1 +Controls: 3 +Missing controls: 0 +Failed controls: 0 +Calibration issues: 2 +QC issues: 0 +Findings: 2 + +## Findings + +- major: weak-calibration-fit - lcms-metabolomics calibration fit 0.982 is below 0.995 +- major: calibration-drift - lcms-metabolomics calibration drift 8.4% exceeds 5% + +## Required Actions + +- provide_standard_curve_evidence: lcms-metabolomics:std-curve-lcms-4 (weak standard curve fit needs source standards or recalibration evidence) +- rerun_or_explain_calibration: lcms-metabolomics:std-curve-lcms-4 (calibration drift must be rerun or explained before release) + +--- +# Assay Control Calibration Assistant Report + +Manuscript: ms-qc-variation +Generated: 2026-05-23T02:00:00Z +Decision: needs-author-response +Readiness score: 70 + +## Packet Summary + +Assays: 1 +Controls: 3 +Missing controls: 0 +Failed controls: 1 +Calibration issues: 0 +QC issues: 1 +Findings: 2 + +## Findings + +- major: failed-control - cell-viability-assay has failed positive control staurosporine +- major: high-qc-variation - cell-viability-assay QC replicate qc-high CV 0.22 exceeds 0.15 + +## Required Actions + +- repeat_or_explain_control: cell-viability-assay:staurosporine (failed control must be repeated or justified before reviewer approval) +- repeat_qc_or_explain_variation: cell-viability-assay:qc-high (high replicate variation needs repeat evidence or reviewer-facing explanation) + +--- +# Assay Control Calibration Assistant Report + +Manuscript: ms-ready-assay-packet +Generated: 2026-05-23T02:00:00Z +Decision: approved +Readiness score: 100 + +## Packet Summary + +Assays: 1 +Controls: 3 +Missing controls: 0 +Failed controls: 0 +Calibration issues: 0 +QC issues: 0 +Findings: 0 + +## Findings + +- None + +## Required Actions + +- None diff --git a/assay-control-calibration-assistant/reports/summary.svg b/assay-control-calibration-assistant/reports/summary.svg new file mode 100644 index 00000000..665006a4 --- /dev/null +++ b/assay-control-calibration-assistant/reports/summary.svg @@ -0,0 +1,23 @@ + + + Assay Control Calibration Assistant + Synthetic reviewer packet for assay controls, standards, calibration drift, and QC replicates + + + Approved + 1 + + + + Author Response + 2 + + + + Hold Review + 1 + + Findings: 7. Missing controls: 3. Calibration issues: 2. QC issues: 1. + Checks: positive/negative/blank controls, standard curve fit, calibration drift, QC replicate variation. + Synthetic data only. No private manuscripts, lab data, credentials, external APIs, or network calls. + diff --git a/assay-control-calibration-assistant/sample-data.js b/assay-control-calibration-assistant/sample-data.js new file mode 100644 index 00000000..43168d01 --- /dev/null +++ b/assay-control-calibration-assistant/sample-data.js @@ -0,0 +1,123 @@ +const scenarios = [ + { + name: 'missing-required-controls', + manuscriptId: 'ms-missing-controls', + generatedAt: '2026-05-23T02:00:00Z', + assays: [ + { + id: 'elisa-cytokine-panel', + name: 'Inflammatory cytokine ELISA', + type: 'immunoassay', + instrument: {id: 'plate-reader-a2', version: '3.4.1', lastCalibrationAt: '2026-04-28'}, + requiredControls: ['positive', 'negative', 'blank', 'vehicle'], + controls: [ + {type: 'vehicle', id: 'vehicle-dmso', status: 'pass'}, + ], + calibration: { + curveId: 'std-il6-plate-7', + rSquared: 0.998, + standards: 8, + acceptedRange: [0.99, 1], + driftPercent: 3.1, + maxDriftPercent: 5, + }, + qcReplicates: [ + {id: 'qc-low', coefficientOfVariation: 0.08, maxCoefficientOfVariation: 0.15}, + ], + }, + ], + }, + { + name: 'calibration-drift-response', + manuscriptId: 'ms-calibration-drift', + generatedAt: '2026-05-23T02:00:00Z', + assays: [ + { + id: 'lcms-metabolomics', + name: 'LC-MS metabolomics panel', + type: 'analytical-chemistry', + instrument: {id: 'orbitrap-qe-3', version: '5.1.0', lastCalibrationAt: '2026-01-11'}, + requiredControls: ['positive', 'negative', 'blank'], + controls: [ + {type: 'positive', id: 'nist-mix', status: 'pass'}, + {type: 'negative', id: 'matrix-negative', status: 'pass'}, + {type: 'blank', id: 'solvent-blank', status: 'pass'}, + ], + calibration: { + curveId: 'std-curve-lcms-4', + rSquared: 0.982, + standards: 4, + acceptedRange: [0.995, 1], + driftPercent: 8.4, + maxDriftPercent: 5, + }, + qcReplicates: [ + {id: 'qc-mid', coefficientOfVariation: 0.12, maxCoefficientOfVariation: 0.15}, + ], + }, + ], + }, + { + name: 'failed-control-and-qc-variation', + manuscriptId: 'ms-qc-variation', + generatedAt: '2026-05-23T02:00:00Z', + assays: [ + { + id: 'cell-viability-assay', + name: 'Cell viability luminescence assay', + type: 'wet-lab', + instrument: {id: 'luminometer-9', version: '2.8.0', lastCalibrationAt: '2026-05-01'}, + requiredControls: ['positive', 'negative', 'vehicle'], + controls: [ + {type: 'positive', id: 'staurosporine', status: 'fail'}, + {type: 'negative', id: 'untreated', status: 'pass'}, + {type: 'vehicle', id: 'dmso', status: 'pass'}, + ], + calibration: { + curveId: 'luminescence-cal-2', + rSquared: 0.996, + standards: 6, + acceptedRange: [0.99, 1], + driftPercent: 1.2, + maxDriftPercent: 5, + }, + qcReplicates: [ + {id: 'qc-high', coefficientOfVariation: 0.22, maxCoefficientOfVariation: 0.15}, + ], + }, + ], + }, + { + name: 'ready-assay-packet', + manuscriptId: 'ms-ready-assay-packet', + generatedAt: '2026-05-23T02:00:00Z', + assays: [ + { + id: 'qpcr-expression', + name: 'qPCR expression assay', + type: 'molecular-biology', + instrument: {id: 'quantstudio-5', version: '1.7.2', lastCalibrationAt: '2026-04-20'}, + requiredControls: ['positive', 'negative', 'no-template'], + controls: [ + {type: 'positive', id: 'validated-reference-cdna', status: 'pass'}, + {type: 'negative', id: 'healthy-control', status: 'pass'}, + {type: 'no-template', id: 'ntc-water', status: 'pass'}, + ], + calibration: { + curveId: 'qpcr-std-11', + rSquared: 0.997, + standards: 7, + acceptedRange: [0.99, 1], + driftPercent: 1.6, + maxDriftPercent: 5, + }, + qcReplicates: [ + {id: 'qc-low', coefficientOfVariation: 0.04, maxCoefficientOfVariation: 0.12}, + {id: 'qc-high', coefficientOfVariation: 0.07, maxCoefficientOfVariation: 0.12}, + ], + }, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/assay-control-calibration-assistant/test.js b/assay-control-calibration-assistant/test.js new file mode 100644 index 00000000..1adc33c7 --- /dev/null +++ b/assay-control-calibration-assistant/test.js @@ -0,0 +1,165 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateAssayControlCalibration, + buildReviewerPacket, +} = require('./index'); + +test('holds assays with missing positive, negative, and blank controls', () => { + const result = evaluateAssayControlCalibration({ + manuscriptId: 'ms-missing-controls', + generatedAt: '2026-05-23T02:00:00Z', + assays: [ + { + id: 'elisa-cytokine-panel', + name: 'Inflammatory cytokine ELISA', + type: 'immunoassay', + requiredControls: ['positive', 'negative', 'blank', 'vehicle'], + controls: [ + {type: 'vehicle', id: 'vehicle-dmso', status: 'pass'}, + ], + calibration: { + curveId: 'std-il6-plate-7', + rSquared: 0.998, + standards: 8, + acceptedRange: [0.99, 1], + driftPercent: 3.1, + maxDriftPercent: 5, + }, + qcReplicates: [ + {id: 'qc-low', coefficientOfVariation: 0.08, maxCoefficientOfVariation: 0.15}, + ], + }, + ], + }); + + assert.equal(result.decision, 'hold-for-review'); + assert.equal(result.summary.assayCount, 1); + assert.equal(result.summary.missingControls, 3); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['missing-control', 'missing-control', 'missing-control'] + ); + assert.equal(result.requiredActions[0].type, 'attach_control_evidence'); +}); + +test('requires author response for calibration drift and weak standard curves', () => { + const result = evaluateAssayControlCalibration({ + manuscriptId: 'ms-calibration-drift', + generatedAt: '2026-05-23T02:00:00Z', + assays: [ + { + id: 'lcms-metabolomics', + name: 'LC-MS metabolomics panel', + type: 'analytical-chemistry', + requiredControls: ['positive', 'negative', 'blank'], + controls: [ + {type: 'positive', id: 'nist-mix', status: 'pass'}, + {type: 'negative', id: 'matrix-negative', status: 'pass'}, + {type: 'blank', id: 'solvent-blank', status: 'pass'}, + ], + calibration: { + curveId: 'std-curve-lcms-4', + rSquared: 0.982, + standards: 4, + acceptedRange: [0.995, 1], + driftPercent: 8.4, + maxDriftPercent: 5, + }, + qcReplicates: [ + {id: 'qc-mid', coefficientOfVariation: 0.12, maxCoefficientOfVariation: 0.15}, + ], + }, + ], + }); + + assert.equal(result.decision, 'needs-author-response'); + assert.equal(result.summary.calibrationIssues, 2); + assert.equal(result.findings[0].type, 'weak-calibration-fit'); + assert.equal(result.findings[1].type, 'calibration-drift'); + assert.equal(result.requiredActions[1].type, 'rerun_or_explain_calibration'); +}); + +test('flags failed controls and high replicate variation as major findings', () => { + const result = evaluateAssayControlCalibration({ + manuscriptId: 'ms-qc-variation', + generatedAt: '2026-05-23T02:00:00Z', + assays: [ + { + id: 'cell-viability-assay', + name: 'Cell viability luminescence assay', + type: 'wet-lab', + requiredControls: ['positive', 'negative', 'vehicle'], + controls: [ + {type: 'positive', id: 'staurosporine', status: 'fail'}, + {type: 'negative', id: 'untreated', status: 'pass'}, + {type: 'vehicle', id: 'dmso', status: 'pass'}, + ], + calibration: { + curveId: 'luminescence-cal-2', + rSquared: 0.996, + standards: 6, + acceptedRange: [0.99, 1], + driftPercent: 1.2, + maxDriftPercent: 5, + }, + qcReplicates: [ + {id: 'qc-high', coefficientOfVariation: 0.22, maxCoefficientOfVariation: 0.15}, + ], + }, + ], + }); + + assert.equal(result.decision, 'needs-author-response'); + assert.equal(result.summary.failedControls, 1); + assert.equal(result.summary.qcIssues, 1); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['failed-control', 'high-qc-variation'] + ); +}); + +test('approves complete assay packets and builds deterministic reviewer packet', () => { + const result = evaluateAssayControlCalibration({ + manuscriptId: 'ms-ready-assay-packet', + generatedAt: '2026-05-23T02:00:00Z', + assays: [ + { + id: 'qpcr-expression', + name: 'qPCR expression assay', + type: 'molecular-biology', + instrument: {id: 'quantstudio-5', version: '1.7.2', lastCalibrationAt: '2026-04-20'}, + requiredControls: ['positive', 'negative', 'no-template'], + controls: [ + {type: 'positive', id: 'validated-reference-cdna', status: 'pass'}, + {type: 'negative', id: 'healthy-control', status: 'pass'}, + {type: 'no-template', id: 'ntc-water', status: 'pass'}, + ], + calibration: { + curveId: 'qpcr-std-11', + rSquared: 0.997, + standards: 7, + acceptedRange: [0.99, 1], + driftPercent: 1.6, + maxDriftPercent: 5, + }, + qcReplicates: [ + {id: 'qc-low', coefficientOfVariation: 0.04, maxCoefficientOfVariation: 0.12}, + {id: 'qc-high', coefficientOfVariation: 0.07, maxCoefficientOfVariation: 0.12}, + ], + }, + ], + }); + + assert.equal(result.decision, 'approved'); + assert.equal(result.readinessScore, 100); + assert.equal(result.findings.length, 0); + + const packet = buildReviewerPacket(result); + assert.match(packet, /# Assay Control Calibration Assistant Report/); + assert.match(packet, /Manuscript: ms-ready-assay-packet/); + assert.match(packet, /Decision: approved/); + assert.match(packet, /Readiness score: 100/); + assert.match(packet, /Findings: 0/); +});