diff --git a/research-image-integrity-assistant/README.md b/research-image-integrity-assistant/README.md
new file mode 100644
index 00000000..abfb1961
--- /dev/null
+++ b/research-image-integrity-assistant/README.md
@@ -0,0 +1,29 @@
+# Research Image Integrity Assistant
+
+Self-contained AI-powered research assistant slice for issue #16. It gives reviewers a deterministic pre-submission image-integrity packet before manuscript figures are released for peer review.
+
+## What It Checks
+
+- Duplicated image panels across manuscript figures using synthetic perceptual hashes.
+- Missing raw-image provenance, including absent raw-image IDs and checksum metadata.
+- Missing processing histories for crop, contrast, compositing, and adjustment review.
+- Scale-bar inconsistencies between declared figure labels and raw pixel-size metadata.
+
+## Outputs
+
+- `reports/image-integrity-packet.json`: structured reviewer decisions and findings.
+- `reports/reviewer-report.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
+node research-image-integrity-assistant/test.js
+node research-image-integrity-assistant/demo.js
+node --check research-image-integrity-assistant/index.js
+node --check research-image-integrity-assistant/test.js
+node --check research-image-integrity-assistant/demo.js
+```
+
+The module is dependency-free, uses synthetic data only, and makes no network calls.
diff --git a/research-image-integrity-assistant/demo.js b/research-image-integrity-assistant/demo.js
new file mode 100644
index 00000000..d08501fb
--- /dev/null
+++ b/research-image-integrity-assistant/demo.js
@@ -0,0 +1,53 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+const {evaluateImageIntegrity, buildReviewerReport} = 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,
+ ...evaluateImageIntegrity(scenario),
+}));
+
+const reviewerReport = evaluations.map(buildReviewerReport).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 svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'image-integrity-packet.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'reviewer-report.md'), reviewerReport);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+console.log(`Wrote ${evaluations.length} image-integrity evaluations to ${reportsDir}`);
+console.log(`Decision counts: approved=${approved}, response=${response}, hold=${hold}`);
+console.log(`Reviewer findings: ${findings}`);
diff --git a/research-image-integrity-assistant/index.js b/research-image-integrity-assistant/index.js
new file mode 100644
index 00000000..325a18fd
--- /dev/null
+++ b/research-image-integrity-assistant/index.js
@@ -0,0 +1,198 @@
+function normalizeList(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function roundMicrometers(value) {
+ return Math.round(value * 100) / 100;
+}
+
+function panelReference(figure, panel) {
+ return `${figure.id}:${panel.id}`;
+}
+
+function imageIntegrityAction(type, target, reason) {
+ return {type, target, reason};
+}
+
+function severityCounts(findings) {
+ return findings.reduce((counts, finding) => {
+ counts[finding.severity] = (counts[finding.severity] || 0) + 1;
+ return counts;
+ }, {});
+}
+
+function evaluateImageIntegrity(input) {
+ const figures = normalizeList(input.figures);
+ const rawImages = normalizeList(input.rawImages);
+ const processingLogs = normalizeList(input.processingLogs);
+ const rawById = new Map(rawImages.map((image) => [image.id, image]));
+ const logById = new Map(processingLogs.map((log) => [log.id, log]));
+ const findings = [];
+ const requiredActions = [];
+ const panelsByHash = new Map();
+ let panelCount = 0;
+
+ for (const figure of figures) {
+ for (const panel of normalizeList(figure.panels)) {
+ panelCount += 1;
+ if (panel.perceptualHash) {
+ const panelSet = panelsByHash.get(panel.perceptualHash) || [];
+ panelSet.push({figure, panel});
+ panelsByHash.set(panel.perceptualHash, panelSet);
+ }
+ }
+ }
+
+ for (const [perceptualHash, panelSet] of panelsByHash.entries()) {
+ if (panelSet.length > 1) {
+ const panels = panelSet.map(({figure, panel}) => panelReference(figure, panel));
+ findings.push({
+ type: 'duplicate-panel',
+ severity: 'critical',
+ panels,
+ perceptualHash,
+ message: `Panels share perceptual hash ${perceptualHash}`,
+ });
+ requiredActions.push(imageIntegrityAction(
+ 'verify_panel_uniqueness',
+ panels.join(', '),
+ 'duplicated image panels need raw-data review before peer-review release'
+ ));
+ }
+ }
+
+ for (const figure of figures) {
+ for (const panel of normalizeList(figure.panels)) {
+ const ref = panelReference(figure, panel);
+ const rawImage = rawById.get(panel.rawImageId);
+ const processingLog = logById.get(panel.processingLogId);
+
+ if (!rawImage) {
+ findings.push({
+ type: 'missing-raw-provenance',
+ severity: 'critical',
+ panel: ref,
+ rawImageId: panel.rawImageId || '',
+ message: `${ref} lacks linked raw image provenance`,
+ });
+ requiredActions.push(imageIntegrityAction(
+ 'attach_raw_image_provenance',
+ ref,
+ 'reviewers need raw-image checksum, capture time, and pixel-size metadata'
+ ));
+ }
+
+ if (!processingLog) {
+ findings.push({
+ type: 'missing-processing-log',
+ severity: 'critical',
+ panel: ref,
+ processingLogId: panel.processingLogId || '',
+ message: `${ref} lacks a linked processing history`,
+ });
+ requiredActions.push(imageIntegrityAction(
+ 'attach_processing_log',
+ ref,
+ 'reviewers need auditable crop, contrast, and adjustment history'
+ ));
+ }
+
+ if (rawImage && panel.scaleBarPixels > 0 && panel.scaleBarMicrometers > 0) {
+ const expectedMicrometers = roundMicrometers(panel.scaleBarPixels * rawImage.pixelSizeMicrometers);
+ const declaredMicrometers = roundMicrometers(panel.scaleBarMicrometers);
+ const relativeDifference = expectedMicrometers === 0
+ ? 0
+ : Math.abs(declaredMicrometers - expectedMicrometers) / expectedMicrometers;
+
+ if (relativeDifference > 0.05) {
+ findings.push({
+ type: 'scale-bar-mismatch',
+ severity: 'major',
+ panel: ref,
+ expectedMicrometers,
+ declaredMicrometers,
+ pixelSizeMicrometers: rawImage.pixelSizeMicrometers,
+ scaleBarPixels: panel.scaleBarPixels,
+ message: `${ref} declares ${declaredMicrometers} um but metadata implies ${expectedMicrometers} um`,
+ });
+ requiredActions.push(imageIntegrityAction(
+ 'reconcile_scale_bar_metadata',
+ ref,
+ 'declared scale bar must match raw pixel-size metadata before reviewer approval'
+ ));
+ }
+ }
+ }
+ }
+
+ 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 integrityScore = Math.max(0, 100 - criticalCount * 35 - majorCount * 20 - minorCount * 10);
+
+ return {
+ manuscriptId: input.manuscriptId,
+ generatedAt: input.generatedAt,
+ decision,
+ integrityScore,
+ findings,
+ requiredActions,
+ summary: {
+ figureCount: figures.length,
+ panelCount,
+ rawImageCount: rawImages.length,
+ processingLogCount: processingLogs.length,
+ duplicatePanelGroups: findings.filter((finding) => finding.type === 'duplicate-panel').length,
+ missingRawProvenance: findings.filter((finding) => finding.type === 'missing-raw-provenance').length,
+ missingProcessingLogs: findings.filter((finding) => finding.type === 'missing-processing-log').length,
+ scaleBarMismatches: findings.filter((finding) => finding.type === 'scale-bar-mismatch').length,
+ severityCounts: counts,
+ },
+ };
+}
+
+function buildReviewerReport(result) {
+ const lines = [
+ '# Research Image Integrity Assistant Report',
+ '',
+ `Manuscript: ${result.manuscriptId}`,
+ `Generated: ${result.generatedAt}`,
+ `Decision: ${result.decision}`,
+ `Integrity score: ${result.integrityScore}`,
+ '',
+ '## Packet Summary',
+ '',
+ `Figures: ${result.summary.figureCount}`,
+ `Panels: ${result.summary.panelCount}`,
+ `Raw images: ${result.summary.rawImageCount}`,
+ `Processing logs: ${result.summary.processingLogCount}`,
+ `Duplicate panel groups: ${result.summary.duplicatePanelGroups}`,
+ `Scale-bar mismatches: ${result.summary.scaleBarMismatches}`,
+ `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 = {
+ evaluateImageIntegrity,
+ buildReviewerReport,
+};
diff --git a/research-image-integrity-assistant/reports/demo.mp4 b/research-image-integrity-assistant/reports/demo.mp4
new file mode 100644
index 00000000..e3eb1761
Binary files /dev/null and b/research-image-integrity-assistant/reports/demo.mp4 differ
diff --git a/research-image-integrity-assistant/reports/image-integrity-packet.json b/research-image-integrity-assistant/reports/image-integrity-packet.json
new file mode 100644
index 00000000..c45975a0
--- /dev/null
+++ b/research-image-integrity-assistant/reports/image-integrity-packet.json
@@ -0,0 +1,148 @@
+[
+ {
+ "scenario": "duplicate-panel-hold",
+ "manuscriptId": "ms-neuron-organoid",
+ "generatedAt": "2026-05-22T13:00:00Z",
+ "decision": "hold-for-review",
+ "integrityScore": 65,
+ "findings": [
+ {
+ "type": "duplicate-panel",
+ "severity": "critical",
+ "panels": [
+ "figure-1:1A",
+ "figure-3:3C"
+ ],
+ "perceptualHash": "phash:001122aa",
+ "message": "Panels share perceptual hash phash:001122aa"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "verify_panel_uniqueness",
+ "target": "figure-1:1A, figure-3:3C",
+ "reason": "duplicated image panels need raw-data review before peer-review release"
+ }
+ ],
+ "summary": {
+ "figureCount": 2,
+ "panelCount": 2,
+ "rawImageCount": 2,
+ "processingLogCount": 2,
+ "duplicatePanelGroups": 1,
+ "missingRawProvenance": 0,
+ "missingProcessingLogs": 0,
+ "scaleBarMismatches": 0,
+ "severityCounts": {
+ "critical": 1
+ }
+ }
+ },
+ {
+ "scenario": "missing-provenance-hold",
+ "manuscriptId": "ms-missing-provenance",
+ "generatedAt": "2026-05-22T13:00:00Z",
+ "decision": "hold-for-review",
+ "integrityScore": 30,
+ "findings": [
+ {
+ "type": "missing-raw-provenance",
+ "severity": "critical",
+ "panel": "figure-2:2B",
+ "rawImageId": "raw-missing",
+ "message": "figure-2:2B lacks linked raw image provenance"
+ },
+ {
+ "type": "missing-processing-log",
+ "severity": "critical",
+ "panel": "figure-2:2B",
+ "processingLogId": "log-missing",
+ "message": "figure-2:2B lacks a linked processing history"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "attach_raw_image_provenance",
+ "target": "figure-2:2B",
+ "reason": "reviewers need raw-image checksum, capture time, and pixel-size metadata"
+ },
+ {
+ "type": "attach_processing_log",
+ "target": "figure-2:2B",
+ "reason": "reviewers need auditable crop, contrast, and adjustment history"
+ }
+ ],
+ "summary": {
+ "figureCount": 1,
+ "panelCount": 1,
+ "rawImageCount": 0,
+ "processingLogCount": 0,
+ "duplicatePanelGroups": 0,
+ "missingRawProvenance": 1,
+ "missingProcessingLogs": 1,
+ "scaleBarMismatches": 0,
+ "severityCounts": {
+ "critical": 2
+ }
+ }
+ },
+ {
+ "scenario": "scale-bar-response",
+ "manuscriptId": "ms-scale-bar-risk",
+ "generatedAt": "2026-05-22T13:00:00Z",
+ "decision": "needs-author-response",
+ "integrityScore": 80,
+ "findings": [
+ {
+ "type": "scale-bar-mismatch",
+ "severity": "major",
+ "panel": "figure-4:4D",
+ "expectedMicrometers": 25,
+ "declaredMicrometers": 50,
+ "pixelSizeMicrometers": 0.25,
+ "scaleBarPixels": 100,
+ "message": "figure-4:4D declares 50 um but metadata implies 25 um"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "reconcile_scale_bar_metadata",
+ "target": "figure-4:4D",
+ "reason": "declared scale bar must match raw pixel-size metadata before reviewer approval"
+ }
+ ],
+ "summary": {
+ "figureCount": 1,
+ "panelCount": 1,
+ "rawImageCount": 1,
+ "processingLogCount": 1,
+ "duplicatePanelGroups": 0,
+ "missingRawProvenance": 0,
+ "missingProcessingLogs": 0,
+ "scaleBarMismatches": 1,
+ "severityCounts": {
+ "major": 1
+ }
+ }
+ },
+ {
+ "scenario": "clean-image-packet",
+ "manuscriptId": "ms-clean-image-packet",
+ "generatedAt": "2026-05-22T13:00:00Z",
+ "decision": "approved",
+ "integrityScore": 100,
+ "findings": [],
+ "requiredActions": [],
+ "summary": {
+ "figureCount": 1,
+ "panelCount": 2,
+ "rawImageCount": 2,
+ "processingLogCount": 2,
+ "duplicatePanelGroups": 0,
+ "missingRawProvenance": 0,
+ "missingProcessingLogs": 0,
+ "scaleBarMismatches": 0,
+ "severityCounts": {}
+ }
+ }
+]
diff --git a/research-image-integrity-assistant/reports/reviewer-report.md b/research-image-integrity-assistant/reports/reviewer-report.md
new file mode 100644
index 00000000..2a4b871f
--- /dev/null
+++ b/research-image-integrity-assistant/reports/reviewer-report.md
@@ -0,0 +1,104 @@
+# Research Image Integrity Assistant Report
+
+Manuscript: ms-neuron-organoid
+Generated: 2026-05-22T13:00:00Z
+Decision: hold-for-review
+Integrity score: 65
+
+## Packet Summary
+
+Figures: 2
+Panels: 2
+Raw images: 2
+Processing logs: 2
+Duplicate panel groups: 1
+Scale-bar mismatches: 0
+Findings: 1
+
+## Findings
+
+- critical: duplicate-panel - Panels share perceptual hash phash:001122aa
+
+## Required Actions
+
+- verify_panel_uniqueness: figure-1:1A, figure-3:3C (duplicated image panels need raw-data review before peer-review release)
+
+---
+# Research Image Integrity Assistant Report
+
+Manuscript: ms-missing-provenance
+Generated: 2026-05-22T13:00:00Z
+Decision: hold-for-review
+Integrity score: 30
+
+## Packet Summary
+
+Figures: 1
+Panels: 1
+Raw images: 0
+Processing logs: 0
+Duplicate panel groups: 0
+Scale-bar mismatches: 0
+Findings: 2
+
+## Findings
+
+- critical: missing-raw-provenance - figure-2:2B lacks linked raw image provenance
+- critical: missing-processing-log - figure-2:2B lacks a linked processing history
+
+## Required Actions
+
+- attach_raw_image_provenance: figure-2:2B (reviewers need raw-image checksum, capture time, and pixel-size metadata)
+- attach_processing_log: figure-2:2B (reviewers need auditable crop, contrast, and adjustment history)
+
+---
+# Research Image Integrity Assistant Report
+
+Manuscript: ms-scale-bar-risk
+Generated: 2026-05-22T13:00:00Z
+Decision: needs-author-response
+Integrity score: 80
+
+## Packet Summary
+
+Figures: 1
+Panels: 1
+Raw images: 1
+Processing logs: 1
+Duplicate panel groups: 0
+Scale-bar mismatches: 1
+Findings: 1
+
+## Findings
+
+- major: scale-bar-mismatch - figure-4:4D declares 50 um but metadata implies 25 um
+
+## Required Actions
+
+- reconcile_scale_bar_metadata: figure-4:4D (declared scale bar must match raw pixel-size metadata before reviewer approval)
+
+---
+# Research Image Integrity Assistant Report
+
+Manuscript: ms-clean-image-packet
+Generated: 2026-05-22T13:00:00Z
+Decision: approved
+Integrity score: 100
+
+## Packet Summary
+
+Figures: 1
+Panels: 2
+Raw images: 2
+Processing logs: 2
+Duplicate panel groups: 0
+Scale-bar mismatches: 0
+Findings: 0
+
+## Findings
+
+- None
+
+## Required Actions
+
+- None
diff --git a/research-image-integrity-assistant/reports/summary.svg b/research-image-integrity-assistant/reports/summary.svg
new file mode 100644
index 00000000..aad6532f
--- /dev/null
+++ b/research-image-integrity-assistant/reports/summary.svg
@@ -0,0 +1,23 @@
+
diff --git a/research-image-integrity-assistant/sample-data.js b/research-image-integrity-assistant/sample-data.js
new file mode 100644
index 00000000..71bee283
--- /dev/null
+++ b/research-image-integrity-assistant/sample-data.js
@@ -0,0 +1,135 @@
+const scenarios = [
+ {
+ name: 'duplicate-panel-hold',
+ manuscriptId: 'ms-neuron-organoid',
+ generatedAt: '2026-05-22T13:00:00Z',
+ figures: [
+ {
+ id: 'figure-1',
+ panels: [
+ {
+ id: '1A',
+ label: 'Untreated brightfield',
+ rawImageId: 'raw-untreated',
+ processingLogId: 'log-1A',
+ perceptualHash: 'phash:001122aa',
+ scaleBarPixels: 80,
+ scaleBarMicrometers: 20,
+ },
+ ],
+ },
+ {
+ id: 'figure-3',
+ panels: [
+ {
+ id: '3C',
+ label: 'Treated brightfield',
+ rawImageId: 'raw-treated',
+ processingLogId: 'log-3C',
+ perceptualHash: 'phash:001122aa',
+ scaleBarPixels: 80,
+ scaleBarMicrometers: 20,
+ },
+ ],
+ },
+ ],
+ rawImages: [
+ {id: 'raw-untreated', checksum: 'sha256:raw1', pixelSizeMicrometers: 0.25, capturedAt: '2026-02-01T09:00:00Z'},
+ {id: 'raw-treated', checksum: 'sha256:raw2', pixelSizeMicrometers: 0.25, capturedAt: '2026-02-01T10:00:00Z'},
+ ],
+ processingLogs: [
+ {id: 'log-1A', operations: ['crop', 'contrast'], software: 'ImageJ 1.54'},
+ {id: 'log-3C', operations: ['crop', 'contrast'], software: 'ImageJ 1.54'},
+ ],
+ },
+ {
+ name: 'missing-provenance-hold',
+ manuscriptId: 'ms-missing-provenance',
+ generatedAt: '2026-05-22T13:00:00Z',
+ figures: [
+ {
+ id: 'figure-2',
+ panels: [
+ {
+ id: '2B',
+ label: 'Western blot lane composite',
+ rawImageId: 'raw-missing',
+ processingLogId: 'log-missing',
+ perceptualHash: 'phash:778899cc',
+ scaleBarPixels: 0,
+ scaleBarMicrometers: 0,
+ },
+ ],
+ },
+ ],
+ rawImages: [],
+ processingLogs: [],
+ },
+ {
+ name: 'scale-bar-response',
+ manuscriptId: 'ms-scale-bar-risk',
+ generatedAt: '2026-05-22T13:00:00Z',
+ figures: [
+ {
+ id: 'figure-4',
+ panels: [
+ {
+ id: '4D',
+ label: 'Cell migration assay',
+ rawImageId: 'raw-migration',
+ processingLogId: 'log-4D',
+ perceptualHash: 'phash:abcdef01',
+ scaleBarPixels: 100,
+ scaleBarMicrometers: 50,
+ },
+ ],
+ },
+ ],
+ rawImages: [
+ {id: 'raw-migration', checksum: 'sha256:migration', pixelSizeMicrometers: 0.25, capturedAt: '2026-03-01T12:00:00Z'},
+ ],
+ processingLogs: [
+ {id: 'log-4D', operations: ['background subtraction'], software: 'ImageJ 1.54'},
+ ],
+ },
+ {
+ name: 'clean-image-packet',
+ manuscriptId: 'ms-clean-image-packet',
+ generatedAt: '2026-05-22T13:00:00Z',
+ figures: [
+ {
+ id: 'figure-1',
+ panels: [
+ {
+ id: '1A',
+ label: 'Control fluorescence',
+ rawImageId: 'raw-control',
+ processingLogId: 'log-control',
+ perceptualHash: 'phash:control',
+ scaleBarPixels: 40,
+ scaleBarMicrometers: 10,
+ },
+ {
+ id: '1B',
+ label: 'Treatment fluorescence',
+ rawImageId: 'raw-treatment',
+ processingLogId: 'log-treatment',
+ perceptualHash: 'phash:treatment',
+ scaleBarPixels: 40,
+ scaleBarMicrometers: 10,
+ },
+ ],
+ },
+ ],
+ rawImages: [
+ {id: 'raw-control', checksum: 'sha256:control', pixelSizeMicrometers: 0.25, capturedAt: '2026-01-15T08:30:00Z'},
+ {id: 'raw-treatment', checksum: 'sha256:treatment', pixelSizeMicrometers: 0.25, capturedAt: '2026-01-15T08:45:00Z'},
+ ],
+ processingLogs: [
+ {id: 'log-control', operations: ['crop', 'linear contrast'], software: 'ImageJ 1.54'},
+ {id: 'log-treatment', operations: ['crop', 'linear contrast'], software: 'ImageJ 1.54'},
+ ],
+ },
+];
+
+module.exports = {scenarios};
diff --git a/research-image-integrity-assistant/test.js b/research-image-integrity-assistant/test.js
new file mode 100644
index 00000000..3073d718
--- /dev/null
+++ b/research-image-integrity-assistant/test.js
@@ -0,0 +1,175 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluateImageIntegrity,
+ buildReviewerReport,
+} = require('./index');
+
+test('flags duplicated image panels across manuscript figures', () => {
+ const result = evaluateImageIntegrity({
+ manuscriptId: 'ms-neuron-organoid',
+ generatedAt: '2026-05-22T13:00:00Z',
+ figures: [
+ {
+ id: 'figure-1',
+ panels: [
+ {
+ id: '1A',
+ label: 'Untreated brightfield',
+ rawImageId: 'raw-untreated',
+ processingLogId: 'log-1A',
+ perceptualHash: 'phash:001122aa',
+ scaleBarPixels: 80,
+ scaleBarMicrometers: 20,
+ },
+ ],
+ },
+ {
+ id: 'figure-3',
+ panels: [
+ {
+ id: '3C',
+ label: 'Treated brightfield',
+ rawImageId: 'raw-treated',
+ processingLogId: 'log-3C',
+ perceptualHash: 'phash:001122aa',
+ scaleBarPixels: 80,
+ scaleBarMicrometers: 20,
+ },
+ ],
+ },
+ ],
+ rawImages: [
+ {id: 'raw-untreated', checksum: 'sha256:raw1', pixelSizeMicrometers: 0.25, capturedAt: '2026-02-01T09:00:00Z'},
+ {id: 'raw-treated', checksum: 'sha256:raw2', pixelSizeMicrometers: 0.25, capturedAt: '2026-02-01T10:00:00Z'},
+ ],
+ processingLogs: [
+ {id: 'log-1A', operations: ['crop', 'contrast'], software: 'ImageJ 1.54'},
+ {id: 'log-3C', operations: ['crop', 'contrast'], software: 'ImageJ 1.54'},
+ ],
+ });
+
+ assert.equal(result.decision, 'hold-for-review');
+ assert.equal(result.summary.duplicatePanelGroups, 1);
+ assert.equal(result.findings[0].type, 'duplicate-panel');
+ assert.deepEqual(result.findings[0].panels, ['figure-1:1A', 'figure-3:3C']);
+});
+
+test('holds reviewer packet when raw image provenance or processing logs are missing', () => {
+ const result = evaluateImageIntegrity({
+ manuscriptId: 'ms-missing-provenance',
+ generatedAt: '2026-05-22T13:00:00Z',
+ figures: [
+ {
+ id: 'figure-2',
+ panels: [
+ {
+ id: '2B',
+ label: 'Western blot lane composite',
+ rawImageId: 'raw-missing',
+ processingLogId: 'log-missing',
+ perceptualHash: 'phash:778899cc',
+ scaleBarPixels: 0,
+ scaleBarMicrometers: 0,
+ },
+ ],
+ },
+ ],
+ rawImages: [],
+ processingLogs: [],
+ });
+
+ assert.equal(result.decision, 'hold-for-review');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ ['missing-raw-provenance', 'missing-processing-log']
+ );
+ assert.equal(result.requiredActions.length, 2);
+});
+
+test('detects scale bar and pixel size metadata inconsistencies', () => {
+ const result = evaluateImageIntegrity({
+ manuscriptId: 'ms-scale-bar-risk',
+ generatedAt: '2026-05-22T13:00:00Z',
+ figures: [
+ {
+ id: 'figure-4',
+ panels: [
+ {
+ id: '4D',
+ label: 'Cell migration assay',
+ rawImageId: 'raw-migration',
+ processingLogId: 'log-4D',
+ perceptualHash: 'phash:abcdef01',
+ scaleBarPixels: 100,
+ scaleBarMicrometers: 50,
+ },
+ ],
+ },
+ ],
+ rawImages: [
+ {id: 'raw-migration', checksum: 'sha256:migration', pixelSizeMicrometers: 0.25, capturedAt: '2026-03-01T12:00:00Z'},
+ ],
+ processingLogs: [
+ {id: 'log-4D', operations: ['background subtraction'], software: 'ImageJ 1.54'},
+ ],
+ });
+
+ assert.equal(result.decision, 'needs-author-response');
+ assert.equal(result.findings.length, 1);
+ assert.equal(result.findings[0].type, 'scale-bar-mismatch');
+ assert.equal(result.findings[0].expectedMicrometers, 25);
+ assert.equal(result.findings[0].declaredMicrometers, 50);
+});
+
+test('approves clean image packet and builds deterministic reviewer report', () => {
+ const result = evaluateImageIntegrity({
+ manuscriptId: 'ms-clean-image-packet',
+ generatedAt: '2026-05-22T13:00:00Z',
+ figures: [
+ {
+ id: 'figure-1',
+ panels: [
+ {
+ id: '1A',
+ label: 'Control fluorescence',
+ rawImageId: 'raw-control',
+ processingLogId: 'log-control',
+ perceptualHash: 'phash:control',
+ scaleBarPixels: 40,
+ scaleBarMicrometers: 10,
+ },
+ {
+ id: '1B',
+ label: 'Treatment fluorescence',
+ rawImageId: 'raw-treatment',
+ processingLogId: 'log-treatment',
+ perceptualHash: 'phash:treatment',
+ scaleBarPixels: 40,
+ scaleBarMicrometers: 10,
+ },
+ ],
+ },
+ ],
+ rawImages: [
+ {id: 'raw-control', checksum: 'sha256:control', pixelSizeMicrometers: 0.25, capturedAt: '2026-01-15T08:30:00Z'},
+ {id: 'raw-treatment', checksum: 'sha256:treatment', pixelSizeMicrometers: 0.25, capturedAt: '2026-01-15T08:45:00Z'},
+ ],
+ processingLogs: [
+ {id: 'log-control', operations: ['crop', 'linear contrast'], software: 'ImageJ 1.54'},
+ {id: 'log-treatment', operations: ['crop', 'linear contrast'], software: 'ImageJ 1.54'},
+ ],
+ });
+
+ assert.equal(result.decision, 'approved');
+ assert.equal(result.findings.length, 0);
+ assert.equal(result.integrityScore, 100);
+
+ const report = buildReviewerReport(result);
+ assert.match(report, /# Research Image Integrity Assistant Report/);
+ assert.match(report, /Manuscript: ms-clean-image-packet/);
+ assert.match(report, /Decision: approved/);
+ assert.match(report, /Integrity score: 100/);
+ assert.match(report, /Findings: 0/);
+});