mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
7752a3d730
Hardens the title-fallback match criterion across all four resolvers to exact-title-or-bust: title similarity + year/author can no longer alone promote a non-exact title to `matched`. New `exact_normalized_title` (base-OR-acronym byte-equality) + `generic_title` shared helpers; per-candidate exact gate in each client loop; `RESOLVER_DECISION_VERSION` cache gate so pre-pivot rows re-resolve. Resolver-boundary distinct-work pins flip from strict-xfail to passing assertions. Closes #431. Spec: docs/design/2026-06-13-431-title-match-hardening-spec.md (§0.12 v5 + §0.13 round-6 disposition). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
4.7 KiB
Python
112 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Resolver-boundary pin for #250 / #430 / #431 — distinct-work rejection.
|
|
|
|
#250 recorded a regression the citation-extraction gold set cannot catch: a
|
|
resolver's title fallback *accidentally* accepts a wrong record as `matched`.
|
|
The decision lives one layer below the gold set — in the client `title_search`
|
|
and `_resolve_doi_then_title`, which `run_evals.py` never executes — so the pin
|
|
must exercise the client HTTP path directly. (See the #250 finding for the trace.)
|
|
|
|
Until #431 these two assertions were marked `xfail(strict=True)`: a character-
|
|
level `SequenceMatcher.ratio() >= 0.70` over punctuation-stripped titles scores
|
|
DISTINCT works far above 0.70 when they share long substrings —
|
|
|
|
"Deep Residual Learning for Image Recognition"
|
|
"...for Image Recognition on Embedded Devices" -> 0.815
|
|
"Attention Is All You Need" vs "...Is Not All You Need" -> 0.926
|
|
|
|
— so a real-but-unindexed citation whose title search surfaced a *different*
|
|
near-identical paper was collapsed to `matched`, with no year/author re-check to
|
|
catch it.
|
|
|
|
#431's exact-title-or-bust pivot (spec §0.12) closes this at the resolver
|
|
boundary: the title fallback now promotes a candidate to `matched` ONLY when its
|
|
normalized title is byte-equal to the cited title. A superstring (extra tail
|
|
words) and a negation ("Not") are both NON-exact, so both correctly return no
|
|
match — the xfail markers are removed and these are now real passing assertions
|
|
(the strict-xfail flipped to XPASS the moment the criterion hardened, by design).
|
|
|
|
The 0.70 + exact-title criterion is shared (via `_text_similarity`) by the
|
|
Crossref / OpenAlex / Semantic Scholar / arXiv clients, so this is not
|
|
Crossref-specific; CrossrefClient is the representative because its
|
|
`title_search` is the most direct. The complementary acceptance matrix (a
|
|
legitimate exact / acronym / case variant still matches, F3 ordering, generic
|
|
gating, the resolver→reducer `unresolvable` chain) is in
|
|
`test_431_exact_or_bust.py`.
|
|
|
|
Run:
|
|
PYTHONPATH=. python -m pytest scripts/test_title_fuzzy_false_positive.py -v
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
def _mock_response(payload: dict) -> MagicMock:
|
|
"""Build a urlopen() context-manager mock returning `payload` as JSON.
|
|
|
|
Mirrors the mock shape in test_crossref_client.py so this pin exercises the
|
|
real client HTTP path, not a stub.
|
|
"""
|
|
resp = MagicMock()
|
|
resp.read.return_value = json.dumps(payload).encode("utf-8")
|
|
resp.__enter__ = MagicMock(return_value=resp)
|
|
resp.__exit__ = MagicMock(return_value=None)
|
|
return resp
|
|
|
|
|
|
def test_superstring_title_is_rejected_as_distinct_work():
|
|
"""A cited work's title search surfaces a DIFFERENT paper whose title merely
|
|
contains the cited title as a prefix. Correct verdict: no match (the cited
|
|
work is genuinely unindexed). Under #431 the superstring is non-exact, so
|
|
`title_search` returns None."""
|
|
from crossref_client import CrossrefClient
|
|
|
|
cited_title = "Deep Residual Learning for Image Recognition"
|
|
# The only candidate the index returns is a real but DIFFERENT paper.
|
|
surfaced_distinct_work = {
|
|
"title": ["Deep Residual Learning for Image Recognition on Embedded Devices"],
|
|
"DOI": "10.0000/not-the-cited-work",
|
|
}
|
|
payload = {"message": {"items": [surfaced_distinct_work]}}
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_response(payload)):
|
|
client = CrossrefClient()
|
|
result = client.title_search(cited_title)
|
|
|
|
assert result is None, (
|
|
"title_search accepted a distinct work (superstring title) as a match — "
|
|
"false positive. The cited work is unindexed; the resolver surfaced a "
|
|
"different paper."
|
|
)
|
|
|
|
|
|
def test_negated_title_is_rejected_as_distinct_work():
|
|
"""A negation ("Not") flips a title's meaning but barely moves the
|
|
character-level ratio (0.926). Correct verdict: no match. Under #431 the
|
|
negated title is non-exact, so `title_search` returns None."""
|
|
from crossref_client import CrossrefClient
|
|
|
|
cited_title = "Attention Is All You Need"
|
|
surfaced_distinct_work = {
|
|
"title": ["Attention Is Not All You Need"],
|
|
"DOI": "10.0000/different-negated-work",
|
|
}
|
|
payload = {"message": {"items": [surfaced_distinct_work]}}
|
|
|
|
with patch("urllib.request.urlopen", return_value=_mock_response(payload)):
|
|
client = CrossrefClient()
|
|
result = client.title_search(cited_title)
|
|
|
|
assert result is None, (
|
|
"title_search accepted a semantically negated title as a match — "
|
|
"false positive on a distinct work."
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import pytest
|
|
sys.exit(pytest.main([__file__, "-v"]))
|