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 @@ + + Negative-Evidence Graph Guard Summary + Reviewer summary for SCIBASE knowledge graph negative evidence publication guard. + + + Negative-Evidence Graph Guard + Status: block_publication | Digest: b245bc907bb0911a + Blockers: 5 + + + Warnings: 1 + + + Held recommendations: 2 + + + Synthetic data only. No external APIs, credentials, private projects, or live research records. + 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 ` + Negative-Evidence Graph Guard Summary + Reviewer summary for SCIBASE knowledge graph negative evidence publication guard. + + + Negative-Evidence Graph Guard + Status: ${safeStatus} | Digest: ${escapeXml(evaluation.digest.slice(0, 16))} + ${svgMetric("Blockers", blockers, 56, 148, barWidth(blockers, max), "#dc2626")} + ${svgMetric("Warnings", warnings, 56, 198, barWidth(warnings, max), "#d97706")} + ${svgMetric("Held recommendations", held, 56, 248, barWidth(held, max), "#2563eb")} + Synthetic data only. No external APIs, credentials, private projects, or live research records. + +`; +} + +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, / { + const score = scoreEvidenceConflict( + [{ confidence: 0.4, source: { status: "published", doi: "10.test/1" }, visibility: "public" }], + [{ confidence: 0.95, source: { status: "published", doi: "10.test/2" }, visibility: "public" }] + ); + + assert.ok(score > 0.55); +});