diff --git a/knowledge-graph-recommendation-visibility-guard/README.md b/knowledge-graph-recommendation-visibility-guard/README.md new file mode 100644 index 00000000..ce505567 --- /dev/null +++ b/knowledge-graph-recommendation-visibility-guard/README.md @@ -0,0 +1,20 @@ +# Knowledge Graph Recommendation Visibility Guard + +This module covers a privacy and governance slice of SCIBASE issue #17. + +It checks scientific knowledge graph recommendations before they appear in graph navigation, entity pages, sidebars, or discovery digests. Recommendations that would expose private, embargoed, restricted, or institution-only research artifacts are suppressed until the viewer has the right institution, project, consent, license, and clearance context. + +## What It Does + +- Evaluates graph recommendation paths against node visibility and evidence access. +- Supports public, institutional, project, private, embargoed, and restricted artifacts. +- Checks embargo windows, consent IDs, license IDs, and clearance tags. +- Suppresses unsafe paths while keeping safe recommendations visible. +- Produces curator actions and a deterministic audit digest for explainable discovery behavior. + +## Run + +```bash +node knowledge-graph-recommendation-visibility-guard/test.js +node knowledge-graph-recommendation-visibility-guard/demo.js +``` diff --git a/knowledge-graph-recommendation-visibility-guard/acceptance-notes.md b/knowledge-graph-recommendation-visibility-guard/acceptance-notes.md new file mode 100644 index 00000000..104965d3 --- /dev/null +++ b/knowledge-graph-recommendation-visibility-guard/acceptance-notes.md @@ -0,0 +1,30 @@ +# Acceptance Notes + +## Review Scenarios + +1. Visible institutional recommendation + - A researcher from the matching institution can see a public concept to institutional dataset route. + - The result includes evidence IDs and a stable audit digest. + +2. Embargoed path suppression + - A non-curator viewer cannot see recommendations that include an embargoed node before the embargo date. + - The suppressed recommendation includes a clear blocker and curator action. + +3. Restricted cohort access + - A viewer with matching consent, license, and clearance can see restricted cohort recommendations. + - Missing consent, license, or clearance suppresses the path. + +4. Expired embargo review + - An expired embargo does not hide the recommendation, but it produces a release-metadata warning. + +## Validation + +```bash +node knowledge-graph-recommendation-visibility-guard/test.js +node knowledge-graph-recommendation-visibility-guard/demo.js +node --check knowledge-graph-recommendation-visibility-guard/index.js +node --check knowledge-graph-recommendation-visibility-guard/test.js +node --check knowledge-graph-recommendation-visibility-guard/demo.js +``` + +The included `demo.mp4` is a five-second visual walkthrough of the visibility guard flow. diff --git a/knowledge-graph-recommendation-visibility-guard/demo.js b/knowledge-graph-recommendation-visibility-guard/demo.js new file mode 100644 index 00000000..7736ea36 --- /dev/null +++ b/knowledge-graph-recommendation-visibility-guard/demo.js @@ -0,0 +1,85 @@ +"use strict" + +const { assessRecommendationVisibility } = require("./index") + +const result = assessRecommendationVisibility({ + now: "2026-05-20T10:00:00Z", + actor: { + id: "researcher-ada", + role: "researcher", + institutionId: "north-lab", + consentIds: ["consent-human-1"], + licenseIds: [], + clearanceTags: ["irb-approved"], + }, + nodes: [ + { id: "concept-crispr", type: "concept", title: "CRISPR screen", visibility: "public" }, + { + id: "dataset-neuro-1", + type: "dataset", + title: "Neuro screen dataset", + visibility: "institutional", + institutionId: "north-lab", + }, + { + id: "paper-embargo", + type: "paper", + title: "Embargoed methods paper", + visibility: "embargoed", + embargoUntil: "2026-06-01T00:00:00Z", + }, + { + id: "restricted-cohort", + type: "dataset", + title: "Human cohort export", + visibility: "restricted", + requiredConsentId: "consent-human-1", + licenseId: "license-cohort-1", + clearanceTag: "irb-approved", + }, + ], + evidence: [ + { id: "ev-open", access: "public" }, + { id: "ev-institution", access: "institutional", institutionId: "north-lab" }, + { + id: "ev-restricted", + access: "restricted", + requiredConsentId: "consent-human-1", + licenseId: "license-cohort-1", + clearanceTag: "irb-approved", + }, + ], + recommendations: [ + { + id: "rec-visible", + title: "Public concept to institutional dataset route", + score: 0.91, + nodeIds: ["concept-crispr", "dataset-neuro-1"], + evidenceIds: ["ev-open", "ev-institution"], + }, + { + id: "rec-embargo", + title: "Embargoed method recommendation", + score: 0.95, + nodeIds: ["paper-embargo"], + evidenceIds: ["ev-open"], + }, + { + id: "rec-restricted", + title: "Restricted cohort reuse recommendation", + score: 0.86, + nodeIds: ["restricted-cohort"], + evidenceIds: ["ev-restricted"], + }, + ], +}) + +console.log("Knowledge Graph Recommendation Visibility Guard Demo") +console.log("===================================================") +console.log(`status: ${result.status}`) +console.log(`visible recommendations: ${result.visibleCount}`) +console.log(`suppressed recommendations: ${result.suppressedCount}`) +console.log(`first visible: ${result.visibleRecommendations[0].title}`) +console.log(`first suppressed reason: ${result.suppressedRecommendations[0].blockers[0]}`) +console.log(`curator action: ${result.curatorActions[0].action}`) +console.log(`digest: ${result.auditDigest.slice(0, 16)}...`) diff --git a/knowledge-graph-recommendation-visibility-guard/demo.mp4 b/knowledge-graph-recommendation-visibility-guard/demo.mp4 new file mode 100644 index 00000000..ba1bd6ae Binary files /dev/null and b/knowledge-graph-recommendation-visibility-guard/demo.mp4 differ diff --git a/knowledge-graph-recommendation-visibility-guard/demo.svg b/knowledge-graph-recommendation-visibility-guard/demo.svg new file mode 100644 index 00000000..777a7e7d --- /dev/null +++ b/knowledge-graph-recommendation-visibility-guard/demo.svg @@ -0,0 +1,29 @@ + + Knowledge Graph Recommendation Visibility Guard + A visual summary showing public, institutional, restricted, and embargoed graph recommendations being evaluated before display. + + + Knowledge Graph Recommendation Visibility Guard + Suppress private, embargoed, and restricted graph paths before discovery surfaces expose them. + + + Visible + Public + institution-safe + + + + Review + Expired embargo metadata + + + + Suppressed + Embargo or missing access + + + + Recommendation path + node visibility + evidence access + viewer context + audit digest + + Output: visible recommendations, suppressed paths, curator actions, stable digest + diff --git a/knowledge-graph-recommendation-visibility-guard/index.js b/knowledge-graph-recommendation-visibility-guard/index.js new file mode 100644 index 00000000..b123581f --- /dev/null +++ b/knowledge-graph-recommendation-visibility-guard/index.js @@ -0,0 +1,222 @@ +"use strict" + +const crypto = require("node:crypto") + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}` + } + return JSON.stringify(value) +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex") +} + +function asSet(value) { + return new Set(Array.isArray(value) ? value : []) +} + +function toTime(value) { + if (!value) return null + const time = new Date(value).getTime() + if (Number.isNaN(time)) return null + return time +} + +function normalizeActor(actor = {}) { + return { + id: actor.id || "anonymous", + role: actor.role || "viewer", + institutionId: actor.institutionId || null, + projectIds: asSet(actor.projectIds), + consentIds: asSet(actor.consentIds), + licenseIds: asSet(actor.licenseIds), + clearanceTags: asSet(actor.clearanceTags), + } +} + +function normalizeNode(node) { + return { + id: node.id, + type: node.type || "artifact", + title: node.title || node.id, + visibility: node.visibility || "public", + institutionId: node.institutionId || null, + projectId: node.projectId || null, + embargoUntil: node.embargoUntil || null, + requiredConsentId: node.requiredConsentId || null, + licenseId: node.licenseId || null, + clearanceTag: node.clearanceTag || null, + status: node.status || "active", + } +} + +function normalizeEvidence(evidence) { + return { + id: evidence.id, + access: evidence.access || "public", + institutionId: evidence.institutionId || null, + projectId: evidence.projectId || null, + embargoUntil: evidence.embargoUntil || null, + requiredConsentId: evidence.requiredConsentId || null, + licenseId: evidence.licenseId || null, + clearanceTag: evidence.clearanceTag || null, + status: evidence.status || "active", + } +} + +function evaluateAccess(item, actor, nowTime, prefix) { + const blockers = [] + const warnings = [] + const label = `${prefix} ${item.id}` + const embargoTime = toTime(item.embargoUntil) + + if (item.status === "retracted" || item.status === "withdrawn") { + blockers.push(`${label} is ${item.status}`) + } + + const visibility = item.visibility || item.access || "public" + if (visibility === "institutional" && item.institutionId && item.institutionId !== actor.institutionId) { + blockers.push(`${label} requires institution ${item.institutionId}`) + } + + if (visibility === "project" && item.projectId && !actor.projectIds.has(item.projectId)) { + blockers.push(`${label} requires project ${item.projectId} membership`) + } + + if (visibility === "private" && !["owner", "admin", "curator"].includes(actor.role)) { + blockers.push(`${label} is private`) + } + + if (visibility === "embargoed") { + if (!embargoTime) { + blockers.push(`${label} has an invalid embargo date`) + } else if (nowTime < embargoTime && !["admin", "curator"].includes(actor.role)) { + blockers.push(`${label} is embargoed until ${item.embargoUntil}`) + } else if (nowTime >= embargoTime) { + warnings.push(`${label} embargo has expired; confirm public release metadata`) + } + } + + if (visibility === "restricted" || item.requiredConsentId || item.licenseId || item.clearanceTag) { + if (item.requiredConsentId && !actor.consentIds.has(item.requiredConsentId)) { + blockers.push(`${label} requires consent ${item.requiredConsentId}`) + } + if (item.licenseId && !actor.licenseIds.has(item.licenseId)) { + blockers.push(`${label} requires license ${item.licenseId}`) + } + if (item.clearanceTag && !actor.clearanceTags.has(item.clearanceTag)) { + blockers.push(`${label} requires clearance ${item.clearanceTag}`) + } + } + + return { blockers, warnings } +} + +function evaluateRecommendation(recommendation, context) { + const blockers = [] + const warnings = [] + const pathNodes = [] + const evidence = [] + + for (const nodeId of recommendation.nodeIds || []) { + const node = context.nodes.get(nodeId) + if (!node) { + blockers.push(`missing graph node ${nodeId}`) + continue + } + pathNodes.push(node) + const access = evaluateAccess(node, context.actor, context.nowTime, "node") + blockers.push(...access.blockers) + warnings.push(...access.warnings) + } + + for (const evidenceId of recommendation.evidenceIds || []) { + const item = context.evidence.get(evidenceId) + if (!item) { + blockers.push(`missing evidence ${evidenceId}`) + continue + } + evidence.push(item) + const access = evaluateAccess(item, context.actor, context.nowTime, "evidence") + blockers.push(...access.blockers) + warnings.push(...access.warnings) + } + + const distinctBlockers = [...new Set(blockers)] + const distinctWarnings = [...new Set(warnings)] + const safeScore = Number(recommendation.score || 0) - distinctWarnings.length * 0.05 + + return { + id: recommendation.id, + title: recommendation.title || recommendation.id, + status: distinctBlockers.length > 0 ? "suppressed" : "visible", + safeScore: Number(Math.max(0, safeScore).toFixed(3)), + nodeIds: pathNodes.map((node) => node.id), + evidenceIds: evidence.map((item) => item.id), + blockers: distinctBlockers, + warnings: distinctWarnings, + explanation: + distinctBlockers.length > 0 + ? "Recommendation is hidden until visibility and evidence access issues are resolved." + : "Recommendation can be shown with the attached visibility explanation.", + } +} + +function assessRecommendationVisibility(input) { + const nowTime = toTime(input.now) || Date.now() + const actor = normalizeActor(input.actor) + const nodes = new Map((input.nodes || []).map((node) => normalizeNode(node)).map((node) => [node.id, node])) + const evidence = new Map( + (input.evidence || []).map((item) => normalizeEvidence(item)).map((item) => [item.id, item]), + ) + + const evaluated = (input.recommendations || []) + .map((recommendation) => evaluateRecommendation(recommendation, { actor, nodes, evidence, nowTime })) + .sort((a, b) => b.safeScore - a.safeScore || a.id.localeCompare(b.id)) + + const visibleRecommendations = evaluated.filter((item) => item.status === "visible") + const suppressedRecommendations = evaluated.filter((item) => item.status === "suppressed") + const curatorActions = suppressedRecommendations.map((item) => ({ + recommendationId: item.id, + action: "resolve-access-before-discovery", + reason: item.blockers[0], + })) + + if (visibleRecommendations.some((item) => item.warnings.length > 0)) { + curatorActions.push({ + recommendationId: "visible-warnings", + action: "confirm-release-metadata", + reason: "some visible recommendations include expired embargo or metadata warnings", + }) + } + + const result = { + status: + suppressedRecommendations.length === 0 + ? visibleRecommendations.some((item) => item.warnings.length > 0) + ? "needs-review" + : "ready" + : "guarded", + actorId: actor.id, + visibleCount: visibleRecommendations.length, + suppressedCount: suppressedRecommendations.length, + visibleRecommendations, + suppressedRecommendations, + curatorActions, + } + + return { + ...result, + auditDigest: digest(result), + } +} + +module.exports = { + assessRecommendationVisibility, +} diff --git a/knowledge-graph-recommendation-visibility-guard/requirements-map.md b/knowledge-graph-recommendation-visibility-guard/requirements-map.md new file mode 100644 index 00000000..72b14af6 --- /dev/null +++ b/knowledge-graph-recommendation-visibility-guard/requirements-map.md @@ -0,0 +1,13 @@ +# Requirements Map + +| Issue #17 requirement | Coverage in this module | +| --- | --- | +| Knowledge navigation across authors, concepts, tools, datasets, protocols, and funders | Evaluates recommendation paths before they appear in graph navigation or entity pages. | +| Filters by institution, time, reproducibility, and related context | Applies institution, project, embargo-time, and evidence-access checks to each path. | +| AI research recommendations | Guards sidebar, digest, and discovery-mode recommendations so unsafe paths are suppressed. | +| Discover related work and influence pathways | Keeps visible recommendations explainable with attached node and evidence IDs. | +| Build trust in structured scientific intelligence | Emits curator actions and a deterministic audit digest for review and governance. | + +## Non-Overlap Note + +This submission is distinct from broad knowledge graph extractors, graph navigators, link audits, knowledge-gap explorers, ontology drift migration, relationship conflict arbitration, author-affiliation disambiguation, artifact reuse lineage, evidence freshness checks, instrument-method compatibility graphs, and reproducibility route planners. It focuses specifically on visibility, embargo, consent, license, and clearance gating before graph recommendations are displayed. diff --git a/knowledge-graph-recommendation-visibility-guard/test.js b/knowledge-graph-recommendation-visibility-guard/test.js new file mode 100644 index 00000000..46f962dd --- /dev/null +++ b/knowledge-graph-recommendation-visibility-guard/test.js @@ -0,0 +1,152 @@ +"use strict" + +const assert = require("node:assert/strict") +const { assessRecommendationVisibility } = require("./index") + +const baseNodes = [ + { id: "concept-crispr", type: "concept", title: "CRISPR screen", visibility: "public" }, + { + id: "dataset-neuro-1", + type: "dataset", + title: "Neuro screen dataset", + visibility: "institutional", + institutionId: "north-lab", + }, + { + id: "paper-embargo", + type: "paper", + title: "Embargoed methods paper", + visibility: "embargoed", + embargoUntil: "2026-06-01T00:00:00Z", + }, + { + id: "restricted-cohort", + type: "dataset", + title: "Human cohort export", + visibility: "restricted", + requiredConsentId: "consent-human-1", + licenseId: "license-cohort-1", + clearanceTag: "irb-approved", + }, +] + +const baseEvidence = [ + { id: "ev-open", access: "public" }, + { id: "ev-institution", access: "institutional", institutionId: "north-lab" }, + { + id: "ev-restricted", + access: "restricted", + requiredConsentId: "consent-human-1", + licenseId: "license-cohort-1", + clearanceTag: "irb-approved", + }, +] + +{ + const result = assessRecommendationVisibility({ + now: "2026-05-20T10:00:00Z", + actor: { id: "ada", role: "researcher", institutionId: "north-lab" }, + nodes: baseNodes, + evidence: baseEvidence, + recommendations: [ + { + id: "rec-safe", + title: "CRISPR dataset to protocol route", + score: 0.91, + nodeIds: ["concept-crispr", "dataset-neuro-1"], + evidenceIds: ["ev-open", "ev-institution"], + }, + { + id: "rec-embargo", + title: "Embargoed method recommendation", + score: 0.95, + nodeIds: ["paper-embargo"], + evidenceIds: ["ev-open"], + }, + ], + }) + + assert.equal(result.status, "guarded") + assert.equal(result.visibleCount, 1) + assert.equal(result.suppressedCount, 1) + assert.equal(result.visibleRecommendations[0].id, "rec-safe") + assert.ok(result.suppressedRecommendations[0].blockers[0].includes("embargoed until")) + assert.match(result.auditDigest, /^[0-9a-f]{64}$/) +} + +{ + const result = assessRecommendationVisibility({ + now: "2026-05-20T10:00:00Z", + actor: { + id: "ben", + role: "researcher", + institutionId: "north-lab", + consentIds: ["consent-human-1"], + licenseIds: ["license-cohort-1"], + clearanceTags: ["irb-approved"], + }, + nodes: baseNodes, + evidence: baseEvidence, + recommendations: [ + { + id: "rec-restricted", + title: "Restricted cohort reuse recommendation", + score: 0.82, + nodeIds: ["restricted-cohort"], + evidenceIds: ["ev-restricted"], + }, + ], + }) + + assert.equal(result.status, "ready") + assert.equal(result.visibleCount, 1) + assert.equal(result.suppressedCount, 0) +} + +{ + const result = assessRecommendationVisibility({ + now: "2026-07-01T10:00:00Z", + actor: { id: "cy", role: "researcher", institutionId: "north-lab" }, + nodes: baseNodes, + evidence: baseEvidence, + recommendations: [ + { + id: "rec-expired-embargo", + title: "Recently released methods route", + score: 0.77, + nodeIds: ["paper-embargo"], + evidenceIds: ["ev-open"], + }, + ], + }) + + assert.equal(result.status, "needs-review") + assert.equal(result.visibleCount, 1) + assert.ok(result.visibleRecommendations[0].warnings.some((warning) => warning.includes("embargo has expired"))) + assert.equal(result.curatorActions[0].action, "confirm-release-metadata") +} + +{ + const result = assessRecommendationVisibility({ + now: "2026-05-20T10:00:00Z", + actor: { id: "dee", role: "researcher", institutionId: "south-lab" }, + nodes: baseNodes, + evidence: baseEvidence, + recommendations: [ + { + id: "rec-missing", + title: "Broken recommendation", + score: 0.9, + nodeIds: ["dataset-neuro-1", "missing-node"], + evidenceIds: ["missing-evidence"], + }, + ], + }) + + assert.equal(result.status, "guarded") + assert.ok(result.suppressedRecommendations[0].blockers.includes("node dataset-neuro-1 requires institution north-lab")) + assert.ok(result.suppressedRecommendations[0].blockers.includes("missing graph node missing-node")) + assert.ok(result.suppressedRecommendations[0].blockers.includes("missing evidence missing-evidence")) +} + +console.log("knowledge-graph-recommendation-visibility-guard tests passed")