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 00000000..9faaaf70 Binary files /dev/null and b/funding-award-provenance-graph-guard/reports/demo.mp4 differ 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");