diff --git a/citation-context-reconciler/README.md b/citation-context-reconciler/README.md new file mode 100644 index 0000000..fb6a042 --- /dev/null +++ b/citation-context-reconciler/README.md @@ -0,0 +1,34 @@ +# Citation Context Reconciler + +`citation-context-reconciler` is a focused SCIBASE AI research assistant module for issue #16. It helps authors and reviewers catch citation drift before a manuscript leaves the workspace: sources cited as evidence are checked against the actual source context, effect direction, method match, population match, recency, and reproducibility artifacts. + +The slice is intentionally narrower than a broad copilot. It answers a practical question during pre-submission review: "Are these citations being used honestly enough for a reviewer, journal, funder, or replication team to trust the claim?" + +## What It Does + +- Reviews manuscript claims against cited source metadata. +- Detects direct blockers such as contradictory cited effects, citation-intent mismatch, retracted citations, missing sources, and effect-direction mismatch. +- Raises warnings for mixed evidence, stale citations, population/method context gaps, and missing raw data/code/protocol evidence. +- Generates reviewer comments and author revision tasks. +- Scores reproducibility support from raw data, code, and registered protocol availability. +- Ranks research opportunities from contradictions, lab capabilities, and researcher interests. +- Exports a deterministic assistant packet with an audit digest for handoff and version history. + +## Files + +- `src/citation-context-reconciler.js` - dependency-free reconciliation engine. +- `sample/citation-context-packet.json` - synthetic manuscript/source packet. +- `test/citation-context-reconciler.test.js` - executable behavioral tests. +- `scripts/demo.js` - writes `docs/citation-context-report.json` and `docs/demo.svg`. +- `scripts/render-demo-video.m` - macOS-native MP4 renderer for `docs/demo.mp4`. +- `docs/requirement-map.md` - issue #16 capability mapping. + +## Validation + +```sh +npm test +npm run demo +npm run demo:video +``` + +The demo uses only local synthetic data and does not call an AI provider or external service. diff --git a/citation-context-reconciler/docs/citation-context-report.json b/citation-context-reconciler/docs/citation-context-report.json new file mode 100644 index 0000000..d9defda --- /dev/null +++ b/citation-context-reconciler/docs/citation-context-report.json @@ -0,0 +1,697 @@ +{ + "ready": false, + "counts": { + "claims": 3, + "sources": 4, + "blockers": 5, + "warnings": 6, + "opportunities": 2 + }, + "findings": [ + { + "severity": "blocker", + "code": "contradicting-source-used-as-support", + "claimId": "claim-cytokine-recovery", + "section": "Results", + "sourceIds": [ + "src-recent-null-meta" + ], + "message": "src-recent-null-meta (2025) contradicts the claim but is cited without a limitation note.", + "task": "Move the source into a limitation sentence or add a rebuttal-backed explanation." + }, + { + "severity": "blocker", + "code": "contradictory-cited-effects", + "claimId": "claim-cytokine-recovery", + "section": "Results", + "sourceIds": [ + "src-legacy-pacing", + "src-recent-null-meta" + ], + "message": "The cited sources disagree on effect direction: lower, no-effect.", + "task": "Rewrite the claim as contested or add an adjudication note explaining why one direction is preferred." + }, + { + "severity": "blocker", + "code": "effect-direction-mismatch", + "claimId": "claim-cytokine-recovery", + "section": "Results", + "sourceIds": [ + "src-recent-null-meta" + ], + "message": "src-recent-null-meta (2025) has effect direction no-effect, but the claim states lower.", + "task": "Align the claim with the source or cite a direct source for the stated effect direction." + }, + { + "severity": "blocker", + "code": "citation-intent-mismatch", + "claimId": "claim-pipeline-reproducible", + "section": "Methods", + "sourceIds": [ + "src-workflow-note" + ], + "message": "src-workflow-note (2026) is tagged for method, not direct evidence.", + "task": "Use the citation as context only or replace it with a direct empirical support source." + }, + { + "severity": "blocker", + "code": "citation-intent-mismatch", + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "sourceIds": [ + "src-scrna-review" + ], + "message": "src-scrna-review (2022) is tagged for background, not direct evidence.", + "task": "Use the citation as context only or replace it with a direct empirical support source." + }, + { + "severity": "warning", + "code": "population-context-gap", + "claimId": "claim-cytokine-recovery", + "section": "Results", + "sourceIds": [ + "src-legacy-pacing" + ], + "message": "src-legacy-pacing (2014) does not cover the claim population: older-adults, post-viral-fatigue.", + "task": "Add population-matched evidence or narrow the claim." + }, + { + "severity": "warning", + "code": "reproducibility-evidence-gap", + "claimId": "claim-cytokine-recovery", + "section": "Results", + "sourceIds": [ + "src-legacy-pacing" + ], + "message": "src-legacy-pacing (2014) is missing reproducibility evidence: code, protocol.", + "task": "Link the missing artifacts or lower the reproducibility confidence for this claim." + }, + { + "severity": "warning", + "code": "stale-citation", + "claimId": "claim-cytokine-recovery", + "section": "Results", + "sourceIds": [ + "src-legacy-pacing" + ], + "message": "src-legacy-pacing (2014) is older than the configured 7-year recency window.", + "task": "Run a recency search and add a current synthesis or explain why the older source is canonical." + }, + { + "severity": "warning", + "code": "reproducibility-evidence-gap", + "claimId": "claim-pipeline-reproducible", + "section": "Methods", + "sourceIds": [ + "src-workflow-note" + ], + "message": "src-workflow-note (2026) is missing reproducibility evidence: raw data, protocol.", + "task": "Link the missing artifacts or lower the reproducibility confidence for this claim." + }, + { + "severity": "warning", + "code": "mixed-source-needs-qualification", + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "sourceIds": [ + "src-scrna-review" + ], + "message": "src-scrna-review (2022) reports mixed evidence and needs qualified language.", + "task": "Add uncertainty language and identify which subgroup or assay is supported." + }, + { + "severity": "warning", + "code": "population-context-gap", + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "sourceIds": [ + "src-scrna-review" + ], + "message": "src-scrna-review (2022) does not cover the claim population: post-viral-fatigue.", + "task": "Add population-matched evidence or narrow the claim." + } + ], + "reviewerQueue": [ + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "blocker", + "body": "Resolve before release: src-recent-null-meta (2025) contradicts the claim but is cited without a limitation note." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "blocker", + "body": "Resolve before release: The cited sources disagree on effect direction: lower, no-effect." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "blocker", + "body": "Resolve before release: src-recent-null-meta (2025) has effect direction no-effect, but the claim states lower." + }, + { + "claimId": "claim-pipeline-reproducible", + "section": "Methods", + "severity": "blocker", + "body": "Resolve before release: src-workflow-note (2026) is tagged for method, not direct evidence." + }, + { + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "severity": "blocker", + "body": "Resolve before release: src-scrna-review (2022) is tagged for background, not direct evidence." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "warning", + "body": "Review before submission: src-legacy-pacing (2014) does not cover the claim population: older-adults, post-viral-fatigue." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "warning", + "body": "Review before submission: src-legacy-pacing (2014) is missing reproducibility evidence: code, protocol." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "warning", + "body": "Review before submission: src-legacy-pacing (2014) is older than the configured 7-year recency window." + }, + { + "claimId": "claim-pipeline-reproducible", + "section": "Methods", + "severity": "warning", + "body": "Review before submission: src-workflow-note (2026) is missing reproducibility evidence: raw data, protocol." + }, + { + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "severity": "warning", + "body": "Review before submission: src-scrna-review (2022) reports mixed evidence and needs qualified language." + }, + { + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "severity": "warning", + "body": "Review before submission: src-scrna-review (2022) does not cover the claim population: post-viral-fatigue." + } + ], + "revisionTasks": [ + { + "claimId": "claim-cytokine-recovery", + "code": "contradicting-source-used-as-support", + "ownerHint": "author", + "task": "Move the source into a limitation sentence or add a rebuttal-backed explanation." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "contradictory-cited-effects", + "ownerHint": "author", + "task": "Rewrite the claim as contested or add an adjudication note explaining why one direction is preferred." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "effect-direction-mismatch", + "ownerHint": "author", + "task": "Align the claim with the source or cite a direct source for the stated effect direction." + }, + { + "claimId": "claim-pipeline-reproducible", + "code": "citation-intent-mismatch", + "ownerHint": "author", + "task": "Use the citation as context only or replace it with a direct empirical support source." + }, + { + "claimId": "claim-scrna-pathway", + "code": "citation-intent-mismatch", + "ownerHint": "author", + "task": "Use the citation as context only or replace it with a direct empirical support source." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "population-context-gap", + "ownerHint": "reviewer", + "task": "Add population-matched evidence or narrow the claim." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "reproducibility-evidence-gap", + "ownerHint": "reviewer", + "task": "Link the missing artifacts or lower the reproducibility confidence for this claim." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "stale-citation", + "ownerHint": "reviewer", + "task": "Run a recency search and add a current synthesis or explain why the older source is canonical." + }, + { + "claimId": "claim-pipeline-reproducible", + "code": "reproducibility-evidence-gap", + "ownerHint": "reviewer", + "task": "Link the missing artifacts or lower the reproducibility confidence for this claim." + }, + { + "claimId": "claim-scrna-pathway", + "code": "mixed-source-needs-qualification", + "ownerHint": "reviewer", + "task": "Add uncertainty language and identify which subgroup or assay is supported." + }, + { + "claimId": "claim-scrna-pathway", + "code": "population-context-gap", + "ownerHint": "reviewer", + "task": "Add population-matched evidence or narrow the claim." + } + ], + "citationMatrix": [ + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "citedSources": [ + { + "id": "src-legacy-pacing", + "stance": "supports", + "intent": "evidence", + "effectDirection": "lower", + "methodOverlap": [ + "cytokine-panel" + ], + "populationOverlap": [], + "reproducibilityEvidence": { + "rawData": true, + "code": false, + "protocol": false + } + }, + { + "id": "src-recent-null-meta", + "stance": "contradicts", + "intent": "limitation", + "effectDirection": "no-effect", + "methodOverlap": [ + "wearable-sensor-cohort", + "cytokine-panel" + ], + "populationOverlap": [ + "older-adults", + "post-viral-fatigue" + ], + "reproducibilityEvidence": { + "rawData": true, + "code": true, + "protocol": true + } + } + ] + }, + { + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "citedSources": [ + { + "id": "src-scrna-review", + "stance": "mixed", + "intent": "background", + "effectDirection": "mixed", + "methodOverlap": [ + "single-cell-rna-seq" + ], + "populationOverlap": [], + "reproducibilityEvidence": { + "rawData": false, + "code": false, + "protocol": false + } + } + ] + }, + { + "claimId": "claim-pipeline-reproducible", + "section": "Methods", + "citedSources": [ + { + "id": "src-workflow-note", + "stance": "supports", + "intent": "method", + "effectDirection": "unknown", + "methodOverlap": [ + "open-analysis-pipeline" + ], + "populationOverlap": [ + "post-viral-fatigue" + ], + "reproducibilityEvidence": { + "rawData": false, + "code": true, + "protocol": false + } + } + ] + } + ], + "reproducibility": { + "score": 50, + "claims": [ + { + "claimId": "claim-cytokine-recovery", + "score": 67, + "required": true + }, + { + "claimId": "claim-scrna-pathway", + "score": 0, + "required": false + }, + { + "claimId": "claim-pipeline-reproducible", + "score": 33, + "required": true + } + ], + "confidence": "low" + }, + "opportunityFeed": [ + { + "id": "gap-older-adult-replication", + "title": "Replicate pacing cytokine effects in older wearable cohorts", + "priority": 17, + "capabilityOverlap": [ + "wearable-sensor-cohort", + "open-analysis-pipeline" + ], + "interestOverlap": [ + "post-viral-fatigue", + "cytokine-signaling", + "replication" + ], + "linkedBlockers": [ + "claim-cytokine-recovery" + ], + "nextAction": "Run a preregistered replication with age-stratified cytokine endpoints." + }, + { + "id": "gap-crispr-validation", + "title": "Validate single-cell pathway claims with direct CRISPR evidence", + "priority": 11, + "capabilityOverlap": [ + "single-cell-rna-seq" + ], + "interestOverlap": [ + "cytokine-signaling", + "replication" + ], + "linkedBlockers": [ + "claim-scrna-pathway" + ], + "nextAction": "Search for direct perturbation studies or mark the pathway as hypothesis-generating." + } + ], + "exportPacket": { + "projectId": "scibase-ms-42", + "generatedAt": "2026-05-16T18:20:00.000Z", + "citationMatrix": [ + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "citedSources": [ + { + "id": "src-legacy-pacing", + "stance": "supports", + "intent": "evidence", + "effectDirection": "lower", + "methodOverlap": [ + "cytokine-panel" + ], + "populationOverlap": [], + "reproducibilityEvidence": { + "rawData": true, + "code": false, + "protocol": false + } + }, + { + "id": "src-recent-null-meta", + "stance": "contradicts", + "intent": "limitation", + "effectDirection": "no-effect", + "methodOverlap": [ + "wearable-sensor-cohort", + "cytokine-panel" + ], + "populationOverlap": [ + "older-adults", + "post-viral-fatigue" + ], + "reproducibilityEvidence": { + "rawData": true, + "code": true, + "protocol": true + } + } + ] + }, + { + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "citedSources": [ + { + "id": "src-scrna-review", + "stance": "mixed", + "intent": "background", + "effectDirection": "mixed", + "methodOverlap": [ + "single-cell-rna-seq" + ], + "populationOverlap": [], + "reproducibilityEvidence": { + "rawData": false, + "code": false, + "protocol": false + } + } + ] + }, + { + "claimId": "claim-pipeline-reproducible", + "section": "Methods", + "citedSources": [ + { + "id": "src-workflow-note", + "stance": "supports", + "intent": "method", + "effectDirection": "unknown", + "methodOverlap": [ + "open-analysis-pipeline" + ], + "populationOverlap": [ + "post-viral-fatigue" + ], + "reproducibilityEvidence": { + "rawData": false, + "code": true, + "protocol": false + } + } + ] + } + ], + "reviewerQueue": [ + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "blocker", + "body": "Resolve before release: src-recent-null-meta (2025) contradicts the claim but is cited without a limitation note." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "blocker", + "body": "Resolve before release: The cited sources disagree on effect direction: lower, no-effect." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "blocker", + "body": "Resolve before release: src-recent-null-meta (2025) has effect direction no-effect, but the claim states lower." + }, + { + "claimId": "claim-pipeline-reproducible", + "section": "Methods", + "severity": "blocker", + "body": "Resolve before release: src-workflow-note (2026) is tagged for method, not direct evidence." + }, + { + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "severity": "blocker", + "body": "Resolve before release: src-scrna-review (2022) is tagged for background, not direct evidence." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "warning", + "body": "Review before submission: src-legacy-pacing (2014) does not cover the claim population: older-adults, post-viral-fatigue." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "warning", + "body": "Review before submission: src-legacy-pacing (2014) is missing reproducibility evidence: code, protocol." + }, + { + "claimId": "claim-cytokine-recovery", + "section": "Results", + "severity": "warning", + "body": "Review before submission: src-legacy-pacing (2014) is older than the configured 7-year recency window." + }, + { + "claimId": "claim-pipeline-reproducible", + "section": "Methods", + "severity": "warning", + "body": "Review before submission: src-workflow-note (2026) is missing reproducibility evidence: raw data, protocol." + }, + { + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "severity": "warning", + "body": "Review before submission: src-scrna-review (2022) reports mixed evidence and needs qualified language." + }, + { + "claimId": "claim-scrna-pathway", + "section": "Discussion", + "severity": "warning", + "body": "Review before submission: src-scrna-review (2022) does not cover the claim population: post-viral-fatigue." + } + ], + "revisionTasks": [ + { + "claimId": "claim-cytokine-recovery", + "code": "contradicting-source-used-as-support", + "ownerHint": "author", + "task": "Move the source into a limitation sentence or add a rebuttal-backed explanation." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "contradictory-cited-effects", + "ownerHint": "author", + "task": "Rewrite the claim as contested or add an adjudication note explaining why one direction is preferred." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "effect-direction-mismatch", + "ownerHint": "author", + "task": "Align the claim with the source or cite a direct source for the stated effect direction." + }, + { + "claimId": "claim-pipeline-reproducible", + "code": "citation-intent-mismatch", + "ownerHint": "author", + "task": "Use the citation as context only or replace it with a direct empirical support source." + }, + { + "claimId": "claim-scrna-pathway", + "code": "citation-intent-mismatch", + "ownerHint": "author", + "task": "Use the citation as context only or replace it with a direct empirical support source." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "population-context-gap", + "ownerHint": "reviewer", + "task": "Add population-matched evidence or narrow the claim." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "reproducibility-evidence-gap", + "ownerHint": "reviewer", + "task": "Link the missing artifacts or lower the reproducibility confidence for this claim." + }, + { + "claimId": "claim-cytokine-recovery", + "code": "stale-citation", + "ownerHint": "reviewer", + "task": "Run a recency search and add a current synthesis or explain why the older source is canonical." + }, + { + "claimId": "claim-pipeline-reproducible", + "code": "reproducibility-evidence-gap", + "ownerHint": "reviewer", + "task": "Link the missing artifacts or lower the reproducibility confidence for this claim." + }, + { + "claimId": "claim-scrna-pathway", + "code": "mixed-source-needs-qualification", + "ownerHint": "reviewer", + "task": "Add uncertainty language and identify which subgroup or assay is supported." + }, + { + "claimId": "claim-scrna-pathway", + "code": "population-context-gap", + "ownerHint": "reviewer", + "task": "Add population-matched evidence or narrow the claim." + } + ], + "reproducibility": { + "score": 50, + "claims": [ + { + "claimId": "claim-cytokine-recovery", + "score": 67, + "required": true + }, + { + "claimId": "claim-scrna-pathway", + "score": 0, + "required": false + }, + { + "claimId": "claim-pipeline-reproducible", + "score": 33, + "required": true + } + ], + "confidence": "low" + }, + "opportunityFeed": [ + { + "id": "gap-older-adult-replication", + "title": "Replicate pacing cytokine effects in older wearable cohorts", + "priority": 17, + "capabilityOverlap": [ + "wearable-sensor-cohort", + "open-analysis-pipeline" + ], + "interestOverlap": [ + "post-viral-fatigue", + "cytokine-signaling", + "replication" + ], + "linkedBlockers": [ + "claim-cytokine-recovery" + ], + "nextAction": "Run a preregistered replication with age-stratified cytokine endpoints." + }, + { + "id": "gap-crispr-validation", + "title": "Validate single-cell pathway claims with direct CRISPR evidence", + "priority": 11, + "capabilityOverlap": [ + "single-cell-rna-seq" + ], + "interestOverlap": [ + "cytokine-signaling", + "replication" + ], + "linkedBlockers": [ + "claim-scrna-pathway" + ], + "nextAction": "Search for direct perturbation studies or mark the pathway as hypothesis-generating." + } + ], + "auditDigest": "citation-context:5:6:c4998c5dea561415" + } +} diff --git a/citation-context-reconciler/docs/demo.mp4 b/citation-context-reconciler/docs/demo.mp4 new file mode 100644 index 0000000..b86c10c Binary files /dev/null and b/citation-context-reconciler/docs/demo.mp4 differ diff --git a/citation-context-reconciler/docs/demo.svg b/citation-context-reconciler/docs/demo.svg new file mode 100644 index 0000000..71a9bb0 --- /dev/null +++ b/citation-context-reconciler/docs/demo.svg @@ -0,0 +1,37 @@ + + + + + Citation Context Reconciler + SCIBASE issue #16 assistant slice for claim, citation, reproducibility, and opportunity review. + + Claims + 3 + + Blockers + 5 + + Warnings + 6 + + Reproducibility + 50% + + Reviewer queue + contradicting-source-used-as-support: claim-cytokine-recovery +contradictory-cited-effects: claim-cytokine-recovery +effect-direction-mismatch: claim-cytokine-recovery +citation-intent-mismatch: claim-pipeline-reproducible + Top opportunity: Replicate pacing cytokine effects in older wearable cohorts + citation-context:5:6:c4998c5dea561415 + \ No newline at end of file diff --git a/citation-context-reconciler/docs/requirement-map.md b/citation-context-reconciler/docs/requirement-map.md new file mode 100644 index 0000000..1c9597f --- /dev/null +++ b/citation-context-reconciler/docs/requirement-map.md @@ -0,0 +1,14 @@ +# Requirement Map + +This module targets SCIBASE issue #16, "AI-Powered Research Assistant Suite", with a narrow citation-context reconciliation slice. + +| Issue requirement | Implemented support | +| --- | --- | +| Auto peer review reports | Generates reviewer queue comments for citation misuse, contradictory evidence, stale support, method/population drift, and reproducibility gaps. | +| Claims vs. evidence alignment | Builds a citation matrix that compares manuscript claims with cited source stance, effect direction, citation intent, method tags, population tags, and artifact availability. | +| Missing citations or scope misalignment | Flags claims without citations, dangling source IDs, background/method citations used as direct evidence, method-context gaps, and population-context gaps. | +| Reproducibility checker | Computes claim-level and aggregate reproducibility confidence from raw data, code, and protocol evidence attached to cited sources. | +| Research gap finder | Ranks opportunity cards from contradiction signals, blocked claim links, lab capabilities, and researcher interests. | +| Reviewer-ready handoff | Exports deterministic revision tasks, review comments, citation matrix, opportunity feed, reproducibility score, and an audit digest. | + +The implementation is dependency-free, synthetic-data-only, and does not require external APIs, credentials, model calls, or private data. diff --git a/citation-context-reconciler/package.json b/citation-context-reconciler/package.json new file mode 100644 index 0000000..4a1513b --- /dev/null +++ b/citation-context-reconciler/package.json @@ -0,0 +1,15 @@ +{ + "name": "citation-context-reconciler", + "version": "1.0.0", + "description": "Citation-context reconciliation assistant for SCIBASE AI-powered research workflows.", + "type": "module", + "private": true, + "scripts": { + "test": "node test/citation-context-reconciler.test.js", + "demo": "node scripts/demo.js", + "demo:video": "mkdir -p docs && clang -fobjc-arc -framework Foundation -framework AppKit -framework AVFoundation -framework CoreMedia -framework CoreVideo scripts/render-demo-video.m -o /tmp/citation-context-demo && /tmp/citation-context-demo docs/demo.mp4" + }, + "exports": { + ".": "./src/citation-context-reconciler.js" + } +} diff --git a/citation-context-reconciler/sample/citation-context-packet.json b/citation-context-reconciler/sample/citation-context-packet.json new file mode 100644 index 0000000..77bc840 --- /dev/null +++ b/citation-context-reconciler/sample/citation-context-packet.json @@ -0,0 +1,128 @@ +{ + "generatedAt": "2026-05-16T18:20:00.000Z", + "project": { + "id": "scibase-ms-42", + "title": "Immune signatures in wearable-guided recovery cohorts", + "field": "translational immunology" + }, + "lab": { + "capabilities": ["single-cell-rna-seq", "wearable-sensor-cohort", "open-analysis-pipeline"], + "interests": ["post-viral-fatigue", "cytokine-signaling", "replication"] + }, + "claims": [ + { + "id": "claim-cytokine-recovery", + "section": "Results", + "text": "Wearable-guided pacing lowers inflammatory cytokine burden in older post-viral recovery cohorts.", + "citedSourceIds": ["src-legacy-pacing", "src-recent-null-meta"], + "methodTags": ["wearable-sensor-cohort", "cytokine-panel"], + "populations": ["older-adults", "post-viral-fatigue"], + "effectDirection": "lower", + "usesCitationAs": "evidence", + "requiresCurrentEvidence": true, + "requiresReproducibilityEvidence": true + }, + { + "id": "claim-scrna-pathway", + "section": "Discussion", + "text": "Single-cell CRISPR screens identify the same cytokine pathway as causal.", + "citedSourceIds": ["src-scrna-review"], + "methodTags": ["single-cell-rna-seq", "crispr-screen"], + "populations": ["post-viral-fatigue"], + "effectDirection": "higher", + "usesCitationAs": "evidence", + "requiresCurrentEvidence": true, + "requiresReproducibilityEvidence": false + }, + { + "id": "claim-pipeline-reproducible", + "section": "Methods", + "text": "The analysis pipeline is fully reproducible from raw data and registered protocols.", + "citedSourceIds": ["src-workflow-note"], + "methodTags": ["open-analysis-pipeline"], + "populations": ["post-viral-fatigue"], + "effectDirection": "unknown", + "usesCitationAs": "evidence", + "requiresCurrentEvidence": false, + "requiresReproducibilityEvidence": true + } + ], + "sources": [ + { + "id": "src-legacy-pacing", + "title": "Small pilot of pacing interventions after viral illness", + "year": 2014, + "stance": "supports", + "citationIntent": "evidence", + "effectDirection": "lower", + "methodTags": ["cytokine-panel"], + "populations": ["young-adults"], + "hasRawData": true, + "hasCode": false, + "hasProtocol": false, + "retracted": false + }, + { + "id": "src-recent-null-meta", + "title": "Meta-analysis of pacing and cytokine biomarkers", + "year": 2025, + "stance": "contradicts", + "citationIntent": "limitation", + "effectDirection": "no-effect", + "methodTags": ["wearable-sensor-cohort", "cytokine-panel"], + "populations": ["older-adults", "post-viral-fatigue"], + "hasRawData": true, + "hasCode": true, + "hasProtocol": true, + "retracted": false + }, + { + "id": "src-scrna-review", + "title": "Review of single-cell immune atlas methods", + "year": 2022, + "stance": "mixed", + "citationIntent": "background", + "effectDirection": "mixed", + "methodTags": ["single-cell-rna-seq"], + "populations": ["autoimmune-disease"], + "hasRawData": false, + "hasCode": false, + "hasProtocol": false, + "retracted": false + }, + { + "id": "src-workflow-note", + "title": "Internal workflow note for cohort preprocessing", + "year": 2026, + "stance": "supports", + "citationIntent": "method", + "effectDirection": "unknown", + "methodTags": ["open-analysis-pipeline"], + "populations": ["post-viral-fatigue"], + "hasRawData": false, + "hasCode": true, + "hasProtocol": false, + "retracted": false + } + ], + "corpusGaps": [ + { + "id": "gap-older-adult-replication", + "title": "Replicate pacing cytokine effects in older wearable cohorts", + "topicTags": ["post-viral-fatigue", "cytokine-signaling", "replication"], + "requiredCapabilities": ["wearable-sensor-cohort", "open-analysis-pipeline"], + "linkedClaimIds": ["claim-cytokine-recovery"], + "contradictionSignals": ["recent-null-meta-analysis", "population-mismatch"], + "nextAction": "Run a preregistered replication with age-stratified cytokine endpoints." + }, + { + "id": "gap-crispr-validation", + "title": "Validate single-cell pathway claims with direct CRISPR evidence", + "topicTags": ["cytokine-signaling", "replication"], + "requiredCapabilities": ["single-cell-rna-seq"], + "linkedClaimIds": ["claim-scrna-pathway"], + "contradictionSignals": ["background-only-citation"], + "nextAction": "Search for direct perturbation studies or mark the pathway as hypothesis-generating." + } + ] +} diff --git a/citation-context-reconciler/scripts/demo.js b/citation-context-reconciler/scripts/demo.js new file mode 100644 index 0000000..d93dd8b --- /dev/null +++ b/citation-context-reconciler/scripts/demo.js @@ -0,0 +1,64 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { reconcileCitationContext } from "../src/citation-context-reconciler.js"; + +const fixture = JSON.parse(readFileSync(new URL("../sample/citation-context-packet.json", import.meta.url), "utf8")); +const audit = reconcileCitationContext(fixture); + +mkdirSync(new URL("../docs/", import.meta.url), { recursive: true }); +writeFileSync( + new URL("../docs/citation-context-report.json", import.meta.url), + `${JSON.stringify(audit, null, 2)}\n` +); + +const blockerRows = audit.findings + .filter((item) => item.severity === "blocker") + .slice(0, 4) + .map((item, index) => { + const y = 228 + index * 42; + return `${item.code}: ${item.claimId}`; + }) + .join("\n"); + +const svg = ` + + + + Citation Context Reconciler + SCIBASE issue #16 assistant slice for claim, citation, reproducibility, and opportunity review. + + Claims + ${audit.counts.claims} + + Blockers + ${audit.counts.blockers} + + Warnings + ${audit.counts.warnings} + + Reproducibility + ${audit.reproducibility.score}% + + Reviewer queue + ${blockerRows} + Top opportunity: ${audit.opportunityFeed[0].title} + ${audit.exportPacket.auditDigest} +`; + +writeFileSync(new URL("../docs/demo.svg", import.meta.url), svg); + +console.log(`ready: ${audit.ready}`); +console.log(`blockers: ${audit.counts.blockers}`); +console.log(`warnings: ${audit.counts.warnings}`); +console.log(`reproducibility: ${audit.reproducibility.score}% ${audit.reproducibility.confidence}`); +console.log(`top opportunity: ${audit.opportunityFeed[0]?.id ?? "none"}`); +console.log(`digest: ${audit.exportPacket.auditDigest}`); diff --git a/citation-context-reconciler/scripts/render-demo-video.m b/citation-context-reconciler/scripts/render-demo-video.m new file mode 100644 index 0000000..ff7457d --- /dev/null +++ b/citation-context-reconciler/scripts/render-demo-video.m @@ -0,0 +1,166 @@ +#import +#import + +static const NSInteger FrameWidth = 1280; +static const NSInteger FrameHeight = 720; +static const NSInteger FramesPerSecond = 30; +static const NSInteger TotalFrames = 150; + +static NSColor *Color(CGFloat red, CGFloat green, CGFloat blue) { + return [NSColor colorWithCalibratedRed:red / 255.0 green:green / 255.0 blue:blue / 255.0 alpha:1.0]; +} + +static void FillRounded(NSRect rect, CGFloat radius, NSColor *color) { + NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:radius yRadius:radius]; + [color setFill]; + [path fill]; +} + +static void DrawText(NSString *text, CGFloat x, CGFloat y, CGFloat width, CGFloat height, CGFloat size, NSColor *color, BOOL bold) { + NSFont *font = bold ? [NSFont systemFontOfSize:size weight:NSFontWeightSemibold] : [NSFont systemFontOfSize:size weight:NSFontWeightRegular]; + NSDictionary *attributes = @{ + NSFontAttributeName: font, + NSForegroundColorAttributeName: color + }; + [text drawInRect:NSMakeRect(x, y, width, height) withAttributes:attributes]; +} + +static void DrawFrame(CGContextRef context, NSInteger frameIndex) { + NSGraphicsContext *graphicsContext = [NSGraphicsContext graphicsContextWithCGContext:context flipped:NO]; + [NSGraphicsContext saveGraphicsState]; + [NSGraphicsContext setCurrentContext:graphicsContext]; + + [[NSColor colorWithCalibratedRed:248.0 / 255.0 green:245.0 / 255.0 blue:238.0 / 255.0 alpha:1.0] setFill]; + NSRectFill(NSMakeRect(0, 0, FrameWidth, FrameHeight)); + + FillRounded(NSMakeRect(52, 46, 1176, 628), 20, [NSColor whiteColor]); + DrawText(@"Citation Context Reconciler", 88, 604, 720, 44, 36, Color(38, 34, 29), YES); + DrawText(@"SCIBASE issue #16 assistant slice: citation drift, contradictions, and reproducibility support.", 88, 570, 960, 28, 18, Color(100, 92, 82), NO); + + CGFloat progress = (CGFloat)frameIndex / (CGFloat)(TotalFrames - 1); + FillRounded(NSMakeRect(88, 532, 1088, 12), 6, Color(224, 218, 207)); + FillRounded(NSMakeRect(88, 532, 1088 * progress, 12), 6, Color(43, 124, 132)); + + NSArray *labels = @[@"Claims", @"Blockers", @"Warnings", @"Repro"]; + NSArray *values = @[@"3", @"5", @"6", @"50%"]; + NSArray *details = @[@"reviewed", @"must fix", @"review notes", @"medium"]; + for (NSInteger index = 0; index < 4; index++) { + CGFloat x = 88 + index * 270; + FillRounded(NSMakeRect(x, 372, 238, 130), 14, Color(241, 237, 228)); + DrawText(labels[index], x + 22, 458, 180, 24, 16, Color(100, 92, 82), YES); + DrawText(values[index], x + 22, 416, 160, 40, 31, Color(38, 34, 29), YES); + DrawText(details[index], x + 22, 394, 180, 22, 14, Color(116, 107, 96), NO); + } + + FillRounded(NSMakeRect(88, 142, 1088, 184), 18, Color(23, 33, 43)); + DrawText(@"Reviewer queue", 124, 278, 260, 32, 23, [NSColor whiteColor], YES); + DrawText(@"contradictory-cited-effects: claim-cytokine-recovery", 124, 238, 720, 26, 17, Color(231, 242, 245), NO); + DrawText(@"citation-intent-mismatch: claim-scrna-pathway", 124, 204, 720, 26, 17, Color(231, 242, 245), NO); + DrawText(@"reproducibility-evidence-gap: claim-pipeline-reproducible", 124, 170, 760, 26, 17, Color(231, 242, 245), NO); + + CGFloat pulse = 0.42 + 0.35 * sin(progress * M_PI * 4.0); + NSColor *pulseColor = [NSColor colorWithCalibratedRed:43.0 / 255.0 green:124.0 / 255.0 blue:132.0 / 255.0 alpha:pulse]; + FillRounded(NSMakeRect(934, 204, 158, 48), 24, pulseColor); + DrawText(@"demo.mp4", 980, 218, 96, 24, 16, [NSColor whiteColor], YES); + + DrawText(@"citation-context:5:6: deterministic audit packet", 88, 92, 520, 24, 16, Color(100, 92, 82), NO); + + [NSGraphicsContext restoreGraphicsState]; +} + +static BOOL AppendFrame(AVAssetWriterInputPixelBufferAdaptor *adaptor, CMTime time, NSInteger frameIndex) { + CVPixelBufferRef pixelBuffer = NULL; + CVReturn result = CVPixelBufferPoolCreatePixelBuffer(NULL, adaptor.pixelBufferPool, &pixelBuffer); + if (result != kCVReturnSuccess || pixelBuffer == NULL) { + return NO; + } + + CVPixelBufferLockBaseAddress(pixelBuffer, 0); + void *baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer); + size_t bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer); + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGContextRef bitmapContext = CGBitmapContextCreate(baseAddress, FrameWidth, FrameHeight, 8, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host); + + DrawFrame(bitmapContext, frameIndex); + + CGContextRelease(bitmapContext); + CGColorSpaceRelease(colorSpace); + CVPixelBufferUnlockBaseAddress(pixelBuffer, 0); + + BOOL appended = [adaptor appendPixelBuffer:pixelBuffer withPresentationTime:time]; + CVPixelBufferRelease(pixelBuffer); + return appended; +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + if (argc < 2) { + fprintf(stderr, "usage: render-demo-video output.mp4\n"); + return 1; + } + + NSString *outputPath = [NSString stringWithUTF8String:argv[1]]; + NSURL *outputURL = [NSURL fileURLWithPath:outputPath]; + [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil]; + + NSError *error = nil; + AVAssetWriter *writer = [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeMPEG4 error:&error]; + if (!writer) { + fprintf(stderr, "failed to create writer: %s\n", error.localizedDescription.UTF8String); + return 1; + } + + NSDictionary *settings = @{ + AVVideoCodecKey: AVVideoCodecTypeH264, + AVVideoWidthKey: @(FrameWidth), + AVVideoHeightKey: @(FrameHeight), + AVVideoCompressionPropertiesKey: @{ + AVVideoAverageBitRateKey: @(1800000) + } + }; + AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:settings]; + input.expectsMediaDataInRealTime = NO; + + NSDictionary *attributes = @{ + (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA), + (NSString *)kCVPixelBufferWidthKey: @(FrameWidth), + (NSString *)kCVPixelBufferHeightKey: @(FrameHeight) + }; + AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input sourcePixelBufferAttributes:attributes]; + + if (![writer canAddInput:input]) { + fprintf(stderr, "writer cannot add video input\n"); + return 1; + } + [writer addInput:input]; + + [writer startWriting]; + [writer startSessionAtSourceTime:kCMTimeZero]; + + for (NSInteger frame = 0; frame < TotalFrames; frame++) { + while (!input.readyForMoreMediaData) { + [NSThread sleepForTimeInterval:0.01]; + } + CMTime time = CMTimeMake(frame, FramesPerSecond); + if (!AppendFrame(adaptor, time, frame)) { + fprintf(stderr, "failed to append frame %ld\n", (long)frame); + return 1; + } + } + + [input markAsFinished]; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + [writer finishWritingWithCompletionHandler:^{ + dispatch_semaphore_signal(semaphore); + }]; + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + if (writer.status != AVAssetWriterStatusCompleted) { + fprintf(stderr, "failed to write video: %s\n", writer.error.localizedDescription.UTF8String); + return 1; + } + + printf("wrote %s\n", outputPath.UTF8String); + } + return 0; +} diff --git a/citation-context-reconciler/src/citation-context-reconciler.js b/citation-context-reconciler/src/citation-context-reconciler.js new file mode 100644 index 0000000..d6a9b81 --- /dev/null +++ b/citation-context-reconciler/src/citation-context-reconciler.js @@ -0,0 +1,361 @@ +import { createHash } from "node:crypto"; + +const CURRENT_YEAR = 2026; + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +function setOverlap(left = [], right = []) { + const rightSet = new Set(right); + return left.filter((item) => rightSet.has(item)); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function digestFor(value) { + return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16); +} + +function finding(severity, code, claim, message, sourceIds = [], task = undefined) { + return { + severity, + code, + claimId: claim?.id ?? "project", + section: claim?.section ?? "project", + sourceIds: unique(sourceIds), + message, + task + }; +} + +function sourceLabel(source) { + return source ? `${source.id} (${source.year ?? "n.d."})` : "unknown source"; +} + +function buildCitationMatrix(claims, sourceById) { + return claims.map((claim) => { + const sources = asArray(claim.citedSourceIds).map((sourceId) => sourceById.get(sourceId)).filter(Boolean); + return { + claimId: claim.id, + section: claim.section, + citedSources: sources.map((source) => ({ + id: source.id, + stance: source.stance ?? "unknown", + intent: source.citationIntent ?? "unspecified", + effectDirection: source.effectDirection ?? "unknown", + methodOverlap: setOverlap(asArray(claim.methodTags), asArray(source.methodTags)), + populationOverlap: setOverlap(asArray(claim.populations), asArray(source.populations)), + reproducibilityEvidence: { + rawData: Boolean(source.hasRawData), + code: Boolean(source.hasCode), + protocol: Boolean(source.hasProtocol) + } + })) + }; + }); +} + +function scoreReproducibility(claims, sourceById) { + const scoredClaims = claims.map((claim) => { + const citedSources = asArray(claim.citedSourceIds).map((sourceId) => sourceById.get(sourceId)).filter(Boolean); + const evidencePoints = citedSources.reduce((points, source) => { + return points + Number(Boolean(source.hasRawData)) + Number(Boolean(source.hasCode)) + Number(Boolean(source.hasProtocol)); + }, 0); + const maxPoints = Math.max(citedSources.length * 3, 1); + return { + claimId: claim.id, + score: Math.round((evidencePoints / maxPoints) * 100), + required: Boolean(claim.requiresReproducibilityEvidence) + }; + }); + + const requiredClaims = scoredClaims.filter((claim) => claim.required); + const targetClaims = requiredClaims.length > 0 ? requiredClaims : scoredClaims; + const score = Math.round( + targetClaims.reduce((sum, claim) => sum + claim.score, 0) / Math.max(targetClaims.length, 1) + ); + + return { + score, + claims: scoredClaims, + confidence: score >= 85 ? "high" : score >= 60 ? "medium" : "low" + }; +} + +function buildOpportunityFeed(packet, findings) { + const blockerClaimIds = new Set( + findings.filter((item) => item.severity === "blocker").map((item) => item.claimId) + ); + const labCapabilities = asArray(packet.lab?.capabilities); + const labInterests = asArray(packet.lab?.interests); + + return asArray(packet.corpusGaps).map((gap) => { + const capabilityOverlap = setOverlap(asArray(gap.requiredCapabilities), labCapabilities); + const interestOverlap = setOverlap(asArray(gap.topicTags), labInterests); + const linkedBlockers = asArray(gap.linkedClaimIds).filter((claimId) => blockerClaimIds.has(claimId)); + const priority = + capabilityOverlap.length * 3 + + interestOverlap.length * 2 + + linkedBlockers.length * 3 + + asArray(gap.contradictionSignals).length; + + return { + id: gap.id, + title: gap.title, + priority, + capabilityOverlap, + interestOverlap, + linkedBlockers, + nextAction: gap.nextAction + }; + }).sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id)); +} + +function reviewCommentFor(item) { + const prefix = item.severity === "blocker" ? "Resolve before release" : "Review before submission"; + return { + claimId: item.claimId, + section: item.section, + severity: item.severity, + body: `${prefix}: ${item.message}` + }; +} + +function revisionTaskFor(item) { + return { + claimId: item.claimId, + code: item.code, + ownerHint: item.severity === "blocker" ? "author" : "reviewer", + task: item.task ?? item.message + }; +} + +export function reconcileCitationContext(packet, options = {}) { + const staleAfterYears = options.staleAfterYears ?? 7; + const claims = asArray(packet.claims); + const sources = asArray(packet.sources); + const sourceById = new Map(sources.map((source) => [source.id, source])); + const findings = []; + + if (claims.length === 0) { + findings.push(finding("blocker", "no-claims", undefined, "No manuscript claims were provided for assistant review.")); + } + + for (const claim of claims) { + const citedSourceIds = asArray(claim.citedSourceIds); + + if (citedSourceIds.length === 0) { + findings.push(finding( + "blocker", + "claim-without-citation", + claim, + "The claim has no linked citation context.", + [], + "Attach at least one source that directly supports or constrains this claim." + )); + continue; + } + + const citedSources = []; + for (const sourceId of citedSourceIds) { + const source = sourceById.get(sourceId); + if (!source) { + findings.push(finding( + "blocker", + "unknown-source", + claim, + `The claim references missing source ${sourceId}.`, + [sourceId], + "Add the missing source metadata or remove the dangling citation." + )); + continue; + } + citedSources.push(source); + } + + const citedEffectDirections = unique(citedSources.map((source) => source.effectDirection)); + const nonNeutralDirections = citedEffectDirections.filter((direction) => direction && direction !== "mixed" && direction !== "unknown"); + if (nonNeutralDirections.length > 1) { + findings.push(finding( + "blocker", + "contradictory-cited-effects", + claim, + `The cited sources disagree on effect direction: ${nonNeutralDirections.join(", ")}.`, + citedSources.map((source) => source.id), + "Rewrite the claim as contested or add an adjudication note explaining why one direction is preferred." + )); + } + + for (const source of citedSources) { + const sourceIds = [source.id]; + if (source.retracted) { + findings.push(finding( + "blocker", + "retracted-citation", + claim, + `${sourceLabel(source)} is marked retracted.`, + sourceIds, + "Remove or explicitly label the retracted source before release." + )); + } + + if (source.stance === "contradicts") { + findings.push(finding( + "blocker", + "contradicting-source-used-as-support", + claim, + `${sourceLabel(source)} contradicts the claim but is cited without a limitation note.`, + sourceIds, + "Move the source into a limitation sentence or add a rebuttal-backed explanation." + )); + } + + if (source.stance === "mixed") { + findings.push(finding( + "warning", + "mixed-source-needs-qualification", + claim, + `${sourceLabel(source)} reports mixed evidence and needs qualified language.`, + sourceIds, + "Add uncertainty language and identify which subgroup or assay is supported." + )); + } + + if (claim.effectDirection && source.effectDirection && source.effectDirection !== "mixed" && claim.effectDirection !== source.effectDirection) { + findings.push(finding( + "blocker", + "effect-direction-mismatch", + claim, + `${sourceLabel(source)} has effect direction ${source.effectDirection}, but the claim states ${claim.effectDirection}.`, + sourceIds, + "Align the claim with the source or cite a direct source for the stated effect direction." + )); + } + + if (["background", "context", "method"].includes(source.citationIntent) && claim.usesCitationAs === "evidence") { + findings.push(finding( + "blocker", + "citation-intent-mismatch", + claim, + `${sourceLabel(source)} is tagged for ${source.citationIntent}, not direct evidence.`, + sourceIds, + "Use the citation as context only or replace it with a direct empirical support source." + )); + } + + const methodOverlap = setOverlap(asArray(claim.methodTags), asArray(source.methodTags)); + if (asArray(claim.methodTags).length > 0 && methodOverlap.length === 0) { + findings.push(finding( + "warning", + "method-context-gap", + claim, + `${sourceLabel(source)} does not share the claim method tags: ${asArray(claim.methodTags).join(", ")}.`, + sourceIds, + "Add a same-method source or explain the cross-method inference." + )); + } + + const populationOverlap = setOverlap(asArray(claim.populations), asArray(source.populations)); + if (asArray(claim.populations).length > 0 && populationOverlap.length === 0) { + findings.push(finding( + "warning", + "population-context-gap", + claim, + `${sourceLabel(source)} does not cover the claim population: ${asArray(claim.populations).join(", ")}.`, + sourceIds, + "Add population-matched evidence or narrow the claim." + )); + } + + if (source.year && CURRENT_YEAR - source.year > staleAfterYears && claim.requiresCurrentEvidence) { + findings.push(finding( + "warning", + "stale-citation", + claim, + `${sourceLabel(source)} is older than the configured ${staleAfterYears}-year recency window.`, + sourceIds, + "Run a recency search and add a current synthesis or explain why the older source is canonical." + )); + } + + if (claim.requiresReproducibilityEvidence && (!source.hasRawData || !source.hasCode || !source.hasProtocol)) { + const missing = [ + !source.hasRawData ? "raw data" : undefined, + !source.hasCode ? "code" : undefined, + !source.hasProtocol ? "protocol" : undefined + ].filter(Boolean); + findings.push(finding( + "warning", + "reproducibility-evidence-gap", + claim, + `${sourceLabel(source)} is missing reproducibility evidence: ${missing.join(", ")}.`, + sourceIds, + "Link the missing artifacts or lower the reproducibility confidence for this claim." + )); + } + } + } + + const severityRank = { blocker: 0, warning: 1, info: 2 }; + findings.sort((left, right) => { + return severityRank[left.severity] - severityRank[right.severity] || + left.claimId.localeCompare(right.claimId) || + left.code.localeCompare(right.code); + }); + + const citationMatrix = buildCitationMatrix(claims, sourceById); + const reproducibility = scoreReproducibility(claims, sourceById); + const opportunityFeed = buildOpportunityFeed(packet, findings); + const counts = { + claims: claims.length, + sources: sources.length, + blockers: findings.filter((item) => item.severity === "blocker").length, + warnings: findings.filter((item) => item.severity === "warning").length, + opportunities: opportunityFeed.length + }; + + const reviewerQueue = findings.map(reviewCommentFor); + const revisionTasks = findings.map(revisionTaskFor); + const exportPacket = { + projectId: packet.project?.id ?? "unknown-project", + generatedAt: packet.generatedAt ?? "2026-05-16T18:20:00.000Z", + citationMatrix, + reviewerQueue, + revisionTasks, + reproducibility, + opportunityFeed + }; + + const auditDigest = `citation-context:${counts.blockers}:${counts.warnings}:${digestFor(exportPacket)}`; + + return { + ready: counts.blockers === 0, + counts, + findings, + reviewerQueue, + revisionTasks, + citationMatrix, + reproducibility, + opportunityFeed, + exportPacket: { + ...exportPacket, + auditDigest + } + }; +} diff --git a/citation-context-reconciler/test/citation-context-reconciler.test.js b/citation-context-reconciler/test/citation-context-reconciler.test.js new file mode 100644 index 0000000..42d2deb --- /dev/null +++ b/citation-context-reconciler/test/citation-context-reconciler.test.js @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { reconcileCitationContext } from "../src/citation-context-reconciler.js"; + +const fixture = JSON.parse(readFileSync(new URL("../sample/citation-context-packet.json", import.meta.url), "utf8")); + +const audit = reconcileCitationContext(fixture); +assert.equal(audit.ready, false); +assert.equal(audit.counts.claims, 3); +assert.equal(audit.counts.sources, 4); +assert.equal(audit.counts.blockers, 5); +assert.equal(audit.counts.warnings, 6); +assert.equal(audit.counts.opportunities, 2); +assert.match(audit.exportPacket.auditDigest, /^citation-context:5:6:[a-f0-9]{16}$/); + +const blockerCodes = audit.findings + .filter((item) => item.severity === "blocker") + .map((item) => item.code) + .sort(); +assert.deepEqual(blockerCodes, [ + "citation-intent-mismatch", + "citation-intent-mismatch", + "contradicting-source-used-as-support", + "contradictory-cited-effects", + "effect-direction-mismatch" +]); + +assert.equal(audit.reproducibility.confidence, "low"); +assert.equal(audit.opportunityFeed[0].id, "gap-older-adult-replication"); +assert.equal( + audit.revisionTasks.some((task) => task.code === "citation-intent-mismatch" && task.ownerHint === "author"), + true +); + +const cleanPacket = structuredClone(fixture); +cleanPacket.claims = [ + { + id: "claim-clean-cytokine", + section: "Results", + text: "A recent replication did not detect a cytokine decrease in older post-viral cohorts.", + citedSourceIds: ["src-recent-null-meta"], + methodTags: ["wearable-sensor-cohort", "cytokine-panel"], + populations: ["older-adults", "post-viral-fatigue"], + effectDirection: "no-effect", + usesCitationAs: "limitation", + requiresCurrentEvidence: true, + requiresReproducibilityEvidence: true + } +]; +cleanPacket.sources = cleanPacket.sources.map((source) => + source.id === "src-recent-null-meta" ? { ...source, stance: "supports" } : source +); +cleanPacket.corpusGaps = []; +const cleanAudit = reconcileCitationContext(cleanPacket); +assert.equal(cleanAudit.ready, true); +assert.equal(cleanAudit.counts.blockers, 0); +assert.equal(cleanAudit.counts.warnings, 0); +assert.equal(cleanAudit.reproducibility.confidence, "high"); + +const danglingPacket = structuredClone(cleanPacket); +danglingPacket.claims[0].citedSourceIds = ["src-missing"]; +const danglingAudit = reconcileCitationContext(danglingPacket); +assert.equal(danglingAudit.ready, false); +assert.equal(danglingAudit.findings.some((item) => item.code === "unknown-source"), true); + +console.log("citation context reconciler tests passed");