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