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
35 changes: 35 additions & 0 deletions knowledge-graph-ethics-provenance-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Knowledge Graph Ethics Provenance Guard

This module adds a focused ethics-provenance gate for Scientific Knowledge Graph recommendations. It evaluates whether graph recommendations and entity-page packets can be shown safely when the path references human-subject data, animal studies, restricted datasets, biosafety-controlled protocols, or dual-use methods.

The implementation is dependency-free, uses synthetic data only, and does not call external services.

## Local Verification

```bash
npm run check
npm test
npm run demo
```

The demo writes reviewer artifacts to `reports/`:

- `ethics-provenance-packet.json`
- `ethics-provenance-report.md`
- `summary.svg`
- `demo.mp4` when `ffmpeg` is available

## What It Guards

- Human-subject graph paths require active IRB evidence and a consent scope matching the intended reuse.
- Restricted evidence requires a data-use agreement before recommendation display.
- Biosafety-controlled protocols are held when user context lacks sufficient clearance.
- Animal-study routes require animal-care protocol evidence.
- Dual-use paths stay visible only with a review warning when no hard blocker exists.

## Reviewer Path

1. Start with `requirements-map.md` for issue coverage.
2. Read `index.js` for the guard decisions and risk scoring.
3. Run `test.js` to verify blocker behavior.
4. Run `demo.js` to regenerate deterministic review artifacts.
7 changes: 7 additions & 0 deletions knowledge-graph-ethics-provenance-guard/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Acceptance Notes

- No private data, credentials, payment information, or external accounts are used.
- All evidence and recommendations are synthetic fixtures.
- The guard is deterministic and dependency-free.
- Tests cover visible, held, warning, blocker, curator-action, digest, and JSON-LD packet behavior.
- The generated report artifacts provide a compact reviewer path without requiring a live SCIBASE deployment.
81 changes: 81 additions & 0 deletions knowledge-graph-ethics-provenance-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
const { evaluateEthicsProvenance } = require("./index")
const { sampleRecommendations, sampleUserContext } = require("./sample-data")

const reportsDir = path.join(__dirname, "reports")
fs.mkdirSync(reportsDir, { recursive: true })

const packet = evaluateEthicsProvenance({
recommendations: sampleRecommendations,
userContext: sampleUserContext,
})

fs.writeFileSync(
path.join(reportsDir, "ethics-provenance-packet.json"),
`${JSON.stringify(packet, null, 2)}\n`,
)

const held = packet.decisions.filter((decision) => decision.status === "hold")
const visible = packet.decisions.filter((decision) => decision.status === "show")

const markdown = [
"# Knowledge Graph Ethics Provenance Guard Report",
"",
`Generated decisions: ${packet.decisions.length}`,
`Visible recommendations: ${visible.length}`,
`Held recommendations: ${held.length}`,
`Curator actions: ${packet.audit.curatorActions}`,
`Audit digest: \`${packet.audit.digest}\``,
"",
"## Held Recommendations",
...held.flatMap((decision) => [
"",
`### ${decision.title}`,
`- Risk score: ${decision.riskScore}`,
`- Blockers: ${decision.blockers.map((blocker) => blocker.code).join(", ")}`,
`- First action: ${decision.curatorActions[0]?.message || "none"}`,
]),
"",
]

