From d6ac4ffa0a214e2e26d41edf4af31b58cf78a1fc Mon Sep 17 00:00:00 2001 From: orenodinner Date: Sun, 31 May 2026 21:55:28 +0900 Subject: [PATCH] Add funding award provenance graph guard --- .../README.md | 27 +++ funding-award-provenance-graph-guard/demo.js | 69 +++++++ .../fundingAwardProvenanceGraphGuard.js | 189 ++++++++++++++++++ .../reports/demo.mp4 | Bin 0 -> 27106 bytes .../funding-award-provenance-packet.json | 98 +++++++++ .../funding-award-provenance-report.md | 23 +++ .../reports/summary.svg | 19 ++ .../sampleFundingRecords.js | 114 +++++++++++ funding-award-provenance-graph-guard/test.js | 65 ++++++ 9 files changed, 604 insertions(+) create mode 100644 funding-award-provenance-graph-guard/README.md create mode 100644 funding-award-provenance-graph-guard/demo.js create mode 100644 funding-award-provenance-graph-guard/fundingAwardProvenanceGraphGuard.js create mode 100644 funding-award-provenance-graph-guard/reports/demo.mp4 create mode 100644 funding-award-provenance-graph-guard/reports/funding-award-provenance-packet.json create mode 100644 funding-award-provenance-graph-guard/reports/funding-award-provenance-report.md create mode 100644 funding-award-provenance-graph-guard/reports/summary.svg create mode 100644 funding-award-provenance-graph-guard/sampleFundingRecords.js create mode 100644 funding-award-provenance-graph-guard/test.js diff --git a/funding-award-provenance-graph-guard/README.md b/funding-award-provenance-graph-guard/README.md new file mode 100644 index 00000000..cd31b66e --- /dev/null +++ b/funding-award-provenance-graph-guard/README.md @@ -0,0 +1,27 @@ +# Funding Award Provenance Graph Guard + +Self-contained reviewer artifact for SCIBASE Scientific Knowledge Graph Integration (#17). + +This slice checks whether funding and grant relationships are safe to publish into knowledge graph entity pages and recommendation paths. It focuses on funder aliases, award identifiers, grant date windows, required acknowledgements, DOI/project linkage, conflict-of-interest flags, and private funding path redaction. + +## Scope + +- Synthetic data only. +- No credentials, external APIs, live funder systems, private user data, or payment systems. +- Distinct from broad graph extractors, multilingual aliases, geospatial sample provenance, organism/strain boundaries, ontology drift, temporal consistency, and generic recommendation modules. + +## Validation + +```bash +node funding-award-provenance-graph-guard/test.js +node funding-award-provenance-graph-guard/demo.js +``` + +The demo writes deterministic reviewer artifacts under `funding-award-provenance-graph-guard/reports/`. + +## Reviewer Artifacts + +- `reports/funding-award-provenance-packet.json` +- `reports/funding-award-provenance-report.md` +- `reports/summary.svg` +- `reports/demo.mp4` diff --git a/funding-award-provenance-graph-guard/demo.js b/funding-award-provenance-graph-guard/demo.js new file mode 100644 index 00000000..ed95280e --- /dev/null +++ b/funding-award-provenance-graph-guard/demo.js @@ -0,0 +1,69 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { analyzeFundingAwardGraph } = require("./fundingAwardProvenanceGraphGuard"); +const { sampleFundingRecords } = require("./sampleFundingRecords"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const packet = analyzeFundingAwardGraph(sampleFundingRecords); +fs.writeFileSync( + path.join(reportsDir, "funding-award-provenance-packet.json"), + JSON.stringify(packet, null, 2) + "\n", +); + +const decisionLines = packet.decisions.map((item) => { + const codes = item.findings.map((finding) => finding.code).join(", ") || "none"; + return `- ${item.recordId}: ${item.decision} (${codes})`; +}); + +const report = [ + "# Funding Award Provenance Graph Guard", + "", + `Status: **${packet.status}**`, + "", + "## Summary", + "", + `- Records checked: ${packet.summary.records}`, + `- Release: ${packet.summary.release}`, + `- Review: ${packet.summary.review}`, + `- Hold: ${packet.summary.hold}`, + `- Findings: ${packet.summary.findings}`, + "", + "## Decisions", + "", + ...decisionLines, + "", + "## Scope", + "", + "This guard supports Scientific Knowledge Graph Integration by blocking unsafe funder, grant, award, project, output, and recommendation relationships before funding paths are published on entity pages or used in recommendations.", + "", +].join("\n"); + +fs.writeFileSync(path.join(reportsDir, "funding-award-provenance-report.md"), report); + +const svg = ` + + Funding award provenance graph guard + Status: ${packet.status.toUpperCase()} + + ${packet.summary.records} + records checked + + ${packet.summary.release} + release + + ${packet.summary.review} + review + + ${packet.summary.hold} + hold + Validates funder aliases, award IDs, acknowledgements, DOI/project links, dates, COI flags, and private funding paths. + Graph action: publish, curator review, or block before recommendation release. + +`; + +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg); +console.log(report); diff --git a/funding-award-provenance-graph-guard/fundingAwardProvenanceGraphGuard.js b/funding-award-provenance-graph-guard/fundingAwardProvenanceGraphGuard.js new file mode 100644 index 00000000..9237b41d --- /dev/null +++ b/funding-award-provenance-graph-guard/fundingAwardProvenanceGraphGuard.js @@ -0,0 +1,189 @@ +"use strict"; + +function normalizeText(value) { + return String(value || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function parseDate(value) { + const raw = String(value || ""); + const date = new Date(raw.includes("T") ? raw : `${raw}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +function daysBetween(startValue, endValue) { + const start = parseDate(startValue); + const end = parseDate(endValue); + if (!start || !end) return null; + return Math.ceil((end.getTime() - start.getTime()) / 86400000); +} + +function inDateWindow(value, startValue, endValue, graceDays = 0) { + const date = parseDate(value); + const start = parseDate(startValue); + const end = parseDate(endValue); + if (!date || !start || !end) return false; + const graceMs = graceDays * 86400000; + return date.getTime() >= start.getTime() - graceMs && date.getTime() <= end.getTime() + graceMs; +} + +function buildFunderLookup(aliasCatalog = []) { + const lookup = new Map(); + for (const funder of aliasCatalog) { + const names = [funder.id, funder.name, ...(funder.aliases || [])]; + for (const name of names) { + const key = normalizeText(name); + if (key) lookup.set(key, funder.id); + } + } + return lookup; +} + +function resolveFunderId(funderName, aliasCatalog) { + const lookup = buildFunderLookup(aliasCatalog); + return lookup.get(normalizeText(funderName)) || null; +} + +function hasRequiredAcknowledgement(record) { + const text = normalizeText(record.acknowledgementText); + const required = normalizeText(record.requiredAcknowledgement); + return Boolean(required && text.includes(required)); +} + +function evaluateFundingRecord(record, context) { + const findings = []; + const policy = context.policy || {}; + const resolvedFunderId = resolveFunderId(record.funderName, context.aliasCatalog); + const expectedFunderId = record.canonicalFunderId || resolvedFunderId; + + if (!record.awardId || !String(record.awardId).trim()) { + findings.push({ + code: "MISSING_AWARD_ID", + severity: "hold", + message: `${record.id} is missing a grant or award identifier for graph publication.`, + }); + } + + if (!resolvedFunderId) { + findings.push({ + code: "UNKNOWN_FUNDER_ALIAS", + severity: "hold", + message: `${record.id} uses an unrecognized funder name: ${record.funderName}.`, + }); + } else if (record.canonicalFunderId && resolvedFunderId !== record.canonicalFunderId) { + findings.push({ + code: "FUNDER_ALIAS_CONFLICT", + severity: "hold", + message: `${record.id} resolves to ${resolvedFunderId}, not expected ${record.canonicalFunderId}.`, + }); + } + + if (!hasRequiredAcknowledgement(record)) { + findings.push({ + code: "MISSING_REQUIRED_ACKNOWLEDGEMENT", + severity: "hold", + message: `${record.id} lacks the required funding acknowledgement phrase.`, + }); + } + + for (const output of record.outputs || []) { + if (!output.doi || !String(output.doi).startsWith("10.")) { + findings.push({ + code: "INVALID_OUTPUT_DOI", + severity: "hold", + message: `${record.id} has an output without a valid DOI-like identifier.`, + }); + } + + if (output.projectId && output.projectId !== record.projectId) { + findings.push({ + code: "DOI_PROJECT_MISMATCH", + severity: "hold", + message: `${record.id} links ${output.doi} to ${output.projectId}, not ${record.projectId}.`, + }); + } + + if (!inDateWindow(output.publishedAt, record.awardStart, record.awardEnd, policy.outputGraceDays || 0)) { + findings.push({ + code: "AWARD_WINDOW_MISMATCH", + severity: "review", + message: `${record.id} output ${output.doi} falls outside the award active window.`, + }); + } + } + + for (const edge of record.recommendationEdges || []) { + if (edge.includesPrivateFunding && !edge.redactedFundingPath) { + findings.push({ + code: "PRIVATE_FUNDER_PATH_EXPOSED", + severity: "hold", + message: `${record.id} exposes a private funding path in recommendation edge ${edge.edgeId}.`, + }); + } + if (edge.conflictOfInterest && !edge.curatorReview) { + findings.push({ + code: "COI_RECOMMENDATION_NEEDS_REVIEW", + severity: "review", + message: `${record.id} recommendation edge ${edge.edgeId} has an unresolved conflict-of-interest flag.`, + }); + } + } + + const highestSeverity = findings.some((finding) => finding.severity === "hold") + ? "hold" + : findings.some((finding) => finding.severity === "review") + ? "review" + : "release"; + + return { + recordId: record.id, + projectId: record.projectId, + awardId: record.awardId || null, + resolvedFunderId: expectedFunderId, + decision: highestSeverity, + graphAction: highestSeverity === "release" ? "publish funding graph edges" : highestSeverity === "review" ? "route to curator review" : "block graph publication", + findings, + }; +} + +function summarize(decisions) { + return decisions.reduce( + (summary, item) => { + summary.records += 1; + summary[item.decision] += 1; + summary.findings += item.findings.length; + return summary; + }, + { records: 0, release: 0, review: 0, hold: 0, findings: 0 }, + ); +} + +function analyzeFundingAwardGraph(input = {}) { + const context = { + checkedAt: input.checkedAt || new Date(0).toISOString(), + aliasCatalog: input.aliasCatalog || [], + policy: input.policy || {}, + }; + const records = input.records || []; + const decisions = records.map((record) => evaluateFundingRecord(record, context)); + const summary = summarize(decisions); + const status = summary.hold > 0 ? "hold" : summary.review > 0 ? "review" : "release"; + + return { + generatedAt: context.checkedAt, + status, + summary, + decisions, + warnings: records.length ? [] : [{ code: "NO_FUNDING_RECORDS", message: "No funding graph records were supplied." }], + }; +} + +module.exports = { + analyzeFundingAwardGraph, + buildFunderLookup, + daysBetween, + evaluateFundingRecord, + hasRequiredAcknowledgement, + inDateWindow, + normalizeText, + resolveFunderId, +}; diff --git a/funding-award-provenance-graph-guard/reports/demo.mp4 b/funding-award-provenance-graph-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..9faaaf70c93f9048a7a62847421d2b26673c65f1 GIT binary patch literal 27106 zcmeFYV|1k5wlG?;-LY-kouq>fI#$QFE9%(j*d03^+qP|WY@40j%DeaZ&c0`S|L(s# z&K$L#>ABXLi=*l(002N@3Us%(aI&)j0Kfqs7*yB{T#Q(4>^WEg0GJ&cJ3AKu0AOw7 zVr~q={{aw(002rh01WW)`EU4tF(C2(;6MMj<^PQW2i1uTbTqI6DYb!C|I`WbABO*q z2CDae!vCn}|5h(V7pRv6|2R^Z7z3R^6oHAg6Y$@mKoLITLjJqW(7`sw76u?4iH-69 z>^mDsJOohf`^S^f+}PUj9}4tvu`o9IFZu(kfp|M916yNjlMfqcRvQajQxNgV#pa)& z|JiH`dFadXbqyfoq$gNH6R~0)gOZf!XNOTG5--j zbb-1p1tLGn2Y_M9g6z3inOJ$5SXo#}tSt=P*tj_UrTpiK^ZpLP+#oA4Fk=Am`xXGt z41~*;KVPDGU!nm35Fgb*0N7A)Sd+j2(B>yQqhwuwA0I!wQSA+!>_PaWAs^+FA<*&P zS?|gLFUs z(fsQHk}KAO$URUI1r>HsAp?~!pz^U1Q2e`)gYd`3^1uAQa`68(A0IjX&wTzT58C^T z>_GYt0F4`3jYTfEdF(@Q4|C1J^~UL z8~o$^F)09`t=|;1^>c%Eeijf5YwBnMIq9z8iG&V5; zDa7pT-K|Ybfh24!EbNSIENq-0p*av}&&SN{>gvk$abVh68`v`0Ihrwl)WT#Aw6O-+ z*x3Uu>};L*NQ?{&4U7a?NkE69AUlb%iJ`Tfk(D4T9}6D~iGi(wwY!svAd4G2AB!6+ zD?5peiJ-ZO8;O&%A&BB8v3GI@xq_a0j>dwlOe`QL(1XOr!p+23?;|2B$V1Q3z}Cz} zkd=$X$lTG+#y}6`%1Q!sG_kg}Z~{?0Zal_DKoDW%U?a!^YJ!3B4?9~EK{i$fR#p;I z11F%Ky_1!N{fEWB0yx;~*_oO;nE(YD*+_uqjvxn4@#(-E7CnFPE6C-D!AP37wGaU^+ za_VT}WDat7G}8M&-Tlxz8VMRXnv&RnE(0bX-2yR!Z0t-dBn}^)5oBTF0+IF~f&ak< zZh~CAAb}Im#9okt#KImlN}w458bnYm0|(Fq_!v%rF97iE%_KAs!2kZXQ~LO6X-M1N zM@NmrNPhBDvh_6vH$mkaj2EZ=$7SW;A0;R5RDQ>$)46|y;IaUUY&edct)qcuKn!Hm zoJi&M%*tVt*ZER%h7a$Nk9%@P1PeDwmH7qP;~=Kz9}cZ;`$}VXS6DTHsc6|eixVmH zA3;1uP18Qn5$y`UF$Og%u?B_I-YKVl**5isHBiMnQzc$5H}EXzN&FZ(i2MXUdpVP} z6fji77^}4Yl~|)wvt$Yw;^MCns#HMvX9jD>JD?U(@k(O-dl`I}52r5kjZ_rT_iB6H z!(FLO@bUf_Tj^-NB2n>4BHCg11W3NDb!9Kf1EAyO|Ao(yYql~un$TL;tE32d9#%ge&V8hFr;RcbdP-| z?w`o^>w>9Yzt6n9Gf44k{XZ>Uk!T<+7PrC!`b{C)`zt*qOb>CLLr2igXxsErAGUGV zmG0GFU!(Io^IlJh#|Pm(9bcwnk)~K_zr3!unsOCIIy2pI82-JnJQ)8gKjPX@ zgi<=2*O-CwG)qpA6%Mo@V-OT;ahaFiZv516#*xC8s4AOn>9#A2pmHpI`5RYABi6L} zMF$U#=!Ui#f3e6U0Oj+~g9E)OZ~-_?bC`-_JBJ^lKR3kd7{0<*26AWZUqn5t9MgYW zNe?N-77d0akcs#y{q|b(bV&=G!%ZQ`3Uu?%4+*^JuGZKqD6(Qt1pvR2{eA)Vbups1 zk}kpsAXXSs$j-=$`G>)Wq|X0x_MVHpGY*_%@-ABUn{*b*g=vENa$G83z4n@T5^4@v zL=tllI|nDX@G>4C6G?q2Y9UpbhuPf{b?r=(HO52E>yb=cn`zu4={akds-d!__Ixyg z785>z@*v1-+Y_itG?(lWrk{)!NCYgp-_Q7M8r487W9co9MvL z#u2kW;CQ7sk#A=KFKCdWUx1u3-eJ@p@`k-dFZu0`IPkqEm`A@zvE}seyTYCBsO$_> zGsPp_j6HPgeJfI`$aHFZ|7=2ea%rxUq?el!7CH1*!4>+!IGU}}{6M`+#oaHIGqcxj zfzSCqovlFK9hI1nZvrXL%DuBoj?pkt0G+PCcVs4~n#SdRGv864$t+yYLEeD+)6WWy zRQ$|)jiOtDON{N%(hLT;a^pTS;jC%{qOSVX`L_X2N*Z^=r-!Le(a73 zo0XmY;4fG%T~+n$C5v1`RM@4S`?jR2?O9-pZX(=!y`K zN_U{W793J)k7y*{3zujf%tX9bC6HQ|MvcNGb+x!iek3iUEN^1xD)2Q0Qk>V3eP8NlWqW<#rxw8q0S%sVhwp`<{T}Q?655| zTJ-mJ-nXtxmlv@;B|q*@moI3l@#CvMI_Tj9E0xE;Oc!{g^=E~j?JYh|tY^9IL6RA? z;nv%c%^2_AMD=kuG*+_e&}PgBI|thhh|NLGZ-fB9;QRdg(qHLM^16ZnoTGZ5Z)*}~ zuEN~}AbnrCfO_>{JelJYR60WN#%^uF(KlocCOQf*8rljZ#oxQ)A5{5y!!CN-W|@8f zBVBJWAhds6eAhHLJeqrWg8ReTdkn0aqSka?efiz3J}aKux*7q(TjBf5)`iysn1jPl z55l}!f5YJ=yd) zF2JU8Ofm={>15-?WU2g09#N#6MWYO7D1?DMNWII!cf{o+f3iG@nha&S~km(u4f z2eZM85gvMqn#%pej7}e84@0R+eN`^g{LsTZ{$=Q--#7VsU5LVKPtDmO0ZX_1GQa3w z0;UmfO>GhHkedZw68>iR=2U3#p~W_1eagqUFT-K3HMf*YE~^r^uOBOS5wd%h){#ux z6+el-^Con@T=E^qZIg{5=$yB*Sq()Wawn!HqrrfnAv!C!365}G@M1gONJ_qYi zT2xRfO%7e3b+AQLVnGe9|Q={>tmo z>&j(LJh&=;SM-xdh0u8uqJBV%BTmv&P2WW#Q#r^bixm5uEponabL!AzZz2gX8EXbm z^HtaDk1)CaYIfwocyf+U-GCCk+XbfO+S9N4hM*!rDf7(BF1N!=q4FRU&h44-;1FmL zOYi+q1<5GSRAc!?{PFiL-}JAd{Kn-kM&!$$<1!k*n0;qdC3Z#>?*;rcXcp3}C>>B=RT|9k}&CkU=K>iW)J z;{os_s}_e@3lpHg952BxcM*% zgZFcWlj>tDzh$E|R#EbSVA?vzKP<2vy5ffZ0n3g{v zj2b@L0Up{UAWdjgS%3zg2HMqWrm#kmD6q>iRq>u7c8&dAjSW~610SlZWOLuFT zBTD>3(E&lnjnk6HPe?O3Rs10^GjRxdhFc1e(QC_(yYl7Q)~M%~bPw1Gc#stvf8E`8 zf_(Q*M+N<N#$1&it6WRvyWTk6OCkKL( z!dd~~GdwB)qtfSR>19Rv{Q!pjwAdlVf;AQ)yopYIaPU~xCUW3u@;s&~!KSj>5S4dq zEHGAHI%kX^1Xm*EWSe4uG%{d#0f%q}MK{(Bo~gnwNVW1l%rbttQ*J7XRbh^pAuB{O zCE$C6at%iM86*uP>$OXx?F5Yjy{KSO#$ckRYoKYxn1`3NuDy74DXh?2e$I@L3i|O$ zNvPh|Hw!xb@|h8fQd2Z~cJK-oq&i%aBZ^5>`c3{cySaAh8b?y{6IPLEGyKd>VWxQv zq5U;4noDHrZ=u5WBO!J~E$MGVE^qA^!7Uz#Urz|cCDPI#ElJl%rAsatnO_vJ3n}9D zLljM1i7K26_oTIK8f@jt=GK;JORD;D{&Lb#>~8LLZ)+DMGDQl%5*ko_<`DbbCfZUx z)UxPEqim;ecXXssaJTk5SujJ5MAqLX($Km^x@I&NzPOHpI?7H#PLg*ER+mq`2_E$k zZ`kKxk8ddwK}W*EE(=_dMBsWI^&SV!*+U8#xA1%T%HO&?2563mml1z@yYD#@m;4!P z&t<@bhSB7lYuuYPPL@j>V}`W~ZG?v=XaG{}Jk&;VK+Uw|UO9=d`CcHe z5YDh^a}#J40;v@23%M=5m)g@yPaIsXh%$$t9-;X?MIbvLHMN2`5h)?mRNaU3Y1!~&#zOxhze=tu8fm5JR8yHoN;I-+bbI8fIp@-z8<*EFFqr^qJ#(7_{HgFNmxmc2R1z;(8tUD-ko; zfyCTcW?PsboBl7T+7l+q_Q`vPNnEUBw&sB>2jI-nHb4 zcD@PHRcFDM8S^YbMgK}}e9m|Yd$i;GX20acBL3*>-JTKC+1>^8nNedN&3(cAv$Jjf z&ld|7E#6}&@5}v}g}mB4{?nPm)+i){y?HmR zi>q8%#&$YzM#H+Ti=-`+*1poQ=gsN?rs*a?! zJLTXPG+FS!jK<^@a6&jgypRG*YO>kYobO5BVPWUYEu4U`sAk?K3$PYL_~^!ikBo^C z>hepia9W5+&P-9!*`v=Wv`2zW-iH2In}5YDzC+`SVX|u=JUK%DDPQ~ZZte;<(DxGp z^+XvNUoobtDRx98`VAQ;CDX`9&i7;-{cvxsmZ~Gx`6BK|R}a3_{>BZ{4i7q<$W{gm z8R;>8^*!lWC{oa!sEuK;zKy~h3;uQm`KoX1L8yCI9zWo$_-Fpw7lwF;;v@+ebpvcD z98IKtBInxD{+z3?0^u+Rz_!2`2>}LR^gphwd&XCLYjE?;^ud)n8icy8(Nq1P46;GOjx= zBYe_`6guR+jqUs5V6$tn`kX>XenKGuPK89{W`{q^6sTZ#F$+}*j%)38yT}Lbf5Gud znKW#op}$;MYB$;P7h`_vSf1La{30lB;#QNk5+D#FDj%sC12-m)&aaWGe>2q@>)D)@ zopkD1Lq0YqIn=XjJmCv~-B;y|g)c`7pZ7p@ndcHoE-iO}r54BE2dMl^Z|FdVKnWOY z&jX?pQ&S^z+AE)h9w#4(lp3_87Q;(LG5BKl7B}WK+%K=LDq{|**R7W3ufK*+G^e`Y z!@d;(s~kky&lV}|d$WJM0l*SX=)>h`>zeFVS@Hr^a%}lbdY4PyZjl(&`yr`!Ss6$D5g#M z*Jqwgf7@X`{`z~>WL;m^jY*wM>|DhsDu)ca1_(=lz@Yw-p*<{LPn=;)6C5ePEn$>P zct*p3n&6@R^9$XGjuJ97*3ozTV=Ou6hx79Vy_T3j`Ybe#?TWA&q*EfFZnu zl;p&!X6&Ws^zVPQ0!T>(quC|wQPqCZnY)<2!||y;Bl@lHu{d8q6*=IbkM2uy_qMtw zS|n=ns{uaWy}aT*$;EH04`WhPdIW7I$S#OPEtQ_eeE-@@?xcz$hOaPA^(kD5>xg-$ zsnLiLe6WutLy5<1`xK=@=g3`F|M$EiV01djbz1?By{VwR?Mv;t%da=deg7Z5o}Y1l zJsF`y$^q^y|N4vbQNTi4w5*{QExrmc>(KBqCbDym9Y;2@Js|uFDUZJ(DM#WXt)`AO z=S(SMBk>XWakmguLUhcbVBs7f{2qnjcZ%tMT%C+~>}ig59V2+=L5d0`TRpZg3^|z# z$Ox4#VG6#}U=_+ws5lWmBzoinmNiak6n}5CO=ji6ve;o!^Ip>EaQIowJ+_xmFP)bv zpVGE!GD66m=2tV*%agxq3)8vN8XuOAY0A|&7#;FO99vb=rTFrY5)r*>a$;(nl$SMg z8J%_v(_)8*soKzJ*^Zg|$xpV3-LbFFRHMR;ll!L}wtLcRsRt6EjX|qL_!C%~<5xy+ zi}{jILSq;Fa3g3!_M0Z371l`Xj*4{!Wbq>eCH20DEa}K*{z1ROjcO#r@5bmV^ig2{ zY{}%fjS(0`K}2@Ngc1~-X2g_&QfMb6IE}>E74R3Og{?G17l_{T$d>4TX+{{Vi{jls zuUpEybTZDGLNf=z=TRF~7zn2Ub0Q8|G)wF@P{w#s$?n#GKI|@fFUbDHgT#Vcni;X| zy{9mSn6~2@FqE|tX(vZpcN(ZoH%trV4IJ4-U7;D$H-ZRI6Dc2s#9$Cv(hexkwG3be=hrHJyXA^sDsy}`Rz*6n*GN7 zuU7DESaq6!?<5^6vG@p%wETSHS!6hv_!q4-V64Bz-B=`d$R4a?Y-=?6pC&GH=y)kk z)niV3u72VA4nD0Gm!2{}aqWfWQ<>OCsh4s$0cQ70U{O9nV{{<4Na5rj<(HKn@ya4i zoGtII;y@^+Ahi0WSVV(yGWc0Gw%lWHhtoieRVeikB<=nqdeq}YQ-pxK5T9NeuMw=x z->v)db9zgEbt1J~^?_gNFXB#I;&Xf_-+R9s$#iy?)x}n1PX$0TILM*JO9s0a=lDBF zCbaojYEhISt>DYFZBlZx^{^s>DrGhrhb`WF?Pq)thp189?)RSb|l3 zvg}EJkqw+2W&L~n^xRHX{&xJ^ojSUdE#fm6%yh&#Y(l*h*9i2o z!?jE&F0ZHA`y%a(jo^078hB*T~gGzPP@76^W${CywEUl250Q9NePq`wFBPEjRpCY3AwR7r? z5NY^upBxUzPPKmu;Yqu;)L^B7%p*@aP6!X%RJIc*S57*Ec6+BudQXH8jXmk&KLfx# zy0!5H1mC@Bh}9BmBqcI;=!31=t#?VlaLd=#qIZ1?7Er99teRal^p^e%fnZOnrw|MJ z9S8zICL10F<=6F_V=I65RUX;>u64aAd-5A#6KTh+OCJ!4R}?$dx3mpgaZUU^(ydf_t%QTX8)fSZ0%LLl^LwH1|nloZo@5fx3r-WpCtZ}{S2{4SRP)vQthUt=WW0Yz~JUM?x&4pIV= zna%TUszyG-MY``By|(%q#v!qkeTCK`qe?h!k<`}MqCt5vB5x@hA1`=h@N{6P%%V-e zT2oXTQEIz`4WC4&>DSIQAl~px=^*RDbsw$XHfme+FZvSgh}7sdyoy8X8?UKm zRU@b01G5Pul|Dm9N1BHG^^%3M;1qXK-e}~Lm0aa*{aHMxDB#qiP+g4W`{-KS*-Cgu2Hl2rL;7+ z6!N%Yb-p0WrWkEl?bMFmGF51+z@8;&8f(RuJ+q_};o}->hpk$)>+N!joncQ9Ci)V) ze+Ac(ds3fthtnHGxtKvXvKW`2+Vsw~0Y>&@i>M9(Y#IHizHPWH+c7;f+N%FP33pLx zXrldkU6A#~Q*?EL-gY^xLZR`qESZLs+C}x4q*zZB&T$GA%!*GFnAw-6S&@I+NZBCxR`-?cUS^AS_=YmA~)Bx#WZB z&OqP6=I2<<+GRQ_Y(9%U#HuXPW(i$qavV^~li*Ytolaz;nu^gVU4BM|lQ`unrovwY zT&=4SFKkMb4{4Zquh@RDP_YwY=SvmH@i1!?c|Y3nP0{fjc1I{)@T}|;zd?9yqod3 zu{<=0;iCij+E3{`uT7*o!%tbgyk*SYQ-pifGO+j3Z0CVL7OUI8X1R1n&!AN5IzFzm z6(7`Ri`|nc_wIAshDXVDU`>;_r^^zwt9Tbhd-pvcjuFmsK0C`z3_C#VdT9g*eux z>1l(uWrCZxPsOCY=e*Q&7dDk|MTesjihUBCHCr3Fs>%*Tzx61UOejI5R_A2Fam=8K zVzII57aM`}XY+3`7X7Afz2uWqH#uup$lmp?26q^*MJc7qHyIW(5@cZc_WW z+f%2Gu|q{e_nQ*nAaA_?6Ry7i@!8Wv)99Agno5ryX`xe`nc@6B#GR%DrU zN<;clZ8D!G_jf(M`M6>WTx(~863NPE#9ZC5#FbL@mS+BfQ%}d)shn@f=cq@DqSElS zzLF|>V*N1|o$e6(aK;g2Fr+eSP>-lz#KR%?Zb&VYvIV~{X#eyY$e+MxAK|a9sESTC zpU*ZO!Ui3uKfX0f6FxW&dvJ$Iw~OsFYr34n2m#$IWwh3&Fw!7vherL6FK}hts5X7L ziAsB@YbNx}%^z_p30Xm;YP{ye;keD}t;zE0%7cKc2KKCVpRuD54a z#5A>1w|f3YdG3WCsg}L2>&HnC6d{kR`M0$N1&bmd^Ak*{O_4mr{eVWd`;@1g;WLU> z3*1PTTs#@)8Vot&;%7`gL2({*ZN?gxh4A|uX52gW4siR$Lpx1Ar~t&{#pBJAH6)oiNFJxc9m+q0!z#NKEl zaB7*7Ij^3RdxqJIC({Ui4Go}(8Q+V+)RPANDP?=;&r=f|@qp!{h8z-|Y2Nm<`n5ACNHqB@Z|EI(3E;#z1Uu9f z?U6?|9;VW@n$31;v>BYx>Ob$3U6UctR-7_~JhSH1SD2E4afs_N_Zg7&`5i4sbn!6a zTzLH*!jYFsME=QBRbdg@0&KVFiDB5GXp7jBjDRkRuy5q|^63csJ!iwY(?tGfuT1IP zgCi#3#l|_?_OA!Vj)wW>s9o~6zV`L(M}6YSyDJVjt!QQ@=71w;#|UcXf&u>V-oHt( z@7dgNjtbAZUO`CAzQS~t@x+}$EoiUp!w$B+=wL@I4t7c=5=ChH^WXj$GHI{c!C8gt zW{HfX7-T|hJ*=Li5di{qqlw!xuP+)`sMQRl7_wnUjXD(vUyJb-TgPMn>bs#d!{%td zXXR%FS46d^gD#qUo<-vxP$48@tq-of`WiS@Cl9U)T)=g)VH-rM(Ipvy)FJ}=7Gpc^ zqO3WVd042t*!7OY&n=h}%fedbii)1w0dvHYl^qYGMBl|FhV#fq>t5_61Q(zjRPxM5 z?OO^p@%VqED!4Q&qg+7=V{tATTm)KGrOl;|b8?mB@|+{Re##I=ye4Yv`75w4wtU%C zr$2gw$GT*&bdp>g&>n0C89n)+&1Rb?c1%cQigwN&PH z%2t1^u#2gnq?C-HHfS*)6XXZh8#lu4i<0vVLV4$M%QEQqCFws`JS4$NHP#3pqxSgZ zeT)J~0mZ|}I5Gy-9OFJDD*ciWcW~AeEkre76CwtICI{+p4?+6Qx1pQCuLe(u-8J3> z-@Cf%E9GIIa0llzy0$DdRW7=lSf91D2hOJ0D%H5iQgl>jEZR@XBxCk@<+t`PvXge6 zoE7XcXgy{aMkU<_3?~=s%9PhY?QGmZV~Ukpq5{X1NbqOzGoN|ejbHM2lAy}#^pXJl`mE3_ z+!3|T((&8g7HxfYKZg?5GNw6SLZa~y zX;`YSuQrqE^oq!e<;3ET&)Ep~KH6`7^{U|1>PBysXDE&#DU0P~ zm`%ji+c6Y{i$2?!|J01`<%DTD_Lr9L4ME`P9(HgnES!P?5bK~bPe1a*gT9M3;TjUh zpDj*wwd1Q*!xx-t*aJd+M`yX-kqqmdtV8G&ScgH* z9O0Ye7XBsp5Ufp9X?&%P7u)c9nla>Al+asWbk(oXk!7gQDKnK06rQ9;MCkBuhkoqi z96hZW2tSW^qq~$mWoFOl(Y`%8hjGa-9uwP0$>|X&vVHT&dkRHB{%eKn7N7oZ$MvA; zojZ!aq0K9UcahPte#jGj5JaBAz!U4qZG7%@Y=(J+Gl5LFDp7!ptQn6^XLqF;*T60{CD(<6GQ+Z1}Jh$FOZKT!(a4iA6A z6?9PqWXMYabc+MjR?5RfN`$%-I+%tsDAGIrD!@|BEbRo}%ILULSM*{2I8Z1(!^~U- zBPP-WXnZl&Gl363xVhi6ZzN(<$wv)0yZvsnTHfD%BUJo&<-9nvAJ28R1MS%Ro29&; zTl%o)mfO9@laN17T8H0ZjLoaHmua>9Ip=i5Fs4a9*?d9rSmIde@qz`tTvpt_hoUFT z)HWEExt3F(=j{GYq&VM2and_=KNu|-VwYuvu+WS*58&)EPO1UN)afN$+3r=$>_(XS z>~@lfy`_ZQK3q8_MqbsRt;6XXw8SNn_VeCr!!Ek221kM~e3Z{2tR8{{5-;!DSj7&i zHuTL6c&DCz9x-oe)XNxy#<*5WAtkL|?3^}s&Zu9%LG{u8BslpBp&TLRo(WJ}cpw3l zcU=Q{0*S&mR)T{G^X>RZi0J1IPr-}SJMJV$U`rutz{b#Hk z8u>mjjbD=ReYTeQA)RYZwcmaBpb0-WYIyjXw?UgN>@)-VeX&*+GcL|adfJ-RzJ0=c zn7q52-g%t`C%z^4deTbv8k^LJ5l_r|pqRIshl$Z2+r=tlhM@*m*sJbHM~9lepxsCa|CPhVN!mM*r`wn_RWV;GOU(;GBGb{wnw!M z(!WyeDVx)U6@VI9Oj=$ExD{4AIX4!PSEJ3rNzjC+QeKzjZ2x&K{*qHLkQ8YXSPiE` zUZc~EB)@!;_h|rT&tAN+hGBic!OdOz%CsZaklkOwIX2kkQJrlf9ZZhYVBqiQ_?{Vr z*3cren@?+-1NZ!(^&_6U)_~St+mom6p_^|mHPMTQxY6*Az9=GQi=+XNmd5`TEmV*41sWBgEfg4YUo?!%WGDd9A-rPgPue9`aoVAMEz;jx9~^4#rfg#;I9N@RmVUrKLy*=%OLSBWB7+2$EY@7-BE{>BtgVpI8iMnRZF^_e6%_USK#CH_P4tE}AlR9#G% zTxqm;w-|M0nq0seQ7WUNGujN43Nr3~(CD{xd$H_cA4q|Af4gIn-yK~)yr?a9Pa}3? z76Gm*kc5iT_hEmkH5fdXDDG3T-~suv#R-uEk_U>x9_ zZ~ok7LG%#+dyuhtFoq_$3`PFLJP0Dvuh?Y!EqP1N(zktGm3n%xo^PF(08ahFfNK@hB0KN%WXJuX6~_kiedTEF(viBKhmSl^^b!1UIjh zsvYv?FDX4FITazLlDLFgbr>>Hp`FQ@P9plvJG9Ig+Q^kWmq!yHly_^Ln>$Xb6zye4 zgD!$?r@Ngx%^Qlp;%s`GNFiN&Oh?5~wU)Y*A1W|sT{AUc*Co`M{u|X2pxR75K zY*obN@N92o$^rwuU0LO?lf*rwW^c@3@apQ@7cD7a^=Ll7^2G_}UQyuL;1nU55*1tj z46*s=O@k{Xoz@Q^GRw~R*`fBqSxTCF=L20&DnBI}1nvHItc&o$oB@0*-iOR6<9a}x zx+&i{?`lSnDF$zxO3;7U8>Mz$Acr`e{F13$*<>K{h9ZI^%euIIEH(|>Foxn3*MwYl zUE`3oVm>vk=({u|N|9xg+rI&|e_QW-i+d{btCYmCV9&0Bl~LP#uTZ+mZ#f531%H$s zVfA*&-f)ve}>3g;;%^U!A3+koT@emwZuYX<~&pey@;qDc&3xs<)$X=>AD1zPqez3venl5;RvOHl%t7RTqPRf|h_svxh0i%Bi#CV6_I9_)<-U`g1k)%yn_2z#TlL^8SlKrZDFk!u z&=Tj&lr@_3*wZP$mY*R4tC78N!(ezfkf^)+W zve^T8|Bp4&de=V7uo5W%-(XIA{*cnBO?gu4x+9-#(S7oToyYcuKHH9uV#)HaICK%H zqhnaBGSwkPr7PCewQ^Z!R)CS2f6IKSeuq?xA$dw_atTBn!>xxfqNCw%cl_htSrc|* zzzOy=pRk)nkncqU_mpYtm13>>XD5ijg>iN308Q1Ow3W7A_2{T8M!@t2xM?5svu1{$ zz05zyWxRKP6S8B@6S4j7Yi=^Of;)YS(ZGHen4iEHfsSwjBdQQ)u-xzgYuhdGfQE?- zLLM$1-A|C^SlJ2akdz_CEZX)7L3_Jt;bK6pqO0mDlB_s3>32dDK#KfH7w>o_eh-(; ze~ukD${fb79bPv;fEbi&`jSKb31f=Na?8@k9Xly z-6@&R8yPsAN*GI6PW(bxLavgbr6<4U#rpB?sVIiW@k*>w?;yaHVkplW)^2k4XoSZ7 z2ZlF$XM}`|Ah3AEY8c&LCmM86%PQ_XqvOV-cR3nVsi~tc9otp7rxgXD0=0Ch^@hx6 zsy}@?|B5|NVXM4VZF?bq+tvKZ@g4ZPbu+5+OM`%!`)}+Bfq5v?7cz|C@aiBMqWA2Mv*(t$3_qN1PiRFA1f3_Zt`NYx1W`#Ymw3ZQ}0@x@BFtiNo_M` z<6wBS4mKF?gl@w-tkkyB(srJpYDpHcB6^)&eIGHE+TKhLa6alpQAXmyJcY*>SHfs| z(>OV`A*&p1!(vmH1#1y5#&G1m71f{yDXku%irY87C#b|E-`ixulJ4srO=pN0ig2OG zMGtLy9L*=JyUsp{r*MRDj5JiSwotnR*7_H%APuQ)P%CC&Y3fwR{|v8-;7=gaJZOFM zSUXV~mMD**IkgR~3jL&V&LicRm>{|7!lqcbc6JdZN?Tk5EKgs*^PZ}g1*!JyuYA>s zR3JX{L3JM^l=Ocl`IHZdmSIUZ10D4P-ep#nc%@q;?YQ|s$9rS;>03SZ2FoaJ9=~uH z7-P_}nPV&Ibl545Em{&wz__u9QW-Ftsthc6&e=KurKioNHuWBXHNgcQCeRN>^t0jL zj0KuAqA2VBs6iJc_@#=>Ow;f4n;Uf{dKS0aG{q+?^lD?K5v6nH8+lxJ_?yeIhBGryb%0=6y4iw9T~_vJNCC+eyZf;{xh*oPmA0vW8w!w@XtbG;|NQU z{v6I#S{~PSg)+F|fmk%K{?9PihMOju)@RD1+fE`+iM-&6i^V6#=+Fsu$DnuQ&3b+% zIB$6pl_zvIEJ?D+a9k`&XMi(qvn`m8E~d;BQM_sHe@p*7d|kYh6@xjMO`ZjHY|b_E zWF|1&vjG>@_{%_7N*$cNvV-?_rbETT1O(o^v zx?dwL`zbqDetVOgt~1cK46np8cB-@{zo}P- zE51vDaS~EW);E$U+-Kk50aL+dLaVS>*Xmmxt<&xQ5~1y0h7x>~x+iS5-@X{Ub9$CQ zV;iwSc_4%0@-+Ul4fZ9XkqXEJ&brLuU8Q!v(KqFj^;|aJ_pIGF-E^-U9yGku;+|}d zhr$}UbaD)swsRynJs)1C`s1+mg&oo3=16;h8Y77DIPrplLa473=Z3%cY1P&wQs2`b z8FpKv6BhTWAt1H8w-x7z>`nxa&&K4(?!=f*R(N7`Oz=_8L3P#qv zxdoovI(RDm{IFms|8t!r_V09?F7KgA#MN<+mwDJ?NPm11nkzIY``$WJOLVx~xU1>(G_%jsd37|&9Fz2(Y zJ`_U6V@%}sb@iZjM^o{s-}vfWLfEiaL*n=lWHjl&`|k7=ct+vlzgH3OE<^pyqCI>X z)-OXSk{{}GMggQfX=`Bc9rB@5w(sy5B=gZtmuc6*IoJ%)`7~}KaBnDN$BgD38K#lwK^J=ChgsCap;Lpn?^q~sfO2Ym| zB=7j~7w-+UJS;#&gBVZ7q8R~nj~WO1^Mj1jS?csFQdEJlGRv2CY#FhLLj8w(a+)@V zFfwl;1Oo&sID?0I<>=5d4@`}ay!4m_xYOL!3A<;K%X%N#NE%X&k)IA>OxkCgyFs23 z`&?F^eaC*T76fn_p3KzV9%7~ZGDj+3g7m{|pofupVGtA!x^Qj3j>QR#E$}s##%>uR+OcTV>v9Y`8 znEadtujM*mPAGTdjuW{ zh#@HHKhFY(8Fu7PHe?#{m=pc>e1iKHyi((^6$~L>BpzTMuQ}Hhcf2@uJ&iek~%ovumTuNE0(R>3_-wC}}$c_e~cnS2Zn7V<(rZ zXcVxbDIQvEO~x%ZnkEVz!5@tJWTMmvZe8Ef*zD;_Mv->13xA7G#5fFth;?ItELZhI|7(=*Fdvbb8fn?zExxfq5XdMhV}hUo5Q z{x7AyzC&-N@Ov0)lUYnP&g>L9Bl8l@HpX#Ca78*BwmS z39c#e#N#$hp~Jj{70&X`oXS%tZV@^+%JTp}9I4h+V^bud=EJF^rK9qbU}Qmj`B5Dl zoqt?d>(e??agu^BWc8`}G1GSd5Jd=zoz{i@HJ+o3fR;x7g@9vZ%va zLaBd=fZ~LG=%`fR_tt3pL`wF$c1i+?eu^cB*fvnNcP%T2$vj6;R+4^=_38D+m1LG2 zybeV?ryOJJ5GkgoDI#@2-u_GtgCcVwJ#UIUt||?Q(ZXY2@yPjo;WTp~M!o%OCI^8$ zJi0}>U|ps3AOD-_KD>h-%Djd^w2Hj&@2=a-{S=0i_Kcrioy^nZL{>&bFluM(LURf! zG)mdJBFBR?e_>9F>=C(>1%*>vjDEY*yWzg#pKd5%5?$A7DCmev(!^X^@XlKPs(l2l zCnS&BU&2FB-GAKk_O)z5aH9M(c+!*d>)_V!bhVSgY8Y`3qygdFU4LY;@f7&^(u%@~f2?E;hDSX1jgVM~Xv2t<%*)50GURXNM5z{vFPRJ-$dcTO&SVS!UZZ+W zS;V^u7Qk%RjOM1e#3<&xs6#7#PmIo^J=B>v4#t6fqOfrl=|072P(*Y{Vl`##7R}A2 z*NR(W#jLR2$9k1xj#97YSkW8cRO~a$*R0{Dh+@{%8z=0DjXs9@{@v>+&9uDkWW+f` z=T<>%4LIFOh`^ksY~_7ejEj!t6E4K9<1rASFEy4I zNE19LwyH$g3(Gw?A4wW8bLA8?!sxISH+i`T95rZDRAFh%jN!J*a_w#JedDc;ut$E(Si zPXl;Xa;j5Ac9*Y`$fDu{B^Kx1c(3*?$?V+83~ISLrl+d~LoTtPoL}5dtA^eQ5?8e? zG?>c=Mcsc?vlm)gM&1*f&tJlFP?9qqs-!{NGC=9PS~*7ScuXQ;{ppCCzW+dKDbwyp zIdAJliwK&q4W-QyOiRyY!((8ajJWg;!E#E6^>BI{M_`AR16eTHB!%Z5G@1o9Bh++% z;enK5{v-q0?m>HCx!#7JVA7MYTY%o{gL^+xqG=> znn6Xi0rLA3HGiVGV?8!5by6DWR_k1G&Y4>Q`HkZ`znOv!K@t_k2nqHl~tZv zG@vC5M<7{zm<&xr1tL4}g(UhF+ncScGxQaw(dTGn9Irz+jhjtLa(u+5N*PAwRz-Aj z^ZXoX51jJvha9z-yjBkuR!=rqhdpe49#K8Bi1^T5Lh;#PzR563xqzZBRJFAHbIgMm z?pSnVtTRvz;r`a%@+QpT9i>C_BHZtpq9eJtW;|Bh#U#Tko14gpUL{(zqQ;C@&8{9>Lq0GA z$y^a#AwWD|_H^B;2Ihw|ZJuX=nZ&g}9^<)ZzGYPSv`yl_S}RD~O3=f9-kjfDns=eg z3ksF49p!rW&~b}`D5I8HdJyDC`A%2KghRoQ*W}x&XMmywf1PW{xjAiyOJCTqHZi`- z*_=|8rWAL0G`Rj7yMlkq%LMph5P4M|Ysg8yo4Wo~N3gbrzI3Gk8r%#V$NYpauAQL( zYM%VEN(lX)jYY!ZDMI|7gT=XttB=@>;nQQI*>C3%^F%s2(^I7rGYyAS4^UMCi)ZMv$rW_D<@{GyM4N7(vkdvpSTE)yB7pt?Z1grMt3bZ4xixMQ zN93)FB5sb<1*1~$y5PhT9~zl~4C#t|yL^xjN{EFApzHs_*0oXzXT8g!1J+ zIwfYbCiOCL_BOC#Hlx#{n~d@0X6kFcd#Nl&a4b9#Xrm>Pl6`8Shd0~SQNk?%HJ@ew z!IZ5M>%*sLESfXOt`o3_Iblq-fowP;_Wq$S`y)!Jsf>$4%xoU1#I!v(&@(m|UJ(}O zr_Z&OvsYirs?$h;Y8`msM9X3fc%5w$M0^i1E2E?+43RbdamMa-|21uXxjo+-W)DKx zoDkQJEwLotiGizzT&j0|)XJK#?lSci>kCetwZn7_g@q8ScCQuM7PjlhPmTQ}t6DMI z)TxV@oWp?1I*kxVx-!#EErf;C`gwDOh0-y0r%2Iyn<>MSm?hE2Wt(R48K1NV4|u{kKdC$bO7%L^hL;Rwg|ni1w{&DH4Q#Um{Vn@)B_;(0;tW(E5Ny z>50I7z0(-^G-B_mQgArgZQXmgJqI-y%SwbS5-Y*sThAkm^k5dkbn1y8L%ks`;O~S6*pZh6sW|Ok+*;r`DsYo&{dRywB)lmak%AxjT&PyIauZB!1m=S0$||&E!UDVAlh~~$w-QM1;gMJ?^S&cVy(MUqS1j)00UMv63Xj~!8t-Sz zL6XL;GiRtk^A{KDdYSdC6H7kBJIe4bj-F6s_MG^sk~y8;$PLjusRmTqSM|fppgN0D z*Tv-R^5`&C16`LG!L_1m(~Amt)OBleZ2YsTJ|%OqyLXOC;{s7cf$KXZL|XQJWfq<+ zY~rxo}k+y6drn*Q6@( z{%kQC_Jb$PPcZ^A0`HZqPBr=zv<3ASbK>xFdpHoKS{N=$*?qR&2Jpc=&d)?9Ojvy( z_;(dNEp3G{;)^Auy!0F zD7j#lU^W%w>T_tE>SH$=`8Z&MiMaV~cQR}FF)79O(?mTrZF z$6vwNsfogHl{BglDN^dx1+pl}q;H}MflfaWPJVrKci>#e4FBQeGw1?;-$%z3VQc

