Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions funding-award-provenance-graph-guard/README.md
Original file line number Diff line number Diff line change
@@ -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`
69 changes: 69 additions & 0 deletions funding-award-provenance-graph-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#101827"/>
<text x="56" y="76" fill="#ffffff" font-family="Arial" font-size="34" font-weight="700">Funding award provenance graph guard</text>
<text x="56" y="126" fill="#9cc9ff" font-family="Arial" font-size="24">Status: ${packet.status.toUpperCase()}</text>
<rect x="56" y="176" width="190" height="118" rx="8" fill="#1f3442"/>
<text x="86" y="226" fill="#ffffff" font-family="Arial" font-size="42">${packet.summary.records}</text>
<text x="86" y="264" fill="#c7d7df" font-family="Arial" font-size="19">records checked</text>
<rect x="276" y="176" width="190" height="118" rx="8" fill="#18382f"/>
<text x="306" y="226" fill="#ffffff" font-family="Arial" font-size="42">${packet.summary.release}</text>
<text x="306" y="264" fill="#bde4d7" font-family="Arial" font-size="19">release</text>
<rect x="496" y="176" width="190" height="118" rx="8" fill="#3c3218"/>
<text x="526" y="226" fill="#ffffff" font-family="Arial" font-size="42">${packet.summary.review}</text>
<text x="526" y="264" fill="#f2dc92" font-family="Arial" font-size="19">review</text>
<rect x="716" y="176" width="190" height="118" rx="8" fill="#3d1f2b"/>
<text x="746" y="226" fill="#ffffff" font-family="Arial" font-size="42">${packet.summary.hold}</text>
<text x="746" y="264" fill="#f0b7c0" font-family="Arial" font-size="19">hold</text>
<text x="56" y="366" fill="#dbe5ea" font-family="Arial" font-size="21">Validates funder aliases, award IDs, acknowledgements, DOI/project links, dates, COI flags, and private funding paths.</text>
<text x="56" y="414" fill="#dbe5ea" font-family="Arial" font-size="21">Graph action: publish, curator review, or block before recommendation release.</text>
</svg>
`;

fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg);
console.log(report);
Original file line number Diff line number Diff line change
@@ -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,
};
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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": []
}
Loading