sciwrite-lint 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,277 @@
1
+ """Check: reference-accuracy — manuscript metadata vs canonical API records.
2
+
3
+ Runs after reference-exists confirms a reference is real. Compares title,
4
+ authors, year, and venue in the bibitem against stored canonical data from
5
+ CrossRef / OpenAlex / Semantic Scholar.
6
+
7
+ Uses metadata already persisted in references/metadata/{key}.json — no new
8
+ API calls.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from sciwrite_lint.checks.registry import check
17
+ from sciwrite_lint.config import LintConfig
18
+ from sciwrite_lint.models import CitationMetadata, Finding
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Field-level comparison helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _check_title(
27
+ key: str, bibitem: dict, canonical: dict, api_source: str = ""
28
+ ) -> Finding | None:
29
+ """Title: fuzzy match, ERROR if below threshold (possible fabricated title)."""
30
+ from sciwrite_lint.references.matching import TITLE_THRESHOLD, title_similarity
31
+
32
+ tex_title = bibitem.get("title", "")
33
+ api_title = canonical.get("title", "")
34
+ if not tex_title or not api_title:
35
+ return None
36
+
37
+ sim = title_similarity(tex_title, api_title)
38
+ if sim >= TITLE_THRESHOLD:
39
+ return None
40
+
41
+ return Finding(
42
+ level="error",
43
+ rule_id="reference-accuracy",
44
+ message=(
45
+ f"{key}: Title mismatch (similarity={sim:.2f}): "
46
+ f"tex='{tex_title[:80]}', canonical='{api_title[:80]}'"
47
+ ),
48
+ context=f"Source: {api_source}" if api_source else "",
49
+ )
50
+
51
+
52
+ def _check_authors(
53
+ key: str, bibitem: dict, canonical: dict, api_source: str = ""
54
+ ) -> Finding | None:
55
+ """Authors: set comparison on normalized last names."""
56
+ from sciwrite_lint.references.matching import AUTHOR_THRESHOLD, author_similarity
57
+
58
+ tex_authors = bibitem.get("authors", [])
59
+ api_authors = canonical.get("authors", [])
60
+ if not tex_authors or not api_authors:
61
+ return None
62
+
63
+ # Handle case where tex_authors is a single joined string
64
+ if len(tex_authors) == 1 and "," in tex_authors[0]:
65
+ tex_authors = [a.strip() for a in tex_authors[0].split(",") if a.strip()]
66
+
67
+ sim = author_similarity(tex_authors, api_authors)
68
+ if sim >= AUTHOR_THRESHOLD:
69
+ return None
70
+
71
+ tex_str = ", ".join(str(a) for a in tex_authors[:3])
72
+ api_str = ", ".join(str(a) for a in api_authors[:3])
73
+ suffix = "..." if len(api_authors) > 3 else ""
74
+ return Finding(
75
+ level="warning",
76
+ rule_id="reference-accuracy",
77
+ message=(
78
+ f"{key}: Author mismatch (similarity={sim:.2f}): "
79
+ f"tex='{tex_str}', canonical='{api_str}{suffix}'"
80
+ ),
81
+ context=f"Source: {api_source}" if api_source else "",
82
+ )
83
+
84
+
85
+ def _check_year(
86
+ key: str, bibitem: dict, canonical: dict, api_source: str = ""
87
+ ) -> Finding | None:
88
+ """Year: exact match, flag if off by >1 year."""
89
+ tex_year = bibitem.get("year", "")
90
+ api_year = canonical.get("year")
91
+ if not tex_year or not api_year:
92
+ return None
93
+
94
+ try:
95
+ ty = int(tex_year)
96
+ ay = int(api_year)
97
+ except (ValueError, TypeError):
98
+ return None
99
+
100
+ diff = abs(ty - ay)
101
+ if diff <= 1:
102
+ return None
103
+
104
+ return Finding(
105
+ level="warning",
106
+ rule_id="reference-accuracy",
107
+ message=f"{key}: Year mismatch: tex={ty}, canonical={ay} (off by {diff})",
108
+ context=f"Source: {api_source}" if api_source else "",
109
+ )
110
+
111
+
112
+ def _check_venue(
113
+ key: str, bibitem: dict, canonical: dict, api_source: str = ""
114
+ ) -> tuple[Finding | None, tuple[str, str] | None]:
115
+ """Venue: fuzzy match on normalized name.
116
+
117
+ Returns (finding_or_none, venue_pair_needing_llm_confirmation_or_none).
118
+ When fuzzy score is below threshold, returns the finding AND the venue
119
+ pair so the caller can optionally confirm via vLLM before emitting.
120
+ """
121
+ from sciwrite_lint.references.matching import VENUE_THRESHOLD, venue_similarity
122
+
123
+ tex_venue = bibitem.get("venue", "")
124
+ api_venue = canonical.get("venue", "")
125
+ if not tex_venue or not api_venue:
126
+ return None, None
127
+
128
+ sim = venue_similarity(tex_venue, api_venue)
129
+ if sim >= VENUE_THRESHOLD:
130
+ return None, None
131
+
132
+ finding = Finding(
133
+ level="warning",
134
+ rule_id="reference-accuracy",
135
+ message=(
136
+ f"{key}: Venue mismatch (similarity={sim:.2f}): "
137
+ f"tex='{tex_venue[:60]}', canonical='{api_venue[:60]}'"
138
+ ),
139
+ context=f"Source: {api_source}" if api_source else "",
140
+ )
141
+ return finding, (tex_venue, api_venue)
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Main check: run on all stored metadata
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def check_reference_accuracy_from_metadata(
150
+ all_metadata: dict[str, CitationMetadata],
151
+ ) -> list[Finding]:
152
+ """Compare bibitem vs canonical for all verified references.
153
+
154
+ Only checks references where api_match is "verified" or "mismatch"
155
+ (i.e., reference-exists already passed).
156
+
157
+ Synchronous version — venue mismatches use fuzzy matching only.
158
+ For vLLM-confirmed venue matching, use the async variant.
159
+ """
160
+ findings: list[Finding] = []
161
+
162
+ for key, meta in sorted(all_metadata.items()):
163
+ # Skip unverified, not-found, and web resources
164
+ if meta.api_match not in ("verified", "mismatch"):
165
+ continue
166
+
167
+ # Skip manually overridden references
168
+ if meta.manual_override and meta.manual_override.get("skip_accuracy"):
169
+ continue
170
+
171
+ canonical = meta.canonical
172
+ bibitem = meta.bibitem
173
+ if not canonical or not bibitem:
174
+ continue
175
+
176
+ src = meta.api_source
177
+ for checker in (_check_title, _check_authors, _check_year):
178
+ finding = checker(key, bibitem, canonical, api_source=src)
179
+ if finding:
180
+ findings.append(finding)
181
+
182
+ venue_finding, _venue_pair = _check_venue(
183
+ key, bibitem, canonical, api_source=src
184
+ )
185
+ if venue_finding:
186
+ findings.append(venue_finding)
187
+
188
+ return findings
189
+
190
+
191
+ async def check_reference_accuracy_from_metadata_async(
192
+ all_metadata: dict[str, CitationMetadata],
193
+ config: Any | None = None,
194
+ ) -> list[Finding]:
195
+ """Compare bibitem vs canonical, with vLLM venue confirmation.
196
+
197
+ Same as sync version, but venue mismatches below the fuzzy threshold
198
+ are sent to vLLM for confirmation before flagging. If vLLM says the
199
+ venues match, the finding is suppressed.
200
+ """
201
+ from sciwrite_lint.references.matching import venue_match_llm
202
+
203
+ findings: list[Finding] = []
204
+ venue_candidates: list[tuple[Finding, str, str]] = []
205
+
206
+ for key, meta in sorted(all_metadata.items()):
207
+ if meta.api_match not in ("verified", "mismatch"):
208
+ continue
209
+ if meta.manual_override and meta.manual_override.get("skip_accuracy"):
210
+ continue
211
+
212
+ canonical = meta.canonical
213
+ bibitem = meta.bibitem
214
+ if not canonical or not bibitem:
215
+ continue
216
+
217
+ src = meta.api_source
218
+ for checker in (_check_title, _check_authors, _check_year):
219
+ finding = checker(key, bibitem, canonical, api_source=src)
220
+ if finding:
221
+ findings.append(finding)
222
+
223
+ venue_finding, venue_pair = _check_venue(
224
+ key, bibitem, canonical, api_source=src
225
+ )
226
+ if venue_finding and venue_pair:
227
+ venue_candidates.append((venue_finding, venue_pair[0], venue_pair[1]))
228
+
229
+ # Confirm venue mismatches via vLLM (concurrent, bounded)
230
+ if venue_candidates:
231
+ import asyncio
232
+
233
+ sem = asyncio.Semaphore(50)
234
+
235
+ async def _confirm(finding: Finding, tex_v: str, api_v: str) -> Finding | None:
236
+ async with sem:
237
+ same = await venue_match_llm(tex_v, api_v, config=config)
238
+ if same is True:
239
+ return None # vLLM says same venue — suppress
240
+ return finding
241
+
242
+ results = await asyncio.gather(
243
+ *[_confirm(f, tv, av) for f, tv, av in venue_candidates]
244
+ )
245
+ for r in results:
246
+ if r is not None:
247
+ findings.append(r)
248
+
249
+ return findings
250
+
251
+
252
+ @check(
253
+ id="reference-accuracy",
254
+ category="reference-db",
255
+ severity="warning",
256
+ description="Manuscript metadata vs canonical API records (title, authors, year, venue).",
257
+ )
258
+ def check_reference_accuracy(tex_path: Path, config: LintConfig) -> list[Finding]:
259
+ """Load stored metadata and compare bibitem fields against canonical records.
260
+
261
+ No API calls — uses references/metadata/{key}.json persisted by verify.
262
+ """
263
+ from sciwrite_lint.references.workspace_db import get_db, query_refs_by_match
264
+
265
+ refs_dir = config.effective_references_dir()
266
+
267
+ if not refs_dir.exists():
268
+ return []
269
+
270
+ # Only load refs that have been verified (the only ones with accuracy data)
271
+ with get_db(refs_dir) as conn:
272
+ candidates = query_refs_by_match(conn, "verified")
273
+ candidates.update(query_refs_by_match(conn, "mismatch"))
274
+ if not candidates:
275
+ return []
276
+
277
+ return check_reference_accuracy_from_metadata(candidates)
@@ -0,0 +1,119 @@
1
+ """Check: reference-exists — citation found in academic APIs or URL alive.
2
+
3
+ Runs during `sciwrite-lint verify`. Checks stored metadata to determine
4
+ whether each reference was found in CrossRef, OpenAlex, or Semantic Scholar
5
+ (academic) or has a live URL (web resources).
6
+
7
+ Uses metadata already persisted in references/metadata/{key}.json — the
8
+ actual API calls happen in the verify pipeline stage, not here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from sciwrite_lint.checks.registry import check
16
+ from sciwrite_lint.config import LintConfig
17
+ from sciwrite_lint.models import CitationMetadata, Finding
18
+
19
+
20
+ def _searched_ids_context(meta: CitationMetadata) -> str:
21
+ """Build a human-readable summary of identifiers available for lookup."""
22
+ bib = meta.bibitem or {}
23
+ ids: list[str] = []
24
+ if bib.get("doi"):
25
+ ids.append(f"doi={bib['doi']}")
26
+ if bib.get("arxiv_id"):
27
+ ids.append(f"arxiv={bib['arxiv_id']}")
28
+ if bib.get("pmid"):
29
+ ids.append(f"pmid={bib['pmid']}")
30
+ if bib.get("isbn"):
31
+ ids.append(f"isbn={bib['isbn']}")
32
+ if bib.get("lccn"):
33
+ ids.append(f"lccn={bib['lccn']}")
34
+ if bib.get("url"):
35
+ ids.append(f"url={bib['url']}")
36
+ return f"Searched with: {', '.join(ids)}" if ids else "No identifiers in bib entry"
37
+
38
+
39
+ def check_reference_exists_from_metadata(
40
+ all_metadata: dict[str, CitationMetadata],
41
+ ) -> list[Finding]:
42
+ """Check that all references were found in APIs.
43
+
44
+ Flags:
45
+ - not_found: ERROR — reference not in any API
46
+ - dead URL: ERROR — web resource URL is dead
47
+ - content extraction failed: WARNING — URL alive but content not extracted
48
+ """
49
+ findings: list[Finding] = []
50
+
51
+ for key, meta in sorted(all_metadata.items()):
52
+ # Skip manually overridden references
53
+ if meta.manual_override and meta.manual_override.get("skip_exists"):
54
+ continue
55
+
56
+ if meta.api_match == "not_found":
57
+ findings.append(
58
+ Finding(
59
+ level="error",
60
+ rule_id="reference-exists",
61
+ message=f"{key}: Not found in CrossRef, OpenAlex, Semantic Scholar, Open Library, or Library of Congress",
62
+ context=_searched_ids_context(meta),
63
+ )
64
+ )
65
+ continue
66
+
67
+ # Check web resource issues stored in the issues list
68
+ for issue in meta.issues:
69
+ issue_lower = issue.lower()
70
+ if "dead url" in issue_lower:
71
+ findings.append(
72
+ Finding(
73
+ level="error",
74
+ rule_id="reference-exists",
75
+ message=f"{key}: {issue}",
76
+ context=f"Source: {meta.api_source}" if meta.api_source else "",
77
+ )
78
+ )
79
+ elif "content extraction failed" in issue_lower:
80
+ findings.append(
81
+ Finding(
82
+ level="warning",
83
+ rule_id="reference-exists",
84
+ message=f"{key}: {issue}",
85
+ context=f"Source: {meta.api_source}" if meta.api_source else "",
86
+ )
87
+ )
88
+
89
+ return findings
90
+
91
+
92
+ @check(
93
+ id="reference-exists",
94
+ category="reference-db",
95
+ severity="error",
96
+ description="Reference not found in CrossRef, OpenAlex, Semantic Scholar, Open Library, or Library of Congress.",
97
+ )
98
+ def check_reference_exists(tex_path: Path, config: LintConfig) -> list[Finding]:
99
+ """Load stored metadata and check existence status.
100
+
101
+ No API calls — uses references/metadata/{key}.json persisted by verify.
102
+ """
103
+ from sciwrite_lint.references.workspace_db import get_db, query_refs_by_match
104
+
105
+ refs_dir = config.effective_references_dir()
106
+
107
+ if not refs_dir.exists():
108
+ return []
109
+
110
+ # Only load refs that could produce findings (not_found, web_dead, or with issues)
111
+ with get_db(refs_dir) as conn:
112
+ candidates = query_refs_by_match(conn, "not_found")
113
+ candidates.update(query_refs_by_match(conn, "web_dead"))
114
+ # Web-verified refs may have content extraction issues
115
+ candidates.update(query_refs_by_match(conn, "web_verified"))
116
+ if not candidates:
117
+ return []
118
+
119
+ return check_reference_exists_from_metadata(candidates)
@@ -0,0 +1,244 @@
1
+ """Check: reference-unreliable — per-reference aggregate reliability.
2
+
3
+ Aggregates signals from whatever checks have run (metadata, claim verdicts,
4
+ chain verification) into a single per-reference reliability score. Fires a
5
+ warning when convergent evidence says a reference is unreliable.
6
+
7
+ Two entry points:
8
+ - metadata_to_unreliable_findings: fast path (after verify), metadata only
9
+ - claims_to_unreliable_findings: deep path (after verify-claims), all signals
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from sciwrite_lint.models import CheckMeta, CitationMetadata, Finding
18
+
19
+ if TYPE_CHECKING:
20
+ from sciwrite_lint.scoring.chain import RefBibCheck
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Threshold
24
+ # ---------------------------------------------------------------------------
25
+
26
+ UNRELIABLE_THRESHOLD = 0.4
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Signal descriptions (for human-readable context)
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _describe_metadata(meta: CitationMetadata) -> list[str]:
35
+ """Build a list of human-readable signal descriptions from metadata."""
36
+ from sciwrite_lint.references.metadata import compute_tier
37
+
38
+ parts: list[str] = []
39
+ if meta.canonical.get("retracted"):
40
+ parts.append("retracted")
41
+ return parts # retraction is definitive
42
+
43
+ tier = meta.access.get("tier") or compute_tier(meta)
44
+ if tier == "T3":
45
+ parts.append("not found in APIs")
46
+ if meta.api_match == "mismatch":
47
+ parts.append("API metadata mismatch")
48
+ if meta.mismatches:
49
+ parts.append(f"{len(meta.mismatches)} field mismatch(es)")
50
+ return parts
51
+
52
+
53
+ def _describe_claims(claims: list[dict[str, Any]]) -> list[str]:
54
+ """Build signal descriptions from claim verification results."""
55
+ parts: list[str] = []
56
+ active = [c for c in claims if not c.get("dismissed")]
57
+ not_sup = sum(1 for c in active if c.get("verdict") == "NOT_SUPPORTED")
58
+ partial = sum(1 for c in active if c.get("verdict") == "PARTIALLY_SUPPORTS")
59
+ if not_sup:
60
+ parts.append(f"{not_sup} claim(s) not supported")
61
+ if partial:
62
+ parts.append(f"{partial} claim(s) partially supported")
63
+ return parts
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Scoring (reuses scilint_score constants)
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _score_from_metadata(meta: CitationMetadata) -> float:
72
+ """Compute reliability score from metadata signals only."""
73
+ from sciwrite_lint.scoring.scilint_score import _score_metadata
74
+
75
+ return _score_metadata(meta)
76
+
77
+
78
+ def _score_from_claims(claims: list[dict[str, Any]]) -> float:
79
+ """Compute verification score from claim verdicts."""
80
+ from sciwrite_lint.scoring.scilint_score import VERDICT_SCORES
81
+
82
+ active = [c for c in claims if not c.get("dismissed")]
83
+ if not active:
84
+ return 1.0 # no claims to verify — no negative signal
85
+ total = sum(
86
+ VERDICT_SCORES.get(c.get("verdict", "CANNOT_DETERMINE"), 0.25) for c in active
87
+ )
88
+ return total / len(active)
89
+
90
+
91
+ def _describe_bib_checks(bib_checks: list[RefBibCheck], key: str) -> list[str]:
92
+ """Build signal descriptions from standalone bibliography checks."""
93
+ parts: list[str] = []
94
+ for rb in bib_checks:
95
+ if rb.key == key and rb.total_entries > 0:
96
+ if rb.not_found > 0:
97
+ parts.append(
98
+ f"{rb.not_found}/{rb.total_entries} bibliography entries "
99
+ f"not found in APIs ({rb.hallucination_rate:.0%})"
100
+ )
101
+ if rb.retracted > 0:
102
+ parts.append(f"{rb.retracted} retracted entries in bibliography")
103
+ if rb.metadata_mismatches > 0:
104
+ parts.append(
105
+ f"{rb.metadata_mismatches} bibliography metadata mismatches "
106
+ f"({rb.mismatch_rate:.0%} of found)"
107
+ )
108
+ return parts
109
+
110
+
111
+ def _score_from_bib_checks(bib_checks: list[RefBibCheck], key: str) -> float:
112
+ """Compute score from standalone bibliography checks."""
113
+ for rb in bib_checks:
114
+ if rb.key == key and rb.total_entries > 0:
115
+ existence_score = rb.found / rb.total_entries
116
+ mismatch_penalty = min(rb.metadata_mismatches * 0.05, 0.3)
117
+ retraction_penalty = min(rb.retracted * 0.15, 0.3)
118
+ return max(existence_score - mismatch_penalty - retraction_penalty, 0.0)
119
+ return 1.0
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Finding generators
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def _make_finding(
128
+ key: str,
129
+ reliability: float,
130
+ signals: list[str],
131
+ tex_path: Path,
132
+ line: int | None = None,
133
+ ) -> Finding:
134
+ return Finding(
135
+ level="warning",
136
+ rule_id="reference-unreliable",
137
+ message=f"{key}: Reference has low reliability ({reliability:.2f})",
138
+ file=tex_path.name,
139
+ line=line,
140
+ context=", ".join(signals) if signals else "",
141
+ )
142
+
143
+
144
+ def metadata_to_unreliable_findings(
145
+ metadata_map: dict[str, CitationMetadata],
146
+ tex_path: Path,
147
+ bib_checks: list[RefBibCheck] | None = None,
148
+ ) -> list[Finding]:
149
+ """Fast path: flag unreliable references from metadata signals only.
150
+
151
+ Call after ``verify`` when metadata is available but claim verification
152
+ has not run. Bibliography checks from the pipeline are included when
153
+ available.
154
+ """
155
+ findings: list[Finding] = []
156
+ for key, meta in metadata_map.items():
157
+ meta_score = _score_from_metadata(meta)
158
+ bib_score = _score_from_bib_checks(bib_checks, key) if bib_checks else 1.0
159
+ reliability = meta_score * bib_score
160
+ if reliability < UNRELIABLE_THRESHOLD:
161
+ signals = _describe_metadata(meta)
162
+ if bib_checks:
163
+ signals.extend(_describe_bib_checks(bib_checks, key))
164
+ findings.append(_make_finding(key, reliability, signals, tex_path))
165
+ return findings
166
+
167
+
168
+ def claims_to_unreliable_findings(
169
+ claim_results: list[dict[str, Any]],
170
+ tex_path: Path,
171
+ metadata_map: dict[str, CitationMetadata] | None = None,
172
+ bib_checks: list[RefBibCheck] | None = None,
173
+ ) -> list[Finding]:
174
+ """Deep path: flag unreliable references using all available signals.
175
+
176
+ Call after ``verify-claims``. Combines metadata, claim verdicts, and
177
+ bibliography checks (existence + metadata + retraction from pipeline).
178
+ """
179
+ # Group claims by reference key
180
+ by_key: dict[str, list[dict[str, Any]]] = {}
181
+ for c in claim_results:
182
+ k = c.get("key", "")
183
+ if k:
184
+ by_key.setdefault(k, []).append(c)
185
+
186
+ findings: list[Finding] = []
187
+ seen_keys = set(by_key.keys())
188
+
189
+ for key, claims in by_key.items():
190
+ # Start with metadata if available
191
+ if metadata_map and key in metadata_map:
192
+ meta_score = _score_from_metadata(metadata_map[key])
193
+ else:
194
+ meta_score = 1.0 # no metadata → no negative signal
195
+
196
+ claim_score = _score_from_claims(claims)
197
+
198
+ # Bibliography checks (from pipeline Stage 4.6)
199
+ bib_score = _score_from_bib_checks(bib_checks, key) if bib_checks else 1.0
200
+
201
+ reliability = meta_score * claim_score * bib_score
202
+
203
+ if reliability < UNRELIABLE_THRESHOLD:
204
+ signals: list[str] = []
205
+ if metadata_map and key in metadata_map:
206
+ signals.extend(_describe_metadata(metadata_map[key]))
207
+ signals.extend(_describe_claims(claims))
208
+ if bib_checks:
209
+ signals.extend(_describe_bib_checks(bib_checks, key))
210
+
211
+ # Use line from first claim for location
212
+ line = claims[0].get("line") if claims else None
213
+ findings.append(_make_finding(key, reliability, signals, tex_path, line))
214
+
215
+ # Also check metadata-only keys not in claims (e.g., T3 refs with no T1 source)
216
+ if metadata_map:
217
+ for key, meta in metadata_map.items():
218
+ if key in seen_keys:
219
+ continue
220
+ meta_score = _score_from_metadata(meta)
221
+ bib_score = _score_from_bib_checks(bib_checks, key) if bib_checks else 1.0
222
+ reliability = meta_score * bib_score
223
+ if reliability < UNRELIABLE_THRESHOLD:
224
+ signals = _describe_metadata(meta)
225
+ if bib_checks:
226
+ signals.extend(_describe_bib_checks(bib_checks, key))
227
+ findings.append(_make_finding(key, reliability, signals, tex_path))
228
+
229
+ return findings
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Registry
234
+ # ---------------------------------------------------------------------------
235
+
236
+
237
+ # Metadata for `sciwrite-lint checks` listing.
238
+ # This check runs as a pipeline stage (not from the check registry).
239
+ REFERENCE_UNRELIABLE_META = CheckMeta(
240
+ id="reference-unreliable",
241
+ severity="warning",
242
+ category="reference-db",
243
+ description="Reference has low aggregate reliability across multiple signals.",
244
+ )