Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions assay-control-calibration-assistant/README.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions assay-control-calibration-assistant/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#0f172a"/>
<text x="48" y="72" fill="#f8fafc" font-family="Arial, sans-serif" font-size="34" font-weight="700">Assay Control Calibration Assistant</text>
<text x="48" y="112" fill="#bae6fd" font-family="Arial, sans-serif" font-size="18">Synthetic reviewer packet for assay controls, standards, calibration drift, and QC replicates</text>
<g transform="translate(48 158)">
<rect width="250" height="154" rx="8" fill="#047857"/>
<text x="24" y="48" fill="#d1fae5" font-family="Arial, sans-serif" font-size="20" font-weight="700">Approved</text>
<text x="24" y="108" fill="#ecfdf5" font-family="Arial, sans-serif" font-size="58" font-weight="700">${approved}</text>
</g>
<g transform="translate(354 158)">
<rect width="250" height="154" rx="8" fill="#a16207"/>
<text x="24" y="48" fill="#fef3c7" font-family="Arial, sans-serif" font-size="20" font-weight="700">Author Response</text>
<text x="24" y="108" fill="#fffbeb" font-family="Arial, sans-serif" font-size="58" font-weight="700">${response}</text>
</g>
<g transform="translate(660 158)">
<rect width="250" height="154" rx="8" fill="#b91c1c"/>
<text x="24" y="48" fill="#fee2e2" font-family="Arial, sans-serif" font-size="20" font-weight="700">Hold Review</text>
<text x="24" y="108" fill="#fef2f2" font-family="Arial, sans-serif" font-size="58" font-weight="700">${hold}</text>
</g>
<text x="48" y="378" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="22">Findings: ${findings}. Missing controls: ${missingControls}. Calibration issues: ${calibrationIssues}. QC issues: ${qcIssues}.</text>
<text x="48" y="420" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="18">Checks: positive/negative/blank controls, standard curve fit, calibration drift, QC replicate variation.</text>
<text x="48" y="478" fill="#94a3b8" font-family="Arial, sans-serif" font-size="16">Synthetic data only. No private manuscripts, lab data, credentials, external APIs, or network calls.</text>
</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}`);
245 changes: 245 additions & 0 deletions assay-control-calibration-assistant/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
12 changes: 12 additions & 0 deletions assay-control-calibration-assistant/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading