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,322 @@
1
+ """Contribution axes for SciLint Score scoring.
2
+
3
+ Five axes, each returning a score in [0, 1]:
4
+
5
+ 1. Empirical content (Popper) — from claim taxonomy
6
+ 2. Progressiveness (Lakatos) — from claim taxonomy
7
+ 3. Unification (Kitcher) — citation graph computation, no LLM
8
+ 4. Problem-solving (Laudan) — LLM prompt on intro + limitations
9
+ 5. Test severity (Mayo) — from claim taxonomy
10
+
11
+ Axes 1, 2, 5 are computed from ClaimClassification (see claims.py).
12
+ Axis 3 is pure graph computation on the citation co-occurrence matrix.
13
+ Axis 4 requires one LLM call per paper.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections import defaultdict
19
+ from typing import Any
20
+
21
+ from loguru import logger
22
+
23
+ from sciwrite_lint.claims import (
24
+ ClaimClassification,
25
+ empirical_content_score,
26
+ progressiveness_score,
27
+ severity_score,
28
+ )
29
+ from sciwrite_lint.config import LintConfig
30
+ from sciwrite_lint.llm_utils import llm_query
31
+ from sciwrite_lint.schemas import LaudanProblemSolving, vllm_schema
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Axis 3: Unification (Kitcher) — graph computation
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _build_co_citation_graph(
40
+ claim_results: list[dict[str, Any]],
41
+ ) -> dict[str, set[str]]:
42
+ """Build co-citation adjacency: refs that appear in the same claim context.
43
+
44
+ Two references are co-cited if they appear in claim contexts from the
45
+ same section or within a small text window. We approximate this by
46
+ grouping claims by their source section (if available) or by line
47
+ proximity.
48
+ """
49
+ # Group refs by section or line bucket (50-line windows)
50
+ buckets: dict[str, set[str]] = defaultdict(set)
51
+ for c in claim_results:
52
+ key = c.get("key", "")
53
+ if not key:
54
+ continue
55
+ section = c.get("source_section", "")
56
+ line = c.get("line", 0)
57
+ bucket_id = section if section else f"lines_{line // 50}"
58
+ buckets[bucket_id].add(key)
59
+
60
+ # Build adjacency from co-occurrence in buckets
61
+ adjacency: dict[str, set[str]] = defaultdict(set)
62
+ for refs in buckets.values():
63
+ refs_list = list(refs)
64
+ for i, r1 in enumerate(refs_list):
65
+ for r2 in refs_list[i + 1 :]:
66
+ adjacency[r1].add(r2)
67
+ adjacency[r2].add(r1)
68
+
69
+ return dict(adjacency)
70
+
71
+
72
+ def _count_clusters(adjacency: dict[str, set[str]], all_refs: set[str]) -> int:
73
+ """Count connected components in the co-citation graph via BFS."""
74
+ visited: set[str] = set()
75
+ clusters = 0
76
+
77
+ for ref in all_refs:
78
+ if ref in visited:
79
+ continue
80
+ clusters += 1
81
+ queue = [ref]
82
+ while queue:
83
+ node = queue.pop()
84
+ if node in visited:
85
+ continue
86
+ visited.add(node)
87
+ for neighbor in adjacency.get(node, set()):
88
+ if neighbor not in visited:
89
+ queue.append(neighbor)
90
+
91
+ return clusters
92
+
93
+
94
+ def _count_bridges(adjacency: dict[str, set[str]], all_refs: set[str]) -> int:
95
+ """Count refs that bridge multiple clusters (appear in multiple components
96
+ when removed)."""
97
+ bridges = 0
98
+ for ref in all_refs:
99
+ if ref not in adjacency or not adjacency[ref]:
100
+ continue
101
+ # Check if removing this ref increases the number of components
102
+ reduced: dict[str, set[str]] = {}
103
+ for r in all_refs:
104
+ if r == ref:
105
+ continue
106
+ reduced[r] = adjacency.get(r, set()) - {ref}
107
+
108
+ remaining = all_refs - {ref}
109
+ original_clusters = _count_clusters(adjacency, all_refs)
110
+ reduced_clusters = _count_clusters(reduced, remaining)
111
+ if reduced_clusters > original_clusters:
112
+ bridges += 1
113
+
114
+ return bridges
115
+
116
+
117
+ def compute_unification_score(claim_results: list[dict[str, Any]]) -> float:
118
+ """Kitcher unification: how many distinct citation clusters does the paper bridge?
119
+
120
+ Score = 1 - (1 / num_clusters) if clusters > 1, else 0.
121
+ A paper that bridges many distinct citation clusters scores higher.
122
+ Bonus for having bridge nodes (refs connecting different clusters).
123
+ """
124
+ all_refs = {c.get("key", "") for c in claim_results if c.get("key")}
125
+ if len(all_refs) < 2:
126
+ return 0.0
127
+
128
+ adjacency = _build_co_citation_graph(claim_results)
129
+ num_clusters = _count_clusters(adjacency, all_refs)
130
+
131
+ if num_clusters <= 1:
132
+ return 0.0 # everything is one cluster — no unification
133
+
134
+ # Base score: more clusters bridged = higher score
135
+ # Formula: 1 - 1/clusters (asymptotes to 1.0 as clusters increase)
136
+ base = 1.0 - (1.0 / num_clusters)
137
+
138
+ # Bonus for bridge nodes (cap at 0.2)
139
+ bridges = _count_bridges(adjacency, all_refs)
140
+ bridge_bonus = min(0.2, bridges * 0.05)
141
+
142
+ return min(1.0, base + bridge_bonus)
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Axis 4: Problem-solving (Laudan) — LLM prompt
147
+ # ---------------------------------------------------------------------------
148
+
149
+ LAUDAN_SYSTEM = """\
150
+ You are a critical philosophy-of-science evaluator assessing a paper's \
151
+ problem-solving effectiveness (Larry Laudan's framework). Be skeptical.
152
+
153
+ Given the introduction and limitations/discussion sections:
154
+
155
+ 1. Count PROBLEMS CLAIMED SOLVED — only count problems with concrete evidence, \
156
+ not aspirational statements. "We address X" without results is not solved.
157
+
158
+ 2. Count ACKNOWLEDGED LIMITATIONS — explicit admissions of weaknesses.
159
+
160
+ 3. Count UNACKNOWLEDGED LIMITATIONS — be thorough. Always check for these \
161
+ common omissions that authors frequently overlook:
162
+ - Generalizability: tested on limited domains/datasets/languages?
163
+ - Scalability: will the approach work at larger scale?
164
+ - Reproducibility: are all details provided to reproduce?
165
+ - Fairness/bias: any social impact considerations missing?
166
+ - Comparison fairness: are baselines truly comparable?
167
+ - Statistical rigor: single seed? No confidence intervals?
168
+
169
+ If the limitations section is EMPTY or MISSING, count at least 2 \
170
+ unacknowledged limitations (missing self-assessment is itself a red flag).
171
+
172
+ If the paper makes grand claims ("solve AGI", "outperform in all domains") \
173
+ without proportional evidence, count each unsupported grand claim as an \
174
+ unacknowledged limitation.
175
+
176
+ Compute: score = num_problems_solved / (num_problems_solved + num_unacknowledged)
177
+ If num_problems_solved is 0, score is 0.
178
+
179
+ Respond with ONLY a valid JSON object:
180
+ {
181
+ "num_problems_solved": <integer>,
182
+ "num_acknowledged": <integer>,
183
+ "num_unacknowledged": <integer>,
184
+ "score": <float 0-1>
185
+ }"""
186
+
187
+ LAUDAN_USER_TEMPLATE = """\
188
+ INTRODUCTION:
189
+ <section>
190
+ {intro_text}
191
+ </section>
192
+
193
+ LIMITATIONS / DISCUSSION:
194
+ <section>
195
+ {limitations_text}
196
+ </section>"""
197
+
198
+ LAUDAN_SCHEMA = vllm_schema(LaudanProblemSolving)
199
+
200
+
201
+ async def compute_problem_solving_score(
202
+ intro_text: str,
203
+ limitations_text: str,
204
+ config: LintConfig | None = None,
205
+ model_name: str = "",
206
+ ) -> tuple[float, str]:
207
+ """Laudan problem-solving: LLM evaluates solved vs unacknowledged problems.
208
+
209
+ Returns (score, reasoning). Score 0-1. Returns (0.0, error_msg) on failure.
210
+ """
211
+ if not intro_text.strip():
212
+ return 0.5, "No introduction text available (neutral default)"
213
+
214
+ # If no limitations section found, that's itself a limitation
215
+ if not limitations_text.strip():
216
+ limitations_text = "(No limitations section found in the paper.)"
217
+
218
+ user = LAUDAN_USER_TEMPLATE.format(
219
+ intro_text=intro_text[:4000],
220
+ limitations_text=limitations_text[:4000],
221
+ )
222
+
223
+ result = await llm_query(
224
+ system=LAUDAN_SYSTEM,
225
+ user=user,
226
+ schema=LAUDAN_SCHEMA,
227
+ schema_name="LaudanProblemSolving",
228
+ config=config,
229
+ model_name=model_name,
230
+ thinking="off",
231
+ )
232
+
233
+ if not result:
234
+ logger.warning("Laudan problem-solving LLM call failed")
235
+ return 0.0, "LLM call failed"
236
+
237
+ score = result.get("score", 0.0)
238
+ score = max(0.0, min(1.0, float(score)))
239
+ n_solved = result.get("num_problems_solved", 0)
240
+ n_ack = result.get("num_acknowledged", 0)
241
+ n_unack = result.get("num_unacknowledged", 0)
242
+ reasoning = f"{n_solved} solved, {n_ack} acknowledged, {n_unack} unacknowledged"
243
+
244
+ return score, reasoning
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Full contribution computation
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ async def compute_all_contribution_axes(
253
+ claim_results: list[dict[str, Any]],
254
+ classifications: list[ClaimClassification],
255
+ intro_text: str = "",
256
+ limitations_text: str = "",
257
+ config: LintConfig | None = None,
258
+ model_name: str = "",
259
+ ) -> tuple[dict[str, float], dict[str, str]]:
260
+ """Compute all five contribution axes.
261
+
262
+ Args:
263
+ claim_results: Claim verification results (for unification graph).
264
+ classifications: Claim taxonomy classifications (for Popper/Lakatos/Mayo).
265
+ intro_text: Introduction section text (for Laudan).
266
+ limitations_text: Limitations/discussion section text (for Laudan).
267
+ config: Lint configuration.
268
+ model_name: vLLM model preset.
269
+
270
+ Returns:
271
+ Tuple of (axis_scores, axis_reasoning) dicts.
272
+ """
273
+ scores: dict[str, float] = {}
274
+ reasoning: dict[str, str] = {}
275
+
276
+ # Axes 1, 2, 5: from claim taxonomy (no LLM calls needed)
277
+ if classifications:
278
+ scores["empirical_content"] = empirical_content_score(classifications)
279
+ reasoning["empirical_content"] = (
280
+ f"{sum(1 for c in classifications if c.testability == 'falsifiable')}"
281
+ f"/{len(classifications)} claims falsifiable"
282
+ )
283
+
284
+ scores["progressiveness"] = progressiveness_score(classifications)
285
+ predictions = sum(1 for c in classifications if c.type == "prediction")
286
+ explanations = sum(1 for c in classifications if c.type == "explanation")
287
+ reasoning["progressiveness"] = (
288
+ f"{predictions} predictions, {explanations} explanations"
289
+ )
290
+
291
+ scores["test_severity"] = severity_score(classifications)
292
+ severe = sum(1 for c in classifications if c.support == "severe_test")
293
+ reasoning["test_severity"] = (
294
+ f"{severe}/{len(classifications)} claims with severe tests"
295
+ )
296
+ else:
297
+ scores["empirical_content"] = 0.0
298
+ scores["progressiveness"] = 0.5
299
+ scores["test_severity"] = 0.0
300
+ reasoning["empirical_content"] = "No claim classifications available"
301
+ reasoning["progressiveness"] = "No claim classifications available"
302
+ reasoning["test_severity"] = "No claim classifications available"
303
+
304
+ # Axis 3: Unification (graph, no LLM)
305
+ scores["unification"] = compute_unification_score(claim_results)
306
+ all_refs = {c.get("key", "") for c in claim_results if c.get("key")}
307
+ reasoning["unification"] = f"{len(all_refs)} references in citation graph"
308
+
309
+ # Axis 4: Problem-solving (LLM)
310
+ if intro_text:
311
+ ps_score, ps_reasoning = await compute_problem_solving_score(
312
+ intro_text, limitations_text, config, model_name
313
+ )
314
+ scores["problem_solving"] = ps_score
315
+ reasoning["problem_solving"] = ps_reasoning
316
+ else:
317
+ scores["problem_solving"] = 0.5
318
+ reasoning["problem_solving"] = (
319
+ "No introduction text available (neutral default)"
320
+ )
321
+
322
+ return scores, reasoning