From dce5cd558ed2dc1856bd1bc2df5189c27dc29492 Mon Sep 17 00:00:00 2001 From: HunterCML <5335527+HunterCML@users.noreply.github.com> Date: Sun, 17 May 2026 07:22:05 -0500 Subject: [PATCH] Add artifact package integrity gate --- artifact-package-integrity-gate/README.md | 26 ++ .../acceptance-notes.md | 24 ++ artifact-package-integrity-gate/demo.js | 89 +++++ artifact-package-integrity-gate/demo.mp4 | Bin 0 -> 58574 bytes artifact-package-integrity-gate/demo.svg | 39 ++ artifact-package-integrity-gate/index.js | 375 ++++++++++++++++++ .../requirements-map.md | 38 ++ artifact-package-integrity-gate/test.js | 154 +++++++ 8 files changed, 745 insertions(+) create mode 100644 artifact-package-integrity-gate/README.md create mode 100644 artifact-package-integrity-gate/acceptance-notes.md create mode 100644 artifact-package-integrity-gate/demo.js create mode 100644 artifact-package-integrity-gate/demo.mp4 create mode 100644 artifact-package-integrity-gate/demo.svg create mode 100644 artifact-package-integrity-gate/index.js create mode 100644 artifact-package-integrity-gate/requirements-map.md create mode 100644 artifact-package-integrity-gate/test.js diff --git a/artifact-package-integrity-gate/README.md b/artifact-package-integrity-gate/README.md new file mode 100644 index 0000000..1aafc9f --- /dev/null +++ b/artifact-package-integrity-gate/README.md @@ -0,0 +1,26 @@ +# Artifact Package Integrity Gate + +This module adds a Scientific Data & Code Hosting slice for reviewer-ready research artifact packages. It is self-contained, dependency-free, and synthetic-data-only so reviewers can validate it without credentials, cloud storage, or a running platform. + +It covers the issue #14 requirements by validating: + +- datasets, notebooks, code, figures, media, and model artifacts with deterministic type classification +- metadata-aware preview plans for tabular data, notebooks, figures, JSON, model files, and deferred large payloads +- DataCite, JSON-LD, and schema.org package metadata completeness +- FAIR access requirements, reusable licenses, reviewer access evidence, version hashes, and persistent links +- pinned executable environments and rerun commands that only reference hosted artifacts +- stable export packets with package and source digests for DOI/API/archive workflows + +## Local Validation + +```sh +node artifact-package-integrity-gate/test.js +node artifact-package-integrity-gate/demo.js +``` + +## Demo Evidence + +- [demo.mp4](demo.mp4) shows the problem, implementation scope, acceptance behavior, and validation commands. +- [demo.svg](demo.svg) provides a static reviewer dashboard preview. +- [requirements-map.md](requirements-map.md) maps the implementation to issue #14. +- [acceptance-notes.md](acceptance-notes.md) lists the reviewer checks. diff --git a/artifact-package-integrity-gate/acceptance-notes.md b/artifact-package-integrity-gate/acceptance-notes.md new file mode 100644 index 0000000..98c2bea --- /dev/null +++ b/artifact-package-integrity-gate/acceptance-notes.md @@ -0,0 +1,24 @@ +# Acceptance Notes + +## What This Adds + +The `artifact-package-integrity-gate` module gives SCIBASE a deterministic validation layer for hosted scientific data and code packages before they are exposed through persistent links, DOI metadata, reviewer packets, or rerun buttons. + +## Why It Is Distinct + +This is not a broad storage sketch or another simple FAIR manifest. It focuses on the package boundary where reviewers need to know whether every artifact is hashed, previewable, licensed, metadata-complete, access-controlled, and connected to a pinned executable environment. + +## Reviewer Checks + +1. Run `node artifact-package-integrity-gate/test.js`. +2. Run `node artifact-package-integrity-gate/demo.js`. +3. Confirm the passing package reports `packageReady: true`. +4. Confirm the broken package test catches missing sha256 hashes, incomplete DataCite fields, unpinned runtimes, and commands that reference missing hosted inputs. +5. Inspect `demo.svg` or `demo.mp4` for the reviewer-facing workflow summary. + +## Payout Conditions Covered + +- Issue #14 has a live Algora bounty route. +- The PR body includes `/claim #14`. +- The module includes a short demo video artifact. +- The implementation is dependency-free and locally verifiable. diff --git a/artifact-package-integrity-gate/demo.js b/artifact-package-integrity-gate/demo.js new file mode 100644 index 0000000..8e3a301 --- /dev/null +++ b/artifact-package-integrity-gate/demo.js @@ -0,0 +1,89 @@ +"use strict"; + +const { evaluateArtifactPackage } = require("./index"); + +const packageInput = { + generatedAt: "2026-05-17T12:00:00.000Z", + project: { id: "proj-ocean-sensor-2026", title: "Ocean sensor reproducibility package" }, + metadata: { + datacite: { + identifier: "10.5555/scibase.ocean-sensor.2026", + creators: ["C. Oceanographer", "D. Data Steward"], + titles: ["Ocean sensor reproducibility package"], + publisher: "SCIBASE.AI", + publicationYear: "2026", + resourceType: "Dataset and software", + }, + jsonLd: { + "@context": "https://schema.org", + "@type": "Dataset", + name: "Ocean sensor reproducibility package", + }, + schemaOrg: { + "@type": "Dataset", + name: "Ocean sensor reproducibility package", + }, + }, + artifacts: [ + { + id: "sensor-readings", + path: "data/sensor-readings.parquet", + bytes: 18_000_000, + hash: "1".repeat(64), + license: "CC-BY-4.0", + access: "public", + version: 2, + previousVersionHash: "2".repeat(64), + metadata: { title: "Sensor readings", creators: ["C. Oceanographer"], keywords: ["ocean", "sensor"] }, + }, + { + id: "calibration-notebook", + path: "notebooks/calibration.ipynb", + bytes: 900_000, + hash: "3".repeat(64), + license: "MIT", + access: "public", + metadata: { title: "Calibration notebook", creators: ["D. Data Steward"], keywords: ["calibration"] }, + }, + { + id: "temperature-map", + path: "figures/temperature-map.png", + bytes: 850_000, + hash: "4".repeat(64), + license: "CC-BY-4.0", + access: "restricted", + accessJustification: "review-only embargo before journal supplement release", + reviewerAccessWindow: "2026-05-17/2026-06-17", + metadata: { title: "Temperature anomaly map", creators: ["D. Data Steward"], keywords: ["figure"] }, + }, + ], + environments: [ + { + id: "python-reproducer", + name: "Pinned Python notebook runner", + image: "ghcr.io/scibase/ocean-runner@sha256:" + "5".repeat(64), + runtimes: ["python", "jupyter"], + trigger: "run-analysis-button", + commands: [ + { + id: "reproduce-calibration", + label: "Reproduce calibration", + command: "python scripts/run_notebook.py notebooks/calibration.ipynb", + inputs: ["data/sensor-readings.parquet", "notebooks/calibration.ipynb"], + outputs: ["figures/temperature-map.png"], + }, + ], + }, + ], +}; + +const result = evaluateArtifactPackage(packageInput); + +console.log("Artifact package integrity demo"); +console.log(JSON.stringify(result.dashboard, null, 2)); +console.log("Preview plan:"); +for (const artifact of result.artifacts) { + console.log(`- ${artifact.path}: ${artifact.preview.previewKind} (${artifact.classification.category})`); +} +console.log("Export packet:"); +console.log(JSON.stringify(result.exportPacket, null, 2)); diff --git a/artifact-package-integrity-gate/demo.mp4 b/artifact-package-integrity-gate/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..7d6edfc566aab0ad2244b6546d3c7a5c481d3099 GIT binary patch literal 58574 zcmeFXV|ZlUwkRCiwrwXJ+qP}nwv&$Sq?2@P+hzwH+w7>Luk!AF&OP@$_xJZ}kEiAw z9@BHJQB^=dKt$%QUXE5S4t795AV6OjP*{xIO_=Q**_eTV5M1pX9Nd9`fNbsDElmOV ze*owS5D=jt5HQf!=l{U}#Q?Pqmj5pb1PBN|&(+z;2B6e+wfUz_(Enlh?`VK_ z|2O=vcK%=Of&+X&=KgUcGc$E{0Z@2mwl1#!4h4wt6&LK^eTK)dGqo}T=!oo0|M%E= z0X928YD52c(p#F^I{!Nckh_(s*?-YrSRKGSNE+Fj+M0dY!1dc%*_#6hG%clNo(E#`h{%6d81mIu8 zmIRPr z)&v#7JAsjafIxx1D$u^$5NtzWAPCFz{YjF(_vhy?ZzM+}7e@g8>d03?Gj?_U_bmUS z0q|dd<>LC!3VhLDHaA~85CCX^fqkva7yR2FGScM#*k1oj4p^ms^f3R%|HXf?Uw;3@ z0^tAX|1}@~nV)~n!`Jrs&;Iz&`S{QM@UL_5pZoD&^YEYh@n7=*sQ<6~5$cNv9F~91 z3m)LvZ1N=lz`vi(U(f9?8gvPu^ZrNkuL>ZqC2!4_z(Y|`S{O%`2Y7j(Et1VeVskPySRx10Dl4Cj3bl)u(6w| zs}TTe+nN1?zjVa^GXnx-P0mJ+j$cX2e*?YMztSx73Rj1(fcU0H|2ThLB0zvt#2k={ zFathJUnvZfxw9D{hx)~02e1O%JT(yDpP!W*VyE3lYm%*LbgM*bq*vEp`3ey|k%@z| z84)uFJCQRh3kxd|6B`$ki7^vEA;|zRV3bo7lb~lK5>gidSelxd02Cq)j$XEA=B`96 zOiZlwEKDry0HLL;t0OlfqlbqF!&e69U~6R0;NWb*_|*!7rK_DSz{bJR)yl!%g`3F4 z$k@n)kC_ONrSY*6nVK2fI+)n-F>^CnH#yd8aTSxSUG-K z{40Qyqk)6Dxr>=AA3Y0^tEDr*!-bE7$kxHZ#>f)D82p!vnaIV~$^_7t{}M0}**pK! zhKZG(k?U7Ktn6LQoNbK&MgY07t(&uvmw}0couiQ}fHwh55g>uLvIm3!7&;q$*_b;U z*_pWjCd=5s(F;IZnewp!Xd_c2$FETs8yH&|xqLNZiGc$^I(`NI2OD|vaeM;^TwKi@ z`Phi8907|2*bsm<1jI6O0<8bnY61lU0hL{wMTP=#etztNKBKKV-m+U(k&pe3k}0!& z8H1Ck32qB!`pTdG`=gkxXiKY5CU;u!j|{c|2v)*j_jKetlu3bgF2t?>hY2|@xa{Ro zfU8JyYLdo3R$IxS1c*1Ryb}EjO~gw5E{JS4dHQn)uYIYVo>YXFYtLw*e}T>}kO0QO z&H)BfZ$c1bfcQ^+dNu)QhpbaOg7t=xT4O|r0eKDuTEb`QE@ounj$xhwhbT`is`=*^ z$U9}vb{h*VzkY<}{#rtnRh|-UJ%@US54ZHCUf#3E_c=K#Ft~!pw5+|;(6sNN>FSau z^a|_biYL#l@n@D}Y(&uKsx*62r^PO%90VINU7m)SrF4P)GfO+z{@BI5tgIiq28mC$1r-&KST9% z_eUR^FSDCc@CH$!%!h5yOvbOA5D1c38;nwI`;Es_6nx@&?1VfX19X1A^&l$0mkd?2 zsKCzg@;^|}y69;fx<(d;Sizz5#;1V-k~CGgj>8Lk4fSBpYlUPLoBag4-Y3KuOyBIx zG>Zn7`*c37SqBGiNdvaR0;|?I_-f~!mZjf*)o(+}iU|2fnaH;HFyg$08W^&(#u8_M z!1j2``3iv!sJ0_aMwppHGoZs29HfOfw~y%e`wADCrROUJVVS<6Y-nLve`ed>^Iw2d zMqHgYRF0P%Y8uu1nDLQ+j}PR`gT)v1MVj{oUUB~D&JVwzO(E4FC;zCLulvSc!|hNq zYaDP*PWP&q#;?!%dyjIrLAM%HEUqnY^=^a;OVl>wAoBFYsMvi zFhaU*7&c{iL{{!_ddNm?!lP^=o=V!J{|s?caa$%r8t%*YPuM;GUioI^-6CCUpSHyQ zrqXk=eK>^^o0u#6mKeGO73`l<%fnEVtRTwcH8oICN_IrhS#JoWtWo+nZf z+|$l@N$JqUb7+&huaazl$`mC0&V$#}zPU)7mS`&aXA+yQyQNP^mL`1RsCHOgbz2K{ z?(D~se_I4YcuFQ*4@0SrM722KhDELwdM4UMoPiAb+G>#ibWp>G&v#pz;uD`Nm;($* zUmiRH(Dq2bN{Nb5$Bk*uTv0I>q&KixVnb#{&}<@$M)$1tcp811olD^1UDH^`C^urGPp7)tGL%h;JPH_O@!^{- z^a~&jdoQ~~j?ewcZ{l7x`#N;fg@QBTjgE-;EQ_NMpVy50{tOgpeu0o)AGBX0f|rC7 zw&z){#)=Z1@XD0)hr#HYv=dz~v+qT-@dkg0r3x>jN9XJsRrr>^6I&XZo5*>DGS>u07*4%KMLj&y}NQgnTf&MV{h-)Iu^l*NUPMxSQAK zDLyx!xuQx=uN`*Wq2GV1b5garkMWV~?qr^{trp47)w{+ZVS@>VBPiU78f8It%rA2~ za{MKL`MSeOIl!Zseoy@z6^e{1#e1?Ve~_m!c54I*ydT#&lI87Zs%m9(*GYY>bvtor z2C3r`P_D6yakJ+5nM6)ta7xqTM9^|;7)MQDM3lZeBz{_1>$sXiW|s`f8QZS(+sWR- z#gt$Y9?v)PKxR5P@lv7xK(q>8ZyS6^CL}1mJJ+Hm7N7#+5nM zfs@x&E{cejq`)-M7Ih; zJYrn*ltZ6)mUCdVLPg?ZqvlU6Z}1JxZ%k|GBpPQ4!};Qo*N9j$XI*%jqiWM;261wy z9F%car$Q*`r#+zCgg2whDsK8jy1o&z4YpV&)+6kKz9Z_~<1)!-Y8PFZ+UY?aD;B*d zJ*6B_$hX@;y2>oHBjE#<5(ZU^SQUn-6&G3tiEqcxQiU#Hxv26{?l@y1R=)m3Lar^6 z+il8kU2j<0AEL8+Jwyl#GkeHPmuqDBh;cN8E%@z&7b%y+qolq5L3lKIjnOM(b>Gjz zQ?m_`TfuP7?btrx9TYT6tIt*kZE;Ct^Qv(9Xi8uZXOt%>`h1{2ii+($JK?VoH^*J% zrI_V^`{I^R3Gg%Z%{6Na)>=EArkWcN59Wx``|ia$^$9Yn1X(QUM^au8qVGTNeeV3_ zxr|XGhMwZ+mm0;$(1vrZ&J*>c^x|>inG?g+Z_rs_FJeY@O*WZB?b#4>7xaxRFuuC$ zXBS5Yd7oal+~{pxV7u8=JA9#qaimr=uf@I+!F_k+pk|O+m zp51TEWT&GjY&34Zvskaex2~We4$WdP5H!Lcq(16r><MiMRo)>J@_IgMvFC!$UXf1T1#~!t2qoym5PXkK zAJ%_ut|{gwa{pjkNbTc4YuI79PY$bLeeV|BxGA$=THltz8C@6z)9`5KWUew^iETQ% zd1gU+$5c=smiu9N?$onSvB?1(3@hD8(h(L8A;;n~@SBnHQYpLM!~n!Fn`%qOY;B_I zJ#q%NBV6j4&MUw~+fl@aja3KY64TD8dAfT!uw}v{nVdgnP|TDTGgysk+={*m{s-mk zeol9-S1GXR+dXz4PTpFT3R}`ozYylJ)MEoVY4#~NckQffdCU(Pm+p+;w5(tF_Ir&MWR{RGjLw43ypPqlC=$u0`wllZNEn03ZHYyHSvYG#Fpd|hP(E- zoH-@bprPfRB+Xy!dbNYv<0dF)1zu_dd12lW_?k8ZPO0*6>)lC%9$80Qa^)AwR6GbP zbzL5f9}XnbSA*m%4{iOC-2)yv0uI93rkJ)alP(foixO~_L*egwME;CJO*F`a=FNC{ zL9^zU{vhp3WS!CU^S;MDhG{%bVaMW2l#(&v!-UB7Z?OLO#)1Bxcd%xEazgrm;i1p3 zn3$SXrk5eC-jL>e`OEJ~>==@Cd!(3m4am^b1TH|*_gooQlWXAHEQa9r z1vS?MJU%_OC2Ex${o=OYJ)J&12!$Ku%D{Hx0=WrDYeo}$(?xwRdsEN=_d7pK^9Gs# z63Yund0t8U69DP2Oc=iBr82`dXMu~s25XyUs8z~c2uu;WfTugJ2DJm143jE!B~m=? z(VV#VgZ1)nS9AKixWo;v2-#^mo)at=#)H9_bO1dUyH@waB>~~Zj4s%`wLMWUPyOc^ z&5cn}mYTT42a(u&JkhWF@pnz$5&5o&F1Nt!X9Q_VA&d`lwJQRMUsXhTUkEj+zYDYo&zYnfJRCBuFTJ^3hmQpx^c(nChvu5oCD4+5#D^&Zo`oPr3JEAv5W3NppeLR*#nzbxn)WBYR4@xra{&SL zb}^P$<+pUZME~(OwI^G%I|#{sa!>;)37uQ?sseZ>lcnN=M#C}0m7fJX=S%s<4XG&Q z@sQD3yPHXi6ykg9Oy^A{C4^li6A-OdV@hHNa9yI~hEp?AP+5wbp)18kG4(pN;E{uL z)Lc<+o1Jf7u$Xzb(~^)~cd^^*>+1oxVo`Z|GmgcQ+z7aA&rfF%d?o%0(yYeS#04GC z%I}NB66o#FRUq~|xnWe%)P12Aw;fJth~lljN@k1dMhWx;JbT-~$h{Jxi+8n*<4Bym z7+z4$-^q8}Y}VGDd>N1CKH++1Ih`p^A4aMQ(5?%!ibEYsXF{-!9@v3G_H^vmYN$#L zCMK&Q^j<1ZX0+H5ap0H0$r7gc9NGyY#2X~UHUF$6h!$*pOM=V4$BGl68`>j-BK{*K znk!CKQ|4kF({73CkWl{k*8XS7r)3Z76?)kt!y9NXbx&?kU|S6Ju(ZdBl_yz4;ZcKs zm>bl-)ORHbHtYlFA80%YZfMh`B<^{G<^{=W_-X5=&;w z(^!2YN7nmQSYT&#wRa!tToex`eCzJI8}vu%MNFOSM)p) z2xuVL?QHa(k-q;W^o-k8BE>;DHu1C3p-Na4-A@@bb_f}LyI{+KNUFwU$5Ok<0{)xE zipo=Rf!#^aZq(3E1xC5|<(EJ;7mikR94@Em_b_=njw^0W;G>fdeshN*DykEM&xKXG zO^vu>j9b|SkAiTLYOK+v0Vyf`vmGU%9H>GzL}Z(qz8ej4hFDdV2Rxj= zQD|tdLams8@j>-;%XdhS2=8K2qt|Isk)1P=ok|;?(p|L6rt+FqA(8I$hMH0Gi9bc> zCCl0D3Rhrk&mOZH;pb7>9f(%T|5#k+7m^~LQ=>uceou%A7m-tmhk9>%%9w$d-7A8x z=ekCgObu$b64AMa#D$p~j;T)lJAsk4=-YG|;#+o`{9)4h?19ZaNvF}~Ext7E`|K0# zNv~Q9)W^!8T*)rbw~vIB`6pKK* z$P?AK&Wha$$GY}Q-Q{V4f3r=;7+vOCR$}Dasx6puT<+ES@VgdyjN90vxuxhcNR(2m zY#g>UOok^yQBsB$3Ke8=ZLm=U+wGb z{f@`^t6EJL>AwGpvH8W$6B=xSP=BtvfHuL|?Z_5Irs(6CtliZ8Km~*)T1gg-%XhOb zN2+pBB|&eL8`r04LJS9U3scA0s@c04cJDe3I+u5h8?=eZxA)P$Q3 z+;Y6x^Zn)>;BY0YY(9Q>--@qlsU2I~E}t@|H8qP?qOQA9Z_^S~);4Lc&fLul`z-aMTMft-G0(%JIM#~xJHa_ENL>10FqX%A}2vPJhLg%kyh=MEVj^3;{#7AP}xasR4~px>JQz=zHB z@tCzC;rnGKN0H77RekiRuo1K^iC_PF@&J8ji~{=<_uiD6Kt;9 z5C;eYu#uitNP++t-4o{NFHG8%6V*xCcNU8+Z|gG-rQJJ4i{ebM4=2Fi`5vMAUzrC7T1 zTS52(cDcb;tWrT4`MzI<(^F&D1_rt;xgm~qw<#iov`oiB_sy7Os*(g%XE6Z_4CTsx z7a|zNDb)`j+Op?>6slziLcN~tqIwH4m}1dKE0?m~0?P}%DmaFdeZ#jM;BDscZ{=9E z4hc^C^dO+V@6+iN$8Lv)oM%nkL;Hx1@3>47;!N~iK3N7U+&C>$dyZ*!sIwXaLGki1 z+1H;S$q?^SthwS*UaJn0>)`EHj5l$JS<8$G4Tb8hEOI*hYy5J9)9fNVg(x~JGt}E` zCKrdhhwP`fj5`J=Z~=4sn5{H-`_il**HOg#OK1M^Qo=7y##oW>Bct1mWzd7T z;P@igKAY@VrRqPhq6Y51$uQB`Z4QFieyWPjyil=LhvHLc-yxIqKG=(NFC!6rnhs=F zOK$0ae#;k6`4dR52-I-5n?@8RQYn%jTOE&ER1G}en?0C3wI7m9W1}d$ylJ>gh1<+L zNwtV>gO3iSbWQh*k9G^>!oND?c3kb9YO?QqD#Acn#5k0bXPo7&@dXS#4(c}^0&z4V zP|IXzVmnd~ZOz}GdeD}?scsq_g9Y=aTNjjRQ{PI>Wt+o#*%=HjiUg?yhLm5biA{>H z#iC?K6d~?#oh{=sv~u--1e&Z=M;DoV1lo?CTBUN4>vF7vjb}!Pc95~$0tri2z^l7; zJ*?!FjHMPKd&Y663&ty`L8%OfI~|E84ku1L*501XL($09?bQ%Jxtrfmq3LJooE&uO7t(;s2BXk5X^Qm$vZ z3gc-+0h{{+p zWm?;rK+tqSLE6qaov7!57wHNSVP72f=9jJVbJMk!cy6>n$S`6v-I538W$AH>%49Tv zlM=^lVf-_I;C|dW7!!LA967NZemY`IWwW2;)|izPxXtWGBJUI4K_~7~hrI%>_yls+ z69c`FbqMaX;OSC8PS<8Bjm9=M^p&rXn6+mPTuVjRBNam>OI!g_Y$3g}?ROQSP-1tg>3!zG^p;Gv()*H78112LB2<*g;=bpFI1i3DW91n>n}V(s*Ut2&`=B zLFt6V9SQl#R~M;+nJVs^B^3}<8GevQW#x5_4wW&Vd++tw?W-(a=8q>7DO`s50zOUb zaOFDlZ>7jW`QXnxc&C|p7VasYH_>?Ki@W?XqtHv23Ej@uI};ebbCPI8rnv~-xW^@T z_bBWj+0sX>>n|nwiq8n7l-LBOWV)`0giKiU!FBr*n|oJbRbmDAI43iL#Ry3nRcSi( zg2UkwKbSFi@Z0eSUa>tXziSXCMP%+GPx2wUC-qON3z7F)X7vH@l?Y06Yjh$VqTI4s z9sE#k`T_ERIcMu7SZ<>QiNxIN;hd1~KPWe+mH@+*HC?#WxZpvPwC#E!vFs1#`xSy^r2XAJHf6cWUbLn%Z!nQ zGvxC~Y7yC{exE0t2StY`IOPl3>QsFTEfHWVD#AR0cXqlj;B&UfR#I|LB#!LC)~6M5 zwN!afqyN!_#Ckf;q32?g(?+fPtIj$W9-Mp_JB5S-2SZMFgZ9{tftt|qnZq@gx(g&m zYzaI4H8p7;*gtEvOZl-zKTVf1hDN369`vomuF=}3d#8F6c7pt}=Z$D5wq962yASYa z4Va5GR{6Ud`n)WbgY`4JyQf3JngxbMP9zvC|HtXmG%kMwaJ({@)$hhzSw7~~xbEzq z+iUmK{jf1_4R} z6q2HY1@yMatp;fv(%;$hIv&}K^N?|+?)sT0%$XX1()=<2{FwZ5#67VS(zRj+o4Q1H z(Bg^X2qHLYO0X?#VUMjk9v6@*=7@GS;I+WE-*tG+x#93pPg6kl4&6nMs(wND8eG?- zp;~5+tQ}lf9aY9eH(a;gE!{6jUwXEVF{h8nTY7_;G3gLAgdXc;!mxbF-jb?%=TQNh zCttFlbK~r6AumwmmC6rBq{}kpJ-4UKrm(QT-|pt~0V?*{>XA=V0gb%|RoAN@jcPrsuCY2+eqQj_}9l$MIkO4`%#Ta=;TjXu=22ef$B zPQT{%R^I}Rr%gjjntlXyRU^Y?USz3p>Jbty{eFD#q4iv)nr0ov%I>ruXSNlh4?@AsJcgG43zX+^rEwmyOR3$TbAoz3-bIh9Bw*zaGDfehi*r{+g!s`Bs?l za{?w|w!J&#y|gJ~lF|7O|DM1jm|YOlo$-eH0|xh4#SFOQeL0)-P5_NlDw6!bkvUT5 z_T#!MgTQZ{2Gcaf)4>g(li~T%A>O6-7ff9#v4Kj@cE!BX{cRMY`&1TjKdgd}Ozu#7 zx7*7XNPYs%+K<&D*kvz`PF`#p-Aa8B9NfS&CFlxS!E<5hbg#gsCLc?4!cl?IH53ZK z*LcxGTR~52pd{s;dlHbsjo8QDnOCn6LJ->@OR_G;4+#>%bI*b48d85|XfcH0Owux* z#2oz8R!`&v-BKoU`yTM|JhbDM!{S|QG;&Zz)h@)JGH?gdgVv!|Hd06zxL3EUa2rnw z&F)>O+}{O9TqoG{i&k$9wv=%A8hZlzEve+6W!8<}|K0&%H9stI@F{&W{aLL(XV*tb zq+PB;=giR^MRw{T)^V};oxx!P7_0x2qmAxS9enEec<}1--8N(Q9+THGcglInw;VMq z{pSF42Fyj5^FvG_xJ_gV)A(vsi=FpaMS8`R+My*<%*@S=cHv=ML;%(w4*MZKrQ)vn zGAP^~Cy=w_U}Z0k{6BVKNb5+Gi=5djm&`LiS}r;G%o@IHN`O(! zMhOTn_!I~%$&MI!<5WPcWj?i}m2*-HTt20026fhQ*&ChdoqW?i81v0pc&{G>bG2n< z0uF$kR5Dqo1!*wqR^j4qCk>RL?4={!Hc9|q)U=Fmrg~x@iYd*&%{#4#7|8-c0}>Lu zd;nUe#SjXRy?g&G5RGQh^l0C)PPhU$$TtIP!3By?aMG7-KOztnYcvE)R>+8M$vRX8 z4|YUs6Y~^I?t6UAF`ou|HO(d$TOWPIFaB(*Z*-)c>W?y1N~FbK`HQOu zK_nEKbPimm+@P7B)^r)U<6QppBDDl_ERnWZ-N<3^{p#1}yxiwoDsmgk;pw)^591eY z--S=P$gr-GQnqh!9Nz;s~d8MZOsM; z!rbfKJe!KTnP)Q9J$y_Kilk1P0D;~7(y-kWgw9xQPMXGGIM&rd{?tnN znt=7KZdc_K-PM=EWO>5tXiw71`-z#SDpA+kJ!>gu^@2=EXTxRGknv!Kc zDp^o3L-F&oBk(y-l)v93DO8a|S1IEH#oTwX`=$^y-aUnT=oCb0gRQyf?ZY-PuiZcU z46}s7it6yi18trV=k4x@dv#%^5VrEW^V$5?@kGFy?3_uYm%4|hHa9*7BpCH3SH$r1 z*0~+^w(HYWh>t|9V^Utek&cUF4^&r!c!B>&vN;O7m1PD#y;ih7N*&YwVxUxv@=x3Y z5RAP z{?(P`+T~x9?qx+A=AMPIG0;3fh=G0(X;>7V3ydfIWk5-vLh>_h{sx?mgDKY?B3B%m zHXNn6fm049tNq5k^tVKA{E@z;z&GipyE`uEAZg2y>l-~L{9Ic)X7uc2vA9MNL~WU_Qq~xHXytyEPYR9p61zK3$-S?0WrQ zCg@-4EY?aNgvG`;HQ6H(xe3mOZKS^Qe53W@>P#;ei>bjG<2Q6jtQau19bs}WjiG(jy18W_eWBMV&v|jKi|HEJDY%*NH@QS{@*KAQbXHHx4pzu9 zt0J6sy^>W^jWscw80(6@CXBe@;OYM9=kI~k+hF)#yY!919U|5MKF=TkXVr0GhCB3$3(ehH#=zL_CAhRkb7ZlHdPPz>f%v@TzGe zNo^nt%1lPFJ*};PgN6#LBfN&b>u>D(cS2VN>nd*RJPsh>4OOio>uZ;v8_Hxqs^379 zm3AMc&SzdQMOwS$M(Oz$GCvhL2geBFh;nN5xf(=fEW!+bJ+=lwgtJ!rbaT~H4Sl-8&&_ffzppa zNa0@BX7y6fUdtD3=KP~RR)*+=`06hrHyM%OgL>dE^!we)OiQ|~k?zEv&sFGwHY5td zZmU;ZXS`irCn}jBq>s=z_a;xM_iO(Ku1~3NY81sS<@|A~9I3?&K@^(0d5^5v)rhgH z%r*QxDXP5>H2!?xC zAgjzsGfR*%?67U&j^`36;}i4#m&&$8;4UPF_i}WD|#A5-O4-rZj(!Ttd zObnLE*0v6G&UT=4Rc4eAwz~ea4?{^d7pY0nN53_HvfzM)%b&+mLr}3y_wV5(7|4^BaSbhQoR5*IkJ#O4cwyNU)zX+-r0X>pxUosg zA&V)$H=%k*qPp+Pv*~J}z&)Z`%7+|=plE>rG7{lowr9wu_Zy)$Dd`-wiC!E{y(fS> zZ673NxquP|6j&Vp1!%^ISJFNYlKdwsj|{WlpZsE%LhvS#v41prS>=0|A6DucdRP0iX58&^N1$L^G{tY81Xl<&4lZ&Igme3Z+~?0h)oWI5&Su=imp$ zCS^%m#E`Oq8yNc-)6P!u(-cQc{&+<+;+qZ07`fW+0s?}>D1rtg!0V1wB`=wt6j;RJo)POwZmfyaRq0tLP9@GlC@9Lw;l!oI2=NFYFaggJ4=(Hq!CY z?y^-1am@woie9(OS`}hhLTP5vGM+J{uVJndt=nU~nJWC?SflOO(sCWc70+9(KqHYI zGhe05X5;p%dIY}9X4FdTS2Zjdg{yePmXdF;ei==>AwqF3kuOx8BljV1`5;s{0tyjn z4MfU4W%;;1bCdnOHfgA7%Zui-M$sQJ;GHkD_c}Mg%KS^@Z^4CVo{8kAnDks+VD9+h zMx{#(#S?`63F4h1Ulg?JAJS~1>#4@Uc^Xpzh$dp2F{*c+Ly%N>g;etr3Z)GDHUt4G z;%;yC+$l_{7)YdJn7nbaQ;4iSvv#U@?BAzU*w_hOhS-B@l$KrK<;svBU2IhBB_a#& zyedZ+{Pw%pqODwi9JdpUr*#E4M#`?z6r^l#omqnS8xv(TB+{&$>os!+s*A!Pw+JEm zgU)Bm2v?a@2d-E+uC6W3NRVJ|P-uili&@?gPMoPCcPL!tWN8jls*4`_q63a+9$gey zx2dl$G!J-FLe*k#q%nXd`vtt5W>Zy`0PRbz1>pT}=5Gbop>P=&2vqxgpz#lk;RJ_ckr#w@AHSb?>L`A5#3wz)N@gGAToH<}C3^ zsY~HSeUYo48s$MVNikgsQ6{xp5*??X)J82> z(%$}i^ImKMuset$ajD}i4DL+M#_0*t1k9Vv)anArMSis7z?zKT8s*#SGx6fnScf#yC~SailW&a36T= zn5(|{-K_=vnqcf2Cy~Xbk+RYKOBZlT{>MeC!@_YBENgEtk9ql)wc*CtAmJ``_G4ys zxt!W33KcfWp#z8VSza(cPR5OEM@IMDffkMC)`dtKcy%{8-zZ0vPZXt`WR*UeuyH67Lp_DQ&7Hf{ zoa36q(NieMsT@C>9-oP7`>qZaw<=eH-RK8-fqP$QFo&y$zrF4oZIZm5^A1x9nR~b^ z)25Ba=F)N@*ms}tNYrw`P2!n)X2t#_5fs96ia)f~1xey9mENt3FCAerIioJQ!WDvUEUJ|{0r1_ zI;{3JahtW=FJJ_g+Hoju?4fQ`p_bJzQnyU8!bEqbvsNSoMh3dzZ!3fQh&uMKw$rN;c&vjx8`IKwg(v54 z(LHZR(0?>`cDJR;r21Vwam2;*5tgA0qb3t%bRGt^VC;fW&tAzm(`2?xl^Cj+VICVIxa=CnvAUbw;AI znd4bVY~@zCfYOVG$AkJ$aSB-q&%cgd&vaAB7CW(>{aqZBPUP|n-51Ap{$X_|xXU=` z`Z%0JEEpTYokTuR1rdv2rn>&a`WMz^!h_k_z1rO)pL2S+V?>YPT(Ae5zJ_hb;m}_s zb?g{RS@)BZ5~b+Cl5)t~fY_WrW=LGWbIatxHN_z9(R6 z!_Rctrao^)%o6T{-7SWNZHbHeMFZj9O|LO;y^9n5tL`5sNb*hPe-wmMsZSf!u|*=a zdqy?hWQNLL6f>cRDI^+UUyY8^$J;eY1ZbgDksh@}zA;V`w|x}l4(rKrDJjDNd)SMz z8U`(QTDZ9+Kf{H#B#6A=t=oZMhNTPu=Pnwf;o8yDMZ@<83aT=Fwkf@BYGyTBz?1cF zlYMtP8=5i|LrYRC$E#5@M{nADx>R*`-9$;~OfrF&9fwDK5BQlvyec@*w_}BfU3Xc8 z8M15&>P+TE)r<6D*K5CI8zIDptwk=*TZj8l>nL|F=RoIOkuVp7^l%}uo52hnBYKC`T9jr z+X&c`x+#8FYgsG|iun%{dQ2CDPE`8eXE5f` zmY!8wi%K1?0H~Ew2#rc&+e)h*lpalQZQm><+;?g?vCcHIrTSZe@=P35$IDzq=nbyF zX@tP}0X*X1;lUH^553uoS6oFB<*;x+60grVWq;JL{9K|%&sW;+dw0;0MNppzG`ODZfF1#id6G|189`U3qt7#e z$3~%_Qp3H{t1C#l_*Usb{fd|)&|4IKB<*8IsiTb;csB1o2{F4PpIwyP1$NZ0-DtXn zSbHzoAk`ra@@0$z(2LqcxOroY=VzB_p(omGv@@Z&{$Fs+B z$O@Z5(b^4YA8fF8R5a*EVh{X$ZfrYv=`_+@af+4?(F}XoNXwP*stw ziC6dMOqYw0gUdQwCu>z1*J^0$Qi6=Q!=!c#fFB=1^YNR^hA!O7FDpizh4(qc`Yrv@ zz@|(GO*fJZuFp=7x}$fh6w6i>wLhX!hTJ zrynhkpBZTvgPqcDI1Fz?AHHw(52CDV{S;r@anm+7#@Sehx?7|>*3g5TImK{@L&NMn z0h+jx(m2dC5(;R-R*~9M=O8AvyLJzc*JekW&?ag}u7Bbc9yTF{1Ktn|k;{6oa+_({BAcdgamAoC|WS9%*y zzF`@d9g3MP730#%#Ezk^_Q?CY16>D}U^LQUv>FA*igg=7ndP^l(LTZ@+M~Rljl!)w zPnXUJMp&R5Wd<`A9u$oYsO4&3&eSZu2@(WRE#%a-R~uO11MnQ35}yd56tazDgcFZu z3So^3{g^X!#g%9*3SpAbm=Vj-pmW^>$8bSzvU~e@pU6M_-$WYSFL)KSyw-FSBr0j> z&*$Xzi07aF&=6VoOw4tjS7qVFW8TpP7T_>Y99H!?V(|qhJK-q%k zjv2$jsw5;zse;GT;elA_0L6hjsHZwHg}fP%4yI1dms1zOox;7zRM%(bt#RpVNB;#k zxlps4PeS6CJeErMXvgqI@fEQGIq`Z}*|<%~f~7}DEE7ZK9YUt!xx3m~K#p{0@ritx ztZ{C6>JTh-8(2`#K(J7p^Iqfm+ogtJhc4NjMgyd~M}75Or*gFg1#yx7L#f?!LmS5f z=;i*DDfr^fEc~;4R1;y?is>AuXINc>ri#KF> zgj7jvhUjm|kCeo!lnf5PJw78#Lj1Ovx6xR)Mp+B&26PP0A)8GZd`R$PY)uC!M13@A z11*bXw{x^hc7v@VsKW=+Kt?b~QE0dy>%-WB9I@~GeAWrB0|zWvpeW=x{$U~qJ}ZxlIB4~?_Q?Rgd6 zxw^ut&db)*lFjGSgtmS^WO-aIOoIt0^^>tyJ&bg@KIB6@r01Cdw0cXa<1G) zBIKooGO@E3>C|c|(B~EL+Qe@my+j&#p7;YNr(4gcWMPX0)0HvaEwx-Qn&w&e0HLTZ zS;-9oamt9WR|{HC=d}aafiDN((tca6Y0_vi0RC39Ie}?YEX`zL3iB8ZRG{kfGXMNPoxS`ls?CE0sB|)dFnFwsg{*{iIBd*>a0M zgd8qI$1AxSH!Dwd2&3j}LPR}ki1 z`vyM2+w?@TFBwJu;$IU?`DlhR)g~+%oG`EZ#lkW?4|1KE5dsjNX6!+GnGs7)HfA)W zAf?ocl{wEY?De@`wd!Uzh{|*cx^lXy3IrwjQcMVYdQB;(;S&L`{0Byebq>rpPsixbffi>?<4;!Z!q~%vsWl`#%(rmLS(g=rT z#Lim@B}LyU$~VkoKVV@3GBu4?&+sHe7PJyah;8n)X|UQycpEHcM?PRZ`a#l@;KTQ< z&BZr|_d;44&6G71bMz0=f*C0f_rKJUQ`3Ho`m=$cJeP|ZmYHC8rO^qKxW}9>hk{x3 zvrqYgG_&wcOEG{8F0RYwPz3#Xi#km;3H7C-;k|>3yRbJn7heWkyCM})%?ffP54rE+ zjHkS>Q7Evy3L`LK+Y3>9n)}6R-Vk18lCM(ykA#{9-lp2Z$`bZ4O!dm#y@jYe&;85o1zE34Bo{15VC`BvpC4DPQfqvB+NP~pG&x@>x@j^d{5YCtYBz8b|Nj| zQjpW7mDcB#FMv?D)VswcKu=pF#!6s_noP%SoN6c3%>Qzb0MRbcCqZAzWy^DzFu3AS$jq@sHS9O=;F!a{N=co6U!&~ zE&;lcp046Teg0gxw=+jO2QSz##YActhx$65Rv$^*ID2mRy_U>bVN^C6z~1p`eyA>Y zXDOy@zuub)uqqC1kwI%zUV~oD8ufh)A+A1k-t1GP*&`3-hp3=L@FfD1&}XJ1-d=xx zS!xzplIZy(<$|P(P1CGBy0e{WreDo-FBT9iuN!>9gGN?;l?QQR=idpTHyuLMAV`gQ z;LWM{{qyX5M{dZNaEf9{ioj&D64rf!gD-~ga z1d2htdps4h;9LC7N`w6FHpLQ8hMDDQ@dDg6(qRH<9Dq5M#r4>!fs&d1*4@{sJPPQ zr!lsU`cfq6D>sfQc%Pm$mc4*22|FwuL!{epe3>ejFHpmeg#Eqv=IHU|Njo8eg4=0#0M0C;WWv&aSc@){KjM^Sb>TO zc#=>96L5sYQh;#pOCqJIjQ_onUs~CAzPNGQd2MKthn*QsRd{kf`(HoQf($^8m3+%x zGS7#?yPgw4b%mR}{lL?{DQ)J&W)~+ktt7Yn<-LegTG84h&Zi(tCxCDv{f`dL!2GC| z`K5F)fWAlIor$f}OWplcJC?)c%FT6R`#P?T)j}LR_BsbSPg0c>qc4cMe8Z0BKq{xh z(fnd<@8y7QPdm&|J#25gFP--l@@f~xX-)$}k4}fcDi*)OV=#vLC=+g;*crXJvTTSo zmQ$GwS4VmnA*a;*#FP=P0RgH$cqEta1r@$O+ui9y1M4SJML0hO4fVU-snCr`b;NU~ zwi?2^lESKXac27Y{uk;(pvM_7T~vDdHKpvjNC-+s*$%T~A`l0f+)gC0Xt-+$ijxo8 z+bQ7xtY^OHhs6zU1a&piPGVw|O9ngM8x>b|H99PMI?%d&DGSFiLUB6X{8Wiv$Z(8! zn&xU0saSpsO;n)qT?>0Lzhr^K3Zjyh+Mj+_JsyM4dn2*zfP8vrLe7wXzQ`gt^PYw) z3uQT9pv3Yz>f0NNSL|=L4X~*r5*Vj2JZ$OisI;i3sk+Aoc2=#bWB>1f0NrpDgmW8~ zx&DgRsrcs{yp?hhHLjxLmmairP$$Ic$D8p%W<&J=2c=F`tu)kf@dPbw_vCP=&8wLTSDoC!^Fljfsi>r} z_?ZXy5M=YEwGS)eu7enw<>_#W#d6fV1WLz;Ft=h6$oc)ZMukt5q>rq7lyny4Qx5@3 z-X#ai7(qes*zC1?NF`??<(rA$S^p=AM}vV?Vy#c2()&>HC49`2+v} z4fN>|tt2m%jo_?l0XNWkJ1L{h{)N{dtWbz${H(AN00AwI#|;#^WdO+U(D?uW0{|9| zl3)N|A3oz*=yuUJG>mK;aaj@e>?F#Wy=O(&^ z4v_vhWMxJrI+;vv7x9u#sZ^a8FkXAkQWkylsGpnNZ!4ZJdr}v1KPY4si{a5}8B05Y zX@00wO$tk$`=1QnfOwSC_fdP=dnT!>tqqGO%tc())Z|M@^K`-(sb+&!b@`W+D){8W zPIkrP2u}}zF|?Ob?J>bu^MSj_pMpWmr>Z_mgeD`;jp$)}{wzNAjD?4Wj>~F2d!GAY_yF@73;k5Y$HL0`* zm``j~W}>;f1Q}2st45H|cm_7ibN}wxv`m529PjTiA6EATwgH2$UEIsQ&a@inm0_4p zaX|f_x{<-X0d!5gvOy% zsWhIx4wZ2qJ4XMSj(VT49zb5w6)HUyGdobmNowuWmF=K}D40j)2*z_jPX}Ynrum=C zs)2G=H!C;_P-U8(|i-mhn^NY}!zGj$_oL=7!q(H+vD&xD&OA|KGfAj^OOUfYV zT6>h8lip?NA935C61Of6!+f0^Czo}I8*e~NO{x!_Te(E+!pt?2eQnp0S8AEH?F9K( zlK^iaNZ>Sp3X9*+hD=cFrS|3y;8MOWR`Qk?@pprFCLuq*^1Udkq_8K`PN{PcBumt) z2@n9rPk|q&r*jB|=6>3ZISWj^5duB(|9+ z5fNP;)52`t?bMOQG<+#WQn7a^$f$=v7a$P5KO+IyTa$AWpC-qo5{dBvp<^v)1=Yog z9-2YMRma+92)|P@Ec+OFrI&SB)%e$5`Lk+z>W+d#*Ln~oDi`rR@15M!&rljk2kH%W z%qwcj9JzTRUv@!g4&Et*w?D{1JU6Za(o#&m{7Ga8cSEN0uoaH{|IEl_lGGzr6}ZAy zg{`&aIjmA3>?C^LnED}tr_fZp6j}gDwnSu?v9B=W<%T#CJ-+~fP-`6+6xVw!smO5} zi*|LevMhr~f%Bl78RADWcMhe{M$9eLEFjM^Ao6L~$5a%M?{kwl69sK9te`0avJDsT zZd)Jfiuv%V=B~Xr5oxH<|IOcs)`5WyRDJ{)O#J0CGrd~4@UmBS+yDRr06twvKCmH) z`K|6Pcdq0+jGA>@A>9mrrW$T1b`INwf@SxJXE&ejpK2zqNcrD^h(I(V$cHeuz>Q|;43VKYV=;HWJNltE;L zF3DmHWSGhBXw3*)gNWH>@ukNGX@yXJ-Viq&*9Sm|o)+njKsPbhz)euLihOKDvrLn5 z;(Io=EsePob_5tssQVS+O7vL*80jf3+l2kN3tZSi%j#2YnPs1Fm8#6$9q+EWHvj-0 zJCB``>xuvX0|J3+ldGT0__f1cgzwT?@dZr-%XSSmH0AvgFo|svdd%wZFwKtB#M9BH zg}J`(pL(_M1N~cP7Rz|q-JiLRrxq`xp2W}3#U1ZUmoK#+&KQ96P@s5Bon(Jr>|Xfr zasmwmL8N;>P>28i+xM86pfnjQEGzm+(v*H%J$qW0>km0G5(SG$rCms^YnbfQ8CV;A zEkPBRYbl3rI1m30?gOzIgrXb}ci6BR&s>Kv5&ZLcm=nd`dY7&5k0FN6#e%V*ihkL& zPbVhqGXlOVMQA1l4u<{hd*%Pne1iqWs?R(=(~)6A5C5{Cmd0RReVW}23^Fzv&BqAf zzFThO&k~5LHvq%|m|#mfXG~&)B5lw39E!NK(9e@V6tPs^5F*!t+Jxb|pHc>6V|k>8 zc(=jq!WFAq09y6AB#jn52^(w>Bi20d*G}@X`tV<{KtnG*aI+x!oUy&vG4+2`C9R~4 zGh`T_pTVzGw6myH6LwrEoTBCqA|Kvd!J%8rLzhR#RjkU^y)!EhX9tiHDqxAd*qkh(3#l=$6ktGse(6 zh&fRkzxr81TUBC)uNqDvw znCZtaRxq+Z?+f7#$)bKD?DqfE?M} zQ@#*Xeo00LrS|COkY#i9TG_dd?FuRLaW5wc{uS_K2HQ_zErO-ua+yMor{REAQN-_x zf9*7A1gpOgsR_r1Z}0iiKvZ4`-smx86*N5IiZg#jpiM@c6|DCLBFq%U(jHTrj{~GI z0^cc5nDgxDyeKv^{G^+|dYU|7{ zKF5&d2zb*oePdqA;q*t=9d;33HFZc6ys%SvVv)RR{3H0FqklR>^c-hX`VhY@@Kf3h zY|J}3N$*enWW&1+iqj3I>m+w+nRg-sxb7i{c7z5{hrHx@N;kUk<}=hgZ{XfEGmGD! z;L7`K{$k4E!}((tG=aRbYJE5qR`kr3))L-Ik$hn=*Abze8K`$ghFjeqED04}0)tUw%V=!N>Ar;1sxX=?K+sJKTW+GebldKlnSERGfPDG zn~7BS#Dq~C2hnJNj7nd!RgX|BYQ~$m#l0}TBt&P?)7-^g#nl$|{l=TqZba4)kyC{y z2>nmJm<1FFG8&)(aEIIhMr%}WsV9S+vEC0EOng#jcps)mBXnx&_RpcggeqyFK5gL= zxwV>zXj#{1QJ^#@hw1@k{9KdC%pVMWwA}f{=d*`mfwHWEUbpC`o_&+*?Hmshk7zC_ zdRSLs_iV|8Ss3+S_z#>cBR_7R|i^EMWo`FvEa-P|Nd_>$_AeE zKf=R(VUU)cC8^z;AuH?H=bo=do(CsGXVOM6>eH~kDycZf*K`1&v?Eq2PtKQn=Up$- z2Q4LA=nVXV!zg-3Z*3_T_jq#XgillZf-`_zPimowFkg0qfP7h!z97a3*nSBC9a5rK zm+RYe`<_uxF$`rXia-iT(~}32`33=J;uz^~Sb{qN=wZGW?pMehuoTS1^xK73rhj2F z(~O+a7p4s@JZ%m}D&TI37Ac1NX8xtOIGsL5aZIt z1p67B{41u&INtq#uFy9!-qmv2;txooiDuwLFdl-W(F+`3vLcaPRuy_{J9)@J$}-DvYV{LoNydMdx#`h3?5pjNpg?T z>suM%(XK)UJQ*ss8sngqv6f+u*ugdX9;hMYrl6=#K@xZZ$%gcWSG&@JOdPnj7#VEX zIW;WwR$0zb{M;G2bSSYZCo`)y6EIchcOe@X9KX*PV`_&ikbIkq>8S~|v&zvxGIi|u zUj%MHXy#{|`os5|^$zwb4+ZBXX7Lrb+MwIyk7x%s*vHhq%2Fg=Fbl)}LbJ9!?LBm~ zskp%>FNyy)TvFFRWDDu%&Jk9XDud%&K9C&SliPowpcRkKf!8P;i9wL!w19<HxT4VqyBwNqjBInApuRfdHR{Y1AR2L$N*;5QnzQOKReesXExCaI zKbKvQhGpXb7Pbf4{w`p`ug5^sqzopwS81G|N$f>SM4{Q_%M^rC&G@JGViuqWXKw0$ zXZz6SRlZ{VKC&zUv1a7EwddKVTDKA_4{oCWQf|4hCT}$Lh?7(c=!ixXU z(D5d>94w~|KTotv?sEO39Y#lz?Ubf^wxa`yG4@r}y3q~ubnhQ5P!HGa0dqp4D2y7@ z6~3;+AirB{@3_dgYx1{q?mTpOU&1G}laCREZP?eC^+swh;m6l(qOF9*7vljR-kaQU{O;zt769q#nTn+0h2I-6(D<@=RzP}Tpu5ea{|j{PB8eXJe#yu0;E{i~Fm zk{;TRp(|67ez)*8@gBb|XaE2M04@W7DUl1CCo37M7Ngp9lbujAq9O;)Y4!8zFW#>` zZzNQa$_O-3Wxi00eFH`H?bn$^4+?IEnJm6h zJ-158SF8+0=3tT!Z;ct5yF%whGaJ_K>MRy*k}}wlAcXZt!qZC|qpc0RTK3j}9VA?! zh31QLGSK$S{ue?HdjMk7s(^ojNRjQZe6&Y^|Z*n7$RLQmk}mtc3u!Wu*NZOg!(4P2Ft{3*L1m#UJ% z-IQ3FV3Q2=szo<7f9@`>IrNHle2|r_sj7vrf(v_@4!*EN`EvtD!nwK-8Evk+a>qSZbz65liA%`s%;4P_lp-pgnp?S1{gPzh1 z#+}V@BftEM(neGYO|~O!IWJ!7Dg~2Y1RXaIbB|K>+8|Ei>-irVWOY$hyncTqHg{S3 zU?N>dRe#1;ZdnH71>&>&8iE;vKS7JYRuLEgkcamS^FM~*A^hVgRt)WnZh(H}!Tbrz z4A5vba!fw0!>E34a$s0S?_iSA(}#I@*XvF!zW25~84!N1)ydds@e7|GR1@vFI*@MR z$!ZQEb;)yZ9|WIL}6G!8dy za=jrv7bw;N#3Yn8FF3D4a&xA(HwaCSYx7)mqhYubzdw&RZ_&a@VOXUah*9`~Li}p=R_DUBUL81y~CD{bdtQWxW6Ddg#rKR);?d6oczkflLW9BHoMG*h4LX;~G zPFc9O=ozrA=eT!|{mW@``-2kn3x0kRd_VzI+>_Pn|4FpeH8s9+HA7H+T)R5B2o?r{ zWSIy5K1HS%old=1@*k8v>{GR~naPMlxf8Tp;6yjtcY-{UCVP%8fOr4PAOHXxu!vJmUMv6omdQzV{zB;Zz_d`k=XPQqy+ehXqe+fO6B9>W zOPW^L?S9cf2!ppCvK?jeCO`?j8AMa0n^Dw!e=YJNN###l2;$j9)I?xTW z&IMO`io5iV(7zg9>s(M3?|}C{3%6t5WxGSdFz$Q~G|i@i{#PDI4%Zde?4bjpo0~%$ zF$jPEFVz)E1|@fcM_D!KQZRun-`=3ZlF*g0K4tA-;%Gmc$%O3!H21h_ePpsdMCE}I z-P+GFR+3Q_+XpKJP1bg`#Dx)hJG7&ZLt)!skQ(H7`A(`Aer=UvwgwkB)}37z6eQK~ z3Wz%xH=n8(>obZ=^QHjTg2Rhc6X-XM)Wb4l8FTZ@8+K}hEW5oR7nT99z~s>r@wnFb zZn~g7IAd!xoK3&30b(>^w^qLU;f@t1PZ5b5D4WI|Ysg&956t z-a-h1OM|3gBGe%KRmA4$QhsD#gzw@QqtPCod9ezXFX_-$*}pY;xE}zXf zm+()eS>r&=y-VA==@$DQujABo)=Zak6WQ&%#!N|!uU9)|p;ISMEQ_bBEJJ1Ga^t@= zOlTv0i0U~tRM*F2qrleCN_(X(Es)#LnYJtF1jb3&3sv+h2vs_I7YJsOr^8|?jD^o; zw|1yyE{2-JHykBgO~D!-lc)5`XXNS63j=ijamt35`7Y8I?{6?XZ=o?r2!fkexU9}soEpqHYUYR?nga$9mCmj(7);ILe9O{0NS#r?NW+`7TkWU9|K$k zvKVB@^@$%QX9-?ce&G4QOz2H&LFK6m3Gx(2&~ zunTi$+*_bz_(n9w2A8on%i^#}D^nHbuw&eX-D*i@Yh`W$#mWL*CFP@7-Yg(n4+y^( zvHxRjh8|r?A8AVyc0rwgsoDVLCYq1T8D7r(CEByYpEg$eV%D39-7${O;yfevJHx^7 z?XxUN;897#2AatzZ;)Ml828pCc%J(OfPToAjnisCl>k*$t|cF{%^b3JqoqH)y){z49xOm2C2f*fUeQ(Ozk44g#^_p%18-EDA zRwMjoCFnah$>EFQv6VH7nyp}C7nX{431D$D=$$9Sn&ZSZ`RQe!-VN*|0rc7Jp9~Bg z=;t^6WKutS8;VuM`A2aVda&ZNHlgwB6(}6a+3glT#Cg<-9mXR^EBEa!Gw!jvH zl0{Tt2fWHeFWpd+0meeNh%Y$Nb2~#~0K#W|=!yV{U7u3{SuhgEDt20?QhsrY6^fZ2 z4L*#^5ulnPJ6Pav{?YXSh4F8Vxo;$Y6K@jM_hX52dR<)TOXP+@^pgov94@bwDBJB7 ziX_LmE~ZjkB0DHju{ms8T7hB&Pf4DBaAFF9u(X4=F{AF|oWWe>{s?Bn(;2VYtP(hl z+u2>9A7Z?&*Y@k-0{ke~r_3P*olp{PvCuigTW*R0og0CNfE^wl^^`BmLo@jarK{c!dSR78jDr+m)Wfxov+{Zh37MUI z$m`h)<3sDksWF^!7i;~qWOmh!iAPB9v)>l7ieLoR;+z>JM%^Mfk{Oix-+)gm1&i{f z`s(Hx7WW|iWg^`as_)RTs(H6 zFbWSwhuPUZnk~5yr%SR#M*SlSyz4UwXQ70SOFYyq@VPzg+HL3%B;~D@BqT_{4-VBM zl)ZH09e;z61u2U+vjyKiu!IdO1=If^r~X0dXz1IR`hoQtG%Z;u?ZHgH$0sOotsXm@ zM`SsiAe-dH_POT!UdIj_DZU9exN*@;+L9T)Zt$E3iyOwCU!Y{W0bl1_CiAa=wDmaZ z>t@?aYTKWvx<5V6sg(Ckh->~pf*Jwxovfke3f8beIU$7k<{Bap!o6b}FV%Pp^K~r4 z90bgJ81N+|h%!Y|8W?~8e!7G^>}2|JMOxD#>9iLLa+Rg9SxG)P^HOt%8Rl9T z+5#iP>2o*Fx*;}Kif8V#`5(9A5o zPfNAB7dEzhC3g1^ETVdQ^>)kkgBc}K^nCs2c3?0QH#4IoP>9roU2v#WOCj*lCC;=W$d@82qAaz+rxBF&$x1xlg?ltss58;= zHqO&e@H27q?^w$GVZ|gzJ<*Nkt(Ss<*}g~bJbzo&L74NJq*e;7Xgi{TRf{F%(>>l7 z|BkNwudew%O_JSWVswW-$ny9Bn%h{a;?Ty8N*V) zIB!O_;~xy7Um2~|Y{J>>%hSCZnS8V};+DP)v{|)F(j02pL5yO!K15p(x(KajML=!;h1b=v2=YakfSR_U&2&2y zPcy!;_LXdx3G=;|X@88R!vFNk|7-&e9+CO-r4`QzFv{w{QH{Q^rNXEt!6sqR-{m`l zg!j3Npe0kK7mJfxylpsfQkFQN9u;@!-D45)%amcNSs!`OYF64F#W7Ge`)qrgFN5>J z#Tjh`YHeNLVZE2C<_lQG?yLOzD&P9QP9nbuE!pA(6EI8Qlo?z(yYeN3i{C0WoMs+( zl19@mQZl(UMJHKUMvf(R(S*T6YG)>7mS8lZ72mf;n$vUs#!T2Lrp#`7sF~g2J}iLE z@ow_LJ4lhKyG0aI8>$UY2*YAuotDeSF1J+T8NGRa>t5*RxWPONo@83NZlkU{;n1bc zzVfe4RyW4BxcAVmm{n-1oN%WU<{ARZe#@X`kH2JxQULaHC<{tWy%V^S1?76f{SQ;qit8Vq3ANpt->pKJ$VR`k1ignKwa&3Gnf zS%wn_+PR#CFJf)s4u%+Sd$4u+qHRJ6y1W5@Hz42RmeGr^r*DKV`c1S*Ix%kozWI#G z4csKnFf4mC%eKsK3fpAH^Vn0F<(upHYa4r=CoxFeY)FD->-EbP$FZL_8WrNCzk;b$ zGHmwT`sj_4F$*#1BXB`5ANt=KO!shLKZW;QXfYO5xlEw%kP8S0K?wAPr{tSrtpr6+ z5z9`W*dK+9+N59~kRVva&Ws>#e{r9iBgzd2s?Bz5aE3m?D4A(#@?p(>+BZ+`Y+c~} zwO=#HxF4CCzG&F(v0k}(0JeadWRZ7sTm-Lt%Jk{ft=Z@4RkOQZUDnf%#?OwFce4;w&NZ3b z3QEJKhwEmV!_=zfcq9U^s(BwT7Wkd8BzcocftKYWEOt2B}mZu zHQTyhvxL9AI#X5#1$LZ)B%a(BRDBM7I)o3!l)dh~!LQ6y)Z)m9*@M|}jOHlE@QsoL zegNG&^kXK2n(|9+)*EvTR+juYh`5`$X#>9vox_rop}rgG-cj-^AX5ngs8O6M@d|*f zHKpYw0Fmgj1EaW(9j<4>{r?yy6j%erS~>Ev;39nqI?3@&k?TVCsm@-kva1u(>qIrv zSiMZ(Hmsyb1iIG2w1USFpPCubWi}N9XZGYBt@p>g^UH?AXzTWVn^6VFF@9`Ue$pCS zJ5eAfeXC%1G)+PU$^Xw+r3*&tsQdU2R){B>Ou$}T!&T3|cfxk(n7v~tKLaexZV>#T z<9Gwp63EzlQ2RH$U3w&*@fcgE`iy)Fk-?7-3h?@;I5JbBV4)>BM4nh!C;h-6zPfIg z=!}y2^_NI*bD*)@PKgIC?g*xDq_jV2UM9KYO@rL;08Gq^4L6RY-$)s?$aw-WfVgn| z2*Q!4q_tH?^+;ZkB?0n@;mZM-3y_F&{8`(_8mNT{xS=9=?vdfxrgrsIvjHD{J#fHD#9zd=d>w3TAhtKfpCEb5;AN1nQXie3sA@>Tw zb`}6lUV!6dB=E3(B35HeGb(giqYAM7TAJ4}1!3kH_*KLj!F^S7-1M9p+(lXCJ{rR_ zRR24l&`r)G5!Z+0UATJ=oR0=4QC(5!KE5TSH_NliUJl9ni#ki9^6v?_*VcasS*iOf zXtxUAGS`i^#5aO_bQ@rwYXzV-s{V1@m2gNH&&A*N*^24qkl73yMC3CDij8gjnV|nv zfEg@@0sWnHwK(8FirO;1=R$S~i;fSmwR{YRg?s5T{?pE{y*_*2J6vWtk##%gs*PPF zU~U8*Fa=iI^EL9yQDq|YA@(+9b?iW23H_k?XTG$Cu*>dbouzXcSeWCqvn#TX0 z9EE?tz4ld^LWr`N=vmiVtOmrQG3^l5CQG8F*I_O>rCE41gX8RHr9aNlb0)HpDRQ9y zZOGufDNU$4_mxdlo2HF_YV_f^*$knaONOzd?i?7l{zu0R4DYvb*!oYBZ{dE_!_r#W zf)MALZL*?_fCJ52MCr_KA@HA6WFizloDJ4etx3IuTeHu8L_;gQzzVy5-~T+vQ$GO7Vy z?Xug_{pF`7g^f{IXneCg(2`?+IwVME+4Hf^%Ojx63+sX5xYtL z4=~$^DbB9FS>qB!hnhFwXtL=0v(}zGq(N%Rn$l@ znbT<`;e9;qy9>=mrPIL#I`(#_PpH@4fe$d(pk_?i63>HTm{&w}~upIDq_WValVc83Wl0pq=^93}!YS zD-8ObR?XTr`?b&`^%!J2=)zjabT7tJ;Q^+jogSxRy-M^>CD1J5XR&x&ixTtbrBD2} zZPgKDq_S1HOh>yRu9EPmooW~+lPwnMZ$9snSay5Jq=EPxi{c^& z!M6*y0xpgIuz?%mK_ZPy0WQYbF6#WJHXy%?}!%0XyC zW@tIu5IpLf?q{1~f(l5(LEZq|pg1z=49fMuZgdj@snloEEg%bXU+l#>{ewoeUI^1l8-ND6vy)ZXs4TGuNd`0Arbz!%o4C595> zt_2a!5|AoO7Md>inNCmD82>KZ98D`j2Azp-dw4WCq`YPg48%TV z=V={lfYO)rzOhC7o3&09Di&Utb7bI5M76*5yESHv)MObuG8?*J5f;dARqZmTm)A%@_|BT65MMlgfB+6jFa@j9 z>=CFz7t&mG=hRxL13zcI_qHdNX0o6ZABU&v87@T4+E;1X#hfI^b*B^BORYQZA#X~~ zEA$SoB%Va<7G77hjT9#G|0Kh9w$H{-@1Kg(&Wvj78>v?vuPklw_&eYqTN76w$4Lv) z#bj2THS?m_`!}-B{*Ia#i1KWpZdSaP&ZGLqS=Q1i6>IxH(F9WZhu~V30YS*@JdX~d z<}I4vM)jV*9dgBh2xwM!v^IhxcN@0|KKHYe1c+D@*wh06Ls%Juj^b4T4-iABljtbB zC6Colq6+{4Ejl?=CGQbJ#kNhN0+#P*M_#b*9LBUR&-vj628pD`$3dFJh(eWhQ@iM$FWX36f?aq|`{t0Y}@Xms={aB60Vy)5?+vv6IMCHa-fKQHJJhxH@EnNvKO^kRl$jYYWzsh8 zn2#`v&V9Uz-_IMno^s$9KHi2syb<;*sSW-}JD6g)lyc@6W}1}^;YJPN#W~!|HkwY& z^8ohG2XH7&iJ<+%5-LAq)+1d`^!9!m3C7-HukqUuMbc>gfZAt zTnL?x`;QkC=M$8G&g7{^n>_WX^nUmT*Af;fd19vI5^R_?Q4bfS=d>+M3r@gd^+aoaG&a!TI*!oN zt|R4t^8nxJfH%tP;d^F>FE(su6;iDAkOFub(J~Udc2`8l##aPqK?GBO1@K>fGzz;) zras@hzrK^9ZIqQG=4({AgMWBU`9Yct9xTty@N9k4eib_g1$oLvW9gB4?hHXx<7apH zwavLQsNjZ;2T$Kth5zZ}#cTqKF)*&H9Isupxo)OzdALp46we2qO2#3hh_&DyU6A1#Ia^vI2}v_FsF=U%k&WMT zWVqTW5*WIKG33(JEd8jT%n>~)phSp{RIxPEjv2ShFqZM3=Li+_S}|MnQZ3v~8j&ga z*EQ?HV&mwj7bp+5t(biQttmuIqX7V?fVG|Kw(21&X*C+WmQ@!BB-YOyo$Z@8WKaMU z;Ei2X6inIEfK>ev8GCW55{qcD-`poBR*^1Tr@rwa2(0am6~jlCce#5XE>7+a)djD9 zpfQc~*Vn;q`lIm`;l+1k^LA2SZK;ywoOF!ILsNLatm)xnSef*{O}m%FgEgp-PE00TR}-^2;m6 z%ZM4JGv!X*`pvX;_0?CNHHuEXOMiQR z|7{O5fRCduiPdJ)o75QWUVSj6JyN9=sKGM{rrIt8S0?3-Kn~4r2$dLZk0&ef_OM7YWYyiZ-oaAx+iTXj94GkYe(VJ+LEkp(xmCAc+z~C9?{m9}$my$f}OU9$o5U>^;C( z1xwOHXpe^?gD*qaTr_8qui2Kj$zJbEFJ)j_L3gVjp|2MBA;a+j9BT*umuvCsbSv&E zDPB4oyOxrGPr^iISi0*$4)7N`y{$>kPED zGPkt54D$4Qo_|qT9oPWs_0Au7CTbqI0f0+(PK@z&4xlOROB_QXzVSHI+7+utcvl0f zwP=H#8%=$ksm&y#P$@^qYn%C}X0A}S6GeM)A#dbNWbBeo4HG*}CEV=sq;{L~F~o!o zn}WRD8YZHYqvfdyNjt%Kul@hnw0TPLozE>p-99X5hKVb{-9>2(pIrzx+jEJ1k_G4$Jd;|16bynV-Ma;6PAw(0PZW%20TgByh@nU6@G$?2d{tziG_sm)LC4&wZe zXpvF&^YRXR(jpb0Zc9?=!HZtITj8C*%Tv5Sg$~e`KMVjUfAWNah@tDO?iTl_w5W?S_mcnF>&wok)o4kU z$Be6DCBd&UX60f`M|!J4sz}?8ReX|nOybor+5XWW%q`W?!uB#4Lm$AcapN}jfayla zxb-lB5)tj0GfEtOof` zZ`K)WSHpek%Q+$ovfhCkoLz1@CsgCLg8jVUZh$i?oqO-k-&(+%Z}o4)u9v0Ak$NlZ zdc0oyc;tYapgZC1eN}W_yO!lKGseu!%rP@F#*8sDGsMgcF*7qWGsTXX9W%2Xv)dQC z>%O{G)%X4N7@eQ}p&@Bct*tY)G_{TvPwOp6PGmv>H9$aaB&cc2VbkO*v;NdvUKyZxdOcb94A`lI13=_;a+9 z@QN$kDzH%+B^g}W`sT)dv3y8O7h*~BRAX>c$ZJ0}_HPm9#J)>AKZW3x5I$NDTs`%W ziQDrNxT*yL>$LHjRF5&0bC;I@bf9Wx!mr&v7EYJM>zy~Q5O02E`(!%wY-&J6yu5fB zK*`L~7`Q5n^_hV?2B_Q>+~xot-dbkQecpNz3>iWPdyK!^`%nw76XIL9E=zq|K^gwW zL?tE=)EN{9ck2;NkmRAI0T}3Qd;`v4HC;uB2kSk*JdX#WZz3c`RBcqgCrbEPX{_AM zrtPsnbk{55W6Qp+9{<@$#rt{Hq9203UczoYF@vO;$^s6omiA~`hjZZOHU%?-ckpH< z)3NA|UZ6I+M(@63MCpn`KfBt>0a!O5uk|RjUFXT*c$$0i=}BU9vM9RyPB3upD+$OM z^a!^fYZda%Hbknxm6zb7S7AUubo5$HV}#e7>K5=$fNjh;27_@F%?e7h;5R)o`jYds z@*)(Uo5Z2_TgMsmK57G&k#clwe!f0O_*Vsx_9c-b>l{_{^gjq(H0snbGO22_3$v=l z4<_>~QO7l-uh$eCKV1sR(JB^f5HiPj`p5!bccd|ROcF^Z@IA^uH*n)_!*H4%W{W50 z<7GVY|9n}_o^7I6#(Z*Yoo{PBY!^26)T$btaK?gF{rWV@OYD2_+@Vn{zXJ-l5iS%uiwI)Pb z%uwI?Rrs#c(#J*@Y~n!ko3^9Gb~lirK~)kuj=7?W~qN9WSbxdM-hxsqjrv#1D8z7DRI| zSuw`PXct0--x9iGCDGig|DP6v z)i?l5^KXyiITK&c!l<#+?7u+F&{R{W4bTgb823KI;Xx-%bYZ4hV+E}0C4TXYVi$`~ zSLED05uBl78C4+|(-lE7;l1{Kzc&b#m&bD{-8w%1dYzA%w@f7R>)FmIkjG#zZFoAk zNsTsnb6bvqQNc-9Z;=rWO@`F9**GHsH!vTID;5;>a_3_3d0M*5I$gyhw$mrD64twC zFpG$azoNpGi%E~H!1#3j|mvF@9ziR0fr z*FnxHE)UtGAP(&d=+Nw9K}XGS@3bbH+YXHBi`5cHc>Oa#76{=KeEgxM>%X7PInC2b z5Fg)3hORUedstGc&lWS@f^vxKQvvftqI`zy=hPK9U6y( zl9eAkTD{3Hh@!JhBM>z1o8J@q$U7uP-RXN67dP0QdLR!zqu4$1RAJ;ePX*6;&6$JD zMZX`i%bK}pl^6fmHz1U@1xHX1XzGV5685-=^dUAcg z?z#j@b@efgRH<4_vzcoSQv&@m!l2o>VH>pn^c~Gx&rA~}mhNW(PU2kFc@pqfSQb(B z(SH1mtcKk4^Q%2L3SDxKX+qp%4db1Ti16nThw~sBj4gN6Wk?dJEB4aMazV1fW9{g> z(9aW3=w#91@Iv!EPRJFntz_fVFGNcER~pJSYU7m~37W>$Z^?expE%W|DEfZXBzb`M znSRB7RBlOcj7H`w1*Cq#nRcG_9!JQlD2Uf=fce@i#)ciiQ9>Q<(U~x7(=1FzI+iu5Kw*=-~gIYT5ri6@rQ2?Q-L~VJQ zFz&z-52;LjMwB)pO1*pB8LW7XZgC;!7H%4LVY38j_Hoxac$ebke6>5n%Cd(=Cg5{CKb9< ziF^rmE>!9C@hTc>FZ3MWq-=>o-?hsa`+%Y)#xAIV&lxbkvj*+ZAXAX6(3Kr1rb&eH ziJ<ZEpgl*VpzB^Nd1OsFhpR_XmUUl?<;!SE`?>3ZtJsWmB zj}s798J@7?hDwh`0=m%gkEJlWKuRO6)y#rRq%s5>*`@$k!3pKE0N^Qx9%FV=^OQ?j z|5ukCYW*~W#OuQ*UTic&X`lCNkKpV>F<#cz8qu}-Lw5F>@R-d+%g-%7;Wv(h z))-X{d@|pLj0Qp#@z8DDO+}Xxlxr|`-%1@LOOaC^6j7^>q;cikp7k4OURNNTbHWxLSI%ZC;zm^ZeX+)IO$nY#m4!_4zgM8b z77-`qDBCkb;-qNK+J_;PIip`*p01diCDD2rowaL$iLQVcm?OM9nuKDs4mFqPlr}`~ z60gV_p7ToT9aC`Mi5(xyB{k4JpvFv?+!VD?sqWzu4`tPjlLS|cNG!L`No+tP7Jhbn zE}RSG<%C9%@)v0dri|{=UnV}0bccVbm zGVYV1ezbR&XY{-nvG9Tg?Uow+KdsnsArA+wDUO@6sg2i zraCF3h{K*lSDG^>jT19QkcEbqu%IaPDQjPvD+BIEWf(A4&IT}mzr7LI1bL$zpHFY9 z;Xn5z`P~IyBg1=JBaJ>A?s2;DsGNj8tyE3zgbGjd9^VGZvX(L|y?&LYqfW0#dg^Q>(%Kk8Ll5A^BXB&N0AsI?8_4ppu5xYX)M$I9 zx((YGAqAn_CcRHXfUon^#zj!3G)!~f3BfaNGO_OTjZ+9kc~`*zP$xnjh8PMcysya3ks`d#mbVAalJri<(f%GZ+g%M3+) zSj=Z4ESmL6bC#@R`85&^RIIS1t7w!rJB5Ws&$ilN40(@|-MLxO;>XV3ABP~9n{Zgr zch8!=vlQR5cM0jIWknnnwO!x{ljVD{KB#nCm?lOibB|RuVw3C_x`t?|K~7{v*du>3 za5&3jblg^Y0;Vkv8piKaOBLH;HPJG^NMQN_|~tC@)a49Z7_x@<$XAq@`6YAq%@3EPp4q3H;dRsWi26o z(~LtC^dk~A)o-C^3*|lZMYCJ@Hh=sw?E)GfGcF9_xZUo^u)5#Dt0u^fUizLXQiw<@ z_S7lR)K>JzWMeNDbn5d32;@aYhnCTmEMr>_nJc>02wWLA^4s9Cw5|-#x15#s%OVhQ z2>Z<}B1=LuF%$~g@pvYg9JR$5E%8+}M*Ok7eM+3+5>FiAf;BOjk)Mp~8ez%K9pU|( z*egwri370Rr>%9sQ?8GpdAT%77BwIjKWGo;)PlmKp$DMO$OL!*G}SHddn+eNi(L%*+KOWZy5*)^xsqytgvlFXOc6A}T(9Mv;TEt?tI|#*HJfQK90&9MN zAWpJoizlS0M=}R)V}x-U+Hs1(DTj7DPN2mbeLSxIeiszw2sQD$*;UtYbl6M6dH`cC9f0gpH8B@F7Ba3 z@(&Y*SI?=3P|cF1`kTw7lvw8o`}w_0KT7uu*3DdLc1?9PhPYywF)Z;I^D!2EH_xdy zIY4P8`8G$MBF|N{y}n-)Vp~-u%!KP6MQ!PTTj#b(Cw+j-H4B?HlvCyHpg%51-cC<2 zqW%=kmky8MTp%)(%S-+l;}qUYK-=&pb4|pFvxx&8{!!)`LoHAabf+zwgU>wPo)Uf6 zw|IrE+9-0FyC5Zv`wmzJcJ6QxADc*HH$ywBDnqT1`UcBHzR{a5X{rx8R(fanNTHzn zAzwhG)di)z`p!j^UGpQj{!fa$RQ_aFG0*JOnLAq1^`&fl6D}4iv*sSFiL5z=87m2s%C8flAClFnSqI58@pm0e$ zex~s{vp6NO5RoFj9z0QADXt{H|Fxn_*~2GsAm$$8RY@$fFr`XnXV7|qZZYu&8@}oA zY+ZrDC1Q-7@0H$_A=vZeaFA8p9)_|)T{)<0C>`Wzb_|+5lTpHFpv;>Pt9T;NkWHsP ze~aw12ZPOHs7(Y0c+#-kJa_`57_dja_aw&1`*pSePy)@(WDz0EhX)$mVx@8z{*clx z7TfG4Ikn!*z=srxxRNen*-!ExGJfK9w8F9$hJO-UJh|%x?##&Op61wlTc9Ehs-cOF z%WcUgY;b>90==Iayn}9_*R;n$pkmu*=7QMwR6xv2v zqz`c&qUzv3(@Mf2tuJ!1aP3tlJGch|B+#cT=LiTIT1g3Ct;m1g&Oi1kl9s0e6}i`0 z7fhQa4oTCmVtKpgI+PBL1%aH9ciZnQ05o(UJjGqS!E3+@<=?N`v+6B`+x4;-!b!Nj zw!Q%N$A)mk8ozu3@93)-x%`SIrNrp^p+sWft`?{m=Ic$AGQ^+(nQh~`YNAz#jLQ9# z+BZEL%1y!@h_Yl+xamy!3s&&Rr47)R9D!A?sdBzNCVyPy0_uPRu#9U2Vw)caKF}nt zQstI>QBtWwat%DDijQ8X^$quI{*r2tr9ne;Ha9{Im7_JPVIU3>F`W$F8wQ#N+EW_$ z3jUr`QsJzO+}`Xu*(d{67gN@9O9sf#k= z_ymlUEIA*y(M97Z(tz13(d3MHZUhA9>N@2ZsABy!TBYPk_~8EPm{Ijk9^&*RhM8aC zn?c=A2Nu>#&8eLFvR8dNrTzuML;$q$WiaW|NI0yef+8MYY(YGy6yKae>5HOXMfke- zPnjd;n?8FV`HI~hL<2|PO$qfYDD|(-UCpi-OrU%Zf~FgvfIc8?a~W}o1}HKAn2wVx z(Nkpma!eOpv&A;=!rYQcXLV52b?Yc4tjBPM1k_*vwbM1Rv1-9jT6URB*-UhY z5~sJ_Fl?D$rfeM_Ji_F*Q%PhWLnR^aEe_y1l%Kq`fPwqL+7i9h54BLfk5m8%OQnCx zn)Q(X^7x`5GkR!Tye=PxuSz{cK_cji+kqo+=~A-gDjYc(?JNp7;*PWg0q;!oC?~D3 zC6q5D%HRVZERRdZokiO1a%+m_xd$-0BTri}BsrhQOu}hb+_|ccsf!Hx=SsL*< z%$uWQR2Z@j2_RR&BOy91w(0+J=Uyn1A zZO96$XA}4&#m`9@jeUg1H7d723K|aMV^l3w z8WPN>@Gsci4*O)!QGN2N#?4PJ5a~)TG!Y+;@1`1|hfS`E#y^>te~@!oY!!tF-jfL5 zvW#MDB2vrqbopvp*Iaig)r52wTBzOXQe}A|u3oeOd%fCkK-g2ua)@cfG?+&&=u(L5 zc83A3+v)D^gsKXW4M27bxi!4)A-#|ATH#qbj4TC`%=+Y<(DkGGj7kH z8l-dk6MH|F5r`g#L)(TyAW}^GC=jMxdWxVpQ#N-#6xeIjBW_!7pm1bs+c#9n*|#}V zzZHK)|MmjqkaxLJsv6Am6291~IKRTMd#8qWu5cK`A5qN7V+{VAYi!!HDu~h1DI=|V zYbqSZn~-H*8X<-r1w~r^T0n*zX;k3hSgz#>n9=p)a4Uj@X*7R64y6@c5oP*gFbU@B z&q`sxXDq0nil7|9&)oZ%U7axr_7lnz?AqHdQ7U1n_83y4ZEnS`nGXWcKbVN=X{U)Mmm96*`}W@Ezf$)g#}9w zA!YSK1hOF81=a1LT&kl@ixU%mq#63$p|vaWdG2OHL@FFKso;k&pleUHGpO|ZOxwFY z>t;7!;Az(~EcW6~gtNBMJ%TmY(e|-&GaFN>e7)O2(*&NzYEC&My?87{AA3(|udaPF|7z$tSlct%kiM zq~}RaTZrhz$2ddf zQBrl->}mW4How&2HzO5$k&uyFTbb|D&#}fQr@gD*-S%~Aj3mk=sfJLN5eUP11DGGm ztsM00Jcc{bA8LUJ5Y-ppHs$sK!u66a>uDF@2uXg=w;>X;z9c5_8@UY%)I17hR!$O0 zy9QrR3doKdN_>GUz37S%Ej5RmmES_NimPtruq5?~%@zmzgcWMCs1}?j2A5iKzI{o~ zz=aQm{DS_h0%Wd1-yKKtV?A&uIwf<<+f&DA>nVAZ^N~Hs8l-S5beoA1-)3x6$_alS zcC$a8O%P_#!gWbKU#B*5W)eYN-wu@`>cZV#QRoqgMqR_=Xp^k4ox*d}R5onTy_+W5 zdBaj1fc7+Ys<@}H-k<&Q0~%se(~pYYA^G!rrURK>+H0mgFY8zQc?|FU-dA$xE`Np% zQ9tJ@&P;*_Uy=n$KRCaLz;T7@=CjVY#{rdE#ko7#A2}xsg z21zde&A5N<<0?NZkGQe_E|!=`M;BPP??=j+TWhH{qz2SDc3>rY*g=_cMF=%0t=0V<&7tp}TV4i?xi^_7^ts zI%E96jBuyh=6)?18ran~KBhw9&!pwNHVeRf5{B=;H(@CRt4sQ0Ps(g5m4##O&l7wuFasY99LzMx8x z70!J1&Ex~T`)v4I!ait{*V~U;#{G(}q8kHtw?*RW>qnE1*pH%5Vo)LCR95}cnyXsw zIO+~p>BeFDEhHwD<}sxrX4$IHGYr>k2&GmjKi!7qN7i67#Pxd0iY7K_u3OIigjVB@ zSYje`+ru0AdTm}Ez}Jk(l3$0AqU!7IetdFm7pWO`m>^uGUoFqm5f4e0|HwvKg!|(x z8T&a&z+C^8=oR>C6BOAa3;jC&J~sM%e`z-nldw%l zJr^1vkqE4c|Fyqmr6K#qiW{&-tQbwVV-{(%*^v4(;)h&fd1-sd%r!KGdd*93@8t}h zkg5lr9T({9-kM_1>>qeC-pGY%5`wM)j)7LtWr>nUexL6dc z791~Yf7o}>$%9<7mI*p1aE9S38}balUszi@!g;80dxflYkrviqFY_JGlWsVJJt-Gh z8l(;HGzFx7%c6XOST5kf-YAh%Xp$pwD5QKyNBpscuwogoX7^Shtw3R=w>9u;c+wvxE#NF3_S&I`<0f+(oKSx^_o3s;b>*i(x4UJ5&q zl8l%8p`wlJ`N;t~7~B*Gh$9BU0m~CWPa;j@>aW2Ir$E$L29@)CIaqJz_(9V}(?a<) znIHj{A`1iw{V%0^4XF!4rB<3Z#LO}R=`|CEUudlQ83K9%giC0>AGAEf(OEYs7RU4H z5feS;Ob=QJ$8kj)MQ~e%wB@_=OMpHfB#>5Oy^Lmy?%+T+1MEO!1(M|x$XhWUB0B7r zrxEc%h}na2K$5nbTQitm=-W%vK8E7Yik6#xd(|}fc0-d(PCy|lCrKVjgw8q0y=50{ z%b{e-cUnbR=SSD>HsTem5nPo}jHeh<=Q(*40a%VSz8D$gA_7;AUMOn0U3UB=0`(fv zayv=_EEP@fTE+*Ss$3P`Hid7=3n8JM6bZ9iUoYEmeG}7rrtNaJW{~7eKfzNJd`UZl zJl$ON+53=!JN>ADBOGeSo!uTAdJ5v6VT< zoS_SK)z>Kygewe?qj4#DO#lXkgGbrI7+9hZRdFb#ZTvnm`v$956Ms+r=p_h=^TvW8 zDPfsXJ}TQYQr4l*C96m=DlaA#$n^!(Y@spABd2MPyJ_QOgT>`mByHu!nUVl~ewU$M z?0GG2G0r_EKN`8zQK_f$BYf72Mtv4in)V8>P7p;XVtfnlxyH_7Bz&YWEzjEoziL)4 zx!1toZzkosWXa~R}~pR=35sY7DGMBRvQ`jB+R zA)GblZ8V$$1>*6XjKniM_RVfi%~z}%D_~>aGzCutEDU^P`5dgEXc;!F%Dgl=B$UvW9OHr4$>HiHgB-MDywh{p1KVs^7kvG(#XdmLtUfY5fUI%-ru++{Y* ztMYhD@9_Z&Nt0g)8wFp_3GDeIT03rFEKFerC7F*FzHYrRJd6_ZIgTMkpAKAdwuz2S zq!~IUe;c$N(3%q=qsY=^5fNr(=eC_F-8b1(d59#T^2CGahUmKGcDlUc$nNf%3ge`E zz2;EHh%J;yigFW3vnXp@?{MThSsG>r2ARjxw&<{2pZ{+8u{LyX@KjW+zdMK-v$h+w zV>)3zy?3=WHDTnr^4!Jds))}6gS!uGW9oj0BJ5d>UH_ITMld%tOm&4X8>^!`Iy;Mf zO>aYQEYFPx`g*%*O)K0T?2YN#w+&0fZy?9TjoqriLB6we4MQ{07rixm!<%)@2$}wX zCF5Y-36~O42tLXysXbh*p`>gSPlSD) z)b?@zddr@E!i-#_9wV*D6yYMYt?%Hzn~4E10!rn<>vh8YGr;AfcgKX8^T5z~pt2;3 z(=~XSZrZElycj>KOLWy!0;*Fz5)Q71R3IC32>}F?70=3Kxs2JO!x+yRJ1rY$=pKAW z0?Ig6yvY%7c#{pTr-&8A1K06X(>|Te}Z!lCo z2;||Al;@2Qk}0g(Z{4#2L0N+!E(qeNw}$IkNjaH?OmDHR!j6T3gT^rK3HyiIQI9hO z%$Z1Ap64!}^4v2Ald>d6Q4}e6se|Rqre}oCE7eHDf|a@LCHt7qg-j9dwD)XTZ0t_H z#^=$)8?iNlzUP1-WHCt0G=HtbT=ya76*;{V{oeD{x+0H7=fW3GwARuMe}sm@gWPQG zh<{SB%SiToro&lh7)(2-Qg>wBuMQ8CDV>FFL;l=f331a9+#H97qU)Qc5-wc?lB9zcs^|p5xnCDhap*_PvRa@ zQ7y zkVKEDN9*Ii2bjZy5f%22((ACcq;dM?v}~Mke~)>{n~`f7xG-goi7pvRFa`s?vTDNV z+$$@NxXC|W!Dh;}n2^7V*TzDFQO^T)D=+qZd;m93N&}WYem|20#p-*m4kh?-qQtt7 z7a7(1#uuF#I9}umvi>$WgZ+q(WnO%&h^s&{%13mcQv*~xnxuEnUbw2$=V7Ht0o8Dh z1~>4*dX8MeXpXt7{R~5f9E9k!vnj`ElkEa@FwZSN2MiCGrxTRgRzJdr{eZ9H>__Wi zvE9jP_?u7T0da)f3+I@E5$9|3pdu@XSP(Ta%ehcuqD6zFn)T&OPKuAErb05ydaPa4K8UZNepa zoB7Q=pkc{03IxF|Bq;T`g#@2Bk35?$BdivR4s{jhrD1nV|k2~s4`b+Bukjx^aLl3qtX2S`gxS+WCA6?qA9#P$AInaV5-KyuL3pkEQAzNQ>#>vL@t=J1h zmOMqV;rn3az*5b1;Gk*acO`~xzr;Zjgad@xl5_wkyS(p*B};=&Z4`Jh6UqY5I2C$_ zq{C>Dh>&K=RKn{gI{P8v_9KQkd6xaGU~-rbgBE3=m~~2U<|FE)3NXF0153lw0Buiy zj)sc&i^Uv(wt_TH6kCWM6CMR+F!-!qE|Kn^R&j|6wjsdrxD(|6yy_i<3j#;pk$PJz zHEAqoV@tRhs#iqbT(?xqB_ei}!1s?@z`%dj29V|e06>lcsU(&{KCeXq@5Mj`)j)uh zfM+%o{YS#pu!7{Lht}5<^73h)39Ze^cdDDCG^%IPgY%?>hM#r=JU4a#zv^2At|@I(*r4pTqc&&9}X4`w0L*j{yJx3Idseu7N(k zjdrV#?%ZN|O*!UuDz+pxpUHKeDa#kM9{|vM|709d*2mr=*>lk z*GMv42)nq3;<6n~0AtkI_Z-y|@161kW$HT${?;iFt{K;`RbrJA>ZBd%{2YiJ27tiP z7XUj`%DTu`9|M{XMw8Iu#ZX#ZE-JDFz|dn++VTZ}S-$r{2b5`O{zo6AtLqqn1Q`RR z(7(Za?{q)Zd6C;10BGSGi2uC^;p=+~KY>(JiDCiMuhNhvL!pj1!46Wv8ps!t@*jsePKqPp;R5ms)`6S@HTNZ%u}0ykbD_a3;^HM`h4s~ zLB{23k@WrwkPn}B&+q|KU#DM;fE^LW{Cb)Qig1j{ga{zeevCzssJH?ERQm5AI0Q0- zM388x-+sfjwJ<0?lu2pbjk+WPz|Ujb1lSQPgk+=0`5wdS0@{Ol1c7!yf1hbg@4tcH zhFtKc*+?yv@J&c$jXBbPA0N0yC05Cc9|JJNmx%7`?%&FQb>CZ117#Ndhmo+Ec@u7b zuMYwY!z(am3jn(!|19!*2{hz;bw+{I@^=ea{?$U5*z(y2e>T#-Vy+No`@|2BZ#|iL ze+%K}dm|=+%ux~4{}_=3AX`z&u;ce8Pzprb6Ww^~-AoB{0h3_2pI+n{Fy;D@cMWPdN=)O&vkIZ)>DUj{;J`zIo)Fn3>obKmCU?{=8= zeXmL-kb3c-2l4x~_@l{xJQ)bzYtsv4!T&3I-C#YZ-zy>P8SMeB;AWq)IO(wi#dUW7 z`3cE@vS|MTUG2{vK@iek0Dusyr8>R$5+IlN&dGtY=>IY5G?sscn&7?TchrjiK5Dyv zjN0L!qjvpgsQDTQ~e;)J^{YwcmTER03)3{|q(3d+m2yFaH~;_5VQq z@Q+bH{d3ft{|xmXw$8-+H&E;SjyjX)pQ6s>`^TtLN&XpXf_Dh?@2kzff%*?y|DUzw zofJv`$6w=LR}Ad`jzj*KA-^vb|3ZZS6NkKW`oA#XKj4tRQu+S{^5gzW)2#_=~Uqm?6LT8U*lPdQZRj z`Vad4lSBTqpZ$xkfBDycZa)6^Ugnp7{kI+1FaP?le#kHX`fq;7zeps%{OezO$lqp2 z)-OHeuN?AA5Bb{+`K5>a>8bv?m-(fK{AWMpmwx>(dzoK)$lo|5>z5w#OAq;dC_|90;9OAq;_hy2n*{x4@>S-ADC)Bggt$e_Id literal 0 HcmV?d00001 diff --git a/artifact-package-integrity-gate/demo.svg b/artifact-package-integrity-gate/demo.svg new file mode 100644 index 0000000..a4afbaf --- /dev/null +++ b/artifact-package-integrity-gate/demo.svg @@ -0,0 +1,39 @@ + + Artifact package integrity gate demo + Static reviewer dashboard for the SCIBASE artifact package integrity module. + + + Artifact Package Integrity Gate + Issue #14 reviewer package validation for hosted data, code, metadata, and rerun environments. + + + + Package Ready + TRUE + + + + + Previewable Artifacts + 3 + + + + + Runnable Commands + 1 + + + + Validation Flow + + + Classify artifacts and assign metadata-aware previews + + Check DataCite, JSON-LD, schema.org, licenses, access windows, and version hashes + + Verify pinned runtimes and rerun commands before enabling reproduce buttons + + Emit persistent links plus source and package digests for reviewer export packets + + diff --git a/artifact-package-integrity-gate/index.js b/artifact-package-integrity-gate/index.js new file mode 100644 index 0000000..200ab20 --- /dev/null +++ b/artifact-package-integrity-gate/index.js @@ -0,0 +1,375 @@ +"use strict"; + +const crypto = require("node:crypto"); +const path = require("node:path"); + +const DEFAULT_POLICY = { + maxInlinePreviewBytes: 25 * 1024 * 1024, + largeArtifactBytes: 2 * 1024 * 1024 * 1024, + requiredMetadataFields: ["identifier", "creators", "titles", "publisher", "publicationYear", "resourceType"], + requiredArtifactMetadataFields: ["title", "creators", "keywords"], + acceptedLicenses: ["CC-BY-4.0", "CC0-1.0", "MIT", "Apache-2.0", "BSD-3-Clause"], + minimumFairScore: 80, + persistentLinkBase: "https://scibase.ai/artifacts", +}; + +const TYPE_BY_EXTENSION = { + ".csv": { category: "dataset", previewKind: "tabular-preview", metadataKind: "Dataset" }, + ".tsv": { category: "dataset", previewKind: "tabular-preview", metadataKind: "Dataset" }, + ".xlsx": { category: "dataset", previewKind: "spreadsheet-preview", metadataKind: "Dataset" }, + ".json": { category: "dataset", previewKind: "json-tree-preview", metadataKind: "Dataset" }, + ".parquet": { category: "dataset", previewKind: "schema-preview", metadataKind: "Dataset" }, + ".py": { category: "code", previewKind: "code-viewer", metadataKind: "SoftwareSourceCode" }, + ".r": { category: "code", previewKind: "code-viewer", metadataKind: "SoftwareSourceCode" }, + ".jl": { category: "code", previewKind: "code-viewer", metadataKind: "SoftwareSourceCode" }, + ".ipynb": { category: "notebook", previewKind: "notebook-render", metadataKind: "SoftwareSourceCode" }, + ".png": { category: "figure", previewKind: "image-thumbnail", metadataKind: "ImageObject" }, + ".jpg": { category: "figure", previewKind: "image-thumbnail", metadataKind: "ImageObject" }, + ".jpeg": { category: "figure", previewKind: "image-thumbnail", metadataKind: "ImageObject" }, + ".svg": { category: "figure", previewKind: "image-thumbnail", metadataKind: "ImageObject" }, + ".mp4": { category: "media", previewKind: "media-thumbnail", metadataKind: "VideoObject" }, + ".h5": { category: "model", previewKind: "model-summary", metadataKind: "DataDownload" }, + ".onnx": { category: "model", previewKind: "model-summary", metadataKind: "DataDownload" }, + ".pt": { category: "model", previewKind: "model-summary", metadataKind: "DataDownload" }, +}; + +function canonicalize(value) { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value && typeof value === "object") { + return Object.keys(value) + .sort() + .reduce((result, key) => { + result[key] = canonicalize(value[key]); + return result; + }, {}); + } + return value; +} + +function stableDigest(value) { + return crypto.createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex"); +} + +function normalizeList(value) { + if (!Array.isArray(value)) { + return []; + } + return value.filter(Boolean).map(String).sort(); +} + +function classifyArtifact(artifact) { + const extension = path.extname(artifact.path || artifact.name || "").toLowerCase(); + const known = TYPE_BY_EXTENSION[extension] || { + category: "supplement", + previewKind: "download-only", + metadataKind: "CreativeWork", + }; + + return { + extension: extension || "none", + category: known.category, + previewKind: known.previewKind, + metadataKind: known.metadataKind, + executable: ["code", "notebook"].includes(known.category), + }; +} + +function isSha256(value) { + return typeof value === "string" && /^[a-f0-9]{64}$/i.test(value); +} + +function hasPinnedRuntimeImage(value) { + return typeof value === "string" && (value.includes("@sha256:") || value.startsWith("sha256:")); +} + +function hasValue(value) { + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== undefined && value !== null && value !== ""; +} + +function makeFinding(target, severity, message, action) { + return { target, severity, message, action }; +} + +function severityWeight(severity) { + return { blocker: 4, high: 3, medium: 2, low: 1 }[severity] || 0; +} + +function compareFindings(a, b) { + const severityDelta = severityWeight(b.severity) - severityWeight(a.severity); + if (severityDelta !== 0) { + return severityDelta; + } + return `${a.target}:${a.message}`.localeCompare(`${b.target}:${b.message}`); +} + +function validateArtifact(artifact, policy) { + const classification = classifyArtifact(artifact); + const findings = []; + const metadata = artifact.metadata || {}; + + if (!artifact.id) { + findings.push(makeFinding(artifact.path || "artifact", "blocker", "artifact lacks stable id", "assign a UUID or repository-local artifact id")); + } + + if (!artifact.path) { + findings.push(makeFinding(artifact.id || "artifact", "blocker", "artifact lacks repository path", "record the hosted path before export")); + } + + if (!isSha256(artifact.hash)) { + findings.push(makeFinding(artifact.id || artifact.path, "blocker", "artifact lacks sha256 content hash", "store a deterministic sha256 digest")); + } + + for (const field of policy.requiredArtifactMetadataFields) { + if (!hasValue(metadata[field])) { + findings.push(makeFinding(artifact.id || artifact.path, "high", `artifact metadata missing ${field}`, "complete reviewer-facing metadata")); + } + } + + if (!policy.acceptedLicenses.includes(artifact.license)) { + findings.push(makeFinding(artifact.id || artifact.path, "high", "artifact license is missing or unsupported", "attach a reusable license or restrict export")); + } + + if (artifact.access === "restricted" && (!artifact.accessJustification || !artifact.reviewerAccessWindow)) { + findings.push( + makeFinding( + artifact.id || artifact.path, + "high", + "restricted artifact lacks reviewer access evidence", + "record access justification and review window" + ) + ); + } + + if ((artifact.bytes || 0) > policy.largeArtifactBytes && artifact.storageTier !== "object-storage") { + findings.push( + makeFinding( + artifact.id || artifact.path, + "medium", + "large artifact is not routed to object storage", + "move large payload to object storage with a persistent link" + ) + ); + } + + if ((artifact.version || 1) > 1 && !artifact.previousVersionHash) { + findings.push( + makeFinding( + artifact.id || artifact.path, + "medium", + "versioned artifact lacks previous version hash", + "link the prior artifact hash for diff and rollback" + ) + ); + } + + const inlinePreview = (artifact.bytes || 0) <= policy.maxInlinePreviewBytes; + const preview = { + artifactId: artifact.id, + path: artifact.path, + category: classification.category, + previewKind: inlinePreview ? classification.previewKind : "deferred-preview", + metadataKind: classification.metadataKind, + inlinePreview, + reason: inlinePreview ? "safe for metadata-aware preview" : "preview generated asynchronously for large payload", + }; + + return { + id: artifact.id, + path: artifact.path, + hash: artifact.hash, + license: artifact.license, + access: artifact.access || "public", + bytes: artifact.bytes || 0, + version: artifact.version || 1, + classification, + preview, + findings, + }; +} + +function validateMetadata(metadata, policy) { + const findings = []; + const datacite = metadata.datacite || {}; + const jsonLd = metadata.jsonLd || {}; + const schemaOrg = metadata.schemaOrg || {}; + + for (const field of policy.requiredMetadataFields) { + if (!hasValue(datacite[field])) { + findings.push(makeFinding("datacite", "high", `DataCite metadata missing ${field}`, "complete the required DataCite field")); + } + } + + if (!jsonLd["@context"] || !jsonLd["@type"]) { + findings.push(makeFinding("json-ld", "high", "JSON-LD context or type is missing", "publish machine-readable JSON-LD")); + } + + if (!schemaOrg["@type"] || !schemaOrg.name) { + findings.push(makeFinding("schema.org", "high", "schema.org type or name is missing", "publish discoverable schema.org markup")); + } + + const totalChecks = policy.requiredMetadataFields.length + 4; + const failedChecks = findings.length; + const score = Math.max(0, Math.round(((totalChecks - failedChecks) / totalChecks) * 100)); + + return { + datacite, + jsonLd, + schemaOrg, + score, + findings, + }; +} + +function validateEnvironments(environments, artifactPaths) { + const findings = []; + const plans = []; + + for (const environment of environments) { + const envFindings = []; + if (!environment.id) { + envFindings.push(makeFinding(environment.name || "environment", "blocker", "environment lacks stable id", "assign a stable runtime id")); + } + if (!hasPinnedRuntimeImage(environment.image)) { + envFindings.push( + makeFinding( + environment.id || environment.name, + "high", + "runtime image is not pinned by digest", + "pin Docker or OCI image with sha256 digest" + ) + ); + } + if (normalizeList(environment.runtimes).length === 0) { + envFindings.push(makeFinding(environment.id || environment.name, "medium", "runtime stack is not declared", "declare Python, R, Julia, or model runtime")); + } + + const commands = Array.isArray(environment.commands) ? environment.commands : []; + if (commands.length === 0) { + envFindings.push(makeFinding(environment.id || environment.name, "medium", "environment has no executable commands", "add rerun or reproduce commands")); + } + + const runnableCommands = commands.map((command) => { + const missingInputs = normalizeList(command.inputs).filter((input) => !artifactPaths.has(input)); + if (missingInputs.length > 0) { + envFindings.push( + makeFinding( + `${environment.id || environment.name}:${command.id || command.label}`, + "blocker", + `command references missing inputs: ${missingInputs.join(", ")}`, + "attach every command input as a hosted artifact" + ) + ); + } + + return { + id: command.id, + label: command.label, + command: command.command, + inputs: normalizeList(command.inputs), + outputs: normalizeList(command.outputs), + eligible: missingInputs.length === 0 && hasPinnedRuntimeImage(environment.image), + }; + }); + + const plan = { + id: environment.id, + name: environment.name, + image: environment.image, + runtimes: normalizeList(environment.runtimes), + trigger: environment.trigger || "manual", + commands: runnableCommands, + ready: envFindings.filter((finding) => finding.severity === "blocker" || finding.severity === "high").length === 0, + }; + + plans.push(plan); + findings.push(...envFindings); + } + + if (environments.length === 0) { + findings.push(makeFinding("environments", "high", "no executable environment is registered", "add a pinned runtime with at least one rerun command")); + } + + return { plans, findings }; +} + +function buildPersistentLinks(artifacts, baseUrl) { + return artifacts.map((artifact) => ({ + artifactId: artifact.id, + path: artifact.path, + href: `${baseUrl.replace(/\/$/, "")}/${encodeURIComponent(artifact.id || artifact.path)}`, + access: artifact.access, + })); +} + +function evaluateArtifactPackage(input, options = {}) { + const policy = { ...DEFAULT_POLICY, ...(input.policy || {}), ...(options.policy || {}) }; + const artifacts = Array.isArray(input.artifacts) ? input.artifacts : []; + const environments = Array.isArray(input.environments) ? input.environments : []; + const metadata = input.metadata || {}; + + const artifactReports = artifacts.map((artifact) => validateArtifact(artifact, policy)); + const artifactPaths = new Set(artifactReports.map((artifact) => artifact.path).filter(Boolean)); + const metadataReport = validateMetadata(metadata, policy); + const environmentReport = validateEnvironments(environments, artifactPaths); + + const findings = [ + ...artifactReports.flatMap((artifact) => artifact.findings), + ...metadataReport.findings, + ...environmentReport.findings, + ].sort(compareFindings); + + const blockerCount = findings.filter((finding) => finding.severity === "blocker").length; + const highCount = findings.filter((finding) => finding.severity === "high").length; + const previewableArtifacts = artifactReports.filter((artifact) => artifact.preview.previewKind !== "download-only").length; + const runnableCommands = environmentReport.plans.flatMap((plan) => plan.commands).filter((command) => command.eligible).length; + const categoryCounts = artifactReports.reduce((counts, artifact) => { + counts[artifact.classification.category] = (counts[artifact.classification.category] || 0) + 1; + return counts; + }, {}); + + const sourceDigest = stableDigest({ artifacts, metadata, environments }); + const exportPacket = { + scope: "scientific-artifact-package-integrity", + projectId: input.project && input.project.id ? input.project.id : "unassigned-project", + generatedAt: input.generatedAt || new Date(0).toISOString(), + sourceDigest, + packageDigest: stableDigest({ + sourceDigest, + previewPlan: artifactReports.map((artifact) => artifact.preview), + metadataScore: metadataReport.score, + environmentPlan: environmentReport.plans, + }), + persistentLinks: buildPersistentLinks(artifactReports, policy.persistentLinkBase), + }; + + return { + dashboard: { + artifacts: artifactReports.length, + categories: categoryCounts, + previewableArtifacts, + executableEnvironments: environmentReport.plans.length, + runnableCommands, + metadataScore: metadataReport.score, + blockers: blockerCount, + highRiskFindings: highCount, + packageReady: blockerCount === 0 && highCount === 0 && metadataReport.score >= policy.minimumFairScore, + }, + artifacts: artifactReports.map(({ findings: _findings, ...artifact }) => artifact), + metadata: metadataReport, + environments: environmentReport.plans, + findings, + exportPacket, + }; +} + +module.exports = { + DEFAULT_POLICY, + classifyArtifact, + evaluateArtifactPackage, + stableDigest, +}; diff --git a/artifact-package-integrity-gate/requirements-map.md b/artifact-package-integrity-gate/requirements-map.md new file mode 100644 index 0000000..373ac12 --- /dev/null +++ b/artifact-package-integrity-gate/requirements-map.md @@ -0,0 +1,38 @@ +# Requirements Map + +Target issue: [#14 Scientific/Engineering Data & Code Hosting](https://github.com/SCIBASE-AI/SCIBASE.AI/issues/14) + +## Scalable Storage Engine + +- `classifyArtifact` recognizes datasets, notebooks, code, figures, media, and model artifacts by file extension. +- Artifact reports include category counts, content hashes, versions, storage tier signals, and persistent export links. +- Large artifacts are routed to deferred preview handling and object-storage policy checks. + +## Metadata-Aware Previews + +- Preview plans cover tabular files, spreadsheet files, JSON trees, notebooks, code viewers, image/media thumbnails, and model summaries. +- Oversized files receive asynchronous preview guidance instead of unsafe inline rendering. + +## Structured Metadata And Standards + +- `evaluateArtifactPackage` validates DataCite required fields, JSON-LD context/type, and schema.org type/name. +- Artifact-level metadata checks enforce titles, creators, and keywords. +- The export packet contains deterministic source and package digests for DOI/API/archive workflows. + +## FAIR Compliance + +- License checks require reusable licenses or explicit restriction handling. +- Restricted artifacts must include access justification and a reviewer access window. +- Persistent links are generated for each artifact in the package. +- Versioned artifacts require prior hashes for diff and rollback evidence. + +## Executable Environments + +- Runtime plans require pinned container images with sha256 digests. +- Rerun commands are eligible only when their declared inputs exist as hosted artifacts. +- The dashboard exposes runnable command counts and package readiness. + +## Reviewer Verification + +- `test.js` covers successful package readiness, file classification, metadata checks, persistent link export, broken-package blockers, and stable digest canonicalization. +- `demo.js` prints a complete synthetic package dashboard, preview plan, and export packet. diff --git a/artifact-package-integrity-gate/test.js b/artifact-package-integrity-gate/test.js new file mode 100644 index 0000000..a9c195d --- /dev/null +++ b/artifact-package-integrity-gate/test.js @@ -0,0 +1,154 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { classifyArtifact, evaluateArtifactPackage, stableDigest } = require("./index"); + +const HASH_A = "a".repeat(64); +const HASH_B = "b".repeat(64); +const HASH_C = "c".repeat(64); +const HASH_D = "d".repeat(64); + +const validPackage = { + generatedAt: "2026-05-17T12:00:00.000Z", + project: { id: "proj-microbiome-2026", title: "Microbiome replication package" }, + metadata: { + datacite: { + identifier: "10.5555/scibase.microbiome.2026", + creators: ["A. Researcher", "B. Analyst"], + titles: ["Microbiome replication package"], + publisher: "SCIBASE.AI", + publicationYear: "2026", + resourceType: "Dataset and software", + }, + jsonLd: { + "@context": "https://schema.org", + "@type": "Dataset", + name: "Microbiome replication package", + }, + schemaOrg: { + "@type": "Dataset", + name: "Microbiome replication package", + }, + }, + artifacts: [ + { + id: "art-raw-counts", + path: "data/raw-counts.csv", + bytes: 1024, + hash: HASH_A, + license: "CC-BY-4.0", + access: "public", + version: 2, + previousVersionHash: HASH_B, + metadata: { title: "Raw abundance counts", creators: ["A. Researcher"], keywords: ["microbiome", "counts"] }, + }, + { + id: "art-notebook", + path: "notebooks/reproduce.ipynb", + bytes: 2048, + hash: HASH_C, + license: "MIT", + access: "public", + metadata: { title: "Reproduction notebook", creators: ["B. Analyst"], keywords: ["notebook"] }, + }, + { + id: "art-figure", + path: "figures/alpha-diversity.png", + bytes: 4096, + hash: HASH_D, + license: "CC-BY-4.0", + access: "restricted", + accessJustification: "contains unpublished cohort label in thumbnail metadata", + reviewerAccessWindow: "2026-05-17/2026-06-17", + metadata: { title: "Alpha diversity preview", creators: ["B. Analyst"], keywords: ["figure"] }, + }, + ], + environments: [ + { + id: "env-python", + name: "Pinned Python reproducer", + image: "ghcr.io/scibase/reproducer@sha256:" + "e".repeat(64), + runtimes: ["python"], + trigger: "run-analysis-button", + commands: [ + { + id: "rerun-notebook", + label: "Rerun notebook", + command: "python scripts/run_notebook.py notebooks/reproduce.ipynb", + inputs: ["data/raw-counts.csv", "notebooks/reproduce.ipynb"], + outputs: ["figures/alpha-diversity.png"], + }, + ], + }, + ], +}; + +const result = evaluateArtifactPackage(validPackage); + +assert.equal(classifyArtifact({ path: "data/results.parquet" }).category, "dataset"); +assert.equal(classifyArtifact({ path: "analysis/model.onnx" }).previewKind, "model-summary"); +assert.equal(result.dashboard.artifacts, 3); +assert.equal(result.dashboard.categories.dataset, 1); +assert.equal(result.dashboard.categories.notebook, 1); +assert.equal(result.dashboard.categories.figure, 1); +assert.equal(result.dashboard.previewableArtifacts, 3); +assert.equal(result.dashboard.runnableCommands, 1); +assert.equal(result.dashboard.blockers, 0); +assert.equal(result.dashboard.highRiskFindings, 0); +assert.equal(result.dashboard.packageReady, true); +assert.match(result.exportPacket.packageDigest, /^[a-f0-9]{64}$/); +assert.equal(result.exportPacket.persistentLinks.length, 3); +assert.ok(result.artifacts.find((artifact) => artifact.id === "art-notebook").classification.executable); + +const brokenPackage = { + generatedAt: "2026-05-17T12:00:00.000Z", + project: { id: "proj-broken" }, + metadata: { + datacite: { + identifier: "10.5555/missing-fields", + }, + jsonLd: { name: "Missing type" }, + schemaOrg: {}, + }, + artifacts: [ + { + id: "art-private", + path: "data/participant-export.csv", + bytes: 4 * 1024 * 1024 * 1024, + hash: "not-a-hash", + access: "restricted", + version: 3, + metadata: { title: "Participant export" }, + }, + ], + environments: [ + { + id: "env-unpinned", + name: "Unpinned runner", + image: "python:3.12", + runtimes: [], + commands: [ + { + id: "rerun", + label: "Rerun", + command: "python analysis.py data/missing.csv", + inputs: ["data/missing.csv"], + }, + ], + }, + ], +}; + +const broken = evaluateArtifactPackage(brokenPackage); +assert.equal(broken.dashboard.packageReady, false); +assert.ok(broken.dashboard.blockers >= 2); +assert.ok(broken.findings.some((finding) => finding.message.includes("missing inputs"))); +assert.ok(broken.findings.some((finding) => finding.message.includes("sha256"))); +assert.ok(broken.findings.some((finding) => finding.message.includes("DataCite"))); +assert.equal(broken.environments[0].commands[0].eligible, false); + +const digestA = stableDigest({ b: 2, a: [3, 1] }); +const digestB = stableDigest({ a: [3, 1], b: 2 }); +assert.equal(digestA, digestB); + +console.log("artifact package integrity gate tests passed");