diff --git a/randomization-blinding-integrity-assistant/README.md b/randomization-blinding-integrity-assistant/README.md new file mode 100644 index 00000000..10dc30eb --- /dev/null +++ b/randomization-blinding-integrity-assistant/README.md @@ -0,0 +1,24 @@ +# Randomization Blinding Integrity Assistant + +Self-contained reviewer assistant slice for SCIBASE issue #16. + +It evaluates synthetic clinical and preclinical study packets before AI peer-review output is shown. The guard checks randomization sequence evidence, allocation concealment, arm balance, stratification balance, blinding role coverage, arm-label leakage, early unblinding, and post-randomization exclusions. + +## Files + +- `index.js` - dependency-free evaluator and Markdown reviewer packet builder +- `sample-data.js` - synthetic study packets +- `test.js` - Node test coverage for hold, author-response, imbalance, and approved paths +- `demo.js` - writes JSON, Markdown, and SVG reviewer artifacts under `reports/` +- `render-video.js` - creates a short MP4 demo artifact + +## Validation + +```bash +npm run check +npm test +npm run demo +npm run video +``` + +Synthetic data only. No private manuscripts, patient records, credentials, external APIs, model calls, network calls, payment data, or payout details are used. diff --git a/randomization-blinding-integrity-assistant/demo.js b/randomization-blinding-integrity-assistant/demo.js new file mode 100644 index 00000000..ff680092 --- /dev/null +++ b/randomization-blinding-integrity-assistant/demo.js @@ -0,0 +1,79 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const {evaluateRandomizationBlindingIntegrity, buildReviewerPacket} = require('./index'); +const {samplePacket} = require('./sample-data'); + +const REPORT_DIR = path.join(__dirname, 'reports'); + +function escapeXml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function buildSummarySvg(result) { + const width = 1280; + const height = 720; + const findingWidth = Math.max(20, Math.min(760, result.findings.length * 72)); + const scoreWidth = Math.max(20, Math.min(760, result.readinessScore * 7.6)); + const actionWidth = Math.max(20, Math.min(760, result.requiredActions.length * 90)); + const topFindings = result.findings.slice(0, 5); + + return ` + + + Randomization and blinding integrity + ${escapeXml(result.manuscriptId)} • ${escapeXml(result.decision)} + + Readiness score + + + ${result.readinessScore}/100 + + + Findings + + + ${result.findings.length} + + + Required actions + + + ${result.requiredActions.length} + + Top reviewer checks + ${topFindings.map((finding, index) => `• ${escapeXml(finding.type)} in ${escapeXml(finding.studyId)}`).join('\n ')} + Synthetic data only. No external services, credentials, patient data, or live manuscripts. +`; +} + +function main() { + fs.mkdirSync(REPORT_DIR, {recursive: true}); + const result = evaluateRandomizationBlindingIntegrity(samplePacket); + const markdown = buildReviewerPacket(result); + + fs.writeFileSync(path.join(REPORT_DIR, 'randomization-blinding-packet.json'), `${JSON.stringify(result, null, 2)}\n`); + fs.writeFileSync(path.join(REPORT_DIR, 'randomization-blinding-report.md'), markdown); + fs.writeFileSync(path.join(REPORT_DIR, 'summary.svg'), buildSummarySvg(result)); + + console.log(JSON.stringify({ + manuscriptId: result.manuscriptId, + decision: result.decision, + readinessScore: result.readinessScore, + findings: result.findings.length, + requiredActions: result.requiredActions.length, + auditDigest: result.auditDigest, + }, null, 2)); +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildSummarySvg, +}; diff --git a/randomization-blinding-integrity-assistant/index.js b/randomization-blinding-integrity-assistant/index.js new file mode 100644 index 00000000..98588fc1 --- /dev/null +++ b/randomization-blinding-integrity-assistant/index.js @@ -0,0 +1,307 @@ +const crypto = require('node:crypto'); + +const RANDOMIZATION_FINDINGS = new Set([ + 'missing-randomization-sequence', + 'missing-allocation-concealment', + 'arm-size-imbalance', + 'stratification-imbalance', +]); + +const BLINDING_FINDINGS = new Set([ + 'missing-blinding-role', + 'arm-label-leakage', + 'early-unblinding-event', +]); + +const EXCLUSION_FINDINGS = new Set([ + 'unblinded-post-randomization-exclusion', +]); + +const BALANCE_FINDINGS = new Set([ + 'arm-size-imbalance', + 'stratification-imbalance', +]); + +function cleanText(value) { + return String(value || '').trim(); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isClinicalStudy(study) { + const text = `${study.design || ''} ${study.population || ''}`.toLowerCase(); + return /clinical|trial|patient|participant|human/.test(text) && !/preclinical|animal|mouse|rat/.test(text); +} + +function percentArmImbalance(arms) { + const counts = asArray(arms) + .map((arm) => Number(arm.enrolled || 0)) + .filter((count) => Number.isFinite(count) && count > 0); + if (counts.length < 2) return 0; + const max = Math.max(...counts); + const min = Math.min(...counts); + return max === 0 ? 0 : ((max - min) / max) * 100; +} + +function percentStratumImbalance(arms, stratumName) { + const counts = asArray(arms) + .map((arm) => Number(arm.strata?.[stratumName] || 0)) + .filter((count) => Number.isFinite(count) && count > 0); + if (counts.length < 2) return 0; + const max = Math.max(...counts); + const min = Math.min(...counts); + return max === 0 ? 0 : ((max - min) / max) * 100; +} + +function addFinding(findings, finding) { + findings.push({ + severity: finding.severity || 'major', + ...finding, + }); +} + +function evaluateStudy(study) { + const findings = []; + const randomization = study.randomization || {}; + const blinding = study.blinding || {}; + + if (!randomization.sequenceEvidence) { + addFinding(findings, { + type: 'missing-randomization-sequence', + severity: 'critical', + studyId: study.id, + message: 'Randomization sequence evidence is missing from the reviewer packet.', + action: 'attach_randomization_sequence', + }); + } + + if (!randomization.allocationConcealment) { + addFinding(findings, { + type: 'missing-allocation-concealment', + severity: 'critical', + studyId: study.id, + message: 'Allocation concealment is not documented before outcome review.', + action: 'document_allocation_concealment', + }); + } + + const maxArmImbalancePercent = Number(randomization.maxArmImbalancePercent || 25); + const armImbalance = percentArmImbalance(study.arms); + if (armImbalance > maxArmImbalancePercent) { + addFinding(findings, { + type: 'arm-size-imbalance', + severity: 'major', + studyId: study.id, + metric: Number(armImbalance.toFixed(1)), + threshold: maxArmImbalancePercent, + message: `Arm sizes differ by ${armImbalance.toFixed(1)}%, above the configured ${maxArmImbalancePercent}% threshold.`, + action: 'explain_randomization_imbalance', + }); + } + + for (const factor of asArray(randomization.stratificationFactors)) { + const imbalance = percentStratumImbalance(study.arms, factor); + if (imbalance > 50) { + addFinding(findings, { + type: 'stratification-imbalance', + severity: 'major', + studyId: study.id, + stratum: factor, + metric: Number(imbalance.toFixed(1)), + threshold: 50, + message: `Stratum ${factor} differs by ${imbalance.toFixed(1)}% between arms.`, + action: 'review_stratification_balance', + }); + } + } + + const requiredRoles = isClinicalStudy(study) + ? ['participant', 'careProvider', 'outcomeAssessor', 'analyst'] + : ['outcomeAssessor', 'analyst']; + const missingRoles = requiredRoles.filter((role) => blinding[role] !== true); + if (missingRoles.length > 0) { + addFinding(findings, { + type: 'missing-blinding-role', + severity: 'major', + studyId: study.id, + roles: missingRoles, + message: `Blinding is missing for ${missingRoles.join(', ')}.`, + action: 'document_blinding_roles', + }); + } + + const exposures = asArray(blinding.armLabelExposure); + if (exposures.length > 0) { + addFinding(findings, { + type: 'arm-label-leakage', + severity: 'major', + studyId: study.id, + artifacts: exposures.map((item) => item.artifact || 'unknown artifact'), + message: 'Arm labels are visible in analysis or reviewer artifacts before endpoint lock.', + action: 'mask_arm_labels_before_analysis', + }); + } + + for (const event of asArray(blinding.unblindingEvents)) { + if (event.beforePrimaryEndpointLock) { + addFinding(findings, { + type: 'early-unblinding-event', + severity: 'major', + studyId: study.id, + role: event.role || 'unknown role', + occurredAt: event.occurredAt || 'unknown date', + message: 'Unblinding occurred before primary endpoint lock.', + action: 'explain_unblinding_event', + }); + } + } + + for (const exclusion of asArray(study.exclusions)) { + if (exclusion.afterRandomization && !exclusion.blindedDecision) { + addFinding(findings, { + type: 'unblinded-post-randomization-exclusion', + severity: 'major', + studyId: study.id, + participantId: exclusion.participantId || 'unknown participant', + reason: exclusion.reason || 'not provided', + message: 'Post-randomization exclusion lacks blinded decision evidence.', + action: 'justify_unblinded_exclusion', + }); + } + } + + return findings; +} + +function summarize(findings, studyCount) { + return { + studyCount, + randomizationIssues: findings.filter((finding) => RANDOMIZATION_FINDINGS.has(finding.type)).length, + blindingIssues: findings.filter((finding) => BLINDING_FINDINGS.has(finding.type)).length, + exclusionIssues: findings.filter((finding) => EXCLUSION_FINDINGS.has(finding.type)).length, + balanceIssues: findings.filter((finding) => BALANCE_FINDINGS.has(finding.type)).length, + }; +} + +function calculateReadinessScore(findings) { + const score = findings.reduce((total, finding) => { + if (finding.severity === 'critical') return total - 25; + if (finding.severity === 'major') return total - 15; + return total - 8; + }, 100); + return Math.max(0, score); +} + +function chooseDecision(findings) { + if (findings.some((finding) => finding.severity === 'critical')) { + return 'hold-for-review'; + } + if (findings.length > 0) { + return 'needs-author-response'; + } + return 'approved'; +} + +function buildRequiredActions(findings) { + const seen = new Set(); + return findings + .map((finding) => ({ + type: finding.action, + studyId: finding.studyId, + findingType: finding.type, + message: actionMessage(finding), + })) + .filter((action) => { + const key = `${action.type}:${action.studyId}:${action.findingType}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function actionMessage(finding) { + const messages = { + attach_randomization_sequence: 'Attach sequence generation evidence or randomization service export.', + document_allocation_concealment: 'Document who concealed allocation and when assignments became visible.', + explain_randomization_imbalance: 'Explain observed arm imbalance and whether it affects the primary endpoint.', + review_stratification_balance: 'Review stratification balance and update the analysis caveat.', + document_blinding_roles: 'Document blinded roles or explain why a role could not be blinded.', + mask_arm_labels_before_analysis: 'Mask labels in notebooks, tables, and reviewer packets before endpoint lock.', + explain_unblinding_event: 'Explain unblinding timing, affected roles, and sensitivity analysis impact.', + justify_unblinded_exclusion: 'Justify post-randomization exclusions with blinded adjudication evidence.', + }; + return messages[finding.action] || finding.message; +} + +function buildDigest(input) { + return crypto.createHash('sha256').update(JSON.stringify(input)).digest('hex').slice(0, 16); +} + +function evaluateRandomizationBlindingIntegrity(packet) { + const studies = asArray(packet.studies); + const findings = studies.flatMap(evaluateStudy); + const summary = summarize(findings, studies.length); + const decision = chooseDecision(findings); + const readinessScore = decision === 'approved' ? 100 : calculateReadinessScore(findings); + + return { + manuscriptId: cleanText(packet.manuscriptId) || 'unknown-manuscript', + generatedAt: packet.generatedAt || new Date().toISOString(), + decision, + readinessScore, + summary, + findings, + requiredActions: buildRequiredActions(findings), + auditDigest: buildDigest({studies, findings, summary, decision}), + }; +} + +function buildReviewerPacket(result) { + const lines = [ + '# Randomization Blinding Integrity Assistant Report', + '', + `Manuscript: ${result.manuscriptId}`, + `Generated: ${result.generatedAt}`, + `Decision: ${result.decision}`, + `Readiness score: ${result.readinessScore}`, + `Findings: ${result.findings.length}`, + `Audit digest: ${result.auditDigest}`, + '', + '## Summary', + '', + `- Studies reviewed: ${result.summary.studyCount}`, + `- Randomization issues: ${result.summary.randomizationIssues}`, + `- Blinding issues: ${result.summary.blindingIssues}`, + `- Exclusion issues: ${result.summary.exclusionIssues}`, + `- Balance issues: ${result.summary.balanceIssues}`, + '', + '## Findings', + '', + ]; + + if (result.findings.length === 0) { + lines.push('- No randomization or blinding integrity findings.'); + } else { + for (const finding of result.findings) { + lines.push(`- ${finding.severity.toUpperCase()} ${finding.type} in ${finding.studyId}: ${finding.message}`); + } + } + + lines.push('', '## Required Actions', ''); + if (result.requiredActions.length === 0) { + lines.push('- No author action required.'); + } else { + for (const action of result.requiredActions) { + lines.push(`- ${action.type} (${action.studyId}): ${action.message}`); + } + } + + return `${lines.join('\n')}\n`; +} + +module.exports = { + evaluateRandomizationBlindingIntegrity, + buildReviewerPacket, +}; diff --git a/randomization-blinding-integrity-assistant/package.json b/randomization-blinding-integrity-assistant/package.json new file mode 100644 index 00000000..9d9de7e3 --- /dev/null +++ b/randomization-blinding-integrity-assistant/package.json @@ -0,0 +1,12 @@ +{ + "name": "randomization-blinding-integrity-assistant", + "version": "1.0.0", + "private": true, + "description": "Dependency-free randomization and blinding integrity 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/randomization-blinding-integrity-assistant/render-video.js b/randomization-blinding-integrity-assistant/render-video.js new file mode 100644 index 00000000..c69c4e16 --- /dev/null +++ b/randomization-blinding-integrity-assistant/render-video.js @@ -0,0 +1,60 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const {spawnSync} = require('node:child_process'); + +const {evaluateRandomizationBlindingIntegrity} = require('./index'); +const {samplePacket} = require('./sample-data'); + +const REPORT_DIR = path.join(__dirname, 'reports'); + +function resolveFfmpeg() { + if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH; + const candidate = path.resolve(__dirname, '..', '..', '..', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe'); + if (fs.existsSync(candidate)) return candidate; + return 'ffmpeg'; +} + +function main() { + fs.mkdirSync(REPORT_DIR, {recursive: true}); + const result = evaluateRandomizationBlindingIntegrity(samplePacket); + const outPath = path.join(REPORT_DIR, 'demo.mp4'); + const ffmpeg = resolveFfmpeg(); + const scoreWidth = Math.max(24, Math.min(820, Math.round(result.readinessScore * 8.2))); + const findingWidth = Math.max(24, Math.min(820, result.findings.length * 86)); + const actionWidth = Math.max(24, Math.min(820, result.requiredActions.length * 92)); + const filters = [ + 'drawbox=x=52:y=52:w=1176:h=616:color=white@0.13:t=fill', + 'drawbox=x=76:y=76:w=1128:h=568:color=white@0.08:t=fill', + 'drawbox=x=110:y=168:w=820:h=44:color=white@0.28:t=fill', + `drawbox=x=110:y=168:w=${scoreWidth}:h=44:color=0x2e7d32@1:t=fill`, + 'drawbox=x=110:y=286:w=820:h=44:color=white@0.28:t=fill', + `drawbox=x=110:y=286:w=${findingWidth}:h=44:color=0xc2410c@1:t=fill`, + 'drawbox=x=110:y=404:w=820:h=44:color=white@0.28:t=fill', + `drawbox=x=110:y=404:w=${actionWidth}:h=44:color=0x1565c0@1:t=fill`, + 'drawbox=x=984:y=168:w=140:h=44:color=0x2e7d32@1:t=fill', + 'drawbox=x=984:y=286:w=140:h=44:color=0xc2410c@1:t=fill', + 'drawbox=x=984:y=404:w=140:h=44:color=0x1565c0@1:t=fill', + 'drawbox=x=110:y=548:w=520:h=38:color=white@0.22:t=fill', + 'drawbox=x=110:y=548:w=420:h=38:color=0xf9ab00@1:t=fill', + ].join(','); + + const args = [ + '-y', + '-f', 'lavfi', + '-i', 'color=c=0x17324d:s=1280x720:d=4:r=25', + '-vf', filters, + '-c:v', 'libx264', + '-pix_fmt', 'yuv420p', + '-movflags', '+faststart', + outPath, + ]; + const resultProcess = spawnSync(ffmpeg, args, {stdio: 'inherit'}); + if (resultProcess.status !== 0) { + throw new Error(`ffmpeg exited with ${resultProcess.status}`); + } + console.log(outPath); +} + +if (require.main === module) { + main(); +} diff --git a/randomization-blinding-integrity-assistant/reports/demo.mp4 b/randomization-blinding-integrity-assistant/reports/demo.mp4 new file mode 100644 index 00000000..312ccda7 Binary files /dev/null and b/randomization-blinding-integrity-assistant/reports/demo.mp4 differ diff --git a/randomization-blinding-integrity-assistant/reports/randomization-blinding-packet.json b/randomization-blinding-integrity-assistant/reports/randomization-blinding-packet.json new file mode 100644 index 00000000..1036a33e --- /dev/null +++ b/randomization-blinding-integrity-assistant/reports/randomization-blinding-packet.json @@ -0,0 +1,106 @@ +{ + "manuscriptId": "ms-randomized-review-demo", + "generatedAt": "2026-05-23T06:00:00Z", + "decision": "hold-for-review", + "readinessScore": 0, + "summary": { + "studyCount": 3, + "randomizationIssues": 2, + "blindingIssues": 3, + "exclusionIssues": 1, + "balanceIssues": 0 + }, + "findings": [ + { + "severity": "critical", + "type": "missing-randomization-sequence", + "studyId": "trial-microbiome-01", + "message": "Randomization sequence evidence is missing from the reviewer packet.", + "action": "attach_randomization_sequence" + }, + { + "severity": "critical", + "type": "missing-allocation-concealment", + "studyId": "trial-microbiome-01", + "message": "Allocation concealment is not documented before outcome review.", + "action": "document_allocation_concealment" + }, + { + "severity": "major", + "type": "missing-blinding-role", + "studyId": "animal-stroke-02", + "roles": [ + "analyst" + ], + "message": "Blinding is missing for analyst.", + "action": "document_blinding_roles" + }, + { + "severity": "major", + "type": "arm-label-leakage", + "studyId": "animal-stroke-02", + "artifacts": [ + "analysis-notebook.ipynb" + ], + "message": "Arm labels are visible in analysis or reviewer artifacts before endpoint lock.", + "action": "mask_arm_labels_before_analysis" + }, + { + "severity": "major", + "type": "early-unblinding-event", + "studyId": "animal-stroke-02", + "role": "analyst", + "occurredAt": "2026-04-11", + "message": "Unblinding occurred before primary endpoint lock.", + "action": "explain_unblinding_event" + }, + { + "severity": "major", + "type": "unblinded-post-randomization-exclusion", + "studyId": "animal-stroke-02", + "participantId": "rat-17", + "reason": "outlier response", + "message": "Post-randomization exclusion lacks blinded decision evidence.", + "action": "justify_unblinded_exclusion" + } + ], + "requiredActions": [ + { + "type": "attach_randomization_sequence", + "studyId": "trial-microbiome-01", + "findingType": "missing-randomization-sequence", + "message": "Attach sequence generation evidence or randomization service export." + }, + { + "type": "document_allocation_concealment", + "studyId": "trial-microbiome-01", + "findingType": "missing-allocation-concealment", + "message": "Document who concealed allocation and when assignments became visible." + }, + { + "type": "document_blinding_roles", + "studyId": "animal-stroke-02", + "findingType": "missing-blinding-role", + "message": "Document blinded roles or explain why a role could not be blinded." + }, + { + "type": "mask_arm_labels_before_analysis", + "studyId": "animal-stroke-02", + "findingType": "arm-label-leakage", + "message": "Mask labels in notebooks, tables, and reviewer packets before endpoint lock." + }, + { + "type": "explain_unblinding_event", + "studyId": "animal-stroke-02", + "findingType": "early-unblinding-event", + "message": "Explain unblinding timing, affected roles, and sensitivity analysis impact." + }, + { + "type": "justify_unblinded_exclusion", + "studyId": "animal-stroke-02", + "findingType": "unblinded-post-randomization-exclusion", + "message": "Justify post-randomization exclusions with blinded adjudication evidence." + } + ], + "auditDigest": "d8ee0413885e0ff3" +} diff --git a/randomization-blinding-integrity-assistant/reports/randomization-blinding-report.md b/randomization-blinding-integrity-assistant/reports/randomization-blinding-report.md new file mode 100644 index 00000000..45ec1ac5 --- /dev/null +++ b/randomization-blinding-integrity-assistant/reports/randomization-blinding-report.md @@ -0,0 +1,34 @@ +# Randomization Blinding Integrity Assistant Report + +Manuscript: ms-randomized-review-demo +Generated: 2026-05-23T06:00:00Z +Decision: hold-for-review +Readiness score: 0 +Findings: 6 +Audit digest: d8ee0413885e0ff3 + +## Summary + +- Studies reviewed: 3 +- Randomization issues: 2 +- Blinding issues: 3 +- Exclusion issues: 1 +- Balance issues: 0 + +## Findings + +- CRITICAL missing-randomization-sequence in trial-microbiome-01: Randomization sequence evidence is missing from the reviewer packet. +- CRITICAL missing-allocation-concealment in trial-microbiome-01: Allocation concealment is not documented before outcome review. +- MAJOR missing-blinding-role in animal-stroke-02: Blinding is missing for analyst. +- MAJOR arm-label-leakage in animal-stroke-02: Arm labels are visible in analysis or reviewer artifacts before endpoint lock. +- MAJOR early-unblinding-event in animal-stroke-02: Unblinding occurred before primary endpoint lock. +- MAJOR unblinded-post-randomization-exclusion in animal-stroke-02: Post-randomization exclusion lacks blinded decision evidence. + +## Required Actions + +- attach_randomization_sequence (trial-microbiome-01): Attach sequence generation evidence or randomization service export. +- document_allocation_concealment (trial-microbiome-01): Document who concealed allocation and when assignments became visible. +- document_blinding_roles (animal-stroke-02): Document blinded roles or explain why a role could not be blinded. +- mask_arm_labels_before_analysis (animal-stroke-02): Mask labels in notebooks, tables, and reviewer packets before endpoint lock. +- explain_unblinding_event (animal-stroke-02): Explain unblinding timing, affected roles, and sensitivity analysis impact. +- justify_unblinded_exclusion (animal-stroke-02): Justify post-randomization exclusions with blinded adjudication evidence. diff --git a/randomization-blinding-integrity-assistant/reports/summary.svg b/randomization-blinding-integrity-assistant/reports/summary.svg new file mode 100644 index 00000000..d5caf53e --- /dev/null +++ b/randomization-blinding-integrity-assistant/reports/summary.svg @@ -0,0 +1,31 @@ + + + + Randomization and blinding integrity + ms-randomized-review-demo • hold-for-review + + Readiness score + + + 0/100 + + + Findings + + + 6 + + + Required actions + + + 6 + + Top reviewer checks + • missing-randomization-sequence in trial-microbiome-01 + • missing-allocation-concealment in trial-microbiome-01 + • missing-blinding-role in animal-stroke-02 + • arm-label-leakage in animal-stroke-02 + • early-unblinding-event in animal-stroke-02 + Synthetic data only. No external services, credentials, patient data, or live manuscripts. + \ No newline at end of file diff --git a/randomization-blinding-integrity-assistant/sample-data.js b/randomization-blinding-integrity-assistant/sample-data.js new file mode 100644 index 00000000..fe8060cf --- /dev/null +++ b/randomization-blinding-integrity-assistant/sample-data.js @@ -0,0 +1,93 @@ +const samplePacket = { + manuscriptId: 'ms-randomized-review-demo', + generatedAt: '2026-05-23T06:00:00Z', + studies: [ + { + id: 'trial-microbiome-01', + design: 'parallel clinical trial', + population: 'adult participants', + arms: [ + {id: 'control', enrolled: 42, strata: {siteA: 20, siteB: 22}}, + {id: 'probiotic', enrolled: 41, strata: {siteA: 21, siteB: 20}}, + ], + randomization: { + sequenceEvidence: false, + method: '', + allocationConcealment: false, + stratificationFactors: ['siteA', 'siteB'], + maxArmImbalancePercent: 15, + }, + blinding: { + participant: true, + careProvider: true, + outcomeAssessor: true, + analyst: true, + armLabelExposure: [], + unblindingEvents: [], + }, + exclusions: [], + }, + { + id: 'animal-stroke-02', + design: 'preclinical animal experiment', + population: 'rat ischemia model', + arms: [ + {id: 'sham', enrolled: 18, strata: {male: 9, female: 9}}, + {id: 'treatment', enrolled: 18, strata: {male: 10, female: 8}}, + ], + randomization: { + sequenceEvidence: true, + method: 'blocked randomization', + allocationConcealment: true, + stratificationFactors: ['male', 'female'], + maxArmImbalancePercent: 20, + }, + blinding: { + participant: false, + careProvider: false, + outcomeAssessor: true, + analyst: false, + armLabelExposure: [ + {artifact: 'analysis-notebook.ipynb', label: 'treatment arm names visible before primary analysis'}, + ], + unblindingEvents: [ + {role: 'analyst', occurredAt: '2026-04-11', beforePrimaryEndpointLock: true}, + ], + }, + exclusions: [ + {participantId: 'rat-17', afterRandomization: true, reason: 'outlier response', blindedDecision: false}, + ], + }, + { + id: 'trial-oncology-04', + design: 'double-blind randomized trial', + population: 'human oncology participants', + arms: [ + {id: 'placebo', enrolled: 120, strata: {siteA: 60, siteB: 60}}, + {id: 'therapy', enrolled: 118, strata: {siteA: 59, siteB: 59}}, + ], + randomization: { + sequenceEvidence: true, + method: 'centralized permuted blocks', + allocationConcealment: true, + stratificationFactors: ['siteA', 'siteB'], + maxArmImbalancePercent: 10, + }, + blinding: { + participant: true, + careProvider: true, + outcomeAssessor: true, + analyst: true, + armLabelExposure: [], + unblindingEvents: [], + }, + exclusions: [ + {participantId: 'p-099', afterRandomization: true, reason: 'withdrew consent', blindedDecision: true}, + ], + }, + ], +}; + +module.exports = { + samplePacket, +}; diff --git a/randomization-blinding-integrity-assistant/test.js b/randomization-blinding-integrity-assistant/test.js new file mode 100644 index 00000000..90cb93a1 --- /dev/null +++ b/randomization-blinding-integrity-assistant/test.js @@ -0,0 +1,179 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateRandomizationBlindingIntegrity, + buildReviewerPacket, +} = require('./index'); + +test('holds studies that lack sequence evidence and allocation concealment', () => { + const result = evaluateRandomizationBlindingIntegrity({ + manuscriptId: 'ms-hidden-allocation-risk', + generatedAt: '2026-05-23T06:00:00Z', + studies: [ + { + id: 'trial-microbiome-01', + design: 'parallel clinical trial', + arms: [ + {id: 'control', enrolled: 42}, + {id: 'probiotic', enrolled: 41}, + ], + randomization: { + sequenceEvidence: false, + method: '', + allocationConcealment: false, + stratificationFactors: ['site'], + }, + blinding: { + participant: true, + careProvider: true, + outcomeAssessor: true, + analyst: true, + armLabelExposure: [], + unblindingEvents: [], + }, + exclusions: [], + }, + ], + }); + + assert.equal(result.decision, 'hold-for-review'); + assert.equal(result.summary.studyCount, 1); + assert.equal(result.summary.randomizationIssues, 2); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['missing-randomization-sequence', 'missing-allocation-concealment'] + ); + assert.equal(result.requiredActions[0].type, 'attach_randomization_sequence'); +}); + +test('requires author response for unblinding, arm-label leakage, and post-randomization exclusions', () => { + const result = evaluateRandomizationBlindingIntegrity({ + manuscriptId: 'ms-unblinded-analysis', + generatedAt: '2026-05-23T06:00:00Z', + studies: [ + { + id: 'animal-stroke-02', + design: 'preclinical animal experiment', + arms: [ + {id: 'sham', enrolled: 18}, + {id: 'treatment', enrolled: 18}, + ], + randomization: { + sequenceEvidence: true, + method: 'blocked randomization', + allocationConcealment: true, + stratificationFactors: ['sex', 'baseline-score'], + }, + blinding: { + participant: false, + careProvider: false, + outcomeAssessor: true, + analyst: false, + armLabelExposure: [ + {artifact: 'analysis-notebook.ipynb', label: 'treatment arm names visible before primary analysis'}, + ], + unblindingEvents: [ + {role: 'analyst', occurredAt: '2026-04-11', beforePrimaryEndpointLock: true}, + ], + }, + exclusions: [ + {participantId: 'rat-17', afterRandomization: true, reason: 'outlier response', blindedDecision: false}, + ], + }, + ], + }); + + assert.equal(result.decision, 'needs-author-response'); + assert.equal(result.summary.blindingIssues, 3); + assert.equal(result.summary.exclusionIssues, 1); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['missing-blinding-role', 'arm-label-leakage', 'early-unblinding-event', 'unblinded-post-randomization-exclusion'] + ); + assert.equal(result.requiredActions.at(-1).type, 'justify_unblinded_exclusion'); +}); + +test('flags stratification imbalance without blocking complete blinded packets', () => { + const result = evaluateRandomizationBlindingIntegrity({ + manuscriptId: 'ms-imbalance-review', + generatedAt: '2026-05-23T06:00:00Z', + studies: [ + { + id: 'trial-cardiology-03', + design: 'multicenter randomized trial', + arms: [ + {id: 'standard-care', enrolled: 33, strata: {siteA: 30, siteB: 3}}, + {id: 'new-device', enrolled: 45, strata: {siteA: 20, siteB: 25}}, + ], + randomization: { + sequenceEvidence: true, + method: 'permuted blocks', + allocationConcealment: true, + stratificationFactors: ['site'], + maxArmImbalancePercent: 20, + }, + blinding: { + participant: true, + careProvider: true, + outcomeAssessor: true, + analyst: true, + armLabelExposure: [], + unblindingEvents: [], + }, + exclusions: [], + }, + ], + }); + + assert.equal(result.decision, 'needs-author-response'); + assert.equal(result.summary.balanceIssues, 1); + assert.equal(result.findings[0].type, 'arm-size-imbalance'); + assert.equal(result.requiredActions[0].type, 'explain_randomization_imbalance'); +}); + +test('approves complete integrity packets and builds deterministic reviewer report', () => { + const result = evaluateRandomizationBlindingIntegrity({ + manuscriptId: 'ms-ready-randomized-study', + generatedAt: '2026-05-23T06:00:00Z', + studies: [ + { + id: 'trial-oncology-04', + design: 'double-blind randomized trial', + arms: [ + {id: 'placebo', enrolled: 120, strata: {siteA: 60, siteB: 60}}, + {id: 'therapy', enrolled: 118, strata: {siteA: 59, siteB: 59}}, + ], + randomization: { + sequenceEvidence: true, + method: 'centralized permuted blocks', + allocationConcealment: true, + stratificationFactors: ['site', 'disease-stage'], + maxArmImbalancePercent: 10, + }, + blinding: { + participant: true, + careProvider: true, + outcomeAssessor: true, + analyst: true, + armLabelExposure: [], + unblindingEvents: [], + }, + exclusions: [ + {participantId: 'p-099', afterRandomization: true, reason: 'withdrew consent', blindedDecision: true}, + ], + }, + ], + }); + + assert.equal(result.decision, 'approved'); + assert.equal(result.readinessScore, 100); + assert.equal(result.findings.length, 0); + + const packet = buildReviewerPacket(result); + assert.match(packet, /# Randomization Blinding Integrity Assistant Report/); + assert.match(packet, /Manuscript: ms-ready-randomized-study/); + assert.match(packet, /Decision: approved/); + assert.match(packet, /Readiness score: 100/); + assert.match(packet, /Findings: 0/); +});