Coverage for src/local_deep_research/journal_quality/scoring.py: 100%
56 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""Pure-function quality scoring helpers.
3These are stateless and called by both the build phase
4(`db.build_db()` populating the `quality` column) and the runtime
5filter (when scoring an institution-only fallback).
7Kept in a small module of their own so the build phase doesn't have
8to import the read-only DB accessor just to get at the score
9thresholds.
10"""
12from __future__ import annotations
14import unicodedata
15from typing import Optional
17from ..constants import (
18 CONFERENCE_QUALITY_DEFAULT,
19 DOAJ_QUALITY_LISTED,
20 INSTITUTION_HINDEX_HIGH,
21 INSTITUTION_HINDEX_TOP,
22 INSTITUTION_QUALITY_DEFAULT,
23 INSTITUTION_QUALITY_HIGH,
24 INSTITUTION_QUALITY_TOP,
25 JOURNAL_HINDEX_ACCEPTABLE,
26 JOURNAL_HINDEX_ELITE,
27 JOURNAL_HINDEX_GOOD,
28 JOURNAL_HINDEX_STRONG,
29 JOURNAL_HINDEX_VERY_GOOD,
30 JOURNAL_QUALITY_ACCEPTABLE,
31 JOURNAL_QUALITY_DEFAULT,
32 JOURNAL_QUALITY_ELITE,
33 JOURNAL_QUALITY_GOOD,
34 JOURNAL_QUALITY_PREDATORY,
35 JOURNAL_QUALITY_STRONG,
36 REPOSITORY_QUALITY_DEFAULT,
37 JOURNAL_QUALITY_VERY_GOOD,
38)
41def normalize_name(name: str) -> str:
42 """NFKC + lowercase + strip — used for consistent name matching.
44 Mirrors the previous `_normalize` helper that lived in both
45 `journal_reference_db.py` and `journal_data_manager.py`. Single
46 home now so the build phase and the runtime accessor agree.
47 """
48 return unicodedata.normalize("NFKC", name).lower().strip()
51def derive_quality_score(
52 *,
53 h_index: Optional[int] = None,
54 quartile: Optional[str] = None,
55 is_in_doaj: bool = False,
56 is_predatory: bool = False,
57 source_type: Optional[str] = None,
58) -> Optional[int]:
59 """Derive a 1–10 quality score from bibliometric data.
61 Inputs (in order of preference):
62 - ``quartile``: SJR-style Q1/Q2/Q3/Q4. Strongest single signal — this
63 is what librarians and reviewers use when evaluating journals, so
64 we honour it directly and only use h-index as a tiebreaker.
65 - ``h_index``: used standalone when no quartile is available.
66 - ``is_in_doaj``: weakest fall-through signal.
68 There is deliberately no DOAJ Seal input anymore: DOAJ retired the
69 Seal in April 2025 and removed it from their metadata, so the old
70 ``has_doaj_seal`` tier (score 8) could never be earned again and
71 only ever fired on stale pre-2025 data.
73 H-index thresholds calibrated from real data:
74 - Nature h-index: 1,442
75 - PLOS ONE h-index: 467
76 - Only 3 journals globally have h-index > 1,000
78 Note: h-index has field-dependent bias (mathematics journals have
79 naturally lower h-index than biomedical journals). These thresholds
80 are general-purpose; field-specific normalization is not yet
81 implemented.
83 Returns:
84 Score 1–10, or `None` if there is not enough signal.
85 """
86 if is_predatory and not is_in_doaj:
87 return JOURNAL_QUALITY_PREDATORY # Auto-remove threshold
89 # Preprint repositories (arXiv, bioRxiv, SSRN, PsyArXiv, ...) are
90 # not peer-reviewed. Their h-index reflects citation accumulation
91 # across the thousands of papers they aggregate — not venue rigor.
92 # Cap them at the ACCEPTABLE tier so Q-tier semantics remain
93 # meaningful. The filter's Tier 3.5 institution-salvage path can
94 # lift this via author affiliations when appropriate.
95 #
96 # NOTE: only ``"repository"`` is capped here. ``"conference"`` gets
97 # its own flat score below via the ``source_type == "conference"``
98 # branch. Other OpenAlex source types — ``"book series"`` (Springer
99 # Lecture Notes etc.) and ``"ebook platform"`` (Elsevier
100 # ScienceDirect, Springer Link) — CAN be peer-reviewed, so we
101 # intentionally let h-index scoring apply for them. Reviewed in
102 # the PR #3081 audit; not a gap.
103 if source_type == "repository":
104 return REPOSITORY_QUALITY_DEFAULT
106 # Quartile takes precedence — it is the canonical librarian signal.
107 # We still let a high h-index bump a Q1 to "elite" so Nature stays
108 # distinguishable from a typical Q1. DOAJ listing applies
109 # orthogonally via max() so it cannot be silently discarded.
110 if quartile:
111 q = quartile.upper().strip()
112 q_score: Optional[int] = None
113 if q == "Q1":
114 if h_index and h_index > JOURNAL_HINDEX_ELITE:
115 q_score = JOURNAL_QUALITY_ELITE
116 else:
117 q_score = JOURNAL_QUALITY_STRONG
118 elif q == "Q2":
119 q_score = JOURNAL_QUALITY_VERY_GOOD
120 elif q == "Q3":
121 q_score = JOURNAL_QUALITY_GOOD
122 elif q == "Q4":
123 q_score = JOURNAL_QUALITY_ACCEPTABLE
125 if q_score is not None:
126 if is_in_doaj:
127 return max(q_score, DOAJ_QUALITY_LISTED)
128 return q_score
130 # h_index=0 means newly indexed, not meaningful. Negative values
131 # would be a data error — treat as no signal rather than returning
132 # DEFAULT which is ambiguous.
133 if h_index and h_index > 0:
134 if h_index > JOURNAL_HINDEX_ELITE:
135 h_score = JOURNAL_QUALITY_ELITE # Nature/Science/NEJM
136 elif h_index > JOURNAL_HINDEX_STRONG:
137 h_score = JOURNAL_QUALITY_STRONG
138 elif h_index > JOURNAL_HINDEX_VERY_GOOD:
139 h_score = JOURNAL_QUALITY_VERY_GOOD
140 elif h_index > JOURNAL_HINDEX_GOOD:
141 h_score = JOURNAL_QUALITY_GOOD
142 elif h_index > JOURNAL_HINDEX_ACCEPTABLE:
143 h_score = JOURNAL_QUALITY_ACCEPTABLE
144 else:
145 h_score = JOURNAL_QUALITY_DEFAULT
147 # DOAJ listing is an orthogonal quality signal (verified open
148 # access). Use max() so the signals reinforce rather than
149 # conflict.
150 if is_in_doaj:
151 return max(h_score, DOAJ_QUALITY_LISTED)
152 return h_score
154 if is_in_doaj:
155 return DOAJ_QUALITY_LISTED
157 if source_type == "conference":
158 return CONFERENCE_QUALITY_DEFAULT # Neutral — in CS, top conferences are Q1-equivalent
160 return None # Insufficient data
163def institution_score_from_h_index(h_index: Optional[int]) -> Optional[int]:
164 """Derive a quality score from an institution's h-index.
166 Capped at 6 — institution alone never beats a real venue match.
167 Used by the Tier 3.5 affiliation salvage path in the filter.
168 """
169 if h_index is None:
170 return None
171 if h_index > INSTITUTION_HINDEX_TOP:
172 return INSTITUTION_QUALITY_TOP # Top-tier research universities
173 if h_index > INSTITUTION_HINDEX_HIGH:
174 return INSTITUTION_QUALITY_HIGH
175 return INSTITUTION_QUALITY_DEFAULT