diff --git a/knowledge-graph-negative-evidence-guard/package.json b/knowledge-graph-negative-evidence-guard/package.json
new file mode 100644
index 00000000..1712daa5
--- /dev/null
+++ b/knowledge-graph-negative-evidence-guard/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "knowledge-graph-negative-evidence-guard",
+ "version": "1.0.0",
+ "description": "Negative-evidence publication guard for SCIBASE scientific knowledge graph recommendations.",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "check": "node --check src/index.js && node --check test/index.test.js && node --check scripts/demo.js",
+ "test": "node --test test/index.test.js",
+ "demo": "node scripts/demo.js"
+ },
+ "keywords": [
+ "scibase",
+ "knowledge-graph",
+ "negative-evidence",
+ "retractions",
+ "replication"
+ ],
+ "license": "MIT"
+}
diff --git a/knowledge-graph-negative-evidence-guard/readme.md b/knowledge-graph-negative-evidence-guard/readme.md
new file mode 100644
index 00000000..a92d932d
--- /dev/null
+++ b/knowledge-graph-negative-evidence-guard/readme.md
@@ -0,0 +1,38 @@
+# Knowledge Graph Negative-Evidence Guard
+
+This module is a focused SCIBASE.AI issue #17 submission for Scientific Knowledge Graph Integration.
+
+It prevents public graph recommendations from quietly routing around negative scientific evidence. The guard inspects synthetic knowledge-graph packets for contradictions, retractions, failed replications, stale unresolved review windows, and private or embargoed negative evidence before entity pages or recommendation edges publish.
+
+## What It Covers
+
+- Retractions or withdrawn evidence blocking dependent graph paths.
+- Conflicting positive and negative evidence requiring curator review.
+- High-confidence failed replications downgrading recommendations until resolved.
+- Public recommendations refusing private or embargoed evidence chains.
+- Reviewer packets that explain blockers, required actions, and recommendation decisions.
+
+## What It Does Not Do
+
+- No live research data is read.
+- No external ontology, DOI, storage, payment, or private repository APIs are called.
+- No credentials, personal data, private manuscripts, or private datasets are included.
+- No broad entity-extraction system is reimplemented; this is a safety gate for negative evidence and recommendation publication.
+
+## Reviewer Path
+
+```bash
+npm run check
+npm test
+npm run demo
+```
+
+Generated reviewer artifacts:
+
+- `reports/negative-evidence-packet.json`
+- `reports/negative-evidence-report.md`
+- `reports/summary.svg`
+
+## Claim
+
+Use `/claim #17` in the pull request body.
diff --git a/knowledge-graph-negative-evidence-guard/reports/negative-evidence-packet.json b/knowledge-graph-negative-evidence-guard/reports/negative-evidence-packet.json
new file mode 100644
index 00000000..151bf970
--- /dev/null
+++ b/knowledge-graph-negative-evidence-guard/reports/negative-evidence-packet.json
@@ -0,0 +1,141 @@
+{
+ "title": "SCIBASE Scientific Knowledge Graph Negative-Evidence Guard",
+ "issue": "SCIBASE.AI#17",
+ "claim": "/claim #17",
+ "purpose": "Prevent public knowledge graph recommendations from hiding contradictions, retractions, failed replications, or private negative evidence.",
+ "evaluation": {
+ "status": "block_publication",
+ "generatedAt": "2026-06-04T00:00:00.000Z",
+ "graphId": "scibase-kg-negative-evidence-demo",
+ "digest": "b245bc907bb0911addeb552f2bd24ed4c51e6f895cceca6840a3985df1af39ac",
+ "counts": {
+ "claims": 4,
+ "recommendations": 2,
+ "blockers": 5,
+ "warnings": 1,
+ "heldRecommendations": 2
+ },
+ "blockers": [
+ {
+ "code": "retracted_evidence_in_graph_path",
+ "claimId": "claim:retracted-dataset-link",
+ "recommendationIds": [
+ "rec:recommend-protocol-v2"
+ ],
+ "message": "A graph path or recommendation still depends on retracted evidence."
+ },
+ {
+ "code": "stale_negative_evidence_review",
+ "claimId": "claim:embargoed-contradiction",
+ "ageDays": 64,
+ "message": "Negative evidence has been unresolved past the stale-review window."
+ },
+ {
+ "code": "private_evidence_used_publicly",
+ "claimId": "claim:embargoed-contradiction",
+ "recommendationIds": [
+ "rec:recommend-gene-x-dataset"
+ ],
+ "message": "A public recommendation uses private or embargoed negative-evidence context."
+ },
+ {
+ "code": "unresolved_contradictory_claims",
+ "tripleKey": "paper:crispr-neuro-2024|supports|finding:gene-x-effect",
+ "conflictScore": 0.644,
+ "positiveClaimIds": [
+ "claim:supports-gene-x"
+ ],
+ "negativeClaimIds": [
+ "claim:failed-replication-gene-x"
+ ],
+ "recommendationIds": [
+ "rec:recommend-gene-x-dataset"
+ ],
+ "message": "Conflicting positive and negative evidence requires curator review before recommendations publish."
+ },
+ {
+ "code": "failed_replication_without_curator_outcome",
+ "claimId": "claim:failed-replication-gene-x",
+ "recommendationIds": [
+ "rec:recommend-gene-x-dataset"
+ ],
+ "message": "High-confidence failed replication evidence needs a curator outcome before graph boosts continue."
+ }
+ ],
+ "warnings": [
+ {
+ "code": "missing_negative_evidence_doi",
+ "claimId": "claim:embargoed-contradiction",
+ "message": "Negative evidence should keep a DOI or stable public source identifier before curator review."
+ }
+ ],
+ "actions": [
+ {
+ "type": "request_curator_decision",
+ "claimId": "claim:embargoed-contradiction",
+ "reason": "Negative evidence is stale and still affects graph recommendations."
+ },
+ {
+ "type": "open_conflict_review",
+ "tripleKey": "paper:crispr-neuro-2024|supports|finding:gene-x-effect",
+ "claimIds": [
+ "claim:supports-gene-x",
+ "claim:failed-replication-gene-x"
+ ],
+ "reason": "Positive and negative evidence both exceed the publication hold threshold."
+ }
+ ],
+ "claimActions": [
+ {
+ "claimId": "claim:retracted-dataset-link",
+ "decision": "block",
+ "reason": "Retracted or withdrawn evidence cannot support public graph recommendations."
+ }
+ ],
+ "recommendationActions": [
+ {
+ "recommendationId": "rec:recommend-gene-x-dataset",
+ "decision": "hold",
+ "reasons": [
+ "private_evidence_for_public_recommendation",
+ "unresolved_conflict",
+ "failed_replication"
+ ],
+ "requiredActions": [
+ "Replace private evidence with a public citation or keep the recommendation internal.",
+ "Redact any embargoed evidence from the public graph packet.",
+ "Attach a curator decision that accepts, qualifies, or suppresses the conflict.",
+ "Show contradiction context on the entity page before publication.",
+ "Downgrade recommendation confidence until the failed replication is resolved.",
+ "Expose failed-replication context beside the graph edge."
+ ]
+ },
+ {
+ "recommendationId": "rec:recommend-protocol-v2",
+ "decision": "hold",
+ "reasons": [
+ "retracted_evidence"
+ ],
+ "requiredActions": [
+ "Remove the retracted claim from recommendation evidence.",
+ "Add a public curator note explaining the withdrawal."
+ ]
+ }
+ ],
+ "policy": {
+ "contradictionHoldConfidence": 0.58,
+ "failedReplicationHoldConfidence": 0.55,
+ "staleReviewDays": 30,
+ "requirePublicEvidenceForPublicRecommendations": true,
+ "retractionsBlockAllRecommendations": true,
+ "requireCuratorForConflicts": true
+ }
+ },
+ "reviewerChecklist": [
+ "Every public recommendation has an explicit evidence chain.",
+ "Retracted evidence blocks dependent graph paths.",
+ "Contradictory evidence opens a curator review before publication.",
+ "Failed replication evidence downgrades recommendations until resolved.",
+ "Private or embargoed evidence is not leaked into public recommendation paths."
+ ]
+}
diff --git a/knowledge-graph-negative-evidence-guard/reports/negative-evidence-report.md b/knowledge-graph-negative-evidence-guard/reports/negative-evidence-report.md
new file mode 100644
index 00000000..38f19fb2
--- /dev/null
+++ b/knowledge-graph-negative-evidence-guard/reports/negative-evidence-report.md
@@ -0,0 +1,38 @@
+# Negative-Evidence Graph Guard Report
+
+Issue: SCIBASE.AI#17
+Claim marker: `/claim #17`
+Status: `block_publication`
+Digest: `b245bc907bb0911addeb552f2bd24ed4c51e6f895cceca6840a3985df1af39ac`
+
+## Reviewer Checklist
+- Every public recommendation has an explicit evidence chain.
+- Retracted evidence blocks dependent graph paths.
+- Contradictory evidence opens a curator review before publication.
+- Failed replication evidence downgrades recommendations until resolved.
+- Private or embargoed evidence is not leaked into public recommendation paths.
+
+## Counts
+- Claims inspected: 4
+- Recommendations inspected: 2
+- Blockers: 5
+- Warnings: 1
+- Held recommendations: 2
+
+## Blockers
+- retracted_evidence_in_graph_path: A graph path or recommendation still depends on retracted evidence.
+- stale_negative_evidence_review: Negative evidence has been unresolved past the stale-review window.
+- private_evidence_used_publicly: A public recommendation uses private or embargoed negative-evidence context.
+- unresolved_contradictory_claims: Conflicting positive and negative evidence requires curator review before recommendations publish.
+- failed_replication_without_curator_outcome: High-confidence failed replication evidence needs a curator outcome before graph boosts continue.
+
+## Warnings
+- missing_negative_evidence_doi: Negative evidence should keep a DOI or stable public source identifier before curator review.
+
+## Recommendation Actions
+- rec:recommend-gene-x-dataset: hold (private_evidence_for_public_recommendation, unresolved_conflict, failed_replication)
+- rec:recommend-protocol-v2: hold (retracted_evidence)
+
+## Required Actions
+- request_curator_decision: Negative evidence is stale and still affects graph recommendations.
+- open_conflict_review: Positive and negative evidence both exceed the publication hold threshold.
diff --git a/knowledge-graph-negative-evidence-guard/reports/summary.svg b/knowledge-graph-negative-evidence-guard/reports/summary.svg
new file mode 100644
index 00000000..e40bd686
--- /dev/null
+++ b/knowledge-graph-negative-evidence-guard/reports/summary.svg
@@ -0,0 +1,18 @@
+
diff --git a/knowledge-graph-negative-evidence-guard/scripts/demo.js b/knowledge-graph-negative-evidence-guard/scripts/demo.js
new file mode 100644
index 00000000..4da8f77f
--- /dev/null
+++ b/knowledge-graph-negative-evidence-guard/scripts/demo.js
@@ -0,0 +1,30 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ buildReviewerPacket,
+ demoPacket,
+ renderMarkdownReport,
+ renderSvgSummary
+} from "../src/index.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const moduleRoot = path.resolve(__dirname, "..");
+const reportsDir = path.join(moduleRoot, "reports");
+const now = "2026-06-04T00:00:00.000Z";
+const packet = demoPacket();
+const reviewerPacket = buildReviewerPacket(packet, { now });
+
+fs.mkdirSync(reportsDir, { recursive: true });
+fs.writeFileSync(path.join(reportsDir, "negative-evidence-packet.json"), `${JSON.stringify(reviewerPacket, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, "negative-evidence-report.md"), renderMarkdownReport(packet, { now }));
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(packet, { now }));
+
+console.log(JSON.stringify({
+ status: reviewerPacket.evaluation.status,
+ digest: reviewerPacket.evaluation.digest,
+ blockers: reviewerPacket.evaluation.counts.blockers,
+ warnings: reviewerPacket.evaluation.counts.warnings,
+ reportsDir
+}, null, 2));
diff --git a/knowledge-graph-negative-evidence-guard/src/index.js b/knowledge-graph-negative-evidence-guard/src/index.js
new file mode 100644
index 00000000..84c32a56
--- /dev/null
+++ b/knowledge-graph-negative-evidence-guard/src/index.js
@@ -0,0 +1,593 @@
+import crypto from "node:crypto";
+
+const DEFAULT_POLICY = {
+ contradictionHoldConfidence: 0.62,
+ failedReplicationHoldConfidence: 0.58,
+ staleReviewDays: 45,
+ requirePublicEvidenceForPublicRecommendations: true,
+ retractionsBlockAllRecommendations: true,
+ requireCuratorForConflicts: true
+};
+
+const NEGATIVE_POLARITIES = new Set([
+ "contradicts",
+ "retracts",
+ "fails_to_replicate",
+ "limits",
+ "withdraws"
+]);
+
+const POSITIVE_POLARITIES = new Set(["supports", "extends", "replicates", "uses"]);
+
+export function evaluateNegativeEvidenceGraph(packet, options = {}) {
+ const policy = { ...DEFAULT_POLICY, ...(packet.policy ?? {}), ...(options.policy ?? {}) };
+ const normalized = normalizePacket(packet);
+ const now = new Date(options.now ?? normalized.generatedAt ?? Date.now());
+ const blockers = [];
+ const warnings = [];
+ const actions = [];
+ const claimActions = new Map();
+ const recommendationActions = new Map();
+
+ for (const recommendation of normalized.recommendations) {
+ recommendationActions.set(recommendation.id, {
+ recommendationId: recommendation.id,
+ decision: "publish",
+ reasons: [],
+ requiredActions: []
+ });
+ }
+
+ const claimsById = new Map(normalized.claims.map((claim) => [claim.id, claim]));
+ const claimsByTriple = groupBy(normalized.claims, (claim) => claim.tripleKey);
+
+ for (const claim of normalized.claims) {
+ const ageDays = daysBetween(new Date(claim.reviewedAt ?? claim.source.issuedAt ?? now), now);
+ const isNegative = NEGATIVE_POLARITIES.has(claim.polarity);
+ const isRetracted = claim.polarity === "retracts" || claim.source.status === "retracted";
+ const usesPrivateEvidence = claim.visibility !== "public";
+ const linkedRecommendations = normalized.recommendations.filter((rec) =>
+ rec.derivedFrom.includes(claim.id) || rec.from === claim.subject || rec.to === claim.object
+ );
+
+ if (isNegative && !claim.source.doi) {
+ warnings.push({
+ code: "missing_negative_evidence_doi",
+ claimId: claim.id,
+ message: "Negative evidence should keep a DOI or stable public source identifier before curator review."
+ });
+ }
+
+ if (isNegative && !claim.curatorDecision && ageDays > policy.staleReviewDays) {
+ blockers.push({
+ code: "stale_negative_evidence_review",
+ claimId: claim.id,
+ ageDays,
+ message: "Negative evidence has been unresolved past the stale-review window."
+ });
+ actions.push({
+ type: "request_curator_decision",
+ claimId: claim.id,
+ reason: "Negative evidence is stale and still affects graph recommendations."
+ });
+ }
+
+ if (isRetracted && policy.retractionsBlockAllRecommendations) {
+ claimActions.set(claim.id, {
+ claimId: claim.id,
+ decision: "block",
+ reason: "Retracted or withdrawn evidence cannot support public graph recommendations."
+ });
+ for (const rec of linkedRecommendations) {
+ holdRecommendation(recommendationActions.get(rec.id), "retracted_evidence", [
+ "Remove the retracted claim from recommendation evidence.",
+ "Add a public curator note explaining the withdrawal."
+ ]);
+ }
+ blockers.push({
+ code: "retracted_evidence_in_graph_path",
+ claimId: claim.id,
+ recommendationIds: linkedRecommendations.map((rec) => rec.id),
+ message: "A graph path or recommendation still depends on retracted evidence."
+ });
+ continue;
+ }
+
+ if (
+ policy.requirePublicEvidenceForPublicRecommendations &&
+ usesPrivateEvidence &&
+ linkedRecommendations.some((rec) => rec.visibility === "public")
+ ) {
+ for (const rec of linkedRecommendations) {
+ if (rec.visibility === "public") {
+ holdRecommendation(recommendationActions.get(rec.id), "private_evidence_for_public_recommendation", [
+ "Replace private evidence with a public citation or keep the recommendation internal.",
+ "Redact any embargoed evidence from the public graph packet."
+ ]);
+ }
+ }
+ blockers.push({
+ code: "private_evidence_used_publicly",
+ claimId: claim.id,
+ recommendationIds: linkedRecommendations.filter((rec) => rec.visibility === "public").map((rec) => rec.id),
+ message: "A public recommendation uses private or embargoed negative-evidence context."
+ });
+ }
+ }
+
+ for (const [tripleKey, sameTripleClaims] of claimsByTriple) {
+ const positiveClaims = sameTripleClaims.filter((claim) => POSITIVE_POLARITIES.has(claim.polarity));
+ const negativeClaims = sameTripleClaims.filter((claim) => NEGATIVE_POLARITIES.has(claim.polarity));
+ if (!positiveClaims.length || !negativeClaims.length) {
+ continue;
+ }
+
+ const conflictScore = scoreEvidenceConflict(positiveClaims, negativeClaims);
+ const affectedRecommendations = normalized.recommendations.filter((rec) =>
+ rec.derivedFrom.some((claimId) => sameTripleClaims.some((claim) => claim.id === claimId))
+ );
+ const hasCuratorDecision = sameTripleClaims.some((claim) => claim.curatorDecision === "resolved");
+
+ if (policy.requireCuratorForConflicts && conflictScore >= policy.contradictionHoldConfidence && !hasCuratorDecision) {
+ blockers.push({
+ code: "unresolved_contradictory_claims",
+ tripleKey,
+ conflictScore,
+ positiveClaimIds: positiveClaims.map((claim) => claim.id),
+ negativeClaimIds: negativeClaims.map((claim) => claim.id),
+ recommendationIds: affectedRecommendations.map((rec) => rec.id),
+ message: "Conflicting positive and negative evidence requires curator review before recommendations publish."
+ });
+ actions.push({
+ type: "open_conflict_review",
+ tripleKey,
+ claimIds: sameTripleClaims.map((claim) => claim.id),
+ reason: "Positive and negative evidence both exceed the publication hold threshold."
+ });
+ for (const rec of affectedRecommendations) {
+ holdRecommendation(recommendationActions.get(rec.id), "unresolved_conflict", [
+ "Attach a curator decision that accepts, qualifies, or suppresses the conflict.",
+ "Show contradiction context on the entity page before publication."
+ ]);
+ }
+ }
+ }
+
+ for (const claim of normalized.claims) {
+ if (claim.polarity !== "fails_to_replicate") {
+ continue;
+ }
+ const linkedRecommendations = normalized.recommendations.filter((rec) =>
+ rec.derivedFrom.includes(claim.id) || rec.from === claim.subject || rec.to === claim.object
+ );
+ if (claim.confidence >= policy.failedReplicationHoldConfidence && !claim.curatorDecision) {
+ blockers.push({
+ code: "failed_replication_without_curator_outcome",
+ claimId: claim.id,
+ recommendationIds: linkedRecommendations.map((rec) => rec.id),
+ message: "High-confidence failed replication evidence needs a curator outcome before graph boosts continue."
+ });
+ for (const rec of linkedRecommendations) {
+ holdRecommendation(recommendationActions.get(rec.id), "failed_replication", [
+ "Downgrade recommendation confidence until the failed replication is resolved.",
+ "Expose failed-replication context beside the graph edge."
+ ]);
+ }
+ }
+ }
+
+ for (const rec of normalized.recommendations) {
+ const recAction = recommendationActions.get(rec.id);
+ const derivedClaims = rec.derivedFrom.map((claimId) => claimsById.get(claimId)).filter(Boolean);
+ const publicEvidenceCount = derivedClaims.filter((claim) => claim.visibility === "public").length;
+ const negativeEvidenceCount = derivedClaims.filter((claim) => NEGATIVE_POLARITIES.has(claim.polarity)).length;
+ if (!derivedClaims.length) {
+ warnings.push({
+ code: "recommendation_without_evidence",
+ recommendationId: rec.id,
+ message: "Recommendation has no explicit evidence chain."
+ });
+ recAction.decision = "needs_evidence";
+ recAction.reasons.push("missing_evidence_chain");
+ }
+ if (negativeEvidenceCount > 0 && publicEvidenceCount === 0) {
+ holdRecommendation(recAction, "negative_context_not_public", [
+ "Add a public explanation for the negative-evidence context before recommendation publication."
+ ]);
+ }
+ }
+
+ const recommendationSummary = [...recommendationActions.values()];
+ const status = blockers.length ? "block_publication" : warnings.length ? "needs_review" : "publish_ready";
+ return {
+ status,
+ generatedAt: now.toISOString(),
+ graphId: normalized.graphId,
+ digest: digest({
+ graphId: normalized.graphId,
+ blockers,
+ warnings,
+ recommendationSummary
+ }),
+ counts: {
+ claims: normalized.claims.length,
+ recommendations: normalized.recommendations.length,
+ blockers: blockers.length,
+ warnings: warnings.length,
+ heldRecommendations: recommendationSummary.filter((rec) => rec.decision === "hold").length
+ },
+ blockers,
+ warnings,
+ actions,
+ claimActions: [...claimActions.values()],
+ recommendationActions: recommendationSummary,
+ policy
+ };
+}
+
+export function normalizePacket(packet) {
+ if (!packet || typeof packet !== "object") {
+ throw new TypeError("A graph packet object is required.");
+ }
+ const graphId = nonEmpty(packet.graphId, "graphId");
+ const entities = Array.isArray(packet.entities) ? packet.entities.map(normalizeEntity) : [];
+ const entityIds = new Set(entities.map((entity) => entity.id));
+ const claims = ensureArray(packet.claims, "claims").map((claim) => normalizeClaim(claim, entityIds));
+ const recommendations = ensureArray(packet.recommendations, "recommendations").map((rec) =>
+ normalizeRecommendation(rec, entityIds)
+ );
+ return {
+ graphId,
+ generatedAt: packet.generatedAt ?? new Date().toISOString(),
+ entities,
+ claims,
+ recommendations,
+ policy: packet.policy ?? {}
+ };
+}
+
+export function scoreEvidenceConflict(positiveClaims, negativeClaims) {
+ const positiveStrength = positiveClaims.reduce((sum, claim) => sum + claim.confidence * evidenceWeight(claim), 0);
+ const negativeStrength = negativeClaims.reduce((sum, claim) => sum + claim.confidence * evidenceWeight(claim), 0);
+ const total = positiveStrength + negativeStrength;
+ if (!total) {
+ return 0;
+ }
+ const balance = Math.min(positiveStrength, negativeStrength) / Math.max(positiveStrength, negativeStrength);
+ const negativePresence = negativeStrength / total;
+ return round((negativePresence * 0.7 + balance * 0.3), 3);
+}
+
+export function buildReviewerPacket(packet, options = {}) {
+ const evaluation = evaluateNegativeEvidenceGraph(packet, options);
+ return {
+ title: "SCIBASE Scientific Knowledge Graph Negative-Evidence Guard",
+ issue: "SCIBASE.AI#17",
+ claim: "/claim #17",
+ purpose:
+ "Prevent public knowledge graph recommendations from hiding contradictions, retractions, failed replications, or private negative evidence.",
+ evaluation,
+ reviewerChecklist: [
+ "Every public recommendation has an explicit evidence chain.",
+ "Retracted evidence blocks dependent graph paths.",
+ "Contradictory evidence opens a curator review before publication.",
+ "Failed replication evidence downgrades recommendations until resolved.",
+ "Private or embargoed evidence is not leaked into public recommendation paths."
+ ]
+ };
+}
+
+export function renderMarkdownReport(packet, options = {}) {
+ const review = buildReviewerPacket(packet, options);
+ const { evaluation } = review;
+ const lines = [
+ "# Negative-Evidence Graph Guard Report",
+ "",
+ `Issue: ${review.issue}`,
+ `Claim marker: \`${review.claim}\``,
+ `Status: \`${evaluation.status}\``,
+ `Digest: \`${evaluation.digest}\``,
+ "",
+ "## Reviewer Checklist",
+ ...review.reviewerChecklist.map((item) => `- ${item}`),
+ "",
+ "## Counts",
+ `- Claims inspected: ${evaluation.counts.claims}`,
+ `- Recommendations inspected: ${evaluation.counts.recommendations}`,
+ `- Blockers: ${evaluation.counts.blockers}`,
+ `- Warnings: ${evaluation.counts.warnings}`,
+ `- Held recommendations: ${evaluation.counts.heldRecommendations}`,
+ "",
+ "## Blockers",
+ ...formatFindings(evaluation.blockers),
+ "",
+ "## Warnings",
+ ...formatFindings(evaluation.warnings),
+ "",
+ "## Recommendation Actions",
+ ...evaluation.recommendationActions.map(
+ (action) =>
+ `- ${action.recommendationId}: ${action.decision} (${action.reasons.length ? action.reasons.join(", ") : "no holds"})`
+ ),
+ "",
+ "## Required Actions",
+ ...(
+ evaluation.actions.length
+ ? evaluation.actions.map((action) => `- ${action.type}: ${action.reason}`)
+ : ["- No curator actions required."]
+ )
+ ];
+ return `${lines.join("\n")}\n`;
+}
+
+export function renderSvgSummary(packet, options = {}) {
+ const review = buildReviewerPacket(packet, options);
+ const { evaluation } = review;
+ const safeStatus = escapeXml(evaluation.status);
+ const held = evaluation.counts.heldRecommendations;
+ const blockers = evaluation.counts.blockers;
+ const warnings = evaluation.counts.warnings;
+ const barWidth = (value, max) => Math.max(10, Math.round((value / Math.max(max, 1)) * 320));
+ const max = Math.max(blockers, warnings, held, 1);
+ return `
+`;
+}
+
+export function demoPacket() {
+ return {
+ graphId: "scibase-kg-negative-evidence-demo",
+ generatedAt: "2026-06-04T00:00:00.000Z",
+ policy: {
+ contradictionHoldConfidence: 0.58,
+ failedReplicationHoldConfidence: 0.55,
+ staleReviewDays: 30
+ },
+ entities: [
+ { id: "paper:crispr-neuro-2024", type: "paper", label: "CRISPR clustering in neural organoids" },
+ { id: "dataset:organoid-response", type: "dataset", label: "Organoid response matrix" },
+ { id: "method:cluster-protocol-v2", type: "protocol", label: "Cluster protocol v2" },
+ { id: "finding:gene-x-effect", type: "finding", label: "Gene X effect on differentiation" }
+ ],
+ claims: [
+ {
+ id: "claim:supports-gene-x",
+ subject: "paper:crispr-neuro-2024",
+ predicate: "supports",
+ object: "finding:gene-x-effect",
+ polarity: "supports",
+ confidence: 0.79,
+ visibility: "public",
+ reviewedAt: "2026-05-24T00:00:00.000Z",
+ source: {
+ doi: "10.5555/scibase.synthetic.1001",
+ title: "Synthetic positive evidence for Gene X",
+ status: "published",
+ issuedAt: "2026-05-20T00:00:00.000Z"
+ }
+ },
+ {
+ id: "claim:failed-replication-gene-x",
+ subject: "paper:crispr-neuro-2024",
+ predicate: "supports",
+ object: "finding:gene-x-effect",
+ polarity: "fails_to_replicate",
+ confidence: 0.83,
+ visibility: "public",
+ reviewedAt: "2026-05-25T00:00:00.000Z",
+ source: {
+ doi: "10.5555/scibase.synthetic.2002",
+ title: "Synthetic replication failure for Gene X",
+ status: "published",
+ issuedAt: "2026-05-24T00:00:00.000Z"
+ }
+ },
+ {
+ id: "claim:retracted-dataset-link",
+ subject: "dataset:organoid-response",
+ predicate: "derived_from",
+ object: "method:cluster-protocol-v2",
+ polarity: "retracts",
+ confidence: 0.91,
+ visibility: "public",
+ reviewedAt: "2026-05-23T00:00:00.000Z",
+ source: {
+ doi: "10.5555/scibase.synthetic.3003",
+ title: "Synthetic dataset withdrawal notice",
+ status: "retracted",
+ issuedAt: "2026-05-23T00:00:00.000Z"
+ }
+ },
+ {
+ id: "claim:embargoed-contradiction",
+ subject: "paper:crispr-neuro-2024",
+ predicate: "uses",
+ object: "dataset:organoid-response",
+ polarity: "contradicts",
+ confidence: 0.68,
+ visibility: "embargoed",
+ reviewedAt: "2026-04-01T00:00:00.000Z",
+ source: {
+ title: "Synthetic embargoed contradiction memo",
+ status: "preprint",
+ issuedAt: "2026-03-31T00:00:00.000Z"
+ }
+ }
+ ],
+ recommendations: [
+ {
+ id: "rec:recommend-gene-x-dataset",
+ from: "paper:crispr-neuro-2024",
+ to: "dataset:organoid-response",
+ visibility: "public",
+ reason: "Entity co-occurrence and shared method path",
+ derivedFrom: ["claim:supports-gene-x", "claim:failed-replication-gene-x", "claim:embargoed-contradiction"]
+ },
+ {
+ id: "rec:recommend-protocol-v2",
+ from: "dataset:organoid-response",
+ to: "method:cluster-protocol-v2",
+ visibility: "public",
+ reason: "Dataset method lineage",
+ derivedFrom: ["claim:retracted-dataset-link"]
+ }
+ ]
+ };
+}
+
+function normalizeEntity(entity) {
+ return {
+ id: nonEmpty(entity.id, "entity.id"),
+ type: nonEmpty(entity.type, "entity.type"),
+ label: nonEmpty(entity.label, "entity.label")
+ };
+}
+
+function normalizeClaim(claim, entityIds) {
+ const subject = nonEmpty(claim.subject, "claim.subject");
+ const predicate = nonEmpty(claim.predicate, "claim.predicate");
+ const object = nonEmpty(claim.object, "claim.object");
+ if (!entityIds.has(subject) || !entityIds.has(object)) {
+ throw new Error(`Claim ${claim.id ?? "(missing id)"} references an unknown entity.`);
+ }
+ const polarity = nonEmpty(claim.polarity, "claim.polarity");
+ if (!NEGATIVE_POLARITIES.has(polarity) && !POSITIVE_POLARITIES.has(polarity)) {
+ throw new Error(`Unsupported evidence polarity: ${polarity}`);
+ }
+ const confidence = Number(claim.confidence ?? 0);
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
+ throw new Error(`Claim ${claim.id ?? "(missing id)"} confidence must be between 0 and 1.`);
+ }
+ return {
+ id: nonEmpty(claim.id, "claim.id"),
+ subject,
+ predicate,
+ object,
+ tripleKey: `${subject}|${predicate}|${object}`,
+ polarity,
+ confidence,
+ visibility: claim.visibility ?? "public",
+ reviewedAt: claim.reviewedAt,
+ curatorDecision: claim.curatorDecision,
+ source: {
+ doi: claim.source?.doi,
+ title: claim.source?.title ?? "Untitled evidence source",
+ status: claim.source?.status ?? "published",
+ issuedAt: claim.source?.issuedAt
+ }
+ };
+}
+
+function normalizeRecommendation(rec, entityIds) {
+ const from = nonEmpty(rec.from, "recommendation.from");
+ const to = nonEmpty(rec.to, "recommendation.to");
+ if (!entityIds.has(from) || !entityIds.has(to)) {
+ throw new Error(`Recommendation ${rec.id ?? "(missing id)"} references an unknown entity.`);
+ }
+ return {
+ id: nonEmpty(rec.id, "recommendation.id"),
+ from,
+ to,
+ visibility: rec.visibility ?? "public",
+ reason: rec.reason ?? "No reason provided.",
+ derivedFrom: Array.isArray(rec.derivedFrom) ? rec.derivedFrom : []
+ };
+}
+
+function holdRecommendation(action, reason, requiredActions) {
+ if (!action) {
+ return;
+ }
+ action.decision = "hold";
+ if (!action.reasons.includes(reason)) {
+ action.reasons.push(reason);
+ }
+ for (const item of requiredActions) {
+ if (!action.requiredActions.includes(item)) {
+ action.requiredActions.push(item);
+ }
+ }
+}
+
+function evidenceWeight(claim) {
+ if (claim.source.status === "retracted") {
+ return 1.2;
+ }
+ if (claim.visibility !== "public") {
+ return 0.75;
+ }
+ if (claim.source.doi) {
+ return 1;
+ }
+ return 0.85;
+}
+
+function groupBy(items, getKey) {
+ const groups = new Map();
+ for (const item of items) {
+ const key = getKey(item);
+ if (!groups.has(key)) {
+ groups.set(key, []);
+ }
+ groups.get(key).push(item);
+ }
+ return groups;
+}
+
+function daysBetween(start, end) {
+ return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86_400_000));
+}
+
+function digest(value) {
+ return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
+}
+
+function ensureArray(value, name) {
+ if (!Array.isArray(value)) {
+ throw new TypeError(`${name} must be an array.`);
+ }
+ return value;
+}
+
+function nonEmpty(value, name) {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new TypeError(`${name} must be a non-empty string.`);
+ }
+ return value.trim();
+}
+
+function round(value, decimals) {
+ const factor = 10 ** decimals;
+ return Math.round(value * factor) / factor;
+}
+
+function formatFindings(findings) {
+ if (!findings.length) {
+ return ["- None."];
+ }
+ return findings.map((finding) => `- ${finding.code}: ${finding.message}`);
+}
+
+function svgMetric(label, value, x, y, width, color) {
+ return `${escapeXml(label)}: ${value}
+
+ `;
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("\"", """);
+}
diff --git a/knowledge-graph-negative-evidence-guard/test/index.test.js b/knowledge-graph-negative-evidence-guard/test/index.test.js
new file mode 100644
index 00000000..ed908432
--- /dev/null
+++ b/knowledge-graph-negative-evidence-guard/test/index.test.js
@@ -0,0 +1,98 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ buildReviewerPacket,
+ demoPacket,
+ evaluateNegativeEvidenceGraph,
+ normalizePacket,
+ renderMarkdownReport,
+ renderSvgSummary,
+ scoreEvidenceConflict
+} from "../src/index.js";
+
+test("blocks recommendations that depend on retracted evidence", () => {
+ const result = evaluateNegativeEvidenceGraph(demoPacket(), {
+ now: "2026-06-04T00:00:00.000Z"
+ });
+
+ assert.equal(result.status, "block_publication");
+ assert.ok(result.blockers.some((blocker) => blocker.code === "retracted_evidence_in_graph_path"));
+ assert.ok(
+ result.recommendationActions.some(
+ (action) => action.recommendationId === "rec:recommend-protocol-v2" && action.decision === "hold"
+ )
+ );
+});
+
+test("detects unresolved contradiction and failed replication conflicts", () => {
+ const result = evaluateNegativeEvidenceGraph(demoPacket(), {
+ now: "2026-06-04T00:00:00.000Z"
+ });
+
+ assert.ok(result.blockers.some((blocker) => blocker.code === "unresolved_contradictory_claims"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "failed_replication_without_curator_outcome"));
+ assert.ok(result.actions.some((action) => action.type === "open_conflict_review"));
+});
+
+test("allows a clean public recommendation with resolved conflict evidence", () => {
+ const packet = demoPacket();
+ packet.claims = packet.claims
+ .filter((claim) => claim.id !== "claim:retracted-dataset-link" && claim.id !== "claim:embargoed-contradiction")
+ .map((claim) => ({ ...claim, curatorDecision: "resolved" }));
+ packet.recommendations = [
+ {
+ id: "rec:resolved-gene-x",
+ from: "paper:crispr-neuro-2024",
+ to: "finding:gene-x-effect",
+ visibility: "public",
+ reason: "Curator accepted qualified finding with replication context displayed.",
+ derivedFrom: ["claim:supports-gene-x", "claim:failed-replication-gene-x"]
+ }
+ ];
+
+ const result = evaluateNegativeEvidenceGraph(packet, {
+ now: "2026-06-04T00:00:00.000Z"
+ });
+
+ assert.equal(result.status, "publish_ready");
+ assert.equal(result.counts.blockers, 0);
+ assert.equal(result.recommendationActions[0].decision, "publish");
+});
+
+test("normalizes packets and rejects unknown recommendation entities", () => {
+ const packet = demoPacket();
+ assert.equal(normalizePacket(packet).claims.length, 4);
+
+ const broken = demoPacket();
+ broken.recommendations[0].to = "dataset:missing";
+ assert.throws(() => normalizePacket(broken), /unknown entity/);
+});
+
+test("produces stable reviewer artifacts", () => {
+ const packet = demoPacket();
+ const review = buildReviewerPacket(packet, {
+ now: "2026-06-04T00:00:00.000Z"
+ });
+ const markdown = renderMarkdownReport(packet, {
+ now: "2026-06-04T00:00:00.000Z"
+ });
+ const svg = renderSvgSummary(packet, {
+ now: "2026-06-04T00:00:00.000Z"
+ });
+
+ assert.equal(review.issue, "SCIBASE.AI#17");
+ assert.match(markdown, /Negative-Evidence Graph Guard Report/);
+ assert.match(markdown, /`\/claim #17`/);
+ assert.match(svg, /