From 796d42f1a12b78c3522b94e260d9e3d29dfd022d Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Sat, 23 May 2026 12:19:56 +0700 Subject: [PATCH] Add randomization blinding integrity assistant --- .../README.md | 24 ++ .../demo.js | 79 +++++ .../index.js | 307 ++++++++++++++++++ .../package.json | 12 + .../render-video.js | 60 ++++ .../reports/demo.mp4 | Bin 0 -> 8510 bytes .../randomization-blinding-packet.json | 106 ++++++ .../reports/randomization-blinding-report.md | 34 ++ .../reports/summary.svg | 31 ++ .../sample-data.js | 93 ++++++ .../test.js | 179 ++++++++++ 11 files changed, 925 insertions(+) create mode 100644 randomization-blinding-integrity-assistant/README.md create mode 100644 randomization-blinding-integrity-assistant/demo.js create mode 100644 randomization-blinding-integrity-assistant/index.js create mode 100644 randomization-blinding-integrity-assistant/package.json create mode 100644 randomization-blinding-integrity-assistant/render-video.js create mode 100644 randomization-blinding-integrity-assistant/reports/demo.mp4 create mode 100644 randomization-blinding-integrity-assistant/reports/randomization-blinding-packet.json create mode 100644 randomization-blinding-integrity-assistant/reports/randomization-blinding-report.md create mode 100644 randomization-blinding-integrity-assistant/reports/summary.svg create mode 100644 randomization-blinding-integrity-assistant/sample-data.js create mode 100644 randomization-blinding-integrity-assistant/test.js 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 0000000000000000000000000000000000000000..312ccda7a50de297401f65830d910f59085f1a0a GIT binary patch literal 8510 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 + + + 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/); +});