From 44b647f9f7a4b33567c6a9f369e225bcbd6bd9c5 Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 20:55:40 +0700 Subject: [PATCH] Add research image integrity assistant --- research-image-integrity-assistant/README.md | 29 +++ research-image-integrity-assistant/demo.js | 53 +++++ research-image-integrity-assistant/index.js | 198 ++++++++++++++++++ .../reports/demo.mp4 | Bin 0 -> 28916 bytes .../reports/image-integrity-packet.json | 148 +++++++++++++ .../reports/reviewer-report.md | 104 +++++++++ .../reports/summary.svg | 23 ++ .../sample-data.js | 135 ++++++++++++ research-image-integrity-assistant/test.js | 175 ++++++++++++++++ 9 files changed, 865 insertions(+) create mode 100644 research-image-integrity-assistant/README.md create mode 100644 research-image-integrity-assistant/demo.js create mode 100644 research-image-integrity-assistant/index.js create mode 100644 research-image-integrity-assistant/reports/demo.mp4 create mode 100644 research-image-integrity-assistant/reports/image-integrity-packet.json create mode 100644 research-image-integrity-assistant/reports/reviewer-report.md create mode 100644 research-image-integrity-assistant/reports/summary.svg create mode 100644 research-image-integrity-assistant/sample-data.js create mode 100644 research-image-integrity-assistant/test.js 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 0000000000000000000000000000000000000000..e3eb1761a36dc7d748409ac5adfee01662989fc8 GIT binary patch literal 28916 zcmX_nW0W9Guw~n}ZQHhO+qP|E+O}=?G^cIbw!Qtm-M8mdWkyEa8yS(2Kk8Iv0RR9H zn7Md5SUTI=0ssI3{MY~9Oond8jJ6Iei~s-t5N1xMrT_qz4z?zSF29&MFwmc$s!h?e zp5rx%wp5x`f;Hmn8xJNn76MuVV|yo40!CJL0w*R;W>x}L7FH8eCbM6I1l=!yURFU= zoR)<^P)+z3X<};pYY?_~@U$^Cb0J`2U|^Ra&^XIB?O^ySuy7xmlW++S?e~ z(b+qh)BiUMorR07%`e8@!NtJnZVYR*TU3;z}eO4S7Ik{aQ6JA{L9$Hg(~pWg>8~aQbEZI$|fV zvA4H2wD=wA|F4pXz}d#q_&1pURWK0PIsMNh#+J5*F8>8$Y3E|KohJIvBeAj*WlU$i>Of((c#AFVM;GKa81^p{=R&?`j$8J9z%emL|MRzp|l;p~HV^ z80i~X8an?siKUb2|0L#aYH4oaV)SdXcQCcnH@A2Awf$?(55bux9f_@z4;>;M0B|Fb(8^BOyu5!f32F6@74 z{SJ7UnCTb@9REv(mw}G$S9JK#@&ELO9=vRvzY1p;QwLra0!xSAD*f(=-y;6{GIadi z0RNQ}AOHYB%f&Pz1OVaZ`?&utRm&f2M_`(uN}@y3qj-?ogD+cv>5zq|;}!q__+E#UteO18o7(y?huVWT;o8tI&p&m4oc@`YNKh zwQRyy+u874jP@IzHW}Z+?u1m3cQ38_=4!dUFLz=^E3}r^=!dW>j(REt_MQh6IEEL{ z)pw`^=$&wb)2}r`{{vq6=Kj2YA-IQ>;OGi7uyM|5(KO{%OBl<-2MQ-W9NalnGR}^) zXL;T%D7_A=JDjzRE<%=`GR}x9>*K=hFFtQiV$}Y5F zNJaNw)H@C)qJ;{I4)Qg_)dRB2Z$d&)4iyDoM(c=@523j@@#^XU8{_@l`(3}JGb zioCH>yvK50rv3K2iDV)?RR|f@w6v!p%}%L3FgeZL0J4bAADW<()!w`Op}SX&R9{vzpBX%|Z~gXc>%lrPRxUNxAn+VJ zOzFi(r5P)2tah2=PSCRabF--n^K{c3(cXRirKP2RndC#NR4MDh6=OhNzoHD`Imh3H z2(KXrIHD>6DtKhyotvN^PvJ5Js#QHZl1R^IGe{LlCmff0esYf%|{@NQ0|zk z>CIZ%NGFVDHz}t%Pb7)=y`K)#HRe(p#sD839lR!~?*+D0xZ22uRChD~tZkrGdr9^y|MzUoNNvBNl!a)Lg0fr}+ww=(Z`_;L@|Z-*+M5(v*a9h<8V!H+YVcqx)U6@SJ9 zYsN3w)yAuMR?jOYwH=ociN>83G)J~rg^eEm=43#sWh2E2-oyo|i`{K1`b_Kly z*g7w#Oa6FpdoTZU2a?kwm5=_Kve0+Z^Hf?;iC{3Jl%-#=3qgUk>Bov-J3BX~y#0q$ z`k4gdWqHuilfx27$OG_=La$&P?)(14lkBl1uS9{DT#}%80a)XA5F`O6I(| zR``a_@XqfxS{$yzVP_w^%NO;^l;fgdST<1c6E?xT_1YVdhKJ|&zNbBr^`16T!l0n6 zK2oYldlMZ~)ys%L5@&!}oFn~BiIO_x_JZGoam@0+}E!OmjaQxTyULM&vnl!XfD?bua z^OcfH(Qmi8a>Z6Wn5?1jp)=1Xb+jcvEF~@ejCxUWfsm7cB_?7%n<;Y6>Iky;G4T9M z&KNLw1+F1U+{`b#i9CJ@*WboMnt-xQ6#CoLdoPk#x2~gk4LMrg-lfhTOc(~vnt}UD z*lO7ntWkL7t?*rGNJN$YtbpH<vpheuf>Z)UPfXyu0MrqQ=@hejbYF}mulk%s- z@5s|=Xa9|7?1!FTburSLTv5+B zUcMiT=9%k|Q3cfJVn5QSDe;GWqDAdB|2z zNTJnZr=~I(>MR*JE|_n6Vnu>LDv9nfFQ`n=3j=R^9Jm1X{6^p!jPc2L(^a%zjWA|; zQn6Pypv(;rvakE3zY)z2kMe9>)f^vGQN_{ zqAo;>iTtI%gN7`hF1ZEV5$F~fnGnwV`@@vWh+((t)%FL^ZS!BF|8mB`Tj0((*I8OVRoU zm!&SyZtIgphLcqs-g(xs_Wd+T$Zor*bX|82q;eSo`V`+VU|MW1e;81G3OhQnB>8ae z*Y|vkJ0Bk7RV@ytWI{|4v{D&yiFIzQ^lir{ECT4-@@duW3}a;Mmv#{5o=!~?Ja2XzO>y4h<;fb&`@3UC|h z&bMsMZh-u$Kv@Vw`KNc@v>e-1n+Lw^KK=t~ciES&weId`Bl7z<_&&DdBru3%X4`gkVPeT;x>{JcvV1ZI5V#sjmJBNQoCosV4iz?-Xz<=}e*{ zlx(t4)@spAqfN=k0>xE!*T0+7qeM6;Jm5jdnv~A5iT(h{OG%foPhm?)D}RYt_Z(2* zJa@_l0q{?DoU^az9LhB6i#Xyj0wn5X?puRPRRoQ{{B%w!MB|lJq;qW~Mwg<4!N8!} z8v1s8$lDUy=jaWnY2nsIMOTGXq{`<#Ab@G65gp zFVT*XO-5WMJ%c2i%5io$<#lLA-SEYUE4{y){P|%7TR&;9jui{-*N!TW*ZLa&V%&h( z)$G1EwzpBbvAIF_SS(TDtHIucdQoE~pWOM*?%0`^kD3eZNpsba#u$!U9;dJR6ISbd zeH=UWT)0_wTl&>ju`%Vhy+Btdbg6h1A?~zB=MfXAB)LFO)O((m)Rp^?wQ^=rV`58s z&WYgmPSb2;{S8oCt-AiF3FE7AXTpbfYfF?vce_b0a4EZsir)}lLQubm@8=Sw#f+fJ z|E~V-ovcSG6Ozi{-W7at(wFSaI=yT4Arrdc7<081K7JbRG6{PzO0*CE zE#`pMDOlO0f&?tWZyQ6U)(hP~{kcE2& z{Mb>+Q2pXLG=d67 zdbFsm&cImm$6YWqf;~Fhv!s6SQ3Iz#d(s=B7bya*-h40O)j9rK&`|oJM0=w8pO~52 z0zCbES4tG*1rHOhuG#k|n8MZ!yN9jkAbw!oSdT^|xYTvInMUHK=hlEh(E0^Af0h~< zuMeNYEK2ux;{G?ybZ#k45x5|E6*2_ma{pwoom!gW7vm%h6oayFvJm&h(jUOaj}_Kf zjJ1UQlUoJ;opTTbb18ncL}gypvRBrYi^4^l3EMcYpxN1vnJ)0ke(09SA7c0K82V zcm?JfZq*%-ApPef$a_S8*13qM+qjR$@u&c;!PtT>fH7X_DcqDYrnZA&HzlkFY8Y%C zjgIulByR3>DWUF&W*a!!TbRfmJ#iyH%GrRoMN;EDR5z<|8~!V)GWYHx1cUsnt0)NG@|Zu`^JR__}QVNkBU<|`&Vl*I__^UhV3*fNa>yrT`^Pr&Q^ zq{y4q`EUe$qM-~LW=}{=&W9;p6@pwtRoJE5wMkyxPV2gu_E#|I>`0 zC=i06d+G+|DaLc7F0qYw*@vExzb!xBp$zccV>wH_@-yebe#?g9T(j9$bVBGeJ_i09 zkOdHPDc?6^ayvSzKm{S^M?Ez%xG2k;W;KjM1!ahB{jfwD=z`WCkx&$w;D;z5ybszgekOVgBp|EbO@oLuiYuS*KCJ6AV*mrE zg?a1SG9NknTM!puG4Vqyf1U~XED9tyc_*54`}K1a`U>lNm;6u;{bfoy1X3EgKRsxb z>_!;X82f5sBud>2&NM(OWF9>rr$()SY=MP$k{DgBdL!mgoe!%v>yFy$eMG|rBl2iJ z%_1JzQz1UuJqBsa7FD+$P9Rd22sqSZC6LlL$2dF`cUQg|x?p5q2hJCe!YuBk#n@tO zbYWle1JcHd^?hTZYt;)uD)nf{i_W~SbA1SDs_Sv8l|T`JCm8bzOH;uJK7Hc1p7GL7Hf3%g#UIL8ZyZZ<_R@f@l zqikBU8Oz4ypLrWs>fSrbGkyyI?2L8g1omv%nmM#1c$J@`JpUnNZuWK2KlFhv%}(~O zZ_@^-n%tMs$c9qki%+jdjBP_XaqYYXy6jwI6SmysUvERw;zQk#7{0$HQq6rA${kki1}JX z2~?*-Fh0bz$Fb75hHi9=q5@tbgry+hE&)4>A)q&nS96C7ZR+lX zKUR;8gz0@PGrtL|&6mxKqZLgx6hD5qOpI#|w52i^KM`1l14kSfRHznc64j0Pupc1v z4baPv_0I(pr7BKKt{94F<(T-8P-b z0D%%1pkdTB{U0@niG{h9zEUl93ye`QJ&x6jOU~oEbF2LM^E7(wX3ckw$cp?pHWqA0 zeOANK;|VIxbfYE-EZG1GpQ}nnDJmN9AFN1myf%?Q6jrhz2}oQL#giizu1Y;$ zWE87^0g{;>VPxYttSn^t38yiGN$0MLZaMSoWA^_tCCgyliEK4{BGU|PL`*CuW-eB1 zTvFTw-~@+^q**#JJCeLWqff=4i0xKS&~fA&8Bqp@*r-?o=|lNo&h-*q*xx!+l-YHl z?EZ48#H$o%MHbFnqmL{%J+=o?@%bv?B%2%Xm$t5|2O*OYr1B`I&lHnN70#D!ZD(T8 zYf49{H?xEP^+zc^&dH6kC@(&B$ETpeB<#{?N3HYm+3SMj1G&SL&2Qpd80AaZU_a^k z%yS33MSK#X*fC2D8Ew-Le0u{uO42=V!MknG-X31G*NYMIy(&^2fcD#&7 z-%Q_g4UMNdMNHU9A22&Ll>^T~-S=CoJ-M4H$Btcaqn43;PY2GS`)Tn6bOKdZcv}qZ zS=nTzBCG2hA|@{toxNRks|~24UGzl8HY#D}G9}3C7X2Bh#{mIMSt&lGPmFve9{nJP z+$nhEZmZYRVnGvRILzyw=Du>W8s{K`&UuGw{AK`$A?D(A15Uh?&{v2L_*EK``f+8M z@=u7kG5Zxyce?#fquJ^pJuU~K&){J&&tSpkz~)a3D5z190bBT>23YDApjDaW>Z?y_ zJf?boJgq#R<$^%&%hS%iWS0vj;#q8JLkZGwFgE;9$9Zi>b>EP&^&-oVWOvwYz1JA6 z;zS|7dmKP)ke7tjKa}RWJ#H$V!)U4zF)Y%XOnn4^K^W;MsZi+RcZ|fxc-JwgXwUxC zw=>!qc2o`Ns9V+7z$;T9P4fIs@U^Dhb* z?p+jYpB~(O3-W}ATF73GUh%gYmIjghhbnO3`$CipXYW1TV^J#uLS7 zuFd=SyI1heoi7ncX5Zvuar@qL)>Z*tqz%Ha!vPD7b3F2vaq<|xXZF0+gQ}yus02gN z)LG-foA)@WcMAx(Hjcb#QH4jIe@v|_kDE?5xut$}zt|62p;Taljg1%1=Sgtd+ucWM@) z_SXucmN-pkNj26f*P^JeDYN;<#tp??3g%E)_H*~)gRy$Ry z4^~Uiz5=S~Q{cBTh>ikkBQ098uQ8H=g25)JaY_?%(r$&Bfoo7p#Tb0Kv~;Qb7Dszl}Ps)dA3n{fXj>6UxRyE`|M?Mnd+A zt)Of%KWlM8($8tz9t?>`>-hOie&&eGIAn?63%RN>d6N*y22Wdvj=e8jV z8Y63<|Ht^nF;IDMEyAKjWk{tU9&1pD0$+QEw0kn`Rqj9?)Vp`|_x;A-_wt8Z@5Gq1 zwR_N&8N#$ge^Xs$Vrk!?(%Wty$N5W1Ea5orXL#s%oPs*!C~PcSL@R_~l3rnW{wALX zX8AIMNyL~uTf}~M1O9*N)TY9 z!h(vDsm%Bu6JY1yqZ|mJLVH%w0YDHW@0Rrp4xFW_1~ax0>_rQQZZ1fZW7?NbZ7}|w zEaco;S>GXIPd-!vFiL}ePeQ!{y(j2{cwrnNwto7w@+bGrz{QBeCIp8%qLi*Ta3$9}e)9%|o_dKQ`mBY*f|t+t40U|_=hok0D`*|sEU z`Y)G@bONq#Yx6$;vWN>cSmr|cpO9SOUW|cBMV#S@YO~RsE?Gs&h2GT{Gs#n8Tggv-knMayQ~9rV-3=v_d-Y zA+2zSw{aL-aabK zo^Lv=^Oe;!SyEAx25Im;1(^WsujIaK=oR%B-A{$qyekwlB#F_kyNx+PJs^%@EO2ez z;xmgaz;>>%r(P2yPJ({4T~maCY|+lD-sXHoWw&5+BN$y~m}QDvOXX<3fV|hq`0SSG zZGBhqEVTaz~XwSy+UFHZ)kr)jQ=l@=YoR1^V(? ze!d#(O7u3yu;EV4!hNp<$?2u5QExQ;a4$dkRH$fsrrMw6oVW33iTaaW(y6^Phrt6G z!RBj$Z#16U;%J+HV)7~=0Mt7)N{y5a8!i+VCnGRh2#7gBd!LG+#}6h|rEepALN6p~Kgk>2oNV;3vS93aKlXV>C%CR&;Hh{DhRgUuPO|@MV3a(T1_}PHp zRKRE7noGCtE4mYI0MOKu{)e^}XAgr@=kiHqMIT9|>g^%s%Gl1TXKe>deCFby&zuK@ zP}{(ceBg37SszFLVul25Jrp>uh5kM%U_27|I9@3mEPe&Sd$B&4y!T;BzV{K< z+t7Nt2sAVEb(KJhu|i-qHauM2bGf|(L7X3Mp;{sN(p>J8;6`4}kIAEk@wrs4*yJ3E z<)71i1R>|H1KQc7BjFu*k-I|M72O*hZH@i;KR<~FJrsa3@Dmbr+%k3W2mmP3f+)ULK zN;_BBg=d+rAdYPYWd(^5@0puX6kS$;)4XQ8wZ%t^L2Ba8B5^gJAe38RFXS6ikFTD} zzH7n=D3{M;mJ&;BlS)YqSD`dlPv^1_pJ4b_$R-TjQSc7*Vv{UmSX86VF+9!{ANyP# z@DsF0{;-lg0<@;%Q@HMD0?lj1t_ulaQ&&Qmj2#48JmM@c2^lRm#Usi>!2UYn*Q2*P zN$aazoY6Dgl2P!2E8@9*Lz14YEcOSiBqxv|<9OFg=iQ&qvrp%*V;kq#MGU+6&7=iV zc^()E#)JLK;6`663_X>N)sP;&Q|2+Tx*Kvcd(Tj?xd9+OigUn2izQF&s_IE@8h0;)($AC6 zZz@3n9jmv5&n#vC7{#f+kIMy*4-(-p5WoyRu{(tFcOw72nY0Y+#}zzq9%1($n4lF5 zjH^I1qqd5!y}ZcA;KrM3GKS&WS!3!n5>g8li8&% z1rT1tlrr=-kX=m0CCH+#7M;r4_N+J4^&MDvox@|&Pc2JctL;4=9P{#je8%n5b9x$B(*HxM(F=%8z=Vh`)*6b3T2qFxN z5E?tU3n^v7c$Fl0Mu`FGxizQ=97I`ciBw zUqBNN3Fei($%eZ-yTs(lM~tI$z^R`SR+w8rel+ea`^Ta~^%iAl+mS>&`|#Spl+*s_ z?7JUW=fD9Az8cUK8<|L*#Q$9?f;QfNx+>SVXXnuJ{$}{g;paY7%w*pjAgJ?-Wy@w#r~gP6Yq}a*=YyD=z;{fLpl0 zb!OQ|TY$T#A|dovO1~VFrppY~@n??b4RxD-a_?{$ya?5ofyCmmUzmKcSkWjPeR^TG z`_pMVk{ct7vb&$ZIJii{K>6qYMae0-wjea=1 z6`-DXV*BW^qYh&X)Z@bNiluAsljgP6!vX+Q8i?^by!`6~7%9E1?&vo6uW!yPrXgNl z_O7Q>MGS|e{~1*wmeeHzjCDG6SaEE_=cN#$3lRBzrKyAn)Dc9y!(;=XE<8F z)mcoZdnR4Yv(QjNCqN?Atu34Uu1CxtWYh8}{cb0+bd^i-!DO8`w*aKeRdvds$ETF9 zuEDRg*SyPKu6~P7#QDk%*PSMT{Dl}bp@Y!XK)09aARf}TYOZB1uvQrr$|=T8Tvs#t zlUbj2L2_~HdUCcN6jTmFnyVOISiYVW)^We)_qJkSZ3tN6kIEJZd64Tg)X=h+5=USw zVN|2+ZOoi4uJK-r4CZ&1r6!4wqMh7ZH%FKFw{&6Oq+@feyBe#Qakbojy;i~GTvQu4l!I^M`GC$C;VoQdBbL4E;;O$rf+%2qfLrp99%F_&JDigK170nY z=R{WnrVfyfn3TZ)YUE`AdYP*O9+DU@0_ZKSN$oZH=hz7B<&!a80Dd8Y2!EEugFIll z$XHx(JQ$lYJUEL%hHPf0KHs7Ji=>x|m;n;x=R&igUd8eXmHffRZqd@UTL(lvK(Qt1sBXhGb^m)}O z2Ycs*(dm@K{A6bk308FVJTYLh{L7yE|9ajJo5lc~17_a&ht6O~!T2G20?*1)$~0&Q z&Qv{_mA6~OZ2~-Tb(jTK1vWlPjdhq8L8LWj60F*PW+?&c>PH#Y&)uRJ9fYWGnK*dGy!7c4#nEkPIWvF=hEKj3i9T;L1b2R{NZ zu2v+*P9~9T8dVG)$Nn7@Tl3HM$Qql`#?BCgB=T6}JcYh2-IP?39@Sg?>$O{)lW!Lz zlKeG0bsEC6LJn?6p?0_qa9h8;l!S$vlHXT`zBt8KdzCiFr_vV}7b=NnL3W+ZHS`2T0=8uxosN2 zKaS~Ief1xjic8EjAetSY>%c^^H+h z!sWjT;;*!}*%S&y0mpw5W$F_1P^MB|yf`B* zV0nojXMuitM-_NgSmi!XK}){Z>wDbbyK7PQLl&ChO6S72*6d6l3N3mUKqmQsDmly| z8o~^FtsN7|%gKOEZAmE#)|-GyA4#^v-g)P^WGJrAP3>W|6WSb{tGtxNVNV=U$<5p) z*IT#lUdD(-Famvj(Ksy%H-Q~Xj+2aM^Y!1M)D-;oGgw*n4bRXOK7jnUkH{{GQZ6~4 zJ1N8o?n@N5Z2vs+BZ+)jrxX*{vG*ycXY2{HMUIcex+w1aG5aCV`ItRtVZ2oHXALPx z>!NzFT!8|y#Lk@yH*E;(!)fTqCb#}db5;8NcD5(cl! z7v>D)UrA=H`QstSD@Fg|w_1J~pbbojN!8cA2_I6(?WtT`ZdD(>?uGGfmtPxU0%yns z>j3_@eYq2)ebqF4QLR0&wy!8z`0L05=GYj71-XhQ(SyNyv`8HCf89JT!=kh_jAD9Ex zMVu}S%;RKNhO^>wLaX*Et*DtwI1v8}VY9RJMpF}sL^AO$4Xh?=5kBi1P=l62=jd{3 zMk8lF*QL-iBHuql@Qz^$^%}Af-dUnp=fA)n&I+0=7N+CNm>FxOsQ z>Pn5Ew>EHl5qG}pT$}rPxWVpJfj_xAEi>l+f*tU@l^wi%AVI29bojXDiifa%!pPPy zgb~4*Qzz#C_VgEug@;57GQWbWbF^Ov-W&D4GZDzgg+7x1cC44 z*0;|lrTux1D-PWH{9?YfdHu0P&VSgzN&?pCWthG**s^GNlV8YlSH}L1+HNfkbwOx9 z_>1Y58HCw2Egn*dQm2NK5Ro;Z3Q=bye1$1%JbXQ?#ogkF&011{aI;ugB4VpZnrwbs z&zlPir>eSAec2gBGBH-pdb+YCJ8My zaU5{el<8B_Cax`j7+>oR=wit?=xsaTkmg>r3dtzCV;V7CQQ0ESDw@#oN={-m+=dqtoMa=bp4Ja%T zY%|pjEiIhPDi_(031(8xv5^kartq}d*+%Q^e#i<_$-MJ6gV(tF-I`YLu=oGp)g9L z1a$lt1(bF(gv;S^_f!XvX-T{G;Td6wywqii0c zGQKdlsLj@$J&(Lt2X7cuKs#Xe_K_$?aL!Su$7DN-3zTum{w&QjMBpD7y-x{;#8oof zA%a~Zw1GAtGrn_i!VMYDk&_%4SeNs!V0HCGBA>61bEJ0qQ=pm6%(B+Gb26^rlh3mr zg*yV3PhzRzf*iP$f8d`qa+k^BE)8vT>8X*%wRRe_cS1N*En#2~TXGtAZ)!`qCD9xt zl(Z=&nOrFxjuVrww>l7a0XVB1=&}Cug!T~9-^xXcwze>)e-UPmO4BYRuLLoH^agKU z;2@R^^KWtSVE2zTD}y!}B(R$e&>;d>CgfLdYAl>?hd1x-!w&+2e9WpXk2$?e2cp|< zB{fz}hT@`6xUT5+%ot2B@>%On+!BCAc*M`DiYm~cep)fZx7JV!xn5yTwid~DgPnK>)b$f{b2^v&nc-}$^}i#?-_+4tVJvz$aa4Um~GANq6 z6*~jX(h&zTDV(MeUvC}9O6z;WFdHG@Sv{MZ(Papm!psKlCqSY)nZikPHfgn`@)W() z*Vae0vScw2sQB8VPA5M(qxTJP!FagZ@v%gi40-#nI+D`M30jX-27P0t2GT0Zl4J4@XvC=t; zE3+upBUW^%{Ow0ZJ2XQ!^$`E2(aR@QRb203@ki}tGSi!{IjH$Op3@czK3)rb*;#$o zWbJ?siS9Aeq*L=dhuF>3F6-HkBqj37tHahj*@o3Or>T5m4*(;^|rIn18%A&>CD5Eh=jt;4z5 z50-}K(iB@)FXcZv&^yq!aY4@nk<;$fzjDrya*RZWq|4n$*wr z|H@knP5+qy8>?bazGtl+2{3CP{CiddLhsiMvXl5la_@tlzzkWZ@dk{RM}c!m5n|-d z3r>oI$7QLL*e8iY#=7^TSl#k>lyh$ipQlAr9oC<~ntTzL9!wR^ZobTW;p@~i z%PJq}K6necdbw3Dtz7$rvvDf&7v;R?9` zss_7HoQm45xT_QqtWs6oW6c|1*HXcH@0}+&x^o=cX|`%8aKMQLTa67K)*K4msK`Zh z%MC#3H~_+L4WgddF%(9SL(BbW7ZwD(ss<%H+g8tYJ_I=^#Z6Y3{R4BQz!$JG<<8!= z4SR>gz#3s&>4Dl5j8SIF+~vux<)#wT9nDii%NfN!NQ=mSp_^#pJP2@2S4|=>=Zj$@ zWF+$UpjpbtmJl_RnT`jvzSt`cCEQ$%126o8$(ADM4?|aT_TOPjJ1ZXbs2F-n`2qM!? zZzAf6JJlZtTbUZKkj4V8qw>05#jvg)Q3D4lNT}?>rZm0vQ<=R$b^M;M+B{@LJyeI0v(1CfQvhhN)E<9R)?f6I?6Sv_-0l~bG+QWp^ zli|%@BG5*krrdN{u1s!Z9+(q$;?|#6v)Ges4eTs z62oBf7E}e}h|!5n;VbyOc-T3W`d=`es>X&BV%N#Hb`go(LcuEj0i6vPaTzHk+RtgD zrC~016hWEIHGf4WFgWj3X>kMo9%WEPi+N~BDXg$DCV2{3)PaM;)527KoBQJERhhcv z7jx$flMmW)FA|wv^YeqI^%$}Z>e@3Qxzrdh*Aq+ZBxcs=g^~-w=AY<7q6IV7YHZuR zASo2<%W5Hzco&VxnK_gBP~@JFCh&q{%ovvZ1ouAww3<9GGSOamMR1XR!l4JM*m*qf z`uSPGXp#o5_w-#r!772rAL-rp4u1p6^kfI6oy5qOq2#g=a@K*j1Fe z%2ixQ;kvH7%=gZ?o=tyIqgG>xR2K`{9w(GJm6$jl8fFZG=yti8nFj&5R$ZdT2)>); zPk~~0Zo7_%$7*dL`TfL~J0PdMN3mZ>0Fk?M$i}Ks6T6$v9^Y| zXgs00Pde!Jpc|$;;AoPh=`o4k$U7@i#>#Fu?U`!sL@eikzm-vmhYhO!kgGoiFsZ*j z{WUmO4HT}iBf<5WHlqXXh1_%p3(7kC?$K5JnOZ_W1X?${Gyhlg<%+jHP6+s$vHswM z)go$8_bMFJBdUYV2dY-oMWe&>s>*H5?dOg3HAxLsbdDnlg-sGL?IoQt^Fx?ZQ5jQ= z*#=6s&H`X4gjx^`r{>fe3;*#ToW_}`jOO_`a~>-uD-nb+NYQmI-rLz4V=Fl*VEj7# z(*2v+ZgV_}{aoiO5M|9Ajd!J5k5pTBw)M-V4d&5J#tA4UUU-=lBi%6ykWV&}>M zKv5u@NZjFquP1J~sCBaYYlK^$#p^EN@4*6`?BmZ1-m28zA!$%Auu01Jey*GUpb1Rk z``K`*viUG6$SkErsj((C;|(X3@cI-=(vn|c>!boJaD;16zgAy`#&GFr(#RSMQ{lz^ z!8c#)DLpOh&`r!*--406&$mhPX^WWqBF?T`8=Y+Fyuooz4Thh&caOAK_`x&s`0+>! zs%;@x__KCUQBad%LIyN13@1tCO~!*C0-`H`(WH^{utHbzivoaNNAr ziG3szdu!OFcPl3r(jSf4)GMQMEwt{MY^43m-KR&`4}7GD9Q{4Xq0LM_bY6!J8Sb%z zXBFMV3~pd*&l{6npzzc$R*W8JOA+##rS}wlk5(?0EQx+GU?;IM6VDdfa{7Pa!bPQn`eTMSSjjvcGFx7J;Z1i!_V z$+i|b!5v5~T$;n`t9X+DG(ssp8~)QcZ2OHZfc=_AP_CeTb=gfh>wVf8VFpc3!z%B<$)Po))u)4cXw+%{7}Th( zIH!t$XH9bb^crbVKzrSAzly%z^B?wI7mFqFRVo5t5Zi&s3ovphu!7$|pn*`GAd^yN z`$Kr$KHwAn6#Z-A+G3il#TAbG7P=0S;;~4ex*s&w8+Br;wY72x3owopqb=lc#qyAU z0Tgt$r0ju=2NA}w%!rQyN%KZ_AlKrFjS~ujpW;E!|M%cm?goiBT&aj^qWo4hVb5^`xTUt79kS-}HX+*la8>Aa)kZzC`B&6$X zP`}rwuJ@nwT<84p&BZXY_gb@lYt5`Rvp4L$r=htuTi}aXm)O`1&li{dRRwkOzaKn*hH%B?V~_FTB-`iN9>o@Bu9`G#3kfwI@c`k;GJcjaD3{@%jfnQw7C@V z{+P^6`l(Ss8bR=FyU~MV^f>>Ti+VDq^mt)=%1PrQNfghr5>Yp5Db9`jBmI5LSvZfe z{HVA#PTkSVygy*^?eojTMiV)f`JV`s9-nPZt!f)QZLvUux6_4(N=9lHd zN13J(y_oZUA2KZ%Wk0>fv|pt@Kofnl<9* zk4T62WIE90=@8UKh{DQ~K8@dZLVT7Hjpl1+D)D^iqkG4~Rd%zA-WY>lN2mvpTob&k zxbb=qr|IMDu-8kIJG-^-H#0DHv8k`-b)W7o&FBqkhkw>r+QXUi9}mBBV(xpd%O5lu z+*7ZU)a;PVi$ZJ3ZWtZ=#=O*@{L+R#C9KRWV(UpT)I^pB46frdwVA0-Msdlm7#Oe6 z!Z86f-9qm542m4LotHR^GS$miqA+Ae^iSW!K2bThvfPW_J#Ej@Vs#b8k@_q&x{BTI zP;A@()z3aMwGH)hgZXkw;oEcxag#vOF|AKHvvT32Nh?0SW~O&M@BHcfY1h~oFPpt( zEB%O*Xe}Cpqp=7I!Z@Blw(I+*DpcHQvkBm_-cbjJfFPvBD#8loc4Wq94`0r`j%bT& z?DrtO8e0e+f~U={mg7#hl9RI(=A$K13uxhS7R6~UWKWrXz3(+O_3SmWmg>HNMMc({ z-%Wo%spI^5P9-sN(foS2XaZ6LT1~0zlBoRrS5eo;}sMQ4{Z* znaJXijSJF9z$N?kqyZ<4*Wjmu8}%ghS{_ASLl+UvQE?ZUyYvGBE#~7h^uWV*kXd#% z_6e|IRmRqqTrx@dN(x?hv5TY!W1GKje=&QsFc(4)`b3T?Mj@Vm**vt7r_*fEZ>CP@F;;HsvTBi*7~-p?#J#p@bf zvM=NGz<99cB${{vM8oEnD24V2%=pNNwjoBPZCTqvC5M?2Xe>=fri3$?FqdHBlLDCi zjmn;U!-4(ke!hWk)*ZdZ9W8c38rh}p*4;DtkGzYobF{YKIfSr5h)#ax3Mu-?L19AI zM_qO8&hs_HlKH~F){tS<%e!>YVRov=#eMo_9oiS1lO!1U) z4A~@(r_4AwvFBR^FODhGbpyIgvg5|2&ty7bMo#Ja1AU(y?kN{~Yn^?Jk#hS8e=ceU z(gJJdKw}k+_?(Le$7a!Y`(J-LJ5z5tXyK2j6jRR}kW=GSDCpZy=qK2EyL5E18@qM* zkl5XW@o2uQ@BrIOilTirHO5VN7H+lEh7LXPRASR+k7S`$l5L}#0C{l+j1mi%2{YW% z&k!on-U5Z8(1e~phf<=JK{F+0NBf;jALfB^-4@7*m*|Vc(Hq7GYMV@=YQFI?Q8r4N zR$|XIR0lV?*6gF)szgOmd4?Eg9+&E;dx%MjMP>IF&bLweE@T*TzF#_T4m7O9pB1k82uVD9d# zS=l?w?tJ4ZFbr2?ZpEU5X`}uya0@=sKB~tlv9+~qDp4F7==3?RY9j2KNcMpBD}AvH zsZor_?|e+lC-*_okOo&_dCl19i_S43m71%YkOjnv7`{K}t)9;yIG$K(=It{4gb>r*DcH%-tY%(xb&kRlDFsZTo7{NJ%|>sls2 za9E1#ByGu@lOsp%!91|lcs_xv0Tqa+xRwf`Ayp_nMEyEx=F)FAMm86fY9YTpHVN|s z{Y^?ikP_s%8$Lh9d|ivxLv6}NO&(2V8QI2sJ6siHr#PWnH43U|#|ukD*`^U4IX-jR z$NeZ!!hPKEr~`#`!!G@ej}10WT!Y6cw#)OCy4E((k*p#fa4yX&6wMCsvBTEKxnP+( z#vUU z6V>uYw9mVIIuZXCG@L4{72Ub!;oIx}wLF*L5UKyAA^2&=#h z#OKeMx*j6Q=G{ElD1Z&wpfWT9-l4whEQA$H;U{JE1DV+BK{RY7Ay6i5Hgk`1YMi#A zJ>Ao-gEV(MmQ|Ih9Ha^O(M%vP*Nx3MxX-6nyZ2EAJ712z`tFl7g!1n8OKsHRw9GgD z0gFhXPm!4raq8~Jxs1RHhd`*pj50gotwa?S13u)0e^LdIv2IT;wBb^ zf&AVD#t0s3fOnYz(PifI_i_zlHH>6<0kjXFS$Cy}(md@dZ+Wu2vJK?vJ%)6nqpRg# zI4e7o(H5aihet_Zi&K7s=ZBZMj&{tQA1}@_Ywp}Uo~*&@->#i(e=5qW-i8}@^!%t4 z+aOb!%Q>Op?b#lOLAcbyr$^@F9(PT(rQ=J)Mq-Uyv|T)yTGC{Jt4+*eA>BHzN0jeVT+2 z{P@t#tQ$iF)rcts%5wfe0MWkCXEw-mL9FD}GoPfIY0PuN8uJU_f(ap2@7+L|D13dF zMCY1IsAxf%pQnE-$d7{k0Qks20L__Kn6TQ}7Yd8!o8Ctpjk~0uW7~IH7nr4W*bXYm zrW_9QN+F725C)+dq#!h}z(TLJU_p>oJfXRq9m1s#EXLlfSJADS@N6~eR!qwiK7at1 zZ-~1Vq8sz=mn|vFcOKzyEewZb8ipE2yVwy%1fjloMzXCDXwi73mFQn>lrr{Z!SexK z2?pN>#4?*Nk*(S1LpC?ED;r!?qBc!lujIzpQD^m~!w8OSNUP@79o{>T4WQ~yIzHg2 zDzuXns(dcLYDlQk`aQGck`@jjkr8=yZ-sAU-1yzW78z92`VxXV2CFw|;zI5!k$xq+ zyL?x8!9GVHeiaUvAGEF_j)Cu{u*xFydrhXEci64U#?jIl6SUck2ZCt?@S+dRR2jZY zs44_TqY7b@HJdgi-YoDGU^Nxp6iifFR*3Pnpo~WKEyLizkQVB_bUI<>>|?Fs8o{gy zEQTnrrh2zAK1K0P$RQ^%R*kjy{Lz=F=%_R{6V2J90K~l!vYJ~L|J_3GzARY_OT(w~ zMDNpsdzO(?OP|gPkZ!B!7Mz=Sc-IwWnXDb_wcSj1U+LmHe~mxU7?KW<03o~J%_cma zz~Df9vr|orx33gR%J!Y<6Etz7a*Z38VLn*~Nr?0*#q{Xyp5I$>s7G3(S`~~WtL|4@{my|wCf)fTmT$7#D4pFQ$WATTU$0AxQAV1z zKTKPPhP`m&3Pn}HD`Wo%a>9$pwoGOiEh%tcqHR^-z#rJoT2c}^hhD+9H9MhK9=2x+ zkz}O^iJ9j-ayMBzV6BDSiboD_w9L%8!V0MlWjWJLRHs6JWJi#zaX#K{l1^$p4`u9r zEr8hOSN`6jQ%9mwjB3K5ZXw;mnSO=EmaM2ig1&z*IAdazenf@R=EKs4vgOc7;zGY+ zSMu%Bhj0~0$~4b}<98F*RqvYeYe** zK8c*cHnlT%UdxOWaSBnLH8I;*lEjmpg+cid69ka}rVcV*KKZ68;B={MN-YL2cM#jF zBKw^@Tm3~FES+uA*6qn9?hAXny>^f4i@9RcE62-X`{9~uC(*7NH2;y529&4?+R^7# z%+7&m9eTmbYrjF)^pMjU!%^& zWNZ7cI$RRd)ou{D;>op8&0$pP&%)>{s6flkvJ1CivAOr#DqBmftH8qGcJA09H5s~g z;^0U0sAu3{HSxSWb*)HrV0DTm3GT4o<I$azbn=i{zSuA$gn9v z&CO1I)9X6T{wZEfubjOhJ5z!pwM4a9tWNrXvb?g%1Rsu2>kE0oHJEE=4V07MJbcL@ zn&_HUvumWsxpRI4=PWRyBya$BhT&Lh9a@5-uiw@1*lmy)hU(DE}a=k?3 zTwUcnEsk*brdUw^0XcMjqI4#wZ0F6%QQKDj62JJj((Uma%S}XW7C~&t;PWsDeTE)g zcrt}3a%pbLG)fbi))QMnM*)`XD~PYH^*I^to(K{=#hmcDPs1+LIr1ja)9kmTvqs@D zxRXO^D~rF-`FQXa622|?c-l6O3fcQ1P`m^5WnzuZXWP^~xk@{ZvT`Xgu@wDj6@dV2 zu{;Xjn+FaziBE)Zns*gn$}a{zE!T-%JZ$U`KD@m-msZyf+g}50-HRi``D(TZG#tmx{{OZJ0SVVi8#GDTY`U_mSHNUeamX zBr2pi2%F%cowfOByS1JxE3R*ha0Mel+FxtuPQQSYK@N(Hj7lGgm`Jh&0*{aP+A#buTUcfGdw`KYmPZ5P8e4~JK|Uliqm zKA(*HbvKp*hw2~^!(8L?@Nx@zTR5wJN{(<08l~7%i%tiD(>Yj+dWZ9}caJ4MRnB@7 zwL%~29eV~v8y2)Ec;cuCSSShL%Ovi%U?5;9blY*3D;2DoZOhL+7Vum{EARI$TR0e!F&Lm8r0hejVZ~%2x|QV8%xEi`4qq5I zhVnY&R+df%1Ml!B1vL4xwpZo%nq7RIWnL_+soT%;?2=XYq)m0hdYfA4Oc>=}p z)+$q^@2ESlZsjWBEGutMG*-d9F!Mi1*O=Ryok5k%cja#O`HkIFHzBcXM?i+PYC=$3n8v}5`5R>Cap zSEnW~78DUHp6IPTMYbb_O=4y#Jvc)UpH_kyIORz4Ur#ChsxyU|O4D-om4?kUb7-h3 z9Gn?JX|At20i#fT#rc7ulj}y46I+kj0@~#)pJA^fd5dE_F2_H-Y&K-N&gLz)2#(lm+zesu9!U}B3Y>b50t1E^=Bswx_*6ictDpRVMW5?}dAD69l( z2YrO*BMy|Gvzx<}@JC`4yn`hky-bD0z5y*lnG1QaH#yUuv>iKin6m8iw^{^`{1#4p z&zZn|7|2)89UgNhGy3N724NVs_VBVN+!EmS7$1{?c=xTvHFS8?4MR2$FOmA`Yez%} zQPqu%rjf0Ww6>Huzxno(c0}4CNJ4IK z5W@X(1aEUP`7(bABxDf*o44~GX%D1-*)xZw2>=3)$UmL@(w2mAD^ z$D(o}i}T6};vg@4it$OaI`WY)b8ernEW^26GA>DG~TDoGL^;*-gPa zeP;)seS!xl#9`!Wk!|uJy=>>$PKpsmNyo*aE=ROrC~TiO=-NY;4!o~8TxOz<7mc4b z8PE;&B>GEuhwc75jJ{)kovJt|W4h9w|E>_gZfq0Syl<4($?!DM6)gd)f*G2R_2 zZ)d^AacwY0faMgN?Zmt@gw)YzV5`CAZR~EuGy%K_Dwv|3?ADgVC+7)cVx6&D0iKRG zU5rwA!mM<3+ynN3#-=Beyzm*=r;1=U&E(mZ>?nTD z@lu%^^HwJ4=KM@XtRXXTb`-H9k!bQG?)RKGazr%J?+<+8pxoKJBXjB3k&m+xhXq># zVC@DVZ)?s72zBUbPMAMZhOO!EEqsjg<=^62sowt>n(jkVffpt$a!Max<^1_gOaho{ zjD-Rtlb2!PX0OC>Rs_807&DFFD-_PuPx=6h?qyLBs{0^|k5Vqbf!nqF5y4>Pe*N_+ z(y?o1swMlA6(7)Uy8Q;=s009Nxf~hduhPs`z*@bzf;s-*!VtokqcW zrwARBIY6Q=zX*y#sZhs5%->6ud%m|+9eg_P3N`3)8z;7*{B&UcAq>^0FUC6YlfDG_ zoemS={D|naAg|*vUcEuxiVpD(<0Leqj4MsdYE||SGZ<)vtRuNi!L`2j!Z9s`sOLq= zxma@^lwzHdE zg|Y}f*sI)7*BxVF1-zyqoEr<5$eu^AOcn6;tUj=00h5l~-(r)bxBPP`nljR}g{q~q z4dbrKcgZ9Au))E#J?U#9Ri16XVbdK=+L=@u%zIlSQ zZ9NwbmloSgT65GV364l1+#TCqaCLSYKIjTmVYzz1NZNDde>vnW>l0@r5WweGVzf75 z1gj}27qnPfJF}wSLR;f)<3DAN?3knib4u03#;)x9A{;7!R;|2yjv}{ z8Qe&orn=|`k-xK_W_asiKeGEQINO7RahycI@=0CJ+EFNH{b`&84$^++;8#pX>Id*$ z$zHu5V-IYVLa(5B>}G7n3t-VCc)vzA)twJXF}QLo_OFOMU-M6S1#VrC zD+6aR9&`KokqV}sT7CpZ2ZuIngh~we)TRjOYwQU+14?*rFOsFy@{FF>NwRT6Qplf3 zuSj@2RcX11f?{9xG)CeV+3xeMqq1oT~EI#-HN3-U)E*=5x=WysYP-4bQN z8X%lJ{pb+FF{(>EN_Fn@Y{<~r)ad2oU>4AzgkMw2mw?Wr9*0%Sr*B0%14OLc1rmga zgU&I=_gf!x97d5s4kh10zxJE%e!Q{Upd@DQd(!h*wXO}>#C3A{5f*Hm)W%oNsD$|0 zZ+;D~v5$$bk35P|)hh=zAxuuxkZ60}`pw8fmm@H}x(>gf9ez_;72UV#_2g!kP%lN} zQzH~9CoGw_r;?y(fifSs1h%SX@vu8Qiys}1g1&(Z*;kQ?o0?ih90W?f`vNqifJ4rs zb{A=_jwQIrBYNHf$=hYG>6@-=bSy&aC0M^1!QcMe`r|9o$j%@T1hh~*g@q96O};V+ z#1iKQf&t%z)#5n_oi)c*b-Fmitt%O<#CYM@c{VlMG1aIGJPWmpQtvmgeeH>{%y+#8 zT8bX_5Z<1Hwu>EhANKY@>VARsjNy=~6gLw!O&%@=Kis8C9;DTPVbMe*$SV>ErRCcL zFTSQ76^__wk%yetsIyfr3ZbMIK6go+A%12eP?vv_<9#ZRWc`*K$`ZD9Ohvb6Z85AR ziH!A&j+kUdmoDPjkuPEdYfg<+F%Gw};V}|$Gm-jY1PwQ8B^-5H<^biH{Mq59d<)2i z`PEn&kvjp56?fCSJ;c(k-6cLL<~adeG9<);cDq#h6m#3qz1?Bst7 z0`U`#A%^1OZcN2=1k#Z_)thvwSp@S%3AUi%oQ{Pg8x?~wUCJ=}s)TajoWel(rK_nZP-LdONFRCOw zEu#){@p2YVxgQ%CN`OH{DE^gXuF$IwGVo-#_oMPq-X&qtHYE;{Ldg8XmAiNprOG0v zM-g5@s;&nszPrYuAXMd!xCKjv_3&#Nk-d_>9!N7}Y5|sCmGIc<#h-L+%Ariujxe{> z4h#NN4vr9@;y@(t+$)DXn$YC+i;@XYD23wp|ESu106;XPKwPJ084#6l@3 z|51ZTRi}cR3B+u~CUVCC5ejhNK%~&$tAIS@@gI<^9~8!af=^)kNBG2mAMjp)2mt=Y ze}%XGEBu{}fMVc(gm?N!_&-@6`(NR!{tExo`kKGO-%%)q5*q#y{!iA={8#w%zrz2t z{_-E;m;Mp{kJcv>|2Oz#zJG#G7WgOlc#407|D*NE!v7Wi$4v7F*ZX1p&q?JEp8uos z?`MO_cuv{$cUI5#Ipt-POXMAf_}5zgcfdkFoa_57;{B?luAIwdK9g-_@ot{6Ql$1okJoYYe12 zI+BB(1?i7Ej49Y&-vX%Tf$hP+2IP+C!`)_CKzWybmwA5zss%7x0;t~QyYv^1AE$r& zjdu&&MSxfVsm~w`K_s`!AOaJhESwhHNB7uAzw;D82o#7I6^}U<0tEYNXSMH93(y43 zMh|>9I(HCv84qlK-_t<#{#XM9g1)O`0S4TSz+L*u3%O+QA6|g(?mRFW_a)ZP^4Ia* z;T`>67Et~#{YQWNZ~y%0hdcN9|H(h@$Au8^d4qe+?sdEO`=5Re;ReuLe$ws=;Jh|1 zz;@S=6UdA}Ci`(7L + + 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/); +});