
Check Citations
- 3 installs
- 3.2k repo stars
- Updated August 4, 2026
- brycewang-stanford/awesome-agent-skills-for-empirical-research
check-citations is a skill that verifies academic citations against CrossRef, Semantic Scholar, and OpenAlex to detect hallucinated references.
About
check-citations is a skill that verifies academic citations in .bib files against CrossRef, Semantic Scholar, and OpenAlex. A researcher uses it to catch AI-hallucinated, chimeric, and altered references before submitting a paper. It runs a Python script with JSON output and can be wired into pre-commit hooks or CI pipelines, requiring no API keys.
- Verifies academic citations against CrossRef, Semantic Scholar, and OpenAlex
- Detects AI-hallucinated, chimeric, and modified-real references
- Runs as a CLI check with JSON output and pre-commit / GitHub Actions integration
Check Citations by the numbers
- 3 all-time installs (skills.sh)
- Ranked #1,268 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
check-citations capabilities & compatibility
free, no API keys required
- Capabilities
- citation verification · hallucination detection
- Works with
- github
- Use cases
- documentation · research
- Pricing
- Free
What check-citations says it does
Verify academic citations against CrossRef, Semantic Scholar, and OpenAlex. Detects AI-hallucinated references, chimeric citations, and suspicious patterns.
No API keys required — uses free tiers of all three databases.
npx skills add https://github.com/brycewang-stanford/awesome-agent-skills-for-empirical-research --skill check-citationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 3.2k |
| Last updated | August 4, 2026 |
| Repository | brycewang-stanford/awesome-agent-skills-for-empirical-research ↗ |
What it does
Verify a .bib bibliography against three databases to catch fabricated or chimeric citations before submitting a paper.
Who is it for?
Catching fabricated or chimeric citations in a .bib file before submission
When should I use this skill?
Auditing a bibliography after AI-assisted writing or before paper submission
What you get
A verification report flagging not-found, suspicious, and chimeric citations before submission.
- citation verification report
- JSON citation check output
By the numbers
- 3 verification databases (CrossRef, Semantic Scholar, OpenAlex)
- 0% false negative rate in testing
- 10% false positive rate in testing
Files
check-citations
Verify academic citations against CrossRef, Semantic Scholar, and OpenAlex. Detects AI-hallucinated references, chimeric citations (real title + wrong authors), and suspicious patterns before submission.
When to Use
- After writing or editing a
.bibfile with AI assistance - Before submitting a paper, thesis, or report
- When reviewing AI-generated literature sections
- As a CI/CD check in LaTeX manuscript pipelines
- When auditing existing bibliographies for dead or fabricated references
Background
- 6-55% of AI-generated citations are fabricated (varies by model/domain)
- 100+ hallucinated references found in NeurIPS 2025 accepted papers
- Universities increasingly treat fake citations as academic misconduct
- Three hallucination types: fully fabricated, chimeric (real title + wrong authors), modified real (slightly altered metadata)
Usage
Quick Check (Single File)
python scripts/citation_checker.py references.bibCheck All .bib Files in a Directory
python scripts/citation_checker.py path/to/report/JSON Output (CI/CD Pipelines)
python scripts/citation_checker.py references.bib --jsonVerbose Mode (Debug API Responses)
python scripts/citation_checker.py references.bib --verboseHow It Works
Cascading Multi-Source Verification
Each citation is checked against three independent databases:
| Source | Coverage | Strength |
|---|---|---|
| CrossRef | 140M+ DOI-registered works | Best for journal/conference papers with DOIs |
| Semantic Scholar | 200M+ papers | Best author disambiguation, arXiv coverage |
| OpenAlex | 240M+ works | Broadest coverage, fully open |
Verification logic:
- Found in 2+ sources with matching title → verified (high confidence)
- Found in 1 source only → suspicious (manual check recommended)
- Found in 0 sources → not_found (likely hallucinated)
Chimeric Detection
When a citation's title matches a real paper but the authors don't overlap at all, it's flagged as a possible chimeric hallucination — the most dangerous type because the title looks real on Google Scholar.
Red Flag Heuristics
- Invalid DOI format (doesn't start with
10.xxxx/) - Suspiciously generic title patterns ("A Comprehensive Survey of...")
- Future publication year
- Missing author or year fields
- Single-word author names (incomplete metadata)
Exit Codes
| Code | Meaning |
|---|---|
| 0 | All citations verified |
| 1 | One or more citations not found |
| 2 | Suspicious citations only (no hard failures) |
Dependencies
pip install requestsNo API keys required — uses free tiers of all three databases.
Accuracy (Tested)
| Category | Result | Description |
|---|---|---|
| Known-good | 9/10 (90%) | Famous ML papers (Vaswani, Devlin, Brown, He, etc.) |
| Known-bad | 10/10 (100%) | Fabricated papers with plausible titles |
| Chimeric | 5/5 (100%) | Real titles with wrong authors |
| False positive rate | 10% | 1 miss: unpublished tech report without DOI |
| False negative rate | 0% | No fake paper was ever verified |
The core guarantee: fake papers are never marked as real.
Limitations
- Papers without DOI that have many derivatives (e.g., BERT without DOI) may not be found via title search alone — always include DOIs when available
- Semantic Scholar free tier rate-limits at ~100 requests/5 minutes — batch verification is slower
- Cannot verify papers behind paywalls or not indexed in any of the three databases
- Book chapters, technical reports, and grey literature have lower coverage
Integration with LaTeX Workflows
Pre-commit Hook
#!/bin/bash
# .git/hooks/pre-commit
python scripts/citation_checker.py references.bib --json > /tmp/cite_check.json
NOT_FOUND=$(python3 -c "import json; d=json.load(open('/tmp/cite_check.json')); print(d['summary']['not_found'])")
if [ "$NOT_FOUND" -gt "0" ]; then
echo "BLOCKED: $NOT_FOUND unfound citations. Run 'python scripts/citation_checker.py references.bib --verbose' to investigate."
exit 1
fiGitHub Actions
- name: Check citations
run: |
pip install requests
python scripts/citation_checker.py references.bib --json > citation_report.json
python -c "
import json, sys
r = json.load(open('citation_report.json'))
if r['summary']['not_found'] > 0:
print(f'FAIL: {r[\"summary\"][\"not_found\"]} citations not found')
sys.exit(1)
print(f'PASS: {r[\"summary\"][\"verified\"]} verified, {r[\"summary\"][\"suspicious\"]} suspicious')
"<!-- Vendored into AERS from https://github.com/PHY041/claude-skill-citation-checker on 2026-06-01. Upstream attribution + license preserved. -->
Vendored upstream skill. Curated snapshot of `PHY041/claude-skill-citation-checker` for the AERS catalog (license: MIT (declared in README; no LICENSE file upstream)). Vendored 2026-06-01. The original upstream README follows verbatim.
---
check-citations
Stop hallucinated citations before they reach your reviewer.
A Claude Code skill that verifies every citation in your .bib file against three academic databases (CrossRef, Semantic Scholar, OpenAlex). Catches fabricated references, chimeric citations, and suspicious patterns — no API keys required.
Why This Exists
| Problem | Scale |
|---|---|
| AI-generated citations are fabricated | 6-55% depending on model |
| NeurIPS 2025 accepted papers contained 100+ fake refs | Source |
| Universities now treat fake citations as misconduct | Growing trend since 2024 |
| Existing tools only check if DOIs resolve | They miss fabricated content |
This is the only tool that detects the actual hallucination problem — not just broken links, but papers that don't exist.
Install
# Clone into your Claude Code skills directory
git clone https://github.com/PHY041/claude-skill-citation-checker.git ~/.claude/skills/check-citations
# Dependencies (just requests)
pip install requestsThen in Claude Code: Use /check-citations to verify my bibliography
Quick Start
# Check a single .bib file
python scripts/citation_checker.py references.bib
# Check all .bib files in a project
python scripts/citation_checker.py path/to/thesis/
# JSON output for CI/CD
python scripts/citation_checker.py references.bib --json
# Verbose (see API responses)
python scripts/citation_checker.py references.bib --verboseWhat It Catches
1. Fully Fabricated Citations
Papers that don't exist in any database. The classic LLM hallucination.
NOT FOUND (0 sources): [zhang2024unified]
Title: Unified Framework for Multi-Modal Reasoning in Dynamic Environments
Authors: Zhang, Wei and Liu, Xiaoming and Chen, Yufei
FLAG: NOT FOUND in any database — likely hallucinated2. Chimeric Citations (Most Dangerous)
Real paper title + wrong authors. Looks legitimate on a quick Google check but is fabricated.
SUSPICIOUS: [chimeric1] confidence=60%
Title: Attention Is All You Need
Best match: Attention Is All You Need
FLAG: Title matches but authors don't — possible chimeric hallucination3. Red Flag Patterns
- Invalid DOI format
- Suspiciously generic titles ("A Comprehensive Survey of...")
- Future publication years
- Missing authors or single-word names
How It Works
BibTeX Entry
|
v
[CrossRef] ──> 140M+ DOI works
|
v
[Semantic Scholar] ──> 200M+ papers
|
v
[OpenAlex] ──> 240M+ works
|
v
Title Similarity + Author Overlap + Red Flags
|
v
verified / suspicious / not_found- 2+ sources match → verified (high confidence)
- 1 source only → suspicious (manual check needed)
- 0 sources → not_found (likely hallucinated)
- Title matches, authors don't → chimeric flag
Accuracy
Tested against 25 curated citations:
| Category | Score | Description |
|---|---|---|
| Real papers caught | 90% (9/10) | Famous ML papers (Vaswani, Devlin, He, Brown, etc.) |
| Fake papers caught | 100% (10/10) | Fabricated with plausible titles and authors |
| Chimeric caught | 100% (5/5) | Real title + wrong authors |
| False negative rate | 0% | No fake paper was ever marked as real |
Run the test suite yourself:
# Unit tests only (no API calls)
python tests/test_citation_checker.py --unit-only
# Full suite (API calls, ~5 min)
python tests/test_citation_checker.py --verboseCI/CD Integration
GitHub Actions
- name: Verify citations
run: |
pip install requests
python scripts/citation_checker.py references.bib --json > citation_report.json
python -c "
import json, sys
r = json.load(open('citation_report.json'))
if r['summary']['not_found'] > 0:
print(f'FAIL: {r[\"summary\"][\"not_found\"]} citations not found')
sys.exit(1)
"Pre-commit Hook
#!/bin/bash
python scripts/citation_checker.py references.bib
exit $?Limitations
- Papers without DOI that have many derivatives (BERT, GPT-2) may not be found via title search — include DOIs when possible
- Semantic Scholar rate-limits at ~100 req/5min on free tier
- Book chapters and grey literature have lower coverage
- No API keys needed, but free tiers have throughput limits
License
MIT
---
Share / Star
If this saved you from submitting hallucinated citations:
[Give it a star on GitHub](https://github.com/PHY041/claude-skill-citation-checker)
---
<details> <summary>Chinese / 中文介绍</summary>
check-citations — AI 论文引用验证
别让 AI 编造的引用出现在你的论文里。
一个 Claude Code 技能,自动检查 .bib 文件中的每一条引用是否真实存在。对接三大学术数据库(CrossRef、Semantic Scholar、OpenAlex),无需 API key。
为什么需要这个工具
- ChatGPT/Claude 生成的引用有 6-55% 是编造的
- NeurIPS 2025 录取论文中发现 100+ 条虚假引用
- 大学已经把虚假引用视为学术不端
- 现有工具只检查 DOI 链接是否能打开,不检查论文是否真的存在
这是唯一一个能检测「论文本身不存在」的工具。
三种检测能力
| 类型 | 说明 | 检测率 |
|---|---|---|
| 完全编造 | 论文标题、作者全是假的 | 100% |
| 嵌合引用 | 真实标题 + 错误作者(最危险) | 100% |
| 红旗模式 | 无效 DOI、过于泛化的标题、缺失字段 | 100% |
安装
git clone https://github.com/PHY041/claude-skill-citation-checker.git ~/.claude/skills/check-citations
pip install requests使用
# 检查单个 .bib 文件
python scripts/citation_checker.py references.bib
# 检查目录下所有 .bib
python scripts/citation_checker.py path/to/thesis/
# JSON 输出(用于 CI)
python scripts/citation_checker.py references.bib --json写论文的时候跑一下,5 分钟,省得被审稿人打回来。
觉得有用就 [给个 Star](https://github.com/PHY041/claude-skill-citation-checker) 吧
</details>
#!/usr/bin/env python3
"""
Citation Checker — Multi-Source Verification for Academic Reports.
Prevents AI-hallucinated citations from reaching submission.
Uses a cascading verification pipeline: CrossRef → Semantic Scholar → OpenAlex.
Background:
- 6-55% of AI-generated citations are fabricated (varies by model/domain)
- 100+ hallucinated refs found in NeurIPS 2025 accepted papers
- Universities increasingly treat fake citations as academic misconduct
- Three hallucination types: fully fabricated, chimeric (blended), modified real
Usage:
# Check a single .bib file
python citation_checker.py references.bib
# Check all .bib files in a directory
python citation_checker.py path/to/report/
# Output as JSON (for CI pipelines)
python citation_checker.py references.bib --json
# Verbose mode (show API responses)
python citation_checker.py references.bib --verbose
Dependencies:
pip install requests
No API keys required — uses free tiers of CrossRef, Semantic Scholar, and OpenAlex.
"""
import re
import sys
import json
import time
import urllib.parse
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
try:
import requests
except ImportError:
print("Error: pip install requests")
sys.exit(1)
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class BibEntry:
key: str
entry_type: str
title: str
authors: str
year: str
doi: Optional[str] = None
arxiv_id: Optional[str] = None
raw: str = ""
line_number: int = 0
file_path: str = ""
@dataclass
class VerificationResult:
entry: BibEntry
status: str # "verified", "suspicious", "not_found", "error"
confidence: float # 0.0 - 1.0
sources_checked: list[str] = field(default_factory=list)
sources_found: list[str] = field(default_factory=list)
best_match_title: Optional[str] = None
best_match_similarity: float = 0.0
notes: list[str] = field(default_factory=list)
red_flags: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# BibTeX Parser
# ---------------------------------------------------------------------------
def parse_bib_file(filepath: Path) -> list[BibEntry]:
"""Parse a .bib file and extract entries."""
entries = []
content = filepath.read_text(encoding="utf-8", errors="replace")
# Match @type{key, ... }
entry_pattern = re.compile(
r"@(\w+)\{([^,\s]+)\s*,(.+?)\n\}",
re.DOTALL,
)
for match in entry_pattern.finditer(content):
entry_type = match.group(1).lower()
key = match.group(2).strip()
body = match.group(3)
line_number = content[: match.start()].count("\n") + 1
# Skip @comment, @string, @preamble
if entry_type in ("comment", "string", "preamble"):
continue
title = _extract_field(body, "title")
authors = _extract_field(body, "author")
year = _extract_field(body, "year")
doi = _extract_field(body, "doi")
arxiv_id = _extract_field(body, "eprint") or _extract_field(
body, "arxiv"
)
if not title:
continue # Can't verify without title
entries.append(
BibEntry(
key=key,
entry_type=entry_type,
title=_clean_latex(title),
authors=_clean_latex(authors) if authors else "",
year=year or "",
doi=doi,
arxiv_id=arxiv_id,
raw=match.group(0),
line_number=line_number,
file_path=str(filepath),
)
)
return entries
def _extract_field(body: str, field_name: str) -> Optional[str]:
"""Extract a field value from BibTeX body."""
pattern = re.compile(
rf"{field_name}\s*=\s*[\{{\"](.+?)[\}}\"]",
re.IGNORECASE | re.DOTALL,
)
match = pattern.search(body)
if match:
value = match.group(1).strip()
# Handle nested braces
value = re.sub(r"\{([^}]*)\}", r"\1", value)
return value
return None
def _clean_latex(text: str) -> str:
"""Remove LaTeX commands from text."""
text = re.sub(r"\\[a-zA-Z]+\{([^}]*)\}", r"\1", text)
text = re.sub(r"[{}]", "", text)
text = re.sub(r"\\.", "", text)
text = re.sub(r"\s+", " ", text).strip()
return text
# ---------------------------------------------------------------------------
# Similarity
# ---------------------------------------------------------------------------
def title_similarity(a: str, b: str) -> float:
"""Compute normalized token-overlap similarity between two titles."""
if not a or not b:
return 0.0
tokens_a = set(a.lower().split())
tokens_b = set(b.lower().split())
# Remove common stop words
stop = {"the", "a", "an", "of", "for", "in", "on", "to", "and", "with", "by", "from", "is", "are", "at"}
tokens_a -= stop
tokens_b -= stop
if not tokens_a or not tokens_b:
return 0.0
intersection = tokens_a & tokens_b
union = tokens_a | tokens_b
return len(intersection) / len(union)
def author_overlap(entry_authors: str, found_authors: str) -> float:
"""Check if author last names overlap between BibTeX entry and API result."""
if not entry_authors or not found_authors:
return 0.0
def extract_last_names(text: str) -> set[str]:
lower = text.lower().strip()
names = set()
if re.search(r"\band\b", lower):
# BibTeX format: "Last, First and Last, First"
people = re.split(r"\band\b", lower)
for person in people:
person = person.strip()
if not person:
continue
if "," in person:
# "Last, First" → take before comma
last = person.split(",")[0].strip().split()[-1]
names.add(last)
else:
# "First Last" → take last word
words = person.split()
if words:
names.add(words[-1])
else:
# API format: "First Last, First Last" (comma-separated)
people = lower.split(",")
for person in people:
person = person.strip()
if not person:
continue
words = person.split()
if len(words) >= 2:
names.add(words[-1])
elif words:
names.add(words[0])
return names
entry_names = extract_last_names(entry_authors)
found_names = extract_last_names(found_authors)
if not entry_names or not found_names:
return 0.0
overlap = entry_names & found_names
return len(overlap) / max(len(entry_names), 1)
# ---------------------------------------------------------------------------
# API Clients (Cascading: CrossRef → Semantic Scholar → OpenAlex)
# ---------------------------------------------------------------------------
HEADERS = {
"User-Agent": "citation-checker/1.0 (academic-report-verification)"
}
def check_crossref(entry: BibEntry, verbose: bool = False) -> Optional[dict]:
"""Search CrossRef for a matching paper. Covers 140M+ DOI-registered works."""
try:
# If DOI is provided, verify directly
if entry.doi:
url = f"https://api.crossref.org/works/{entry.doi}"
resp = requests.get(url, headers=HEADERS, timeout=10)
if resp.status_code == 200:
data = resp.json()["message"]
title = data.get("title", [""])[0]
authors_raw = data.get("author", [])
authors = ", ".join(
f"{a.get('given', '')} {a.get('family', '')}"
for a in authors_raw
)
return {
"source": "CrossRef",
"title": title,
"authors": authors,
"doi": entry.doi,
"year": str(
data.get("published-print", data.get("published-online", {}))
.get("date-parts", [[""]])[0][0]
),
}
# Title search — try full title, then subtitle if colon present
search_titles = [entry.title]
if ":" in entry.title:
subtitle = entry.title.split(":", 1)[1].strip()
if len(subtitle.split()) >= 4:
search_titles.append(subtitle)
for search_title in search_titles:
query = urllib.parse.quote(search_title)
url = f"https://api.crossref.org/works?query.title={query}&rows=5"
resp = requests.get(url, headers=HEADERS, timeout=15)
if resp.status_code == 200:
items = resp.json().get("message", {}).get("items", [])
for item in items:
found_title = item.get("title", [""])[0]
sim = title_similarity(entry.title, found_title)
if sim > 0.6:
authors_raw = item.get("author", [])
authors = ", ".join(
f"{a.get('given', '')} {a.get('family', '')}"
for a in authors_raw
)
return {
"source": "CrossRef",
"title": found_title,
"authors": authors,
"doi": item.get("DOI", ""),
"year": str(
item.get("published-print", item.get("published-online", {}))
.get("date-parts", [[""]])[0][0]
),
"similarity": sim,
}
except Exception as e:
if verbose:
print(f" CrossRef error for '{entry.key}': {e}")
return None
def check_semantic_scholar(
entry: BibEntry, verbose: bool = False
) -> Optional[dict]:
"""Search Semantic Scholar. Covers 200M+ papers with author disambiguation."""
query = urllib.parse.quote(entry.title)
url = f"https://api.semanticscholar.org/graph/v1/paper/search?query={query}&limit=3&fields=title,authors,year,externalIds"
for attempt in range(2):
try:
resp = requests.get(url, headers=HEADERS, timeout=15)
if resp.status_code == 200:
papers = resp.json().get("data", [])
for paper in papers:
found_title = paper.get("title", "")
sim = title_similarity(entry.title, found_title)
if sim > 0.6:
authors = ", ".join(
a.get("name", "") for a in paper.get("authors", [])
)
ext_ids = paper.get("externalIds", {})
return {
"source": "Semantic Scholar",
"title": found_title,
"authors": authors,
"doi": ext_ids.get("DOI", ""),
"arxiv": ext_ids.get("ArXiv", ""),
"year": str(paper.get("year", "")),
"similarity": sim,
}
return None # Got 200 but no match
elif resp.status_code == 429:
wait = 5 if attempt == 0 else 10
if verbose:
print(f" Semantic Scholar rate limited, waiting {wait}s (attempt {attempt + 1})...")
time.sleep(wait)
continue
else:
return None
except Exception as e:
if verbose:
print(f" Semantic Scholar error for '{entry.key}': {e}")
return None
return None
def check_openalex(entry: BibEntry, verbose: bool = False) -> Optional[dict]:
"""Search OpenAlex. Fully open, broadest coverage (240M+ works)."""
# Try full title, then subtitle if colon present
search_titles = [entry.title]
if ":" in entry.title:
subtitle = entry.title.split(":", 1)[1].strip()
if len(subtitle.split()) >= 4:
search_titles.append(subtitle)
for search_title in search_titles:
try:
query = urllib.parse.quote(search_title)
url = f"https://api.openalex.org/works?filter=title.search:{query}&per_page=5"
resp = requests.get(
url,
headers={**HEADERS, "Accept": "application/json"},
timeout=15,
)
if resp.status_code == 200:
results = resp.json().get("results", [])
for work in results:
found_title = work.get("title", "")
sim = title_similarity(entry.title, found_title)
if sim > 0.6:
authors = ", ".join(
a.get("author", {}).get("display_name", "")
for a in work.get("authorships", [])
)
return {
"source": "OpenAlex",
"title": found_title,
"authors": authors,
"doi": (work.get("doi") or "").replace(
"https://doi.org/", ""
),
"year": str(work.get("publication_year", "")),
"similarity": sim,
}
except Exception as e:
if verbose:
print(f" OpenAlex error for '{entry.key}': {e}")
return None
# ---------------------------------------------------------------------------
# Red Flag Detection
# ---------------------------------------------------------------------------
def detect_red_flags(entry: BibEntry) -> list[str]:
"""Detect markers that suggest a citation may be AI-hallucinated."""
flags = []
# 1. DOI format check
if entry.doi:
if not re.match(r"10\.\d{4,}/", entry.doi):
flags.append(f"Invalid DOI format: '{entry.doi}'")
# 2. Suspiciously generic title
generic_patterns = [
r"^a (?:comprehensive |novel |survey)",
r"^towards ",
r"(?:comprehensive|systematic) (?:survey|review|study|analysis)",
]
for pattern in generic_patterns:
if re.search(pattern, entry.title.lower()):
flags.append(
"Title matches common AI-hallucination pattern (overly generic)"
)
break
# 3. Missing key fields
if not entry.authors:
flags.append("Missing author field")
if not entry.year:
flags.append("Missing year field")
# 4. Future year
if entry.year and entry.year.isdigit():
if int(entry.year) > 2026:
flags.append(f"Future year: {entry.year}")
# 5. Author name patterns — split by "and" (BibTeX convention), not comma
if entry.authors:
author_parts = re.split(r"\band\b", entry.authors)
for part in author_parts:
stripped = part.strip().strip(",").strip()
if stripped and len(stripped.split()) == 1 and len(stripped) > 2:
flags.append(
f"Single-word author name: '{stripped}' (may be incomplete)"
)
return flags
# ---------------------------------------------------------------------------
# Verification Engine
# ---------------------------------------------------------------------------
def verify_entry(entry: BibEntry, verbose: bool = False) -> VerificationResult:
"""Verify a single BibTeX entry using cascading multi-source lookup."""
result = VerificationResult(entry=entry, status="not_found", confidence=0.0)
result.red_flags = detect_red_flags(entry)
# Cascade: CrossRef → Semantic Scholar → OpenAlex
checkers = [
("CrossRef", check_crossref),
("Semantic Scholar", check_semantic_scholar),
("OpenAlex", check_openalex),
]
matches = []
for source_name, checker in checkers:
result.sources_checked.append(source_name)
if verbose:
print(f" Checking {source_name} for '{entry.key}'...")
match = checker(entry, verbose=verbose)
if match:
result.sources_found.append(source_name)
matches.append(match)
# Store best match
sim = match.get("similarity", 1.0)
if sim > result.best_match_similarity:
result.best_match_similarity = sim
result.best_match_title = match.get("title", "")
# Rate limiting between APIs (Semantic Scholar needs ~1s between requests)
time.sleep(1.0)
# Compute confidence
if len(result.sources_found) >= 2:
result.status = "verified"
result.confidence = min(0.95, 0.5 + 0.2 * len(result.sources_found))
# Boost confidence if title similarity is high
if result.best_match_similarity > 0.85:
result.confidence = min(1.0, result.confidence + 0.1)
# Check author overlap for best match
if matches:
best = max(matches, key=lambda m: m.get("similarity", 0))
ao = author_overlap(entry.authors, best.get("authors", ""))
if ao > 0.3:
result.confidence = min(1.0, result.confidence + 0.1)
result.notes.append(f"Author overlap: {ao:.0%}")
elif ao == 0 and entry.authors:
# Zero author overlap = likely chimeric (real title, wrong authors)
result.notes.append("WARNING: No author overlap with best match")
result.confidence -= 0.3
result.red_flags.append(
"Title matches but authors don't — possible chimeric hallucination"
)
result.status = "suspicious"
elif len(result.sources_found) == 1:
result.status = "suspicious"
result.confidence = 0.4 + (result.best_match_similarity * 0.2)
result.notes.append("Found in only 1 source — verify manually")
else:
result.status = "not_found"
result.confidence = 0.0
result.notes.append("NOT FOUND in any database — likely hallucinated")
# Red flags reduce confidence
if result.red_flags:
result.confidence = max(0.0, result.confidence - 0.1 * len(result.red_flags))
return result
def verify_all(
entries: list[BibEntry], verbose: bool = False
) -> list[VerificationResult]:
"""Verify all entries with progress reporting."""
results = []
total = len(entries)
for i, entry in enumerate(entries, 1):
if not verbose:
print(f" [{i}/{total}] Checking: {entry.key}", end="", flush=True)
result = verify_entry(entry, verbose=verbose)
if not verbose:
status_icon = {
"verified": " ✓",
"suspicious": " ?",
"not_found": " ✗",
"error": " !",
}
print(status_icon.get(result.status, " ?"))
results.append(result)
# Rate limiting between entries
time.sleep(1.0)
return results
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def print_report(results: list[VerificationResult]) -> None:
"""Print formatted verification report."""
verified = [r for r in results if r.status == "verified"]
suspicious = [r for r in results if r.status == "suspicious"]
not_found = [r for r in results if r.status == "not_found"]
with_flags = [r for r in results if r.red_flags]
print("\n" + "=" * 65)
print(" CITATION VERIFICATION REPORT")
print("=" * 65)
print(f"\n Total citations: {len(results)}")
print(f" Verified (2+ sources): {len(verified)}")
print(f" Suspicious (1 source): {len(suspicious)}")
print(f" NOT FOUND (0 sources): {len(not_found)}")
print(f" With red flags: {len(with_flags)}")
if not_found:
print(f"\n {'='*60}")
print(" CITATIONS NOT FOUND — LIKELY HALLUCINATED")
print(f" {'='*60}")
for r in not_found:
print(f"\n [{r.entry.key}] {r.entry.file_path}:{r.entry.line_number}")
print(f" Title: {r.entry.title}")
print(f" Authors: {r.entry.authors}")
print(f" Year: {r.entry.year}")
if r.red_flags:
for flag in r.red_flags:
print(f" FLAG: {flag}")
if suspicious:
print(f"\n {'-'*60}")
print(" SUSPICIOUS — FOUND IN ONLY 1 SOURCE (verify manually)")
print(f" {'-'*60}")
for r in suspicious:
print(f"\n [{r.entry.key}] confidence={r.confidence:.0%}")
print(f" Title: {r.entry.title}")
print(f" Best match: {r.best_match_title}")
print(f" Similarity: {r.best_match_similarity:.0%}")
print(f" Found in: {', '.join(r.sources_found)}")
for note in r.notes:
print(f" Note: {note}")
if r.red_flags:
for flag in r.red_flags:
print(f" FLAG: {flag}")
if with_flags and not not_found and not suspicious:
print(f"\n {'-'*60}")
print(" RED FLAGS ON VERIFIED CITATIONS")
print(f" {'-'*60}")
for r in with_flags:
if r.status == "verified":
print(f"\n [{r.entry.key}]")
for flag in r.red_flags:
print(f" FLAG: {flag}")
# Summary
print("\n" + "=" * 65)
if not_found:
print(
f" RESULT: {len(not_found)} UNFOUND CITATION(S) — "
"remove or replace before submission"
)
elif suspicious:
print(
f" RESULT: {len(suspicious)} SUSPICIOUS — "
"manually verify these citations"
)
else:
print(" RESULT: All citations verified in 2+ sources")
print("=" * 65 + "\n")
def json_report(results: list[VerificationResult]) -> str:
"""Generate JSON report."""
output = {
"summary": {
"total": len(results),
"verified": sum(1 for r in results if r.status == "verified"),
"suspicious": sum(1 for r in results if r.status == "suspicious"),
"not_found": sum(1 for r in results if r.status == "not_found"),
},
"citations": [],
}
for r in results:
output["citations"].append(
{
"key": r.entry.key,
"title": r.entry.title,
"status": r.status,
"confidence": round(r.confidence, 2),
"sources_found": r.sources_found,
"best_match_title": r.best_match_title,
"best_match_similarity": round(r.best_match_similarity, 2),
"red_flags": r.red_flags,
"notes": r.notes,
}
)
return json.dumps(output, indent=2)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
import argparse
parser = argparse.ArgumentParser(
description="Verify academic citations against CrossRef, Semantic Scholar, and OpenAlex"
)
parser.add_argument(
"path", help="Path to .bib file or directory containing .bib files"
)
parser.add_argument("--verbose", "-v", action="store_true")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
target = Path(args.path)
bib_files = []
if target.is_file() and target.suffix == ".bib":
bib_files = [target]
elif target.is_dir():
bib_files = sorted(target.rglob("*.bib"))
else:
print(f"Error: {target} is not a .bib file or directory", file=sys.stderr)
sys.exit(1)
if not bib_files:
print(f"No .bib files found in {target}", file=sys.stderr)
sys.exit(1)
# Parse all entries
all_entries = []
for bib_file in bib_files:
entries = parse_bib_file(bib_file)
all_entries.extend(entries)
if not args.json:
print(f"Parsed {len(entries)} entries from {bib_file}")
if not all_entries:
print("No BibTeX entries found")
sys.exit(0)
if not args.json:
print(f"\nVerifying {len(all_entries)} citations across 3 databases...\n")
# Verify
results = verify_all(all_entries, verbose=args.verbose)
# Report
if args.json:
print(json_report(results))
else:
print_report(results)
# Exit code: 1 if any not_found, 2 if suspicious only, 0 if all verified
not_found = sum(1 for r in results if r.status == "not_found")
suspicious = sum(1 for r in results if r.status == "suspicious")
if not_found:
sys.exit(1)
elif suspicious:
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Accuracy test suite for citation_checker.py.
Tests against a curated set of:
- 10 KNOWN-GOOD citations (real papers, must verify as "verified")
- 10 KNOWN-BAD citations (fabricated, must verify as "not_found" or "suspicious")
- 5 CHIMERIC citations (real title + wrong authors, must flag)
This prevents the worst failure mode: marking correct citations as wrong.
Usage:
python tests/test_citation_checker.py
python tests/test_citation_checker.py --verbose
"""
import sys
import time
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.citation_checker import (
BibEntry,
verify_entry,
title_similarity,
author_overlap,
detect_red_flags,
)
# ============================================================
# Test Data: Known-Good Citations (MUST verify as "verified")
# ============================================================
KNOWN_GOOD = [
BibEntry(
key="vaswani2017attention",
entry_type="inproceedings",
title="Attention Is All You Need",
authors="Vaswani, Ashish and Shazeer, Noam and Parmar, Niki",
year="2017",
doi="10.48550/arXiv.1706.03762",
),
BibEntry(
key="devlin2019bert",
entry_type="inproceedings",
title="BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding",
authors="Devlin, Jacob and Chang, Ming-Wei and Lee, Kenton and Toutanova, Kristina",
year="2019",
doi="10.18653/v1/N19-1423", # DOI helps — title-only search fails for BERT (too many derivatives)
),
BibEntry(
key="brown2020language",
entry_type="misc",
title="Language Models are Few-Shot Learners",
authors="Brown, Tom and Mann, Benjamin and Ryder, Nick",
year="2020",
arxiv_id="2005.14165",
),
BibEntry(
key="he2016deep",
entry_type="inproceedings",
title="Deep Residual Learning for Image Recognition",
authors="He, Kaiming and Zhang, Xiangyu and Ren, Shaoqing and Sun, Jian",
year="2016",
),
BibEntry(
key="goodfellow2014generative",
entry_type="inproceedings",
title="Generative Adversarial Nets",
authors="Goodfellow, Ian and Pouget-Abadie, Jean and Mirza, Mehdi",
year="2014",
),
BibEntry(
key="kingma2015adam",
entry_type="inproceedings",
title="Adam: A Method for Stochastic Optimization",
authors="Kingma, Diederik P. and Ba, Jimmy",
year="2015",
),
BibEntry(
key="hochreiter1997long",
entry_type="article",
title="Long Short-Term Memory",
authors="Hochreiter, Sepp and Schmidhuber, Jurgen",
year="1997",
),
BibEntry(
key="lecun1998gradient",
entry_type="article",
title="Gradient-based learning applied to document recognition",
authors="LeCun, Yann and Bottou, Leon and Bengio, Yoshua and Haffner, Patrick",
year="1998",
),
BibEntry(
key="mikolov2013efficient",
entry_type="misc",
title="Efficient Estimation of Word Representations in Vector Space",
authors="Mikolov, Tomas and Chen, Kai and Corrado, Greg and Dean, Jeffrey",
year="2013",
),
BibEntry(
key="radford2019language",
entry_type="misc",
title="Language Models are Unsupervised Multitask Learners",
authors="Radford, Alec and Wu, Jeffrey and Child, Rewon",
year="2019",
# Note: GPT-2 has no DOI/arXiv (OpenAI tech report). Relies on Semantic Scholar.
# May fail when SS is rate-limited — a realistic limitation for unpublished papers.
),
]
# ============================================================
# Test Data: Known-Bad Citations (MUST verify as "not_found")
# ============================================================
KNOWN_BAD = [
BibEntry(
key="zhang2024unified",
entry_type="inproceedings",
title="Unified Framework for Multi-Modal Reasoning in Dynamic Environments",
authors="Zhang, Wei and Liu, Xiaoming and Chen, Yufei",
year="2024",
),
BibEntry(
key="smith2023scaling",
entry_type="article",
title="Scaling Laws for Neural Architecture Search with Evolutionary Pruning",
authors="Smith, Jonathan and Williams, Sarah and Brown, Michael",
year="2023",
),
BibEntry(
key="wang2024robust",
entry_type="inproceedings",
title="Robust Alignment Through Iterative Self-Refinement of Language Model Preferences",
authors="Wang, Zhenghao and Li, Mingxuan and Patel, Arun",
year="2024",
),
BibEntry(
key="johnson2023efficient",
entry_type="article",
title="Efficient Sparse Transformers with Locality-Sensitive Hashing for Long Document Understanding",
authors="Johnson, Emily and Davis, Robert and Thompson, Lisa",
year="2023",
),
BibEntry(
key="kumar2024adaptive",
entry_type="inproceedings",
title="Adaptive Graph Neural Networks for Heterogeneous Knowledge Base Completion",
authors="Kumar, Rajesh and Singh, Priya and Gupta, Amit",
year="2024",
),
BibEntry(
key="chen2023progressive",
entry_type="misc",
title="Progressive Distillation for Continual Learning in Open-World Visual Recognition",
authors="Chen, Tianyu and Zhao, Wenlong and Sun, Haifeng",
year="2023",
),
BibEntry(
key="miller2024dynamic",
entry_type="article",
title="Dynamic Reward Shaping via Constitutional Meta-Learning for Safe Reinforcement Learning",
authors="Miller, James and Anderson, Patricia and Lee, Dongwook",
year="2024",
),
BibEntry(
key="taylor2023multiagent",
entry_type="inproceedings",
title="Multi-Agent Cooperative Planning with Emergent Communication Protocols",
authors="Taylor, Christopher and Wilson, Diana and Garcia, Carlos",
year="2023",
),
BibEntry(
key="park2024neural",
entry_type="article",
title="Neural Symbolic Integration for Compositional Program Synthesis from Natural Language",
authors="Park, Jihyun and Kim, Seonghyun and Choi, Yejin",
year="2024",
doi="10.1234/fake.2024.12345",
),
BibEntry(
key="garcia2023foundation",
entry_type="misc",
title="Foundation Models for Autonomous Scientific Discovery in Protein Engineering",
authors="Garcia, Maria and Santos, Pedro and Fernandez, Ana",
year="2023",
),
]
# ============================================================
# Test Data: Chimeric Citations (real title + wrong authors)
# ============================================================
CHIMERIC = [
BibEntry(
key="chimeric1",
entry_type="inproceedings",
title="Attention Is All You Need", # Real title
authors="Zhang, Wei and Chen, Li", # Wrong authors
year="2017",
),
BibEntry(
key="chimeric2",
entry_type="inproceedings",
title="Deep Residual Learning for Image Recognition", # Real title
authors="Smith, John and Williams, Jane", # Wrong authors
year="2016",
),
BibEntry(
key="chimeric3",
entry_type="inproceedings",
title="Generative Adversarial Nets", # Real title
authors="Kumar, Raj and Patel, Ankit", # Wrong authors
year="2014",
),
BibEntry(
key="chimeric4",
entry_type="article",
title="Long Short-Term Memory", # Real title
authors="Johnson, Michael and Davis, Robert", # Wrong authors
year="1997",
),
BibEntry(
key="chimeric5",
entry_type="inproceedings",
title="Adam: A Method for Stochastic Optimization", # Real title
authors="Miller, James and Anderson, Patricia", # Wrong authors
year="2015",
),
]
# ============================================================
# Unit Tests (No API calls)
# ============================================================
def test_title_similarity():
"""Test title similarity scoring."""
print("\n--- Title Similarity Tests ---")
cases = [
("Attention Is All You Need", "Attention Is All You Need", 1.0, "exact match"),
("Attention Is All You Need", "Attention Is All We Need", 0.7, "near match"),
("Deep Learning for NLP", "Quantum Physics Today", 0.0, "no overlap"),
("BERT Pre-training", "BERT: Pre-training of Deep Bidirectional Transformers", 0.3, "partial"),
]
passed = 0
for a, b, min_expected, label in cases:
sim = title_similarity(a, b)
ok = sim >= min_expected - 0.15 # Allow some tolerance
status = "PASS" if ok else "FAIL"
print(f" {status}: {label} — sim={sim:.2f} (expected >={min_expected:.2f})")
if ok:
passed += 1
return passed, len(cases)
def test_author_overlap():
"""Test author overlap detection."""
print("\n--- Author Overlap Tests ---")
cases = [
("Vaswani, Ashish and Shazeer, Noam", "Ashish Vaswani, Noam Shazeer", 0.5, "same authors API format"),
("Vaswani, Ashish and Shazeer, Noam", "Zhang, Wei and Chen, Li", 0.0, "different authors"),
("He, Kaiming", "Kaiming He", 0.5, "single author"),
("", "Vaswani, Ashish", 0.0, "empty entry"),
]
passed = 0
for entry_a, found_a, min_expected, label in cases:
overlap = author_overlap(entry_a, found_a)
ok = overlap >= min_expected - 0.1
status = "PASS" if ok else "FAIL"
print(f" {status}: {label} — overlap={overlap:.2f} (expected >={min_expected:.2f})")
if ok:
passed += 1
return passed, len(cases)
def test_red_flags():
"""Test red flag detection."""
print("\n--- Red Flag Tests ---")
passed = 0
total = 0
# Invalid DOI
total += 1
entry = BibEntry(key="test", entry_type="article", title="Test", authors="A", year="2024", doi="not-a-doi")
flags = detect_red_flags(entry)
ok = any("Invalid DOI" in f for f in flags)
print(f" {'PASS' if ok else 'FAIL'}: Invalid DOI detected")
if ok: passed += 1
# Future year
total += 1
entry = BibEntry(key="test", entry_type="article", title="Test", authors="A", year="2030")
flags = detect_red_flags(entry)
ok = any("Future year" in f for f in flags)
print(f" {'PASS' if ok else 'FAIL'}: Future year detected")
if ok: passed += 1
# Missing authors
total += 1
entry = BibEntry(key="test", entry_type="article", title="Test", authors="", year="2024")
flags = detect_red_flags(entry)
ok = any("Missing author" in f for f in flags)
print(f" {'PASS' if ok else 'FAIL'}: Missing authors detected")
if ok: passed += 1
# Valid entry (no flags expected except maybe generic title)
total += 1
entry = BibEntry(key="test", entry_type="article", title="Specific Novel Method for X", authors="Smith, John and Doe, Jane", year="2024", doi="10.1234/test")
flags = detect_red_flags(entry)
ok = len(flags) == 0
print(f" {'PASS' if ok else 'FAIL'}: Valid entry has no flags (got {len(flags)})")
if ok: passed += 1
return passed, total
# ============================================================
# Integration Tests (API calls — rate limited)
# ============================================================
def test_known_good(verbose: bool = False) -> tuple[int, int]:
"""Test that known-good citations verify correctly."""
print("\n--- Known-Good Citations (should verify) ---")
passed = 0
for entry in KNOWN_GOOD:
result = verify_entry(entry, verbose=verbose)
ok = result.status in ("verified", "suspicious") # Suspicious is acceptable (API flakiness)
false_positive = result.status == "not_found"
status = "PASS" if ok else "FALSE POSITIVE" if false_positive else "WARN"
print(f" {status}: [{entry.key}] status={result.status} conf={result.confidence:.0%} sources={result.sources_found}")
if ok:
passed += 1
elif false_positive:
print(f" !!! CRITICAL: Real paper marked as not found — investigate!")
time.sleep(3) # Rate limiting (Semantic Scholar needs ~3s between calls)
return passed, len(KNOWN_GOOD)
def test_known_bad(verbose: bool = False) -> tuple[int, int]:
"""Test that fabricated citations are caught."""
print("\n--- Known-Bad Citations (should NOT verify) ---")
passed = 0
for entry in KNOWN_BAD:
result = verify_entry(entry, verbose=verbose)
ok = result.status in ("not_found", "suspicious")
false_negative = result.status == "verified"
status = "PASS" if ok else "FALSE NEGATIVE" if false_negative else "WARN"
print(f" {status}: [{entry.key}] status={result.status} conf={result.confidence:.0%}")
if ok:
passed += 1
elif false_negative:
print(f" !!! CRITICAL: Fake paper verified — investigate match: {result.best_match_title}")
time.sleep(3)
return passed, len(KNOWN_BAD)
def test_chimeric(verbose: bool = False) -> tuple[int, int]:
"""Test that chimeric citations (real title, wrong authors) are flagged."""
print("\n--- Chimeric Citations (should flag author mismatch) ---")
passed = 0
for entry in CHIMERIC:
result = verify_entry(entry, verbose=verbose)
# Should either be flagged with red flags or caught as suspicious
has_chimeric_flag = any("chimeric" in f.lower() or "author" in f.lower() for f in result.red_flags)
is_suspicious = result.status == "suspicious"
ok = has_chimeric_flag or is_suspicious or result.confidence < 0.7
status = "PASS" if ok else "MISS"
print(f" {status}: [{entry.key}] status={result.status} conf={result.confidence:.0%} flags={len(result.red_flags)}")
if ok:
passed += 1
else:
print(f" Note: Chimeric not caught — conf={result.confidence:.0%}, flags={result.red_flags}")
time.sleep(3)
return passed, len(CHIMERIC)
# ============================================================
# Main
# ============================================================
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--verbose", "-v", action="store_true")
parser.add_argument("--unit-only", action="store_true", help="Skip API tests")
args = parser.parse_args()
print("=" * 60)
print(" CITATION CHECKER ACCURACY TEST SUITE")
print("=" * 60)
results = {}
# Unit tests (no API)
results["title_similarity"] = test_title_similarity()
results["author_overlap"] = test_author_overlap()
results["red_flags"] = test_red_flags()
if not args.unit_only:
print("\n" + "=" * 60)
print(" INTEGRATION TESTS (API calls — ~2 min)")
print("=" * 60)
results["known_good"] = test_known_good(verbose=args.verbose)
results["known_bad"] = test_known_bad(verbose=args.verbose)
results["chimeric"] = test_chimeric(verbose=args.verbose)
# Summary
print("\n" + "=" * 60)
print(" SUMMARY")
print("=" * 60)
total_passed = 0
total_tests = 0
for name, (passed, total) in results.items():
rate = passed / total * 100 if total > 0 else 0
icon = "PASS" if passed == total else "PARTIAL" if passed > 0 else "FAIL"
print(f" {icon}: {name}: {passed}/{total} ({rate:.0f}%)")
total_passed += passed
total_tests += total
overall = total_passed / total_tests * 100 if total_tests > 0 else 0
print(f"\n OVERALL: {total_passed}/{total_tests} ({overall:.0f}%)")
if "known_good" in results:
good_passed, good_total = results["known_good"]
fp_rate = (good_total - good_passed) / good_total * 100
print(f" FALSE POSITIVE RATE: {fp_rate:.0f}% (real papers marked wrong)")
if "known_bad" in results:
bad_passed, bad_total = results["known_bad"]
fn_rate = (bad_total - bad_passed) / bad_total * 100
print(f" FALSE NEGATIVE RATE: {fn_rate:.0f}% (fake papers not caught)")
print("=" * 60)
# Allow 1 FP for known_good (unpublished tech reports without DOI are hard)
# Core guarantee: 0% false negatives (fake papers never verified)
known_good_ok = True
if "known_good" in results:
gp, gt = results["known_good"]
known_good_ok = gp >= gt - 1 # Allow at most 1 miss
known_bad_ok = True
if "known_bad" in results:
bp, bt = results["known_bad"]
known_bad_ok = bp == bt # Zero tolerance for false negatives
sys.exit(0 if known_bad_ok and known_good_ok else 1)
if __name__ == "__main__":
main()