C>_V|l0BGc^YxG<@l;zb7b$4zE2$=cEf2dew=F ze5Q)t*scP@r$%&2I>lsv;pc7eJ@ussczHgN;>x#&rI!4|9(_iaM#Rj?f$@VxYJIu= z%7pYv94t;kgg?XjM9^wG3X0+flO3Okh`uZIDU6(05nskJSk#yFvywZ?>q$M}nj3#T z4|}+~No9h-8QEjH#whV38@E+_+{51PCOP%$Lm{7qo{Lub3dfvJe5WUAjeta(#Pe)( zY({!b=s=B6pPY=dEmhhxD{4N*!2q6100falS?#Fx z2@Sv0U(0;NhS(S}8TJ5@WqBkMDZr15hxyS>w^d7B?#n(p7Xm76)#c2yvbZF`4Bx>{ zT1{H$#Tcwz2j7|Fj40#+gB34m{Tk$UD#u3#LmuFJGAxR#rO1C>`SylTfi_vl6`#s& zNqHliLB<+P@pQZdQ@HX6MOAPI>sqHzZ{=L#vn7cX;j!_(_NAugmZ*8B9o%OuoXzUF zEl=*8_ep6mMyFP=J1ZN(OCkAV|co$dmSis~R> zAiV`k*sdT}DCs0o$i_7)-(wtfkBY9!)|-rdxCmq7WMcMr)QaurC8z8{i|OmsUFV{X zj3$wqusz#-v-954(JfOxze{8f48s#h8Bcw>v^82Zj7Tf5i}?X;|6B(3C3ww)9StXF zw6XOED?Sp6G&7^=-qr-$-R=q=!Uk`AuMutG*A%zVgU&(Gs^wuzi+5~Q-JmC{R+x>7 z=dWfjoSW@A@Pm8l;Y6rtDyUKK73zmm0s2-n*y^sbbDACk6nSGNm;k{XMS50N4mY&Z zi?B*#`3L;7NqFKI6dP$?JbCRendkRdmtGpkB|v0wmzh6Xhm#~``X5<27zP+ zxt8eB2J>)x`nS<8n+^OhcSt+-)aO4=__ov$8*I>3H{o@AihN01VElfxT=7|IR<)Br znDKG{_RxrLm~(2bg_u~d}J1k}Ztx==>rOT-|0xRPrM!08VhYuXjE?CyZ@(QHzs`v*VoLII(4ixNiB zR0Xo!4!;MZ{k*q*@-SbfetJ~xVbYGKN4sLyN(mOKGymK!Qyosgc14G^URBMS8gSF& zD;XF=^Xj=UjsRvDQ|J_2*jFTZIiOj3r|#oc<;#1sUh3a>1Dqjy@=RddtHtWz-20VD zIJ8I2*lM;t;ij!1|q(Cy<(vm9=)&CJuHzwrSoI#r0kS z0RU-(zET?@L-G9SJsWOkBl$Ot#IW!e^R8!+_g!DHa`gl^sJhcBMvBF%og`G9cbNoi z-y_XEiAV^1M-Px(9(=O3ith8Z#)Qi1Xfq?4x=56h@nT+O$gOqZW2BpDFBZq;cC{tX zSkhs+6&l<3{;Xo&1ac<7slqPu{pkl`OM~zu31&(v!Z%0w2kk+4i4m~GLF-7;=hr^cf|E;<%-@03OyH#}rsP59%^)|$Sh1?Z2 zx@6V$(f0-z4{N3xemu=kQS@cFpRUnKEYDp+!?a9d(GfyRJSe0`P?-YZ4S4d_xIz2t zbKiatOl6BSQ~3m2_25;7dZM^^KKClU6uU9T+gm$Zv;AuuVlj01FsgzsyY2(&=4Xx2 zK+Q5$kp6Ckoc?fZce(G@F1cyi(?bPDSCpawBfyk(xtco<~Gxh+p=CZlALW~$@-5a(S(Fb`WAGi^B$vCM38SYp1Jfm?BKi4N|X*5G!Xrkh; zz?C?m-@Pu6jV>FKJG;J9H$WhxzU zY-!vnTq9BS^f{r^GWKdh4~^B{94Xb1_l7T@k}$-f8pM3 zi^}y8JZ~AtQP~66rxf5S_E*b=1U}xb<&j!01BLC^5sM{jLVTH%CJKeaiy^*!-20;m zpHv?%WGMnXthhE4Y=#--`@SP~!)QixrddRiHKvvu!j=q}KTZZ8zp@{vziX+b>mpuj ze_u-$v$1iR+L8a=Qck-YP(HXQRr0g4YBN1*&i07(z zhir(0jdCV{tWYcNg-#L-{r>#Q{D`ibvF%geW^r3`LFgNOPE7~uFn2N@F_8clN-0ez zGB~=PUqxd=5d*--BB--wS&kf7AH0AQn>2;~Y*c(mzFW?N?BoqNad5uS6a+mjxQILm=ckq}gvY(W5OQfBWnlT(* zW#)@|wHzRsx_UnF)wJ(*G-JXy_8c)00LnY$47@0rmL4+g095LAt%x>ZrP@!xYlTj9 zh|zkw1zw8}9ChXrZO2EcLPZap0swF@B8SkUWLi5Shy2z%uyk^>fvI8e#Lk?mKUwf& z002c886gcybx|b*T#^-^ko@fMAah zdTQMshFn#_QR8R)ULZRZ7a`~117(KE6LIq*n=WH8Fwc?6YRAZ(>_-e%&sdD1nyGhs z%1vR>-v)xtHGO|0DGo%AVMNKy`)x5l$?|a;n2J<d_ zTN&ZEA5a$a=^hVXzg`{c>~CEiD81AI{*-#Kzw zc4Yr-)0^XN{E7{F6SMgz*!F(G2KvdCh5r}XvUq;Qc9XX(-ha=QCin|B=^Hk!zslzJ zD>f){Qbv@lr+hx^JPe?;mS3<*BSUyVS<`=- z?dE#RI{y_L6gep}ZdCQdf(`>)r zr#}VpUunoq-u|xw{!v42&goAL{8t+Cd)~tSD%;=s_VZr&Ti^b9&r17S-~QKL|F^#V zW|RJ3AM&@p{q5h7uKkaD){XD|+rRyDy^!@+{M!@6bpf(6`$w^W{E{00_z(IwdQjSb z`!{>Ubtd5-*ieIiqWy>L8$BrX_V4yhA8{=r{RcFhh8r{x(jjpni$KipA8vhQUM&^X n1<4#Zxdl}MXIkE0F`7hHVnORO9wBQ}CgcEQ1!(mv*QoPf*VHK^ literal 0 HcmV?d00001 diff --git a/funding-award-provenance-graph-guard/reports/funding-award-provenance-packet.json b/funding-award-provenance-graph-guard/reports/funding-award-provenance-packet.json new file mode 100644 index 00000000..955bf055 --- /dev/null +++ b/funding-award-provenance-graph-guard/reports/funding-award-provenance-packet.json @@ -0,0 +1,98 @@ +{ + "generatedAt": "2026-05-31T12:55:00.000Z", + "status": "hold", + "summary": { + "records": 5, + "release": 1, + "review": 1, + "hold": 3, + "findings": 7 + }, + "decisions": [ + { + "recordId": "funding-clean-nih", + "projectId": "project-neuro-graph", + "awardId": "R01-NS-2048", + "resolvedFunderId": "funder-nih", + "decision": "release", + "graphAction": "publish funding graph edges", + "findings": [] + }, + { + "recordId": "funding-coi-review", + "projectId": "project-ai-reuse", + "awardId": "HE-REUSE-8842", + "resolvedFunderId": "funder-eu-horizon", + "decision": "review", + "graphAction": "route to curator review", + "findings": [ + { + "code": "COI_RECOMMENDATION_NEEDS_REVIEW", + "severity": "review", + "message": "funding-coi-review recommendation edge edge-coi-1 has an unresolved conflict-of-interest flag." + } + ] + }, + { + "recordId": "funding-missing-award", + "projectId": "project-cell-line", + "awardId": null, + "resolvedFunderId": "funder-jsps", + "decision": "hold", + "graphAction": "block graph publication", + "findings": [ + { + "code": "MISSING_AWARD_ID", + "severity": "hold", + "message": "funding-missing-award is missing a grant or award identifier for graph publication." + }, + { + "code": "MISSING_REQUIRED_ACKNOWLEDGEMENT", + "severity": "hold", + "message": "funding-missing-award lacks the required funding acknowledgement phrase." + } + ] + }, + { + "recordId": "funding-doi-mismatch", + "projectId": "project-climate-model", + "awardId": "R01-CLIMATE-12", + "resolvedFunderId": "funder-nih", + "decision": "hold", + "graphAction": "block graph publication", + "findings": [ + { + "code": "DOI_PROJECT_MISMATCH", + "severity": "hold", + "message": "funding-doi-mismatch links 10.5555/climate.model.2026 to project-other, not project-climate-model." + }, + { + "code": "AWARD_WINDOW_MISMATCH", + "severity": "review", + "message": "funding-doi-mismatch output 10.5555/climate.model.2026 falls outside the award active window." + } + ] + }, + { + "recordId": "funding-private-path", + "projectId": "project-private-consortium", + "awardId": "CONSORT-77", + "resolvedFunderId": "funder-private-consortium", + "decision": "hold", + "graphAction": "block graph publication", + "findings": [ + { + "code": "UNKNOWN_FUNDER_ALIAS", + "severity": "hold", + "message": "funding-private-path uses an unrecognized funder name: Unlisted Consortium Fund." + }, + { + "code": "PRIVATE_FUNDER_PATH_EXPOSED", + "severity": "hold", + "message": "funding-private-path exposes a private funding path in recommendation edge edge-private-1." + } + ] + } + ], + "warnings": [] +} diff --git a/funding-award-provenance-graph-guard/reports/funding-award-provenance-report.md b/funding-award-provenance-graph-guard/reports/funding-award-provenance-report.md new file mode 100644 index 00000000..bf0f3789 --- /dev/null +++ b/funding-award-provenance-graph-guard/reports/funding-award-provenance-report.md @@ -0,0 +1,23 @@ +# Funding Award Provenance Graph Guard + +Status: **hold** + +## Summary + +- Records checked: 5 +- Release: 1 +- Review: 1 +- Hold: 3 +- Findings: 7 + +## Decisions + +- funding-clean-nih: release (none) +- funding-coi-review: review (COI_RECOMMENDATION_NEEDS_REVIEW) +- funding-missing-award: hold (MISSING_AWARD_ID, MISSING_REQUIRED_ACKNOWLEDGEMENT) +- funding-doi-mismatch: hold (DOI_PROJECT_MISMATCH, AWARD_WINDOW_MISMATCH) +- funding-private-path: hold (UNKNOWN_FUNDER_ALIAS, PRIVATE_FUNDER_PATH_EXPOSED) + +## Scope + +This guard supports Scientific Knowledge Graph Integration by blocking unsafe funder, grant, award, project, output, and recommendation relationships before funding paths are published on entity pages or used in recommendations. diff --git a/funding-award-provenance-graph-guard/reports/summary.svg b/funding-award-provenance-graph-guard/reports/summary.svg new file mode 100644 index 00000000..8ed06d99 --- /dev/null +++ b/funding-award-provenance-graph-guard/reports/summary.svg @@ -0,0 +1,19 @@ + + + Funding award provenance graph guard + Status: HOLD + + 5 + records checked + + 1 + release + + 1 + review + + 3 + hold + Validates funder aliases, award IDs, acknowledgements, DOI/project links, dates, COI flags, and private funding paths. + Graph action: publish, curator review, or block before recommendation release. + diff --git a/funding-award-provenance-graph-guard/sampleFundingRecords.js b/funding-award-provenance-graph-guard/sampleFundingRecords.js new file mode 100644 index 00000000..ce9e5457 --- /dev/null +++ b/funding-award-provenance-graph-guard/sampleFundingRecords.js @@ -0,0 +1,114 @@ +"use strict"; + +const sampleFundingRecords = { + checkedAt: "2026-05-31T12:55:00.000Z", + policy: { + outputGraceDays: 60, + }, + aliasCatalog: [ + { + id: "funder-nih", + name: "National Institutes of Health", + aliases: ["NIH", "U.S. NIH", "National Institute of Health"], + }, + { + id: "funder-eu-horizon", + name: "Horizon Europe", + aliases: ["EU Horizon", "European Union Horizon Programme", "HorizonEU"], + }, + { + id: "funder-jsps", + name: "Japan Society for the Promotion of Science", + aliases: ["JSPS", "KAKENHI"], + }, + ], + records: [ + { + id: "funding-clean-nih", + projectId: "project-neuro-graph", + funderName: "NIH", + canonicalFunderId: "funder-nih", + awardId: "R01-NS-2048", + awardStart: "2024-04-01", + awardEnd: "2027-03-31", + requiredAcknowledgement: "R01-NS-2048", + acknowledgementText: "This work was supported by NIH award R01-NS-2048.", + outputs: [ + { doi: "10.5555/neuro.graph.2026", projectId: "project-neuro-graph", publishedAt: "2026-02-14" }, + ], + recommendationEdges: [ + { edgeId: "edge-clean-1", includesPrivateFunding: false, conflictOfInterest: false }, + ], + }, + { + id: "funding-coi-review", + projectId: "project-ai-reuse", + funderName: "HorizonEU", + canonicalFunderId: "funder-eu-horizon", + awardId: "HE-REUSE-8842", + awardStart: "2025-01-01", + awardEnd: "2026-12-31", + requiredAcknowledgement: "HE-REUSE-8842", + acknowledgementText: "Funded by Horizon Europe under HE-REUSE-8842.", + outputs: [ + { doi: "10.5555/reuse.ai.2026", projectId: "project-ai-reuse", publishedAt: "2026-06-01" }, + ], + recommendationEdges: [ + { edgeId: "edge-coi-1", includesPrivateFunding: false, conflictOfInterest: true, curatorReview: false }, + ], + }, + { + id: "funding-missing-award", + projectId: "project-cell-line", + funderName: "JSPS", + canonicalFunderId: "funder-jsps", + awardId: "", + awardStart: "2025-04-01", + awardEnd: "2028-03-31", + requiredAcknowledgement: "KAKENHI", + acknowledgementText: "Supported by a competitive research award.", + outputs: [ + { doi: "10.5555/cell.line.2026", projectId: "project-cell-line", publishedAt: "2026-01-12" }, + ], + recommendationEdges: [ + { edgeId: "edge-missing-1", includesPrivateFunding: false, conflictOfInterest: false }, + ], + }, + { + id: "funding-doi-mismatch", + projectId: "project-climate-model", + funderName: "National Institutes of Health", + canonicalFunderId: "funder-nih", + awardId: "R01-CLIMATE-12", + awardStart: "2024-01-01", + awardEnd: "2025-01-01", + requiredAcknowledgement: "R01-CLIMATE-12", + acknowledgementText: "Acknowledges NIH R01-CLIMATE-12 support.", + outputs: [ + { doi: "10.5555/climate.model.2026", projectId: "project-other", publishedAt: "2026-04-15" }, + ], + recommendationEdges: [ + { edgeId: "edge-doi-1", includesPrivateFunding: false, conflictOfInterest: false }, + ], + }, + { + id: "funding-private-path", + projectId: "project-private-consortium", + funderName: "Unlisted Consortium Fund", + canonicalFunderId: "funder-private-consortium", + awardId: "CONSORT-77", + awardStart: "2025-03-01", + awardEnd: "2027-03-01", + requiredAcknowledgement: "CONSORT-77", + acknowledgementText: "Consortium award CONSORT-77 supported this project.", + outputs: [ + { doi: "10.5555/private.consort.2026", projectId: "project-private-consortium", publishedAt: "2026-05-20" }, + ], + recommendationEdges: [ + { edgeId: "edge-private-1", includesPrivateFunding: true, redactedFundingPath: false, conflictOfInterest: false }, + ], + }, + ], +}; + +module.exports = { sampleFundingRecords }; diff --git a/funding-award-provenance-graph-guard/test.js b/funding-award-provenance-graph-guard/test.js new file mode 100644 index 00000000..f1d758e9 --- /dev/null +++ b/funding-award-provenance-graph-guard/test.js @@ -0,0 +1,65 @@ +"use strict"; + +const assert = require("assert"); +const { + analyzeFundingAwardGraph, + daysBetween, + hasRequiredAcknowledgement, + inDateWindow, + normalizeText, + resolveFunderId, +} = require("./fundingAwardProvenanceGraphGuard"); +const { sampleFundingRecords } = require("./sampleFundingRecords"); + +assert.strictEqual(normalizeText(" NIH / Grant! "), "nih grant"); +assert.strictEqual(daysBetween("2026-05-01", "2026-05-31"), 30); +assert.strictEqual(inDateWindow("2026-02-01", "2026-01-01", "2026-01-15", 20), true); +assert.strictEqual(resolveFunderId("KAKENHI", sampleFundingRecords.aliasCatalog), "funder-jsps"); +assert.strictEqual( + hasRequiredAcknowledgement({ + requiredAcknowledgement: "R01-NS-2048", + acknowledgementText: "Supported by NIH award R01-NS-2048.", + }), + true, +); + +const packet = analyzeFundingAwardGraph(sampleFundingRecords); +assert.strictEqual(packet.status, "hold"); +assert.strictEqual(packet.summary.records, 5); +assert.strictEqual(packet.summary.release, 1); +assert.strictEqual(packet.summary.review, 1); +assert.strictEqual(packet.summary.hold, 3); + +const clean = packet.decisions.find((item) => item.recordId === "funding-clean-nih"); +assert(clean); +assert.strictEqual(clean.decision, "release"); +assert.strictEqual(clean.findings.length, 0); + +const coi = packet.decisions.find((item) => item.recordId === "funding-coi-review"); +assert(coi); +assert.strictEqual(coi.decision, "review"); +assert(coi.findings.some((finding) => finding.code === "COI_RECOMMENDATION_NEEDS_REVIEW")); + +const missingAward = packet.decisions.find((item) => item.recordId === "funding-missing-award"); +assert(missingAward); +assert.strictEqual(missingAward.decision, "hold"); +assert(missingAward.findings.some((finding) => finding.code === "MISSING_AWARD_ID")); +assert(missingAward.findings.some((finding) => finding.code === "MISSING_REQUIRED_ACKNOWLEDGEMENT")); + +const doiMismatch = packet.decisions.find((item) => item.recordId === "funding-doi-mismatch"); +assert(doiMismatch); +assert.strictEqual(doiMismatch.decision, "hold"); +assert(doiMismatch.findings.some((finding) => finding.code === "DOI_PROJECT_MISMATCH")); +assert(doiMismatch.findings.some((finding) => finding.code === "AWARD_WINDOW_MISMATCH")); + +const privatePath = packet.decisions.find((item) => item.recordId === "funding-private-path"); +assert(privatePath); +assert.strictEqual(privatePath.decision, "hold"); +assert(privatePath.findings.some((finding) => finding.code === "UNKNOWN_FUNDER_ALIAS")); +assert(privatePath.findings.some((finding) => finding.code === "PRIVATE_FUNDER_PATH_EXPOSED")); + +const empty = analyzeFundingAwardGraph({ records: [] }); +assert.strictEqual(empty.status, "release"); +assert.strictEqual(empty.warnings[0].code, "NO_FUNDING_RECORDS"); + +console.log("funding-award-provenance-graph-guard tests passed");