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 = `
+`;
+
+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 @@
+
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/);
+});