fs.writeFileSync(path.join(reportsDir, "ethics-provenance-report.md"), markdown.join("\n"))

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="#111827"/>
<text x="48" y="78" fill="#f9fafb" font-family="Arial" font-size="34" font-weight="700">Knowledge Graph Ethics Provenance Guard</text>
<text x="48" y="124" fill="#cbd5e1" font-family="Arial" font-size="18">Synthetic recommendation review packet for SCIBASE issue #17</text>
<rect x="48" y="170" width="250" height="150" rx="14" fill="#0f766e"/>
<text x="78" y="230" fill="#ecfeff" font-family="Arial" font-size="56" font-weight="700">${visible.length}</text>
<text x="78" y="270" fill="#ccfbf1" font-family="Arial" font-size="22">visible</text>
<rect x="355" y="170" width="250" height="150" rx="14" fill="#b45309"/>
<text x="385" y="230" fill="#fff7ed" font-family="Arial" font-size="56" font-weight="700">${held.length}</text>
<text x="385" y="270" fill="#ffedd5" font-family="Arial" font-size="22">held for ethics review</text>
<rect x="662" y="170" width="250" height="150" rx="14" fill="#1d4ed8"/>
<text x="692" y="230" fill="#eff6ff" font-family="Arial" font-size="56" font-weight="700">${packet.audit.curatorActions}</text>
<text x="692" y="270" fill="#dbeafe" font-family="Arial" font-size="22">curator actions</text>
<text x="48" y="390" fill="#e5e7eb" font-family="Arial" font-size="20">Checks: IRB, consent scope, biosafety level, animal-care evidence, restricted data use, dual-use review.</text>
<text x="48" y="430" fill="#9ca3af" font-family="Arial" font-size="16">Digest ${packet.audit.digest.slice(0, 24)}...</text>
</svg>
`
fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg)

const ffmpeg = spawnSync("ffmpeg", [
"-y",
"-f",
"lavfi",
"-i",
"color=c=0x111827:s=960x540:d=6:r=15",
"-vf",
"drawbox=x=48:y=170:w=250:h=150:color=0x0f766e@1:t=fill,drawbox=x=355:y=170:w=250:h=150:color=0xb45309@1:t=fill,drawbox=x=662:y=170:w=250:h=150:color=0x1d4ed8@1:t=fill,drawbox=x=48:y=370:w=864:h=18:color=0xfbbf24@1:t=fill",
"-pix_fmt",
"yuv420p",
path.join(reportsDir, "demo.mp4"),
], { stdio: "ignore" })

if (ffmpeg.status !== 0) {
console.warn("ffmpeg video generation failed; summary.svg and JSON/Markdown reports were still generated.")
}

console.log(`Wrote review artifacts to ${reportsDir}`)
185 changes: 185 additions & 0 deletions knowledge-graph-ethics-provenance-guard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
const crypto = require("node:crypto")

const BLOCKER_WEIGHTS = {
missing_irb: 35,
consent_scope_mismatch: 30,
restricted_data_without_dua: 25,
biosafety_clearance_gap: 30,
missing_animal_protocol: 28,
dual_use_review: 18,
stale_or_retracted_evidence: 40,
}

function normalizeList(value) {
if (!value) return []
return Array.isArray(value) ? value.filter(Boolean) : [value]
}

function hasScope(evidence, requestedScope) {
const scopes = normalizeList(evidence.consentScope)
return scopes.includes(requestedScope) || scopes.includes("open_reuse")
}

function evaluateEvidence(evidence, userContext) {
const blockers = []
const warnings = []

if (["retracted", "stale", "superseded"].includes(evidence.status)) {
blockers.push({
code: "stale_or_retracted_evidence",
message: `Evidence ${evidence.id} is ${evidence.status}.`,
})
}

if (evidence.subjectType === "human") {
if (!evidence.irbApproval || !userContext.hasActiveIrbProtocol) {
blockers.push({
code: "missing_irb",
message: `Human-subject evidence ${evidence.id} requires active IRB approval before graph reuse.`,
})
}

if (!hasScope(evidence, evidence.requestedScope || userContext.intendedUse)) {
blockers.push({
code: "consent_scope_mismatch",
message: `Consent scope for ${evidence.id} does not cover ${evidence.requestedScope || userContext.intendedUse}.`,
})
}
}

if (evidence.dataUse === "restricted" && !userContext.hasDataUseAgreement) {
blockers.push({
code: "restricted_data_without_dua",
message: `Restricted evidence ${evidence.id} requires a data-use agreement before recommendation display.`,
})
}

if ((evidence.biosafetyLevel || 1) > (userContext.biosafetyClearanceLevel || 1)) {
blockers.push({
code: "biosafety_clearance_gap",
message: `Evidence ${evidence.id} requires BSL-${evidence.biosafetyLevel}, user context has BSL-${userContext.biosafetyClearanceLevel || 1}.`,
})
}

if (evidence.subjectType === "animal" && !(evidence.animalProtocol || userContext.hasAnimalCareProtocol)) {
blockers.push({
code: "missing_animal_protocol",
message: `Animal-study evidence ${evidence.id} needs IACUC/animal-care protocol evidence.`,
})
}

if (evidence.dualUseRisk === "review") {
warnings.push({
code: "dual_use_review",
message: `Evidence ${evidence.id} should be reviewed for dual-use context before broad discovery placement.`,
})
}

return { blockers, warnings }
}

function scoreRisk(blockers, warnings) {
const blockerScore = blockers.reduce((sum, blocker) => sum + (BLOCKER_WEIGHTS[blocker.code] || 15), 0)
const warningScore = warnings.reduce((sum, warning) => sum + (BLOCKER_WEIGHTS[warning.code] || 8), 0)
return Math.min(100, blockerScore + warningScore)
}

function buildCuratorActions(recommendation, blockers, warnings) {
return [...blockers, ...warnings].map((item) => ({
recommendationId: recommendation.id,
action: item.code.startsWith("missing") ? "attach_required_ethics_evidence" : "review_recommendation_path",
code: item.code,
message: item.message,
}))
}

function buildJsonLdPacket(recommendation, decision) {
return {
"@context": "https://schema.org",
"@type": "Recommendation",
identifier: recommendation.id,
name: recommendation.title,
description: recommendation.rationale,
ethicsReview: {
"@type": "Review",
reviewBody: decision.blockers.length
? "Held until ethics provenance gaps are resolved."
: "Visible with current ethics provenance.",
reviewRating: {
"@type": "Rating",
ratingValue: String(100 - decision.riskScore),
bestRating: "100",
worstRating: "0",
},
},
about: recommendation.nodes.map((node) => ({
"@type": node.type,
identifier: node.id,
name: node.name,
})),
}
}

function evaluateRecommendation(recommendation, userContext) {
const evidenceResults = recommendation.evidence.map((evidence) => evaluateEvidence(evidence, userContext))
const blockers = evidenceResults.flatMap((result) => result.blockers)
const warnings = evidenceResults.flatMap((result) => result.warnings)
const riskScore = scoreRisk(blockers, warnings)
const status = blockers.length > 0 ? "hold" : "show"

const decision = {
recommendationId: recommendation.id,
title: recommendation.title,
status,
riskScore,
blockers,
warnings,
curatorActions: buildCuratorActions(recommendation, blockers, warnings),
}

return {
...decision,
jsonLd: buildJsonLdPacket(recommendation, decision),
}
}

function makeAuditDigest(decisions) {
const canonical = JSON.stringify(decisions.map((decision) => ({
id: decision.recommendationId,
status: decision.status,
riskScore: decision.riskScore,
blockers: decision.blockers.map((blocker) => blocker.code).sort(),
warnings: decision.warnings.map((warning) => warning.code).sort(),
})))

return {
visible: decisions.filter((decision) => decision.status === "show").length,
held: decisions.filter((decision) => decision.status === "hold").length,
curatorActions: decisions.reduce((sum, decision) => sum + decision.curatorActions.length, 0),
digest: crypto.createHash("sha256").update(canonical).digest("hex"),
}
}

function evaluateEthicsProvenance(input) {
const decisions = input.recommendations.map((recommendation) =>
evaluateRecommendation(recommendation, input.userContext),
)

return {
generatedAt: new Date("2026-05-21T00:00:00.000Z").toISOString(),
userContext: {
institutionId: input.userContext.institutionId,
intendedUse: input.userContext.intendedUse,
biosafetyClearanceLevel: input.userContext.biosafetyClearanceLevel,
},
decisions,
audit: makeAuditDigest(decisions),
}
}

module.exports = {
evaluateEthicsProvenance,
evaluateEvidence,
buildJsonLdPacket,
makeAuditDigest,
}
11 changes: 11 additions & 0 deletions knowledge-graph-ethics-provenance-guard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "knowledge-graph-ethics-provenance-guard",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js",
"test": "node test.js",
"demo": "node demo.js"
}
}
Binary file not shown.
Loading