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 = ` + + Research Image Integrity Assistant + Synthetic reviewer packet for manuscript figure forensics + + + Approved + ${approved} + + + + Author Response + ${response} + + + + Hold Review + ${hold} + + Checks: duplicate panels, raw provenance, processing logs, scale-bar metadata + Reviewer findings: ${findings}. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No patient data, private images, secrets, external APIs, or network calls. + +`; + +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 @@ + + + Research Image Integrity Assistant + Synthetic reviewer packet for manuscript figure forensics + + + Approved + 1 + + + + Author Response + 1 + + + + Hold Review + 2 + + Checks: duplicate panels, raw provenance, processing logs, scale-bar metadata + Reviewer findings: 4. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No patient data, private images, secrets, external APIs, or network calls. + 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/); +});