From b890ab55cb331846941ed4115e106cee9ebabd89 Mon Sep 17 00:00:00 2001 From: zeemac Date: Sun, 14 Jun 2026 00:54:50 +0900 Subject: [PATCH] Add citation retraction evidence guard --- citation-retraction-evidence-guard/README.md | 41 +++ .../artifacts/citation-evidence-demo.mp4 | Bin 0 -> 6750 bytes .../artifacts/citation-evidence-report.md | 35 ++ .../artifacts/citation-evidence-results.json | 87 +++++ .../artifacts/citation-evidence-summary.svg | 20 ++ .../examples/citation-evidence-packets.json | 120 +++++++ .../package.json | 11 + .../scripts/demo.js | 110 +++++++ .../src/index.js | 310 ++++++++++++++++++ .../citationRetractionEvidenceGuard.test.js | 59 ++++ 10 files changed, 793 insertions(+) create mode 100644 citation-retraction-evidence-guard/README.md create mode 100644 citation-retraction-evidence-guard/artifacts/citation-evidence-demo.mp4 create mode 100644 citation-retraction-evidence-guard/artifacts/citation-evidence-report.md create mode 100644 citation-retraction-evidence-guard/artifacts/citation-evidence-results.json create mode 100644 citation-retraction-evidence-guard/artifacts/citation-evidence-summary.svg create mode 100644 citation-retraction-evidence-guard/examples/citation-evidence-packets.json create mode 100644 citation-retraction-evidence-guard/package.json create mode 100644 citation-retraction-evidence-guard/scripts/demo.js create mode 100644 citation-retraction-evidence-guard/src/index.js create mode 100644 citation-retraction-evidence-guard/test/citationRetractionEvidenceGuard.test.js 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 0000000000000000000000000000000000000000..f89cff1796ee0d094ffe69da48de7bdb3c48b39f GIT binary patch literal 6750 zcmeHMc~nzZ8owceBC@Cms3?yrE{81%t2h~r$P}s`J20YFZRX|Wg#@zk-UEWpC<-bw zf>y1l-~>8T7Yfz76_=t|MXZk2sg8=rV{w7H)Q(#f6v=$|1r!s`cn*iZ=5SBmyWjo3 z?|%1pzq@_!1|dYKxNM__HRup>Ksbj-LS|B8oiSXD5bCF5Xd0nGluk)nX$l0f24l9CR&j(xBnk_bh$IofRCAm$CNwlFD=Q>ZqofU5QXgVqQbKVn zA!<&i1s#Kt(-`z@3_+0!l9GxEhE_?#2qmr18mKg>I7SpBB1k={&1Pw-$P^YMGKs}u zgpQV~X%oR_D1eG0jBGYkg}0nhO2r`}s01%Ur!mn=IkqT<8ghozr_fSyBtfYegN~F# zRWZRav{tKOfr>UoD=7{LDqSZP0R&RH(4eQK5^<1NOsGhflN;GIjS*{D4Wt|829=7X zIcczj;M5G%07GIzYcQmdYADHHGYM9!p#V$=LqzBqz7a~JBRLF2qvvQwOM(*c71|7j z%$8FIoss0AOo5L$hScc61Sm2j)=@E}j%LAI3b`>GXpK?|gC!@Gq!D*RAy;Tf7B`|{ zXue}vv?fK(DL`g0(t3G{!3a_-(+E}5=xnepl|+czNx4p=ha$mJw4SCiIBB>D!(>S8 zQ-)^MP@SRVue*!o3?-!)6`@mrV{x~jAeDrLh=_FD8L22F5=bL9%;%&@8aV+NEJquq z;e^HrqXaVo1`%wL=`aECaH1TTvW0Yh9-3%ryH(NbS-)?2gQW6zrRMP=^|#09C&*jf zUZmC_B;^p6`E8l#sVt zH)oTiuzX>&G}ARl^~xi3_tmPbe0Sy2>RAOxgBMrc_^ki2n7to(zgjn6;zFBqa~HHZ ztUv0J{_y!vF{KX#t&U<-%(ScHP>|mS*U%lmnY3be{NnSwTi3N6t8&WU8#vbMeiPO7 zRcn1g$}Y~u{a z1J7ofzw}vjEN-A+V(qEyjOS6Kc9yj?S$;Tu;QKX)e1lr5FACCEIWYfDQeAEu#pNzC zJo(dM+2MQM%l_s&t~zh-`IfTSrN2%7XWJV8D%=X8k?)z8dTOHksaes5l^(|j&7lTt zl5g1+^?_xsMjxBH>i3uT+-%NOoujhjzrA!u_%bT$@$nqw^Fo-o{hJYU=bLgvBz4x<(93<%Ta=_s0Ph8hr8hh@@qbxrzY4z-)0r3cJ z7#q>ruxQK1pwQx?euH;UtsM1-&^yJ|LpFbn(6GAk`>2-*%JSO3$n+<-t&)vwxeV=-p+gcv=wn&&Xl?A#w(=t6+E|yWJaeMNETnoPJuB z`mY$b4LG>}5OeU#m*37X z?b@tAUJx~6#igwuy1J(SeL&#uj{+;AWW@Ms33bMU&PCWT?zV@_ydrL3P$=Ix_JP!-40MC0MfnJbl_r*r|xx)pU^_FeBoHp{+eJhY+@sX9h9sBUnE^1tA zwb`w0!&_F;5xy8KZQJwOBj`G-$!-N8Ub2#o03^lIX7=DsI{nI9>|O-nB`Y4)9zo2u zv_((mEe;o1Ez01;4eW^g@n;6a`HHt9kiHjm216!Dw7N{S67o(-cn2Z(G9*Cw_a?nn z0Jcqe@*B;rC{Dpg3BF#iTB&7t3SRhftb@jRGQKQ}C-q7# zjdh&h9!swR!ZTCH8|@5xw9+c`Rl=1Ft_%sw2V^i>!b&?fu$)2*^j9p$b_@vCDZ}@J zkmJ&wRi69YwpR;)mYMp|y5 zp(&78)=w@AZ6WbAf>wk`;_ek(BtTB;`?t=#SM#d58Q1oN`=WNIj)pIAKKZa5?{Hke z_y9YMHRwV=@Ic^%`Pk|>>WwaC<-wrvvi|LPM;RA!9o`n?y>hERdi`Vd1D+oLPxGT6 z?hj0Me!ly{+QI7sr}njj*A=D(pMz{6FR{LW?>H4m1w2FApFxn%g~t;fO!t=G?T + + + 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");