diff --git a/knowledge-graph-conflict-arbiter/README.md b/knowledge-graph-conflict-arbiter/README.md new file mode 100644 index 0000000..187cc34 --- /dev/null +++ b/knowledge-graph-conflict-arbiter/README.md @@ -0,0 +1,34 @@ +# Knowledge Graph Conflict Arbiter + +This is a self-contained module for SCIBASE issue #17, focused on trust operations for Scientific Knowledge Graph Integration. + +The module arbitrates contradictory scientific knowledge graph relationships before they flow into graph search, entity pages, or AI research recommendations. It groups extracted relationship triples, scores evidence by confidence, evidence type, citation count, and freshness, then decides whether to accept, quarantine, suppress, or send an edge for low-confidence review. + +## What It Covers + +- Relationship grouping for subject/predicate/object graph triples. +- Support/refute polarity handling for contradictory scientific claims. +- Evidence scoring for replicated experiments, replication failures, dataset lineage, curator assertions, and weak text mentions. +- Recommendation suppression when dependent graph edges are quarantined or suppressed. +- Curator action packets for conflict review, relationship suppression, and low-confidence evidence. +- Deterministic audit hashes for relationship evidence and full arbitration packets. + +## Demo + +Run npm run check, npm test, and npm run demo from this directory. + +Expected demo output includes relationship groups, conflicts, quarantined relationships, suppressed recommendations, curator actions, and an audit digest. + +The reviewer-facing visual is in docs/demo.svg; a short demo video is in docs/demo.mp4. + +## Files + +- src/conflict-arbiter.js - deterministic graph arbitration logic. +- data/sample-graph.json - synthetic scientific graph entities, relationships, and recommendations. +- test/conflict-arbiter.test.js - Node assert coverage for conflict quarantine, accepted lineage, weak evidence review, and recommendation suppression. +- scripts/demo.js - terminal demo for reviewers. +- docs/requirement-map.md - maps this module to issue #17 requirements. + +## Safety Notes + +No live ontology, DOI, PubMed, Crossref, LLM, private research, credential, or external service is used. All graph data is synthetic. diff --git a/knowledge-graph-conflict-arbiter/data/sample-graph.json b/knowledge-graph-conflict-arbiter/data/sample-graph.json new file mode 100644 index 0000000..96a43e6 --- /dev/null +++ b/knowledge-graph-conflict-arbiter/data/sample-graph.json @@ -0,0 +1,98 @@ +{ + "graphId": "kg-crispr-neuro-2026", + "freshnessHalfLifeDays": 365, + "entities": [ + { "id": "method-crispr-prime-editing", "type": "method", "label": "CRISPR prime editing" }, + { "id": "disease-parkinsons", "type": "disease", "label": "Parkinsons disease" }, + { "id": "dataset-neuro-organoid-q2", "type": "dataset", "label": "Neuro organoid perturbation Q2" }, + { "id": "protocol-dopamine-neurons", "type": "protocol", "label": "Dopamine neuron differentiation" }, + { "id": "paper-alpha", "type": "paper", "label": "Alpha lab preprint" }, + { "id": "paper-beta", "type": "paper", "label": "Beta lab replication note" } + ], + "relationships": [ + { + "id": "edge-1", + "subject": "method-crispr-prime-editing", + "predicate": "improves-model-for", + "object": "disease-parkinsons", + "polarity": "supports", + "evidenceType": "replicated-experiment", + "confidence": 0.89, + "citationCount": 42, + "sourceDate": "2026-04-20", + "source": "paper-alpha", + "extractor": "pubmed-ner-v2", + "evidenceSpan": "Prime editing improved dopaminergic neuron rescue in three replicated organoid batches." + }, + { + "id": "edge-2", + "subject": "method-crispr-prime-editing", + "predicate": "improves-model-for", + "object": "disease-parkinsons", + "polarity": "refutes", + "evidenceType": "replication-failure", + "confidence": 0.82, + "citationCount": 17, + "sourceDate": "2026-05-08", + "source": "paper-beta", + "extractor": "crossref-abstract-v1", + "evidenceSpan": "Independent replication did not reproduce rescue after batch correction." + }, + { + "id": "edge-3", + "subject": "dataset-neuro-organoid-q2", + "predicate": "reuses-protocol", + "object": "protocol-dopamine-neurons", + "polarity": "supports", + "evidenceType": "dataset-lineage", + "confidence": 0.93, + "citationCount": 9, + "sourceDate": "2026-05-01", + "source": "dataset-neuro-organoid-q2", + "extractor": "metadata-parser-v3", + "evidenceSpan": "Dataset manifest links batch generation to protocol v3.2." + }, + { + "id": "edge-4", + "subject": "dataset-neuro-organoid-q2", + "predicate": "reuses-protocol", + "object": "protocol-dopamine-neurons", + "polarity": "supports", + "evidenceType": "curator-assertion", + "confidence": 0.78, + "citationCount": 3, + "sourceDate": "2026-04-28", + "source": "paper-alpha", + "extractor": "manual-curation", + "evidenceSpan": "Curator confirmed protocol linkage during import review." + }, + { + "id": "edge-5", + "subject": "method-crispr-prime-editing", + "predicate": "requires-dataset", + "object": "dataset-neuro-organoid-q2", + "polarity": "supports", + "evidenceType": "weak-text-mention", + "confidence": 0.47, + "citationCount": 1, + "sourceDate": "2024-09-10", + "source": "paper-alpha", + "extractor": "legacy-pdf-ocr", + "evidenceSpan": "An OCR mention suggests possible use of organoid data." + } + ], + "recommendations": [ + { + "id": "rec-1", + "topic": "prime-editing-parkinsons-collaboration", + "dependsOnEdges": ["edge-1", "edge-2"], + "text": "Recommend collaborators using CRISPR prime editing for Parkinsons disease." + }, + { + "id": "rec-2", + "topic": "organoid-protocol-reuse", + "dependsOnEdges": ["edge-3", "edge-4"], + "text": "Recommend projects that reuse the dopamine neuron differentiation protocol." + } + ] +} diff --git a/knowledge-graph-conflict-arbiter/docs/demo.mp4 b/knowledge-graph-conflict-arbiter/docs/demo.mp4 new file mode 100644 index 0000000..cb96536 Binary files /dev/null and b/knowledge-graph-conflict-arbiter/docs/demo.mp4 differ diff --git a/knowledge-graph-conflict-arbiter/docs/demo.svg b/knowledge-graph-conflict-arbiter/docs/demo.svg new file mode 100644 index 0000000..6c5ad8a --- /dev/null +++ b/knowledge-graph-conflict-arbiter/docs/demo.svg @@ -0,0 +1,26 @@ + + Knowledge graph conflict arbiter demo + A dashboard-style summary of scientific graph relationship arbitration and recommendation suppression. + + + Scientific Knowledge Graph Conflict Arbiter + Contradictory graph edges are quarantined before search, entity pages, or recommendations use them. + + Relationship groups + 3 + + Conflicts + 1 + + Quarantined + 1 + + Suppressed recs + 1 + + CRISPR prime editing -> improves-model-for -> Parkinsons disease + Support and refute evidence are close, so the edge is quarantined for curator review. + Recommendation suppressed until curated + The recommendation engine receives provenance and a reason instead of silently using conflicting evidence. + Audit digest: deterministic SHA-256 arbitration packet hash. + diff --git a/knowledge-graph-conflict-arbiter/docs/requirement-map.md b/knowledge-graph-conflict-arbiter/docs/requirement-map.md new file mode 100644 index 0000000..ee63f82 --- /dev/null +++ b/knowledge-graph-conflict-arbiter/docs/requirement-map.md @@ -0,0 +1,18 @@ +# Requirement Map + +This module targets SCIBASE-AI/SCIBASE.AI issue #17, "Scientific Knowledge Graph Integration". + +| Issue capability | Implementation evidence | +| --- | --- | +| Entity extraction output and linked graph relationships | data/sample-graph.json models scientific entities and extracted relationship edges with evidence spans, extractors, and sources. | +| Knowledge navigation with trustworthy graph search | src/conflict-arbiter.js groups relationship triples and quarantines contradictory edges before graph search or recommendations use them. | +| AI research recommendations with provenance | Recommendation decisions explain whether suggestions are allowed or suppressed based on relationship arbitration. | +| Entity pages with citations and usage contexts | Leading evidence records include source, source date, evidence type, polarity, and audit hash for entity-page conflict notes. | +| Cross-project inference and knowledge gaps | The arbiter prevents inference from mutually contradictory claims and emits curator actions for review gaps. | +| Structured linked data quality | Deterministic audit digests make curation packets reproducible and reviewable. | + +## Non-goals + +- No live PubMed, Crossref, DOI, ontology, or LLM service calls. +- No private research content or external credentials are used. +- This slice is a graph-quality control module, not another broad extractor or graph UI. diff --git a/knowledge-graph-conflict-arbiter/package.json b/knowledge-graph-conflict-arbiter/package.json new file mode 100644 index 0000000..cdb8c08 --- /dev/null +++ b/knowledge-graph-conflict-arbiter/package.json @@ -0,0 +1,12 @@ +{ + "name": "knowledge-graph-conflict-arbiter", + "version": "1.0.0", + "private": true, + "description": "Dependency-free scientific knowledge graph relationship conflict arbiter for SCIBASE.", + "type": "commonjs", + "scripts": { + "check": "node --check src/conflict-arbiter.js && node --check scripts/demo.js && node --check test/conflict-arbiter.test.js", + "test": "node test/conflict-arbiter.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/knowledge-graph-conflict-arbiter/scripts/demo.js b/knowledge-graph-conflict-arbiter/scripts/demo.js new file mode 100644 index 0000000..66f1b28 --- /dev/null +++ b/knowledge-graph-conflict-arbiter/scripts/demo.js @@ -0,0 +1,17 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { arbitrateKnowledgeGraph } = require("../src/conflict-arbiter"); + +const graph = JSON.parse(fs.readFileSync(path.join(__dirname, "../data/sample-graph.json"), "utf8")); +const packet = arbitrateKnowledgeGraph(graph, { asOfDate: "2026-05-15" }); + +console.log("Graph: " + packet.graphId); +console.log("Relationship groups: " + packet.summary.relationshipGroups); +console.log("Conflicts: " + packet.summary.conflicts); +console.log("Quarantined relationships: " + packet.summary.quarantined); +console.log("Suppressed recommendations: " + packet.summary.suppressedRecommendations); +console.log("Curator actions: " + packet.summary.curatorActions); +console.log("First action: " + packet.actions[0].message); +console.log("Audit digest: " + packet.auditDigest); diff --git a/knowledge-graph-conflict-arbiter/src/conflict-arbiter.js b/knowledge-graph-conflict-arbiter/src/conflict-arbiter.js new file mode 100644 index 0000000..4ed5c69 --- /dev/null +++ b/knowledge-graph-conflict-arbiter/src/conflict-arbiter.js @@ -0,0 +1,200 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const EVIDENCE_WEIGHTS = { + "replicated-experiment": 1.0, + "replication-failure": 0.95, + "dataset-lineage": 0.9, + "curator-assertion": 0.82, + "weak-text-mention": 0.35 +}; + +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 validateGraph(graph) { + const issues = []; + if (!graph || typeof graph !== "object") issues.push("graph"); + if (!graph.graphId) issues.push("graphId"); + if (!Array.isArray(graph.entities)) issues.push("entities"); + if (!Array.isArray(graph.relationships)) issues.push("relationships"); + if (!Array.isArray(graph.recommendations)) issues.push("recommendations"); + + for (const edge of graph.relationships || []) { + for (const field of ["id", "subject", "predicate", "object", "polarity", "evidenceType", "sourceDate"]) { + if (!edge[field]) issues.push((edge.id || "unknown") + "." + field); + } + if (typeof edge.confidence !== "number") issues.push((edge.id || "unknown") + ".confidence"); + } + + if (issues.length) throw new Error("Invalid graph: " + issues.join(", ")); +} + +function daysBetween(laterIso, earlierIso) { + const later = Date.parse(laterIso + "T00:00:00.000Z"); + const earlier = Date.parse(earlierIso + "T00:00:00.000Z"); + return Math.max(0, Math.round((later - earlier) / 86400000)); +} + +function freshnessScore(edge, asOfDate, halfLifeDays) { + const age = daysBetween(asOfDate, edge.sourceDate); + return Math.pow(0.5, age / halfLifeDays); +} + +function evidenceScore(edge, asOfDate, halfLifeDays) { + const evidenceWeight = EVIDENCE_WEIGHTS[edge.evidenceType] || 0.5; + const citationBoost = Math.min(0.18, Math.log10((edge.citationCount || 0) + 1) * 0.08); + const raw = edge.confidence * evidenceWeight * freshnessScore(edge, asOfDate, halfLifeDays) + citationBoost; + return Math.max(0, Math.min(1, Number(raw.toFixed(4)))); +} + +function relationshipKey(edge) { + return [edge.subject, edge.predicate, edge.object].join("::"); +} + +function groupRelationships(relationships) { + const groups = new Map(); + for (const edge of relationships) { + const key = relationshipKey(edge); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(edge); + } + return groups; +} + +function summarizeGroup(key, edges, asOfDate, halfLifeDays) { + const scored = edges.map((edge) => ({ + ...edge, + evidenceScore: evidenceScore(edge, asOfDate, halfLifeDays), + auditHash: digest(edge) + })).sort((a, b) => b.evidenceScore - a.evidenceScore); + const supporting = scored.filter((edge) => edge.polarity === "supports"); + const refuting = scored.filter((edge) => edge.polarity === "refutes"); + const supportScore = Number(supporting.reduce((sum, edge) => sum + edge.evidenceScore, 0).toFixed(4)); + const refuteScore = Number(refuting.reduce((sum, edge) => sum + edge.evidenceScore, 0).toFixed(4)); + const margin = Number(Math.abs(supportScore - refuteScore).toFixed(4)); + const hasConflict = supporting.length > 0 && refuting.length > 0; + const winner = supportScore >= refuteScore ? "supports" : "refutes"; + let decision = "accept"; + if (hasConflict && margin < 0.3) decision = "quarantine-for-curation"; + else if (hasConflict) decision = winner === "supports" ? "accept-with-conflict-note" : "suppress-relationship"; + else if (scored[0].evidenceScore < 0.5) decision = "low-confidence-review"; + + return { + key, + subject: scored[0].subject, + predicate: scored[0].predicate, + object: scored[0].object, + edgeIds: scored.map((edge) => edge.id), + supportScore, + refuteScore, + margin, + hasConflict, + decision, + winner, + leadingEvidence: scored.slice(0, 3).map((edge) => ({ + id: edge.id, + polarity: edge.polarity, + evidenceType: edge.evidenceType, + evidenceScore: edge.evidenceScore, + source: edge.source, + sourceDate: edge.sourceDate, + auditHash: edge.auditHash + })) + }; +} + +function explainRecommendation(recommendation, arbitrationByEdge) { + const related = recommendation.dependsOnEdges.map((edgeId) => arbitrationByEdge.get(edgeId)).filter(Boolean); + const quarantined = related.filter((item) => item.decision === "quarantine-for-curation" || item.decision === "suppress-relationship"); + return { + id: recommendation.id, + topic: recommendation.topic, + action: quarantined.length ? "suppress-until-curated" : "allow-with-provenance", + reasons: quarantined.length + ? quarantined.map((item) => item.key + " -> " + item.decision) + : related.map((item) => item.key + " -> " + item.decision), + text: recommendation.text + }; +} + +function curatorActions(arbitrations) { + const actions = []; + for (const item of arbitrations) { + if (item.decision === "quarantine-for-curation") { + actions.push({ + type: "curator-review", + target: item.key, + message: "Conflicting support/refute evidence is too close; require curator decision before graph search or recommendations use this edge." + }); + } + if (item.decision === "suppress-relationship") { + actions.push({ + type: "relationship-suppression", + target: item.key, + message: "Refuting evidence dominates; suppress relationship and show conflict note on entity pages." + }); + } + if (item.decision === "low-confidence-review") { + actions.push({ + type: "low-confidence-review", + target: item.key, + message: "Only weak or stale evidence is available; request stronger evidence before promotion." + }); + } + } + return actions; +} + +function arbitrateKnowledgeGraph(graph, options = {}) { + validateGraph(graph); + const asOfDate = options.asOfDate || "2026-05-15"; + const halfLifeDays = options.freshnessHalfLifeDays || graph.freshnessHalfLifeDays || 365; + const groups = groupRelationships(graph.relationships); + const arbitrations = Array.from(groups.entries()).map(([key, edges]) => summarizeGroup(key, edges, asOfDate, halfLifeDays)); + const arbitrationByEdge = new Map(); + for (const item of arbitrations) { + for (const edgeId of item.edgeIds) arbitrationByEdge.set(edgeId, item); + } + const recommendationDecisions = graph.recommendations.map((recommendation) => explainRecommendation(recommendation, arbitrationByEdge)); + const actions = curatorActions(arbitrations); + const packet = { + graphId: graph.graphId, + asOfDate, + summary: { + relationshipGroups: arbitrations.length, + conflicts: arbitrations.filter((item) => item.hasConflict).length, + quarantined: arbitrations.filter((item) => item.decision === "quarantine-for-curation").length, + suppressedRecommendations: recommendationDecisions.filter((item) => item.action === "suppress-until-curated").length, + curatorActions: actions.length + }, + arbitrations, + recommendationDecisions, + actions + }; + return { + ...packet, + auditDigest: digest(packet) + }; +} + +module.exports = { + EVIDENCE_WEIGHTS, + arbitrateKnowledgeGraph, + digest, + evidenceScore, + freshnessScore, + relationshipKey, + stableStringify, + validateGraph +}; diff --git a/knowledge-graph-conflict-arbiter/test/conflict-arbiter.test.js b/knowledge-graph-conflict-arbiter/test/conflict-arbiter.test.js new file mode 100644 index 0000000..f907af9 --- /dev/null +++ b/knowledge-graph-conflict-arbiter/test/conflict-arbiter.test.js @@ -0,0 +1,50 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { + arbitrateKnowledgeGraph, + evidenceScore, + relationshipKey, + validateGraph +} = require("../src/conflict-arbiter"); + +const graph = JSON.parse(fs.readFileSync(path.join(__dirname, "../data/sample-graph.json"), "utf8")); +validateGraph(graph); + +const packet = arbitrateKnowledgeGraph(graph, { asOfDate: "2026-05-15" }); +assert.equal(packet.graphId, "kg-crispr-neuro-2026"); +assert.equal(packet.summary.relationshipGroups, 3); +assert.equal(packet.summary.conflicts, 1); +assert.equal(packet.summary.quarantined, 1); +assert.equal(packet.summary.suppressedRecommendations, 1); +assert.equal(packet.summary.curatorActions, 2); +assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); + +const conflict = packet.arbitrations.find((item) => item.predicate === "improves-model-for"); +assert.equal(conflict.hasConflict, true); +assert.equal(conflict.decision, "quarantine-for-curation"); +assert.equal(conflict.winner, "supports"); +assert.ok(conflict.margin < 0.3); +assert.equal(conflict.leadingEvidence.length, 2); +assert.match(conflict.leadingEvidence[0].auditHash, /^[a-f0-9]{64}$/); + +const lineage = packet.arbitrations.find((item) => item.predicate === "reuses-protocol"); +assert.equal(lineage.hasConflict, false); +assert.equal(lineage.decision, "accept"); +assert.ok(lineage.supportScore > 1.4); + +const weak = packet.arbitrations.find((item) => item.predicate === "requires-dataset"); +assert.equal(weak.decision, "low-confidence-review"); +assert.equal(weak.leadingEvidence[0].evidenceType, "weak-text-mention"); + +const rec1 = packet.recommendationDecisions.find((item) => item.id === "rec-1"); +assert.equal(rec1.action, "suppress-until-curated"); +const rec2 = packet.recommendationDecisions.find((item) => item.id === "rec-2"); +assert.equal(rec2.action, "allow-with-provenance"); + +assert.equal(relationshipKey(graph.relationships[0]), "method-crispr-prime-editing::improves-model-for::disease-parkinsons"); +assert.ok(evidenceScore(graph.relationships[0], "2026-05-15", 365) > evidenceScore(graph.relationships[4], "2026-05-15", 365)); + +console.log("knowledge-graph-conflict-arbiter tests passed");