diff --git a/citation-retraction-evidence-guard/README.md b/citation-retraction-evidence-guard/README.md new file mode 100644 index 00000000..52e52796 --- /dev/null +++ b/citation-retraction-evidence-guard/README.md @@ -0,0 +1,41 @@ +# Citation Retraction Evidence Guard + +Self-contained guard for SCIBASE issue #16, focused on AI-powered research assistant output safety. + +The module evaluates synthetic manuscript-assistant packets before auto peer review or research-gap suggestions are released. It prevents AI-generated review text from relying on retracted, contradicted, stale, unsupported, or superseded citations. + +## Scope + +- citation retraction and expression-of-concern status +- claim-to-citation support direction +- stale evidence windows +- preprint-to-published-version drift +- citation-context evidence presence +- assistant recommendation leakage from unsafe citations +- deterministic reviewer release decisions + +## Decisions + +- `RELEASE_ASSISTANT_OUTPUT` +- `HOLD_FOR_EDITOR` +- `BLOCK_ASSISTANT_OUTPUT` + +## Local Validation + +```bash +npm test +npm run demo +node --check src/index.js +node --check scripts/demo.js +node --check test/citationRetractionEvidenceGuard.test.js +git diff --check +``` + +## Boundaries + +- synthetic citation packets only +- no live publisher, Crossref, PubMed, OpenAlex, or Retraction Watch calls +- no private manuscripts +- no credentials +- no external AI APIs +- no production medical or legal review claims diff --git a/citation-retraction-evidence-guard/artifacts/citation-evidence-demo.mp4 b/citation-retraction-evidence-guard/artifacts/citation-evidence-demo.mp4 new file mode 100644 index 00000000..f89cff17 Binary files /dev/null and b/citation-retraction-evidence-guard/artifacts/citation-evidence-demo.mp4 differ diff --git a/citation-retraction-evidence-guard/artifacts/citation-evidence-report.md b/citation-retraction-evidence-guard/artifacts/citation-evidence-report.md new file mode 100644 index 00000000..f50814fa --- /dev/null +++ b/citation-retraction-evidence-guard/artifacts/citation-evidence-report.md @@ -0,0 +1,35 @@ +# Citation Retraction Evidence Guard Report + +Summary: blocks=4, reviews=3, passes=1 + +## packet:oncology-review-ready + +- Manuscript: ms:oncology-review-42 +- Decision: RELEASE_ASSISTANT_OUTPUT + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| pass | citation_evidence_ready | Citation evidence is current, unretracted, and aligned with assistant output. | No remediation required. | + +## packet:neuro-preprint-review + +- Manuscript: ms:neuro-preprint-18 +- Decision: HOLD_FOR_EDITOR + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| review | citation_superseded | cite:preprint-2020 appears superseded by cite:journal-2022. | Prefer the current published version or explain why the older version remains relevant. | +| review | background_citation_used_as_support | cite:preprint-2020 is only background evidence for claim:biomarker-generalizes. | Attach direct support or lower the assistant confidence. | + +## packet:materials-block + +- Manuscript: ms:materials-battery-07 +- Decision: BLOCK_ASSISTANT_OUTPUT + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| block | citation_retracted | cite:retracted-2017 is marked retracted. | Remove the citation from supporting evidence or rewrite the assistant note as a retraction warning. | +| review | citation_stale_without_replication | cite:retracted-2017 is older than the configured evidence window and has no recent replication marker. | Attach a recent replication, meta-analysis, or recency caveat. | +| block | retracted_citation_supports_claim | claim:electrolyte-stability is supported by retracted citation cite:retracted-2017. | Remove this evidence path and regenerate the assistant output. | +| block | citation_contradicts_claim | cite:contradiction-2025 is marked as contradicting claim:electrolyte-stability. | Rewrite the assistant note to present the contradiction rather than support the claim. | +| block | assistant_recommends_retracted_citation | Assistant recommends retracted citation cite:retracted-2017. | Regenerate the assistant note with retraction-safe evidence. | diff --git a/citation-retraction-evidence-guard/artifacts/citation-evidence-results.json b/citation-retraction-evidence-guard/artifacts/citation-evidence-results.json new file mode 100644 index 00000000..3490a877 --- /dev/null +++ b/citation-retraction-evidence-guard/artifacts/citation-evidence-results.json @@ -0,0 +1,87 @@ +{ + "generatedAt": "2026-06-13T15:54:40.327Z", + "summary": { + "blocks": 4, + "reviews": 3, + "passes": 1 + }, + "results": [ + { + "packetId": "packet:oncology-review-ready", + "manuscriptId": "ms:oncology-review-42", + "decision": "RELEASE_ASSISTANT_OUTPUT", + "findings": [ + { + "severity": "pass", + "code": "citation_evidence_ready", + "message": "Citation evidence is current, unretracted, and aligned with assistant output.", + "remediation": "No remediation required.", + "ref": "packet:oncology-review-ready" + } + ] + }, + { + "packetId": "packet:neuro-preprint-review", + "manuscriptId": "ms:neuro-preprint-18", + "decision": "HOLD_FOR_EDITOR", + "findings": [ + { + "severity": "review", + "code": "citation_superseded", + "message": "cite:preprint-2020 appears superseded by cite:journal-2022.", + "remediation": "Prefer the current published version or explain why the older version remains relevant.", + "ref": "cite:preprint-2020" + }, + { + "severity": "review", + "code": "background_citation_used_as_support", + "message": "cite:preprint-2020 is only background evidence for claim:biomarker-generalizes.", + "remediation": "Attach direct support or lower the assistant confidence.", + "ref": "cite:preprint-2020" + } + ] + }, + { + "packetId": "packet:materials-block", + "manuscriptId": "ms:materials-battery-07", + "decision": "BLOCK_ASSISTANT_OUTPUT", + "findings": [ + { + "severity": "block", + "code": "citation_retracted", + "message": "cite:retracted-2017 is marked retracted.", + "remediation": "Remove the citation from supporting evidence or rewrite the assistant note as a retraction warning.", + "ref": "cite:retracted-2017" + }, + { + "severity": "review", + "code": "citation_stale_without_replication", + "message": "cite:retracted-2017 is older than the configured evidence window and has no recent replication marker.", + "remediation": "Attach a recent replication, meta-analysis, or recency caveat.", + "ref": "cite:retracted-2017" + }, + { + "severity": "block", + "code": "retracted_citation_supports_claim", + "message": "claim:electrolyte-stability is supported by retracted citation cite:retracted-2017.", + "remediation": "Remove this evidence path and regenerate the assistant output.", + "ref": "cite:retracted-2017" + }, + { + "severity": "block", + "code": "citation_contradicts_claim", + "message": "cite:contradiction-2025 is marked as contradicting claim:electrolyte-stability.", + "remediation": "Rewrite the assistant note to present the contradiction rather than support the claim.", + "ref": "cite:contradiction-2025" + }, + { + "severity": "block", + "code": "assistant_recommends_retracted_citation", + "message": "Assistant recommends retracted citation cite:retracted-2017.", + "remediation": "Regenerate the assistant note with retraction-safe evidence.", + "ref": "cite:retracted-2017" + } + ] + } + ] +} \ No newline at end of file diff --git a/citation-retraction-evidence-guard/artifacts/citation-evidence-summary.svg b/citation-retraction-evidence-guard/artifacts/citation-evidence-summary.svg new file mode 100644 index 00000000..c4cdadc2 --- /dev/null +++ b/citation-retraction-evidence-guard/artifacts/citation-evidence-summary.svg @@ -0,0 +1,20 @@ + + + + Citation Retraction Evidence Guard + blocks=4 reviews=3 passes=1 + packet:oncology-review-ready + + RELEASE_ASSISTANT_OUTPUT + packet:neuro-preprint-review + + HOLD_FOR_EDITOR + packet:materials-block + + BLOCK_ASSISTANT_OUTPUT + diff --git a/citation-retraction-evidence-guard/examples/citation-evidence-packets.json b/citation-retraction-evidence-guard/examples/citation-evidence-packets.json new file mode 100644 index 00000000..4f9cf0fa --- /dev/null +++ b/citation-retraction-evidence-guard/examples/citation-evidence-packets.json @@ -0,0 +1,120 @@ +[ + { + "packetId": "packet:oncology-review-ready", + "manuscriptId": "ms:oncology-review-42", + "generatedAt": "2026-06-14", + "claims": [ + { + "id": "claim:survival-signal", + "text": "The intervention improves progression-free survival in the target subgroup.", + "evidence": [ + { + "citationId": "cite:phase2-2024", + "supportRelation": "supports" + } + ] + } + ], + "citations": [ + { + "id": "cite:phase2-2024", + "title": "Phase 2 subgroup survival analysis", + "doi": "10.1000/safe.2024.11", + "status": "active", + "sourceType": "journal", + "usedAs": "primary_support", + "publishedAt": "2024-04-12", + "hasRecentReplication": true, + "contextQuote": "The subgroup analysis showed improved progression-free survival under the prespecified endpoint." + } + ], + "assistantNote": { + "text": "Evidence appears aligned, recent, and unretracted for the target subgroup.", + "recommendedCitationIds": ["cite:phase2-2024"] + } + }, + { + "packetId": "packet:neuro-preprint-review", + "manuscriptId": "ms:neuro-preprint-18", + "generatedAt": "2026-06-14", + "claims": [ + { + "id": "claim:biomarker-generalizes", + "text": "The biomarker generalizes across independent neurodegeneration cohorts.", + "evidence": [ + { + "citationId": "cite:preprint-2020", + "supportRelation": "background" + } + ] + } + ], + "citations": [ + { + "id": "cite:preprint-2020", + "title": "Early biomarker cohort preprint", + "doi": "10.1101/2020.01.02.abc", + "status": "superseded", + "sourceType": "preprint", + "usedAs": "primary_support", + "publishedVersionId": "cite:journal-2022", + "publishedAt": "2020-01-02", + "hasRecentReplication": false, + "contextQuote": "The preliminary cohort suggested possible transfer, but the authors called for external validation." + } + ], + "assistantNote": { + "text": "The assistant should hold this claim for an editor because the cited preprint was superseded.", + "recommendedCitationIds": ["cite:preprint-2020"] + } + }, + { + "packetId": "packet:materials-block", + "manuscriptId": "ms:materials-battery-07", + "generatedAt": "2026-06-14", + "claims": [ + { + "id": "claim:electrolyte-stability", + "text": "The electrolyte is stable for 1,000 cycles under high temperature.", + "evidence": [ + { + "citationId": "cite:retracted-2017", + "supportRelation": "supports" + }, + { + "citationId": "cite:contradiction-2025", + "supportRelation": "contradicts" + } + ] + } + ], + "citations": [ + { + "id": "cite:retracted-2017", + "title": "High-temperature electrolyte cycling", + "doi": "10.1000/retracted.2017.9", + "status": "retracted", + "sourceType": "journal", + "usedAs": "primary_support", + "publishedAt": "2017-09-01", + "hasRecentReplication": false, + "contextQuote": "The original article claimed stability at high temperature but was later retracted." + }, + { + "id": "cite:contradiction-2025", + "title": "Independent electrolyte degradation study", + "doi": "10.1000/active.2025.4", + "status": "active", + "sourceType": "journal", + "usedAs": "primary_support", + "publishedAt": "2025-04-18", + "hasRecentReplication": true, + "contextQuote": "The independent study found rapid degradation under the same temperature and cycling protocol." + } + ], + "assistantNote": { + "text": "Unsafe draft: cite the older article as positive support for high-temperature stability.", + "recommendedCitationIds": ["cite:retracted-2017"] + } + } +] diff --git a/citation-retraction-evidence-guard/package.json b/citation-retraction-evidence-guard/package.json new file mode 100644 index 00000000..7c60dc02 --- /dev/null +++ b/citation-retraction-evidence-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "citation-retraction-evidence-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic citation retraction and evidence recency guard for AI research assistant outputs.", + "type": "commonjs", + "scripts": { + "test": "node test/citationRetractionEvidenceGuard.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/citation-retraction-evidence-guard/scripts/demo.js b/citation-retraction-evidence-guard/scripts/demo.js new file mode 100644 index 00000000..8ba98ff6 --- /dev/null +++ b/citation-retraction-evidence-guard/scripts/demo.js @@ -0,0 +1,110 @@ +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const packets = require("../examples/citation-evidence-packets.json"); +const { evaluateCitationEvidenceBatch, summarize } = require("../src"); + +const root = path.join(__dirname, ".."); +const artifactDir = path.join(root, "artifacts"); +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = evaluateCitationEvidenceBatch(packets, { asOf: "2026-06-14", maxEvidenceAgeYears: 8 }); +const summary = summarize(results); + +fs.writeFileSync( + path.join(artifactDir, "citation-evidence-results.json"), + JSON.stringify({ generatedAt: new Date().toISOString(), summary, results }, null, 2) +); + +function reportMarkdown() { + const lines = [ + "# Citation Retraction Evidence Guard Report", + "", + `Summary: blocks=${summary.blocks}, reviews=${summary.reviews}, passes=${summary.passes}`, + "" + ]; + for (const result of results) { + lines.push(`## ${result.packetId}`, ""); + lines.push(`- Manuscript: ${result.manuscriptId}`); + lines.push(`- Decision: ${result.decision}`, ""); + lines.push("| Severity | Code | Message | Remediation |"); + lines.push("| --- | --- | --- | --- |"); + for (const finding of result.findings) { + lines.push(`| ${finding.severity} | ${finding.code} | ${finding.message} | ${finding.remediation} |`); + } + lines.push(""); + } + return lines.join("\n").trimEnd() + "\n"; +} + +fs.writeFileSync(path.join(artifactDir, "citation-evidence-report.md"), reportMarkdown()); + +function svg() { + const rows = results.map((result, index) => { + const y = 116 + index * 72; + const color = result.decision === "BLOCK_ASSISTANT_OUTPUT" ? "#b91c1c" : result.decision === "HOLD_FOR_EDITOR" ? "#b45309" : "#15803d"; + return [ + ` ${result.packetId}`, + ` `, + ` ${result.decision}` + ].join("\n"); + }).join("\n"); + return ` + + + Citation Retraction Evidence Guard + blocks=${summary.blocks} reviews=${summary.reviews} passes=${summary.passes} +${rows} + +`; +} + +fs.writeFileSync(path.join(artifactDir, "citation-evidence-summary.svg"), svg()); + +function writePpmFrame(file) { + const width = 640; + const height = 360; + const pixels = []; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + let color = [248, 250, 252]; + if (x > 70 && x < 570 && y > 80 && y < 128) color = [21, 128, 61]; + if (x > 70 && x < 570 && y > 156 && y < 204) color = [180, 83, 9]; + if (x > 70 && x < 570 && y > 232 && y < 280) color = [185, 28, 28]; + pixels.push(Buffer.from(color)); + } + } + fs.writeFileSync(file, Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), ...pixels])); +} + +function renderVideo() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "citation-evidence-")); + const frame = path.join(tempDir, "frame.ppm"); + const output = path.join(artifactDir, "citation-evidence-demo.mp4"); + writePpmFrame(frame); + const result = spawnSync("ffmpeg", [ + "-y", + "-loop", "1", + "-i", frame, + "-t", "4", + "-vf", "scale=640:360,format=yuv420p", + output + ], { stdio: "ignore" }); + if (result.status !== 0) { + fs.writeFileSync(path.join(artifactDir, "citation-evidence-demo.txt"), "ffmpeg unavailable; SVG summary is the visual demo artifact.\n"); + } +} + +renderVideo(); + +console.log("Citation retraction evidence guard demo generated"); +console.log(`- decisions: ${results.map((result) => `${result.packetId}:${result.decision}`).join(", ")}`); +console.log(`- summary: ${JSON.stringify(summary)}`); diff --git a/citation-retraction-evidence-guard/src/index.js b/citation-retraction-evidence-guard/src/index.js new file mode 100644 index 00000000..3f151cc1 --- /dev/null +++ b/citation-retraction-evidence-guard/src/index.js @@ -0,0 +1,310 @@ +"use strict"; + +const RELEASE = "RELEASE_ASSISTANT_OUTPUT"; +const HOLD = "HOLD_FOR_EDITOR"; +const BLOCK = "BLOCK_ASSISTANT_OUTPUT"; + +function asDate(value, field, findings, severity = "review") { + if (!value) { + findings.push({ + severity, + code: `${field}_missing`, + message: `${field} is missing.`, + remediation: `Attach ${field} evidence before release.` + }); + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + findings.push({ + severity: "block", + code: `${field}_invalid`, + message: `${field} is not a valid date: ${value}.`, + remediation: `Normalize ${field} to ISO-8601.` + }); + return null; + } + return date; +} + +function yearsBetween(older, newer) { + return (newer.getTime() - older.getTime()) / (365.25 * 24 * 60 * 60 * 1000); +} + +function normalizeList(value) { + return Array.isArray(value) ? value : []; +} + +function addFinding(findings, severity, code, message, remediation, ref) { + findings.push({ severity, code, message, remediation, ref }); +} + +function indexCitations(citations) { + return new Map(normalizeList(citations).map((citation) => [citation.id, citation])); +} + +function evaluateCitation(citation, packet, options, findings) { + const now = asDate(options.asOf || packet.generatedAt || "2026-06-14", "asOf", findings, "block"); + const publishedAt = asDate(citation.publishedAt, "publishedAt", findings); + + if (!citation.id || !citation.title || !citation.doi) { + addFinding( + findings, + "review", + "citation_identity_incomplete", + `Citation identity is incomplete for ${citation.id || "unknown citation"}.`, + "Attach stable citation id, title, and DOI before assistant output release.", + citation.id + ); + } + + if (citation.status === "retracted") { + addFinding( + findings, + "block", + "citation_retracted", + `${citation.id} is marked retracted.`, + "Remove the citation from supporting evidence or rewrite the assistant note as a retraction warning.", + citation.id + ); + } + + if (citation.status === "expression_of_concern") { + addFinding( + findings, + "review", + "citation_expression_of_concern", + `${citation.id} has an expression of concern.`, + "Route to an editor before AI review output cites this source as support.", + citation.id + ); + } + + if (citation.status === "superseded" || citation.publishedVersionId) { + addFinding( + findings, + "review", + "citation_superseded", + `${citation.id} appears superseded by ${citation.publishedVersionId || "a newer version"}.`, + "Prefer the current published version or explain why the older version remains relevant.", + citation.id + ); + } + + if (citation.sourceType === "preprint" && !citation.publishedVersionId && citation.usedAs === "primary_support") { + addFinding( + findings, + "review", + "primary_preprint_without_published_version_check", + `${citation.id} is a primary-support preprint without a published-version check.`, + "Record a published-version search or downgrade the assistant confidence.", + citation.id + ); + } + + if (now && publishedAt && yearsBetween(publishedAt, now) > (options.maxEvidenceAgeYears || 8) && !citation.hasRecentReplication) { + addFinding( + findings, + "review", + "citation_stale_without_replication", + `${citation.id} is older than the configured evidence window and has no recent replication marker.`, + "Attach a recent replication, meta-analysis, or recency caveat.", + citation.id + ); + } + + if (!citation.contextQuote || citation.contextQuote.length < 24) { + addFinding( + findings, + "review", + "citation_context_missing", + `${citation.id} lacks a usable citation-context quote.`, + "Attach the local evidence sentence used by the assistant.", + citation.id + ); + } +} + +function evaluateClaim(claim, citationMap, findings) { + const evidence = normalizeList(claim.evidence); + if (!claim.id || !claim.text) { + addFinding( + findings, + "block", + "claim_identity_missing", + "A claim is missing id or text.", + "Attach stable claim id and text before review output release.", + claim.id + ); + } + + if (evidence.length === 0) { + addFinding( + findings, + "block", + "claim_without_evidence", + `${claim.id} has no citation evidence.`, + "Attach at least one citation or remove the assistant claim.", + claim.id + ); + } + + for (const item of evidence) { + const citation = citationMap.get(item.citationId); + if (!citation) { + addFinding( + findings, + "block", + "claim_evidence_missing_citation", + `${claim.id} references missing citation ${item.citationId}.`, + "Add the cited source packet or remove the evidence reference.", + item.citationId + ); + continue; + } + + if (item.supportRelation === "contradicts") { + addFinding( + findings, + "block", + "citation_contradicts_claim", + `${citation.id} is marked as contradicting ${claim.id}.`, + "Rewrite the assistant note to present the contradiction rather than support the claim.", + citation.id + ); + } + + if (item.supportRelation === "background") { + addFinding( + findings, + "review", + "background_citation_used_as_support", + `${citation.id} is only background evidence for ${claim.id}.`, + "Attach direct support or lower the assistant confidence.", + citation.id + ); + } + + if (citation.status === "retracted" && item.supportRelation === "supports") { + addFinding( + findings, + "block", + "retracted_citation_supports_claim", + `${claim.id} is supported by retracted citation ${citation.id}.`, + "Remove this evidence path and regenerate the assistant output.", + citation.id + ); + } + } +} + +function evaluateAssistantNote(note, citationMap, findings) { + if (!note || !note.text) { + addFinding( + findings, + "review", + "assistant_note_missing", + "Assistant note text is missing.", + "Generate reviewer-visible text only after the evidence packet is complete.", + "assistantNote" + ); + return; + } + + for (const citationId of normalizeList(note.recommendedCitationIds)) { + const citation = citationMap.get(citationId); + if (!citation) { + addFinding( + findings, + "block", + "assistant_recommends_missing_citation", + `Assistant recommends missing citation ${citationId}.`, + "Remove the recommendation or attach the cited source packet.", + citationId + ); + continue; + } + if (citation.status === "retracted") { + addFinding( + findings, + "block", + "assistant_recommends_retracted_citation", + `Assistant recommends retracted citation ${citationId}.`, + "Regenerate the assistant note with retraction-safe evidence.", + citationId + ); + } + } +} + +function decide(findings) { + if (findings.some((finding) => finding.severity === "block")) return BLOCK; + if (findings.some((finding) => finding.severity === "review")) return HOLD; + return RELEASE; +} + +function evaluateCitationEvidencePacket(packet, options = {}) { + const findings = []; + if (!packet || typeof packet !== "object") { + throw new TypeError("packet must be an object"); + } + if (!packet.packetId || !packet.manuscriptId) { + addFinding( + findings, + "block", + "packet_identity_missing", + "Packet id or manuscript id is missing.", + "Attach packetId and manuscriptId before release.", + "packet" + ); + } + + const citations = normalizeList(packet.citations); + const citationMap = indexCitations(citations); + for (const citation of citations) evaluateCitation(citation, packet, options, findings); + for (const claim of normalizeList(packet.claims)) evaluateClaim(claim, citationMap, findings); + evaluateAssistantNote(packet.assistantNote, citationMap, findings); + + if (findings.length === 0) { + addFinding( + findings, + "pass", + "citation_evidence_ready", + "Citation evidence is current, unretracted, and aligned with assistant output.", + "No remediation required.", + packet.packetId + ); + } + + return { + packetId: packet.packetId, + manuscriptId: packet.manuscriptId, + decision: decide(findings), + findings + }; +} + +function evaluateCitationEvidenceBatch(packets, options = {}) { + return normalizeList(packets).map((packet) => evaluateCitationEvidencePacket(packet, options)); +} + +function summarize(results) { + const summary = { blocks: 0, reviews: 0, passes: 0 }; + for (const result of normalizeList(results)) { + for (const finding of result.findings) { + if (finding.severity === "block") summary.blocks += 1; + if (finding.severity === "review") summary.reviews += 1; + if (finding.severity === "pass") summary.passes += 1; + } + } + return summary; +} + +module.exports = { + RELEASE, + HOLD, + BLOCK, + evaluateCitationEvidencePacket, + evaluateCitationEvidenceBatch, + summarize +}; diff --git a/citation-retraction-evidence-guard/test/citationRetractionEvidenceGuard.test.js b/citation-retraction-evidence-guard/test/citationRetractionEvidenceGuard.test.js new file mode 100644 index 00000000..735a6486 --- /dev/null +++ b/citation-retraction-evidence-guard/test/citationRetractionEvidenceGuard.test.js @@ -0,0 +1,59 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const packets = require("../examples/citation-evidence-packets.json"); +const { + RELEASE, + HOLD, + BLOCK, + evaluateCitationEvidencePacket, + evaluateCitationEvidenceBatch, + summarize +} = require("../src"); + +const results = evaluateCitationEvidenceBatch(packets, { asOf: "2026-06-14", maxEvidenceAgeYears: 8 }); + +assert.equal(results[0].decision, RELEASE); +assert.deepEqual( + results[0].findings.map((finding) => finding.code), + ["citation_evidence_ready"] +); + +assert.equal(results[1].decision, HOLD); +assert(results[1].findings.some((finding) => finding.code === "citation_superseded")); +assert(results[1].findings.some((finding) => finding.code === "background_citation_used_as_support")); + +assert.equal(results[2].decision, BLOCK); +assert(results[2].findings.some((finding) => finding.code === "citation_retracted")); +assert(results[2].findings.some((finding) => finding.code === "retracted_citation_supports_claim")); +assert(results[2].findings.some((finding) => finding.code === "citation_contradicts_claim")); +assert(results[2].findings.some((finding) => finding.code === "assistant_recommends_retracted_citation")); + +const missingCitation = evaluateCitationEvidencePacket({ + packetId: "packet:missing-citation", + manuscriptId: "ms:missing-citation", + generatedAt: "2026-06-14", + claims: [ + { + id: "claim:unsupported", + text: "Unsupported claim.", + evidence: [{ citationId: "cite:missing", supportRelation: "supports" }] + } + ], + citations: [], + assistantNote: { + text: "Recommend a missing source.", + recommendedCitationIds: ["cite:missing"] + } +}); + +assert.equal(missingCitation.decision, BLOCK); +assert(missingCitation.findings.some((finding) => finding.code === "claim_evidence_missing_citation")); +assert(missingCitation.findings.some((finding) => finding.code === "assistant_recommends_missing_citation")); + +const summary = summarize(results); +assert.equal(summary.blocks, 4); +assert.equal(summary.reviews, 3); +assert.equal(summary.passes, 1); + +console.log("citation retraction evidence guard tests passed");