From 8415b45ee294a10a8bfdf830e7d18290f9a802df Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Sat, 23 May 2026 02:26:21 +0700 Subject: [PATCH] Add assay control calibration assistant --- assay-control-calibration-assistant/README.md | 29 +++ assay-control-calibration-assistant/demo.js | 59 +++++ assay-control-calibration-assistant/index.js | 245 ++++++++++++++++++ .../package.json | 12 + .../render-video.js | 59 +++++ .../assay-control-calibration-packet.json | 178 +++++++++++++ .../reports/demo.mp4 | Bin 0 -> 35313 bytes .../reports/reviewer-packet.md | 110 ++++++++ .../reports/summary.svg | 23 ++ .../sample-data.js | 123 +++++++++ assay-control-calibration-assistant/test.js | 165 ++++++++++++ 11 files changed, 1003 insertions(+) create mode 100644 assay-control-calibration-assistant/README.md create mode 100644 assay-control-calibration-assistant/demo.js create mode 100644 assay-control-calibration-assistant/index.js create mode 100644 assay-control-calibration-assistant/package.json create mode 100644 assay-control-calibration-assistant/render-video.js create mode 100644 assay-control-calibration-assistant/reports/assay-control-calibration-packet.json create mode 100644 assay-control-calibration-assistant/reports/demo.mp4 create mode 100644 assay-control-calibration-assistant/reports/reviewer-packet.md create mode 100644 assay-control-calibration-assistant/reports/summary.svg create mode 100644 assay-control-calibration-assistant/sample-data.js create mode 100644 assay-control-calibration-assistant/test.js 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 0000000000000000000000000000000000000000..98e8b9170356682d705bd9d0e68b6bdec436f5df GIT binary patch literal 35313 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 literal 0 HcmV?d00001 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/); +});