diff --git a/README.md b/README.md index d338cf6..818881f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Scientific knowledge graph additions + +- `knowledge-graph-retraction-guard`: retraction and correction propagation guard for suppressing unsafe knowledge graph recommendations and producing curator-ready evidence. diff --git a/knowledge-graph-retraction-guard/README.md b/knowledge-graph-retraction-guard/README.md new file mode 100644 index 0000000..38da28e --- /dev/null +++ b/knowledge-graph-retraction-guard/README.md @@ -0,0 +1,25 @@ +# Knowledge Graph Retraction Guard + +Scientific knowledge graphs need to react when a paper is retracted, corrected, or flagged with an expression of concern. This module adds a deterministic retraction and correction propagation guard for graph edges, entity pages, and AI recommendation payloads. + +The guard is self-contained and credential-free. It consumes synthetic graph entities, evidence relationships, recommendation candidates, and publication notices, then emits: + +- retraction/correction findings tied to graph entities and evidence edges +- suppressed or review-only recommendation decisions +- curator actions for entity pages, relationship evidence, and digest exports +- JSON-LD style evidence output for downstream graph and audit systems +- a deterministic evidence digest for reproducible review + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +The demo reads `data/sample-knowledge-graph.json`. Visual review artifacts are in `docs/demo.svg` and `docs/demo.gif`. + +## Fit For Issue #17 + +This targets the Scientific Knowledge Graph Integration requirements for cited references, entity pages, graph navigation, and AI recommendations. It is distinct from prior extractor, navigator, link-audit, ontology-drift, conflict-arbiter, author-affiliation, knowledge-gap, and artifact-lineage slices because it focuses specifically on propagating publication safety notices through graph recommendations. diff --git a/knowledge-graph-retraction-guard/data/sample-knowledge-graph.json b/knowledge-graph-retraction-guard/data/sample-knowledge-graph.json new file mode 100644 index 0000000..8ae8577 --- /dev/null +++ b/knowledge-graph-retraction-guard/data/sample-knowledge-graph.json @@ -0,0 +1,136 @@ +{ + "generatedAt": "2026-05-16T14:30:00Z", + "workspace": "SCIBASE translational neuroscience graph", + "entities": [ + { + "id": "paper-alpha", + "type": "paper", + "label": "Alpha-synuclein organoid response atlas", + "doi": "10.5555/alpha.2023.101", + "domain": "neuroscience", + "projectIds": ["proj-organoid-map"] + }, + { + "id": "dataset-dopamine", + "type": "dataset", + "label": "Dopamine neuron morphology panel", + "doi": "10.5555/data.2024.220", + "domain": "neuroscience", + "projectIds": ["proj-organoid-map", "proj-neurochip"] + }, + { + "id": "protocol-immunostain", + "type": "protocol", + "label": "High-throughput immunostaining v2", + "doi": "10.5555/protocol.2022.014", + "domain": "cell-biology", + "projectIds": ["proj-organoid-map"] + }, + { + "id": "paper-beta", + "type": "paper", + "label": "CRISPR screen for synaptic rescue", + "doi": "10.5555/beta.2024.017", + "domain": "genomics", + "projectIds": ["proj-neurochip"] + }, + { + "id": "tool-labpipe", + "type": "software", + "label": "LabPipe notebook runner", + "doi": "10.5555/tool.2021.002", + "domain": "software", + "projectIds": ["proj-metabolomics"] + } + ], + "relationships": [ + { + "id": "rel-alpha-dataset", + "sourceId": "paper-alpha", + "targetId": "dataset-dopamine", + "type": "uses_dataset", + "evidenceDoi": "10.5555/alpha.2023.101", + "confidence": 0.91, + "recommendationId": "rec-organoid-reuse" + }, + { + "id": "rel-dataset-protocol", + "sourceId": "dataset-dopamine", + "targetId": "protocol-immunostain", + "type": "validated_by_protocol", + "evidenceDoi": "10.5555/protocol.2022.014", + "confidence": 0.74, + "recommendationId": "rec-protocol-transfer" + }, + { + "id": "rel-beta-dataset", + "sourceId": "paper-beta", + "targetId": "dataset-dopamine", + "type": "cites_dataset", + "evidenceDoi": "10.5555/beta.2024.017", + "confidence": 0.82, + "recommendationId": "rec-crispr-collab" + }, + { + "id": "rel-tool-dataset", + "sourceId": "tool-labpipe", + "targetId": "dataset-dopamine", + "type": "processes_dataset", + "evidenceDoi": "10.5555/tool.2021.002", + "confidence": 0.68, + "recommendationId": "rec-runner-adoption" + } + ], + "recommendations": [ + { + "id": "rec-organoid-reuse", + "title": "Reuse dopamine morphology panel for organoid comparison", + "relationshipIds": ["rel-alpha-dataset", "rel-dataset-protocol"], + "audience": "project-sidebar" + }, + { + "id": "rec-protocol-transfer", + "title": "Transfer immunostaining protocol into neurochip workflow", + "relationshipIds": ["rel-dataset-protocol"], + "audience": "weekly-digest" + }, + { + "id": "rec-crispr-collab", + "title": "Invite CRISPR rescue team to validate morphology findings", + "relationshipIds": ["rel-beta-dataset"], + "audience": "discovery-mode" + }, + { + "id": "rec-runner-adoption", + "title": "Adopt LabPipe runner for dataset refresh jobs", + "relationshipIds": ["rel-tool-dataset"], + "audience": "admin-dashboard" + } + ], + "publicationNotices": [ + { + "doi": "10.5555/alpha.2023.101", + "type": "retraction", + "severity": "critical", + "publishedAt": "2026-05-12", + "source": "Crossmark synthetic notice", + "reason": "Image panel duplication invalidates primary evidence" + }, + { + "doi": "10.5555/protocol.2022.014", + "type": "expression_of_concern", + "severity": "high", + "publishedAt": "2026-05-09", + "source": "Publisher synthetic alert", + "reason": "Protocol reagent concentration under investigation" + }, + { + "doi": "10.5555/tool.2021.002", + "type": "correction", + "severity": "medium", + "publishedAt": "2026-05-01", + "source": "Software release note", + "reason": "Default notebook kernel version corrected" + } + ] +} diff --git a/knowledge-graph-retraction-guard/docs/demo.gif b/knowledge-graph-retraction-guard/docs/demo.gif new file mode 100644 index 0000000..7aa5c7d Binary files /dev/null and b/knowledge-graph-retraction-guard/docs/demo.gif differ diff --git a/knowledge-graph-retraction-guard/docs/demo.mp4 b/knowledge-graph-retraction-guard/docs/demo.mp4 new file mode 100644 index 0000000..60ad44f Binary files /dev/null and b/knowledge-graph-retraction-guard/docs/demo.mp4 differ diff --git a/knowledge-graph-retraction-guard/docs/demo.svg b/knowledge-graph-retraction-guard/docs/demo.svg new file mode 100644 index 0000000..98dacf1 --- /dev/null +++ b/knowledge-graph-retraction-guard/docs/demo.svg @@ -0,0 +1,34 @@ + + Knowledge graph retraction guard demo + Dashboard preview showing retraction findings, suppressed recommendations, and curator actions. + + + SCIBASE Knowledge Graph Retraction Guard + SCIBASE translational neuroscience graph + Publication notices propagated into graph decisions + Retracted or flagged evidence suppresses unsafe recommendations before they reach researchers. + + + Findings + 6 + + Critical + 2 + + Suppressed recs + 1 + + Review recs + 1 + + + Unsafe evidence flow + + Retracted paper edge suppresses organoid reuse + + Expression of concern queues protocol review + + Evidence stream + JSON-LD nodes: 9 + Digest: generated by npm run demo + diff --git a/knowledge-graph-retraction-guard/docs/requirement-map.md b/knowledge-graph-retraction-guard/docs/requirement-map.md new file mode 100644 index 0000000..fc37307 --- /dev/null +++ b/knowledge-graph-retraction-guard/docs/requirement-map.md @@ -0,0 +1,14 @@ +# Requirement Map + +| Issue #17 capability | Implementation evidence | +| --- | --- | +| Cited references and DOIs | `publicationNotices` and relationship `evidenceDoi` matching in `src/retraction-signal-guard.js` | +| Entity pages with aggregated context | `entity_publication_notice` findings and `annotate_entity_page` curator actions | +| Graph navigation safety | relationship findings suppress or review graph edges that rely on unsafe evidence | +| AI recommendations | recommendation decisions emit `allow`, `annotate`, `review`, or `suppress` for sidebar, digest, and discovery recommendations | +| Linked data / schema.org metadata | JSON-LD style export is produced in `report.jsonLd` | +| Auditability | deterministic `evidenceDigest`, sorted findings, sample data, CLI demo, and Node tests | + +## Distinctness + +This module is not a broad extractor, navigator, ontology migration, conflict arbiter, author disambiguation, knowledge-gap explorer, or artifact-lineage tracker. It focuses on publication safety notices and how those notices propagate through graph edges and recommendations. diff --git a/knowledge-graph-retraction-guard/package.json b/knowledge-graph-retraction-guard/package.json new file mode 100644 index 0000000..a64c792 --- /dev/null +++ b/knowledge-graph-retraction-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "knowledge-graph-retraction-guard", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "check": "node --check src/retraction-signal-guard.js && node --check scripts/demo.js && node --check test/retraction-signal-guard.test.js", + "test": "node --test test/retraction-signal-guard.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/knowledge-graph-retraction-guard/scripts/demo.js b/knowledge-graph-retraction-guard/scripts/demo.js new file mode 100644 index 0000000..76774f2 --- /dev/null +++ b/knowledge-graph-retraction-guard/scripts/demo.js @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { analyzeRetractionSignals } from "../src/retraction-signal-guard.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const input = JSON.parse(readFileSync(join(root, "data", "sample-knowledge-graph.json"), "utf8")); +const report = analyzeRetractionSignals(input); + +console.log(`${report.workspace} retraction guard`); +console.log(`Evidence digest: ${report.evidenceDigest}`); +console.log(`Findings: ${report.summary.findings} (${report.summary.criticalFindings} critical, ${report.summary.highFindings} high)`); +console.log(`Recommendations: ${report.summary.suppressedRecommendations} suppressed, ${report.summary.reviewRecommendations} review`); +console.log("Top curator actions:"); +for (const action of report.curatorActions.slice(0, 5)) { + console.log(`- [${action.severity}] ${action.action}: ${action.subject}`); +} diff --git a/knowledge-graph-retraction-guard/src/retraction-signal-guard.js b/knowledge-graph-retraction-guard/src/retraction-signal-guard.js new file mode 100644 index 0000000..9ee5997 --- /dev/null +++ b/knowledge-graph-retraction-guard/src/retraction-signal-guard.js @@ -0,0 +1,218 @@ +import { createHash } from "node:crypto"; + +function stableHash(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +function severityRank(severity) { + return { critical: 0, high: 1, medium: 2, low: 3 }[severity] ?? 4; +} + +function normalizeDoi(doi) { + return String(doi ?? "").trim().toLowerCase(); +} + +function byId(items = []) { + return new Map(items.map((item) => [item.id, item])); +} + +function sortFindings(findings) { + return findings.sort((a, b) => { + const bySeverity = severityRank(a.severity) - severityRank(b.severity); + if (bySeverity !== 0) return bySeverity; + return a.id.localeCompare(b.id); + }); +} + +function noticeDecision(type) { + if (type === "retraction") return "suppress"; + if (type === "expression_of_concern") return "review"; + return "annotate"; +} + +function buildFinding(kind, severity, subject, message, details = {}) { + return { + id: stableHash({ kind, subject, message, details }), + kind, + severity, + subject, + message, + details + }; +} + +function buildNoticeIndex(notices = []) { + const index = new Map(); + for (const notice of notices) { + const doi = normalizeDoi(notice.doi); + if (!doi) continue; + if (!index.has(doi)) index.set(doi, []); + index.get(doi).push({ + ...notice, + doi, + decision: noticeDecision(notice.type) + }); + } + return index; +} + +function impactedEntityFindings(entities, noticeIndex) { + return entities.flatMap((entity) => { + const notices = noticeIndex.get(normalizeDoi(entity.doi)) ?? []; + return notices.map((notice) => buildFinding( + "entity_publication_notice", + notice.severity, + entity.id, + `${entity.label} is linked to a ${notice.type.replaceAll("_", " ")}`, + { + entityType: entity.type, + doi: notice.doi, + noticeType: notice.type, + source: notice.source, + reason: notice.reason, + projectIds: entity.projectIds ?? [] + } + )); + }); +} + +function impactedRelationshipFindings(relationships, noticeIndex, entitiesById) { + return relationships.flatMap((relationship) => { + const notices = noticeIndex.get(normalizeDoi(relationship.evidenceDoi)) ?? []; + return notices.map((notice) => { + const source = entitiesById.get(relationship.sourceId); + const target = entitiesById.get(relationship.targetId); + + return buildFinding( + "relationship_evidence_notice", + notice.severity, + relationship.id, + `${relationship.type} evidence has a ${notice.type.replaceAll("_", " ")}`, + { + evidenceDoi: notice.doi, + noticeType: notice.type, + decision: notice.decision, + confidence: relationship.confidence, + sourceId: relationship.sourceId, + sourceLabel: source?.label, + targetId: relationship.targetId, + targetLabel: target?.label, + recommendationId: relationship.recommendationId, + reason: notice.reason + } + ); + }); + }); +} + +function recommendationDecisions(recommendations, relationshipsById, relationshipFindings) { + const findingsByRelationship = new Map(); + for (const finding of relationshipFindings) { + const relationshipId = finding.subject; + if (!findingsByRelationship.has(relationshipId)) findingsByRelationship.set(relationshipId, []); + findingsByRelationship.get(relationshipId).push(finding); + } + + return recommendations.map((recommendation) => { + const findings = (recommendation.relationshipIds ?? []).flatMap((relationshipId) => + findingsByRelationship.get(relationshipId) ?? [] + ); + const hasCritical = findings.some((finding) => finding.severity === "critical"); + const hasHigh = findings.some((finding) => finding.severity === "high"); + const decision = hasCritical ? "suppress" : hasHigh ? "review" : findings.length ? "annotate" : "allow"; + const evidenceEdges = (recommendation.relationshipIds ?? []).map((relationshipId) => relationshipsById.get(relationshipId)).filter(Boolean); + + return { + recommendationId: recommendation.id, + title: recommendation.title, + audience: recommendation.audience, + decision, + findingIds: findings.map((finding) => finding.id).sort(), + evidenceRelationshipIds: evidenceEdges.map((edge) => edge.id).sort() + }; + }); +} + +function actionForFinding(finding) { + const actionByKind = { + entity_publication_notice: "annotate_entity_page", + relationship_evidence_notice: finding.severity === "critical" ? "suppress_relationship_recommendations" : "queue_curator_review" + }; + + return { + action: actionByKind[finding.kind] ?? "review", + severity: finding.severity, + subject: finding.subject, + findingId: finding.id + }; +} + +function buildJsonLd(input, findings, recommendationResults) { + return { + "@context": { + scibase: "https://scibase.ai/kg#", + schema: "https://schema.org/", + id: "@id", + type: "@type" + }, + "@graph": [ + ...findings.map((finding) => ({ + id: `scibase:finding/${finding.id}`, + type: "scibase:PublicationNoticeFinding", + "schema:name": finding.message, + "scibase:severity": finding.severity, + "scibase:subject": finding.subject, + "scibase:kind": finding.kind + })), + ...recommendationResults + .filter((result) => result.decision !== "allow") + .map((result) => ({ + id: `scibase:recommendation/${result.recommendationId}`, + type: "scibase:RecommendationDecision", + "schema:name": result.title, + "scibase:decision": result.decision, + "scibase:findingIds": result.findingIds + })) + ], + generatedAt: input.generatedAt + }; +} + +export function analyzeRetractionSignals(input) { + const entities = input.entities ?? []; + const relationships = input.relationships ?? []; + const recommendations = input.recommendations ?? []; + const noticeIndex = buildNoticeIndex(input.publicationNotices ?? []); + const entitiesById = byId(entities); + const relationshipsById = byId(relationships); + const entityFindings = impactedEntityFindings(entities, noticeIndex); + const relationshipFindings = impactedRelationshipFindings(relationships, noticeIndex, entitiesById); + const findings = sortFindings([...entityFindings, ...relationshipFindings]); + const recommendationResults = recommendationDecisions(recommendations, relationshipsById, relationshipFindings); + const actions = findings.map(actionForFinding).sort((a, b) => { + const bySeverity = severityRank(a.severity) - severityRank(b.severity); + if (bySeverity !== 0) return bySeverity; + return a.subject.localeCompare(b.subject); + }); + const jsonLd = buildJsonLd(input, findings, recommendationResults); + + return { + workspace: input.workspace, + generatedAt: input.generatedAt, + summary: { + entities: entities.length, + relationships: relationships.length, + notices: input.publicationNotices?.length ?? 0, + findings: findings.length, + criticalFindings: findings.filter((finding) => finding.severity === "critical").length, + highFindings: findings.filter((finding) => finding.severity === "high").length, + suppressedRecommendations: recommendationResults.filter((result) => result.decision === "suppress").length, + reviewRecommendations: recommendationResults.filter((result) => result.decision === "review").length + }, + findings, + recommendations: recommendationResults, + curatorActions: actions, + jsonLd, + evidenceDigest: stableHash({ generatedAt: input.generatedAt, findings, recommendationResults, jsonLd }) + }; +} diff --git a/knowledge-graph-retraction-guard/test/retraction-signal-guard.test.js b/knowledge-graph-retraction-guard/test/retraction-signal-guard.test.js new file mode 100644 index 0000000..670aae6 --- /dev/null +++ b/knowledge-graph-retraction-guard/test/retraction-signal-guard.test.js @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { analyzeRetractionSignals } from "../src/retraction-signal-guard.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sample = JSON.parse(readFileSync(join(root, "data", "sample-knowledge-graph.json"), "utf8")); + +describe("analyzeRetractionSignals", () => { + it("detects entity and relationship findings from publication notices", () => { + const report = analyzeRetractionSignals(sample); + const kinds = new Set(report.findings.map((finding) => finding.kind)); + + assert.equal(report.summary.findings, 6); + assert.equal(report.summary.criticalFindings, 2); + assert.equal(report.summary.highFindings, 2); + assert.equal(kinds.has("entity_publication_notice"), true); + assert.equal(kinds.has("relationship_evidence_notice"), true); + }); + + it("suppresses recommendations backed by retracted evidence", () => { + const report = analyzeRetractionSignals(sample); + const decisions = new Map(report.recommendations.map((recommendation) => [recommendation.recommendationId, recommendation.decision])); + + assert.equal(decisions.get("rec-organoid-reuse"), "suppress"); + assert.equal(decisions.get("rec-protocol-transfer"), "review"); + assert.equal(decisions.get("rec-crispr-collab"), "allow"); + assert.equal(report.summary.suppressedRecommendations, 1); + assert.equal(report.summary.reviewRecommendations, 1); + }); + + it("exports JSON-LD evidence for findings and recommendation decisions", () => { + const report = analyzeRetractionSignals(sample); + + assert.equal(report.jsonLd["@context"].schema, "https://schema.org/"); + assert.equal(report.jsonLd["@graph"].length, 9); + assert.equal(report.jsonLd["@graph"].some((node) => node.type === "scibase:RecommendationDecision"), true); + }); + + it("is deterministic for audit evidence", () => { + const first = analyzeRetractionSignals(sample); + const second = analyzeRetractionSignals(sample); + + assert.equal(first.evidenceDigest, second.evidenceDigest); + assert.deepEqual(first.findings.map((finding) => finding.id), second.findings.map((finding) => finding.id)); + }); +});