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.
- sciwrite_lint/__init__.py +3 -0
- sciwrite_lint/__main__.py +527 -0
- sciwrite_lint/_network.py +195 -0
- sciwrite_lint/api.py +1484 -0
- sciwrite_lint/checks/__init__.py +1 -0
- sciwrite_lint/checks/_section_utils.py +111 -0
- sciwrite_lint/checks/cite_purpose.py +122 -0
- sciwrite_lint/checks/claim_support.py +96 -0
- sciwrite_lint/checks/cross_section_consistency.py +185 -0
- sciwrite_lint/checks/dangling_cite.py +93 -0
- sciwrite_lint/checks/dangling_ref.py +116 -0
- sciwrite_lint/checks/full_paper_consistency.py +604 -0
- sciwrite_lint/checks/ref_internal_checks.py +919 -0
- sciwrite_lint/checks/reference_accuracy.py +277 -0
- sciwrite_lint/checks/reference_exists.py +119 -0
- sciwrite_lint/checks/reference_unreliable.py +244 -0
- sciwrite_lint/checks/registry.py +136 -0
- sciwrite_lint/checks/retracted_cite.py +96 -0
- sciwrite_lint/checks/structure_promises.py +115 -0
- sciwrite_lint/checks/unreferenced_figure.py +70 -0
- sciwrite_lint/claims.py +330 -0
- sciwrite_lint/claude_backend.py +94 -0
- sciwrite_lint/claude_cli.py +405 -0
- sciwrite_lint/cli/__init__.py +1 -0
- sciwrite_lint/cli/check.py +480 -0
- sciwrite_lint/cli/config.py +229 -0
- sciwrite_lint/cli/fetch.py +250 -0
- sciwrite_lint/cli/misc.py +1202 -0
- sciwrite_lint/cli/rank.py +470 -0
- sciwrite_lint/cli/verify.py +437 -0
- sciwrite_lint/config.py +646 -0
- sciwrite_lint/cross_paper.py +174 -0
- sciwrite_lint/eval_claims.py +1196 -0
- sciwrite_lint/fulltext.py +851 -0
- sciwrite_lint/llm_utils.py +386 -0
- sciwrite_lint/local_pdfs.py +122 -0
- sciwrite_lint/manuscript_store.py +674 -0
- sciwrite_lint/models.py +139 -0
- sciwrite_lint/pdf/__init__.py +1 -0
- sciwrite_lint/pdf/grobid.py +785 -0
- sciwrite_lint/pdf/pdf_download.py +258 -0
- sciwrite_lint/pipeline.py +2694 -0
- sciwrite_lint/prompt_safety.py +30 -0
- sciwrite_lint/rate_limiter.py +227 -0
- sciwrite_lint/references/__init__.py +1 -0
- sciwrite_lint/references/citations.py +715 -0
- sciwrite_lint/references/crossref.py +282 -0
- sciwrite_lint/references/embedding_store.py +380 -0
- sciwrite_lint/references/matching.py +273 -0
- sciwrite_lint/references/metadata.py +273 -0
- sciwrite_lint/references/reference_store.py +823 -0
- sciwrite_lint/references/retraction_watch.py +178 -0
- sciwrite_lint/references/workspace_db.py +1390 -0
- sciwrite_lint/report.py +163 -0
- sciwrite_lint/schemas.py +260 -0
- sciwrite_lint/scoring/__init__.py +1 -0
- sciwrite_lint/scoring/chain.py +716 -0
- sciwrite_lint/scoring/contribution.py +322 -0
- sciwrite_lint/scoring/scilint_score.py +611 -0
- sciwrite_lint/tex_parser.py +248 -0
- sciwrite_lint/usage.py +594 -0
- sciwrite_lint/vision/__init__.py +1 -0
- sciwrite_lint/vision/cache.py +140 -0
- sciwrite_lint/vision/describe.py +311 -0
- sciwrite_lint/vision/image_extraction.py +491 -0
- sciwrite_lint/vision/pipeline.py +207 -0
- sciwrite_lint/vllm/__init__.py +1 -0
- sciwrite_lint/vllm/metrics.py +157 -0
- sciwrite_lint/vllm/vllm_server.py +445 -0
- sciwrite_lint/web.py +369 -0
- sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
- sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
- sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
- sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
- sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
- sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""SciLint Score: integrity × contribution scoring for scientific manuscripts.
|
|
2
|
+
|
|
3
|
+
Two independent components multiplied into a final score:
|
|
4
|
+
|
|
5
|
+
S(p) = integrity(p) × contribution(p)
|
|
6
|
+
|
|
7
|
+
**Integrity** (recursive, depth-N):
|
|
8
|
+
integrity(p) = α × I(p) + (1-α) × mean(w_i × V(p, r_i) × integrity(r_i))
|
|
9
|
+
|
|
10
|
+
Where:
|
|
11
|
+
- I(p): internal consistency score (fraction of non-error findings)
|
|
12
|
+
- V(p, r_i): existence × accuracy × claim_support for reference r_i
|
|
13
|
+
- w_i: citation purpose weight
|
|
14
|
+
- integrity(r_i): child integrity of cited paper
|
|
15
|
+
- α: balance factor (default 0.3 — evidence chain > internal consistency)
|
|
16
|
+
|
|
17
|
+
**Child integrity modes** (for computing integrity(r_i)):
|
|
18
|
+
- Lightweight (default): uses metadata signals (tier, retraction, mismatches)
|
|
19
|
+
— fast, no LLM/GROBID needed, runs after ``verify`` stage.
|
|
20
|
+
- Ref-internal (automatic in pipeline): LLM consistency checks + contribution
|
|
21
|
+
scoring on cited papers.
|
|
22
|
+
|
|
23
|
+
**Contribution** (five axes, each 0–1):
|
|
24
|
+
- Empirical content (Popper): fraction of falsifiable claims
|
|
25
|
+
- Progressiveness (Lakatos): novel predictions vs accommodations
|
|
26
|
+
- Unification (Kitcher): distinct citation clusters bridged
|
|
27
|
+
- Problem-solving (Laudan): problems solved vs unacknowledged limitations
|
|
28
|
+
- Test severity (Mayo): ablations, baselines, alternatives addressed
|
|
29
|
+
|
|
30
|
+
Citation purpose weights are defined in ``sciwrite_lint.checks.cite_purpose``
|
|
31
|
+
(single source of truth) and imported here.
|
|
32
|
+
|
|
33
|
+
Since the citation graph is a DAG, a single forward pass suffices —
|
|
34
|
+
no iterative PageRank convergence is needed.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
from pydantic import BaseModel, Field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from loguru import logger
|
|
45
|
+
|
|
46
|
+
from sciwrite_lint.checks.cite_purpose import PURPOSE_WEIGHTS
|
|
47
|
+
from sciwrite_lint.models import CitationMetadata
|
|
48
|
+
from sciwrite_lint.references.metadata import compute_tier
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Constants
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
# Verdict → verification score [0, 1]
|
|
56
|
+
VERDICT_SCORES: dict[str, float] = {
|
|
57
|
+
"SUPPORTS": 1.0,
|
|
58
|
+
"PARTIALLY_SUPPORTS": 0.5,
|
|
59
|
+
"CANNOT_DETERMINE": 0.25,
|
|
60
|
+
"NOT_SUPPORTED": 0.0,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Default α for integrity: internal vs evidence chain balance.
|
|
64
|
+
# Lower α = evidence chain matters more than internal consistency.
|
|
65
|
+
|
|
66
|
+
# Contribution axis weights (equal by default).
|
|
67
|
+
CONTRIBUTION_WEIGHTS: dict[str, float] = {
|
|
68
|
+
"empirical_content": 0.2,
|
|
69
|
+
"progressiveness": 0.2,
|
|
70
|
+
"unification": 0.2,
|
|
71
|
+
"problem_solving": 0.2,
|
|
72
|
+
"test_severity": 0.2,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Data types
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RefScore(BaseModel):
|
|
82
|
+
"""Verification score for a single reference."""
|
|
83
|
+
|
|
84
|
+
key: str
|
|
85
|
+
purpose: str
|
|
86
|
+
weight: float
|
|
87
|
+
verdict: str
|
|
88
|
+
verification_score: float
|
|
89
|
+
weighted_score: float
|
|
90
|
+
claim_count: int
|
|
91
|
+
supports_count: int
|
|
92
|
+
partial_count: int
|
|
93
|
+
not_supported_count: int
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ContributionScores(BaseModel):
|
|
97
|
+
"""Five-axis contribution assessment."""
|
|
98
|
+
|
|
99
|
+
empirical_content: float = 0.0 # Popper: falsifiability
|
|
100
|
+
progressiveness: float = 0.0 # Lakatos: novel predictions
|
|
101
|
+
unification: float = 0.0 # Kitcher: citation cluster bridging
|
|
102
|
+
problem_solving: float = 0.0 # Laudan: solved vs unacknowledged
|
|
103
|
+
test_severity: float = 0.0 # Mayo: ablations, baselines, alternatives
|
|
104
|
+
overall: float = 0.0 # weighted mean of all axes
|
|
105
|
+
|
|
106
|
+
# Per-axis reasoning from LLM (optional, for explainability)
|
|
107
|
+
reasoning: dict[str, str] = Field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict[str, Any]:
|
|
110
|
+
d: dict[str, Any] = {
|
|
111
|
+
"empirical_content": round(self.empirical_content, 4),
|
|
112
|
+
"progressiveness": round(self.progressiveness, 4),
|
|
113
|
+
"unification": round(self.unification, 4),
|
|
114
|
+
"problem_solving": round(self.problem_solving, 4),
|
|
115
|
+
"test_severity": round(self.test_severity, 4),
|
|
116
|
+
"overall": round(self.overall, 4),
|
|
117
|
+
}
|
|
118
|
+
if self.reasoning:
|
|
119
|
+
d["reasoning"] = self.reasoning
|
|
120
|
+
return d
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class IntegrityResult(BaseModel):
|
|
124
|
+
"""Integrity scoring result with child integrity detail."""
|
|
125
|
+
|
|
126
|
+
internal_consistency: float # manuscript's own consistency
|
|
127
|
+
referencing_quality: float # weighted mean of weight × verdict × reliability
|
|
128
|
+
reference_reliability: dict[str, float] = Field(default_factory=dict)
|
|
129
|
+
integrity_source: str = "default" # "ref_internal", "metadata", "default"
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict[str, Any]:
|
|
132
|
+
d: dict[str, Any] = {
|
|
133
|
+
"internal_consistency": round(self.internal_consistency, 4),
|
|
134
|
+
"referencing_quality": round(self.referencing_quality, 4),
|
|
135
|
+
"integrity_source": self.integrity_source,
|
|
136
|
+
}
|
|
137
|
+
if self.reference_reliability:
|
|
138
|
+
d["reference_reliability"] = {
|
|
139
|
+
k: round(v, 4) for k, v in self.reference_reliability.items()
|
|
140
|
+
}
|
|
141
|
+
return d
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class SciLintScoreResult(BaseModel):
|
|
145
|
+
"""Complete SciLint Score result for a paper."""
|
|
146
|
+
|
|
147
|
+
paper: str
|
|
148
|
+
scilint_score: float
|
|
149
|
+
integrity_result: IntegrityResult
|
|
150
|
+
contribution: ContributionScores
|
|
151
|
+
total_claims: int
|
|
152
|
+
total_refs_scored: int
|
|
153
|
+
ref_scores: list[RefScore] = Field(default_factory=list)
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> dict[str, Any]:
|
|
156
|
+
"""Serialize to JSON-compatible dict."""
|
|
157
|
+
return {
|
|
158
|
+
"paper": self.paper,
|
|
159
|
+
"scilint_score": round(self.scilint_score, 4),
|
|
160
|
+
"integrity": self.integrity_result.to_dict(),
|
|
161
|
+
"contribution": self.contribution.to_dict(),
|
|
162
|
+
"total_claims": self.total_claims,
|
|
163
|
+
"total_refs_scored": self.total_refs_scored,
|
|
164
|
+
"ref_scores": [
|
|
165
|
+
{
|
|
166
|
+
"key": rs.key,
|
|
167
|
+
"purpose": rs.purpose,
|
|
168
|
+
"weight": rs.weight,
|
|
169
|
+
"verdict": rs.verdict,
|
|
170
|
+
"verification_score": round(rs.verification_score, 4),
|
|
171
|
+
"weighted_score": round(rs.weighted_score, 4),
|
|
172
|
+
"claim_count": rs.claim_count,
|
|
173
|
+
"supports_count": rs.supports_count,
|
|
174
|
+
"partial_count": rs.partial_count,
|
|
175
|
+
"not_supported_count": rs.not_supported_count,
|
|
176
|
+
}
|
|
177
|
+
for rs in self.ref_scores
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Score computation
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def compute_internal_score(findings: list[dict[str, Any]]) -> float:
|
|
188
|
+
"""Compute internal manuscript quality score from check findings.
|
|
189
|
+
|
|
190
|
+
Returns a score in [0, 1] where 1.0 means no errors.
|
|
191
|
+
Based on the ratio of non-error findings to total findings.
|
|
192
|
+
If no findings at all, returns 1.0 (clean manuscript).
|
|
193
|
+
"""
|
|
194
|
+
if not findings:
|
|
195
|
+
return 1.0
|
|
196
|
+
|
|
197
|
+
errors = sum(1 for f in findings if f.get("level") == "error")
|
|
198
|
+
total = len(findings)
|
|
199
|
+
return max(0.0, 1.0 - (errors / total))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _aggregate_ref_claims(
|
|
203
|
+
claims: list[dict[str, Any]],
|
|
204
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
205
|
+
"""Group claim results by reference key."""
|
|
206
|
+
by_key: dict[str, list[dict[str, Any]]] = {}
|
|
207
|
+
for c in claims:
|
|
208
|
+
key = c.get("key", "")
|
|
209
|
+
if key:
|
|
210
|
+
by_key.setdefault(key, []).append(c)
|
|
211
|
+
return by_key
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _score_reference(claims: list[dict[str, Any]]) -> RefScore:
|
|
215
|
+
"""Compute the verification score for a single reference.
|
|
216
|
+
|
|
217
|
+
Aggregates all claims citing this reference. The overall verdict
|
|
218
|
+
is the *worst* verdict across all claims (conservative).
|
|
219
|
+
The purpose is taken from the most common purpose across claims.
|
|
220
|
+
"""
|
|
221
|
+
key = claims[0].get("key", "")
|
|
222
|
+
|
|
223
|
+
# Count verdicts
|
|
224
|
+
supports = sum(1 for c in claims if c.get("verdict") == "SUPPORTS")
|
|
225
|
+
partial = sum(1 for c in claims if c.get("verdict") == "PARTIALLY_SUPPORTS")
|
|
226
|
+
not_supported = sum(1 for c in claims if c.get("verdict") == "NOT_SUPPORTED")
|
|
227
|
+
|
|
228
|
+
# Determine dominant purpose (most frequent, breaking ties by weight)
|
|
229
|
+
purpose_counts: dict[str, int] = {}
|
|
230
|
+
for c in claims:
|
|
231
|
+
p = c.get("citation_purpose", "evidence")
|
|
232
|
+
purpose_counts[p] = purpose_counts.get(p, 0) + 1
|
|
233
|
+
dominant_purpose = max(
|
|
234
|
+
purpose_counts,
|
|
235
|
+
key=lambda p: (purpose_counts[p], PURPOSE_WEIGHTS.get(p, 0.2)),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Verification score: weighted average of all claim verdicts for this ref
|
|
239
|
+
total_score = 0.0
|
|
240
|
+
for c in claims:
|
|
241
|
+
if not c.get("dismissed"):
|
|
242
|
+
v = c.get("verdict", "CANNOT_DETERMINE")
|
|
243
|
+
total_score += VERDICT_SCORES.get(v, 0.25)
|
|
244
|
+
active_claims = [c for c in claims if not c.get("dismissed")]
|
|
245
|
+
avg_score = total_score / len(active_claims) if active_claims else 0.25
|
|
246
|
+
|
|
247
|
+
# Overall verdict (worst non-dismissed)
|
|
248
|
+
priority = ["NOT_SUPPORTED", "CANNOT_DETERMINE", "PARTIALLY_SUPPORTS", "SUPPORTS"]
|
|
249
|
+
overall_verdict = "SUPPORTS"
|
|
250
|
+
for c in active_claims:
|
|
251
|
+
v = c.get("verdict", "CANNOT_DETERMINE")
|
|
252
|
+
if priority.index(v) < priority.index(overall_verdict):
|
|
253
|
+
overall_verdict = v
|
|
254
|
+
|
|
255
|
+
weight = PURPOSE_WEIGHTS.get(dominant_purpose, 0.2)
|
|
256
|
+
|
|
257
|
+
return RefScore(
|
|
258
|
+
key=key,
|
|
259
|
+
purpose=dominant_purpose,
|
|
260
|
+
weight=weight,
|
|
261
|
+
verdict=overall_verdict,
|
|
262
|
+
verification_score=avg_score,
|
|
263
|
+
weighted_score=weight * avg_score,
|
|
264
|
+
claim_count=len(claims),
|
|
265
|
+
supports_count=supports,
|
|
266
|
+
partial_count=partial,
|
|
267
|
+
not_supported_count=not_supported,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def compute_integrity(
|
|
272
|
+
findings: list[dict[str, Any]],
|
|
273
|
+
claim_results: list[dict[str, Any]],
|
|
274
|
+
metadata_map: dict[str, CitationMetadata] | None = None,
|
|
275
|
+
ref_internal_scores: dict[str, float] | None = None,
|
|
276
|
+
) -> IntegrityResult:
|
|
277
|
+
"""Compute internal consistency and referencing quality.
|
|
278
|
+
|
|
279
|
+
These are two independent components of SciLint Score:
|
|
280
|
+
- internal_consistency: manuscript's own consistency
|
|
281
|
+
- referencing_quality: weighted mean of weight × verdict × reliability
|
|
282
|
+
|
|
283
|
+
Child reliability sources (in priority order):
|
|
284
|
+
1. ref_internal_scores: LLM consistency checks on cited papers
|
|
285
|
+
2. metadata_map: metadata-based signals (tier, retraction, mismatches)
|
|
286
|
+
3. default: reliability = verdict score
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
IntegrityResult with both components.
|
|
290
|
+
"""
|
|
291
|
+
internal = compute_internal_score(findings)
|
|
292
|
+
|
|
293
|
+
# Group claims by reference and score each
|
|
294
|
+
by_ref = _aggregate_ref_claims(claim_results)
|
|
295
|
+
ref_scores: list[RefScore] = []
|
|
296
|
+
for _key, claims in sorted(by_ref.items()):
|
|
297
|
+
ref_scores.append(_score_reference(claims))
|
|
298
|
+
|
|
299
|
+
if not ref_scores:
|
|
300
|
+
return IntegrityResult(
|
|
301
|
+
internal_consistency=internal,
|
|
302
|
+
referencing_quality=1.0, # no refs → no negative signal
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Build child integrity map:
|
|
306
|
+
# Blend ref_internal (content-based consistency) with metadata (credibility signals)
|
|
307
|
+
# ref_internal is always available (consistency checks run in default pipeline)
|
|
308
|
+
child_integrity: dict[str, float] = {}
|
|
309
|
+
integrity_source = "default"
|
|
310
|
+
|
|
311
|
+
if ref_internal_scores and metadata_map:
|
|
312
|
+
metadata_scores = _compute_metadata_integrity(metadata_map)
|
|
313
|
+
all_keys = set(ref_internal_scores) | set(metadata_scores)
|
|
314
|
+
for key in all_keys:
|
|
315
|
+
ri = ref_internal_scores.get(key)
|
|
316
|
+
ms = metadata_scores.get(key)
|
|
317
|
+
if ri is not None and ms is not None:
|
|
318
|
+
child_integrity[key] = 0.6 * ri + 0.4 * ms
|
|
319
|
+
elif ri is not None:
|
|
320
|
+
child_integrity[key] = ri
|
|
321
|
+
else:
|
|
322
|
+
child_integrity[key] = ms # type: ignore[assignment]
|
|
323
|
+
integrity_source = "ref_internal"
|
|
324
|
+
elif ref_internal_scores:
|
|
325
|
+
child_integrity = dict(ref_internal_scores)
|
|
326
|
+
integrity_source = "ref_internal"
|
|
327
|
+
elif metadata_map:
|
|
328
|
+
child_integrity = _compute_metadata_integrity(metadata_map)
|
|
329
|
+
integrity_source = "metadata"
|
|
330
|
+
|
|
331
|
+
# Compute weighted reference score with recursive integrity
|
|
332
|
+
weighted_sum = 0.0
|
|
333
|
+
weight_sum = 0.0
|
|
334
|
+
reference_reliability: dict[str, float] = {}
|
|
335
|
+
|
|
336
|
+
for rs in ref_scores:
|
|
337
|
+
# V(p, r_i) = verification_score from claim verdicts
|
|
338
|
+
v_score = rs.verification_score
|
|
339
|
+
# integrity(r_i): from chain/metadata/ref_internal, or V_self at leaf
|
|
340
|
+
child_score = child_integrity.get(rs.key, v_score)
|
|
341
|
+
combined = rs.weight * v_score * child_score
|
|
342
|
+
weighted_sum += combined
|
|
343
|
+
weight_sum += rs.weight
|
|
344
|
+
if rs.key in child_integrity:
|
|
345
|
+
reference_reliability[rs.key] = child_score
|
|
346
|
+
|
|
347
|
+
referencing_quality = weighted_sum / weight_sum if weight_sum > 0 else 0.0
|
|
348
|
+
|
|
349
|
+
return IntegrityResult(
|
|
350
|
+
internal_consistency=internal,
|
|
351
|
+
referencing_quality=referencing_quality,
|
|
352
|
+
reference_reliability=reference_reliability,
|
|
353
|
+
integrity_source=integrity_source,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# Lightweight child integrity from metadata signals
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
# Base scores by verification tier
|
|
362
|
+
TIER_BASE_SCORES: dict[str, float] = {
|
|
363
|
+
"T1": 0.9,
|
|
364
|
+
"T2": 0.7,
|
|
365
|
+
"T3": 0.3,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
MISMATCH_PENALTY = 0.1 # per accuracy mismatch (title, author, year, venue)
|
|
369
|
+
API_MISMATCH_PENALTY = 0.1 # api_match == "mismatch"
|
|
370
|
+
NON_FORMAL_PENALTY = 0.2 # non-formal document (news, guide, etc.)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _compute_metadata_integrity(
|
|
374
|
+
metadata_map: dict[str, CitationMetadata],
|
|
375
|
+
) -> dict[str, float]:
|
|
376
|
+
"""Compute child integrity scores from citation metadata signals.
|
|
377
|
+
|
|
378
|
+
Uses tier, api_match, retraction status, and mismatches
|
|
379
|
+
to produce a [0, 1] integrity estimate per reference.
|
|
380
|
+
"""
|
|
381
|
+
return {key: _score_metadata(meta) for key, meta in metadata_map.items()}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _score_metadata(meta: CitationMetadata) -> float:
|
|
385
|
+
"""Compute a single reference's lightweight integrity from its metadata."""
|
|
386
|
+
# Retracted papers get zero integrity
|
|
387
|
+
if meta.canonical.get("retracted"):
|
|
388
|
+
# Expression of Concern: severe penalty but not terminal
|
|
389
|
+
rs = meta.canonical.get("retraction_status")
|
|
390
|
+
if rs and rs.get("nature") == "Expression of Concern":
|
|
391
|
+
return 0.3
|
|
392
|
+
return 0.0
|
|
393
|
+
|
|
394
|
+
# Base score from tier
|
|
395
|
+
tier = meta.access.get("tier") or compute_tier(meta)
|
|
396
|
+
score = TIER_BASE_SCORES.get(tier, TIER_BASE_SCORES["T3"])
|
|
397
|
+
|
|
398
|
+
# Penalty for API-level mismatch (title didn't match well)
|
|
399
|
+
if meta.api_match == "mismatch":
|
|
400
|
+
score -= API_MISMATCH_PENALTY
|
|
401
|
+
|
|
402
|
+
# Penalty for accuracy mismatches (title, author, year, venue)
|
|
403
|
+
score -= MISMATCH_PENALTY * len(meta.mismatches)
|
|
404
|
+
|
|
405
|
+
# Penalty for non-formal documents (news, guides, etc.)
|
|
406
|
+
if meta.access.get("is_formal") is False:
|
|
407
|
+
score -= NON_FORMAL_PENALTY
|
|
408
|
+
|
|
409
|
+
return max(0.0, min(1.0, score))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def compute_contribution(
|
|
413
|
+
axis_scores: dict[str, float],
|
|
414
|
+
axis_reasoning: dict[str, str] | None = None,
|
|
415
|
+
weights: dict[str, float] | None = None,
|
|
416
|
+
) -> ContributionScores:
|
|
417
|
+
"""Compute overall contribution from individual axis scores.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
axis_scores: Dict with keys matching ContributionScores fields.
|
|
421
|
+
Expected keys: empirical_content, progressiveness, unification,
|
|
422
|
+
problem_solving, test_severity. Missing keys default to 0.
|
|
423
|
+
axis_reasoning: Optional per-axis reasoning strings.
|
|
424
|
+
weights: Optional custom weights per axis.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
ContributionScores with per-axis and overall scores.
|
|
428
|
+
"""
|
|
429
|
+
w = weights or CONTRIBUTION_WEIGHTS
|
|
430
|
+
axes = [
|
|
431
|
+
"empirical_content",
|
|
432
|
+
"progressiveness",
|
|
433
|
+
"unification",
|
|
434
|
+
"problem_solving",
|
|
435
|
+
"test_severity",
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
scores = {a: max(0.0, min(1.0, axis_scores.get(a, 0.0))) for a in axes}
|
|
439
|
+
total_weight = sum(w.get(a, 0.2) for a in axes)
|
|
440
|
+
weighted = (
|
|
441
|
+
sum(w.get(a, 0.2) * scores[a] for a in axes) / total_weight
|
|
442
|
+
if total_weight > 0
|
|
443
|
+
else 0.0
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
# Bold-claims penalty: papers with high Popper/Lakatos but zero
|
|
447
|
+
# problem-solving get dampened. A null-result paper (low Lakatos)
|
|
448
|
+
# with zero Laudan is fine — it's not claiming much. But a paper
|
|
449
|
+
# that makes bold predictions (high Lakatos) without solving any
|
|
450
|
+
# problem or acknowledging limitations is suspect.
|
|
451
|
+
ps = scores.get("problem_solving", 0.0)
|
|
452
|
+
lakatos = scores.get("progressiveness", 0.0)
|
|
453
|
+
if ps < 0.1 and lakatos > 0.5:
|
|
454
|
+
# Bold claims + zero self-awareness → dampen by how bold the claims are
|
|
455
|
+
penalty = 0.5 + 0.5 * (1.0 - lakatos) # lakatos=1.0→0.5x, lakatos=0.5→0.75x
|
|
456
|
+
overall = weighted * penalty
|
|
457
|
+
else:
|
|
458
|
+
overall = weighted
|
|
459
|
+
|
|
460
|
+
return ContributionScores(
|
|
461
|
+
empirical_content=scores["empirical_content"],
|
|
462
|
+
progressiveness=scores["progressiveness"],
|
|
463
|
+
unification=scores["unification"],
|
|
464
|
+
problem_solving=scores["problem_solving"],
|
|
465
|
+
test_severity=scores["test_severity"],
|
|
466
|
+
overall=overall,
|
|
467
|
+
reasoning=axis_reasoning or {},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def compute_scilint_score(
|
|
472
|
+
paper_name: str,
|
|
473
|
+
claim_results: list[dict[str, Any]],
|
|
474
|
+
findings: list[dict[str, Any]] | None = None,
|
|
475
|
+
metadata_map: dict[str, CitationMetadata] | None = None,
|
|
476
|
+
ref_internal_scores: dict[str, float] | None = None,
|
|
477
|
+
contribution_scores: dict[str, float] | None = None,
|
|
478
|
+
contribution_reasoning: dict[str, str] | None = None,
|
|
479
|
+
) -> SciLintScoreResult:
|
|
480
|
+
"""Compute SciLint Score for a paper.
|
|
481
|
+
|
|
482
|
+
SciLint Score = internal_consistency × referencing_quality × contribution
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
paper_name: Name of the paper.
|
|
486
|
+
claim_results: Results from run_claim_verification().
|
|
487
|
+
findings: Optional check findings for internal score.
|
|
488
|
+
metadata_map: Optional metadata for child reliability.
|
|
489
|
+
ref_internal_scores: Optional per-ref internal scores from
|
|
490
|
+
consistency checks on cited papers.
|
|
491
|
+
contribution_scores: Optional dict of axis name → score (0-1).
|
|
492
|
+
If None, contribution defaults to 1.0.
|
|
493
|
+
contribution_reasoning: Optional per-axis reasoning.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
SciLintScoreResult with overall score and detailed breakdown.
|
|
497
|
+
"""
|
|
498
|
+
integrity_result = compute_integrity(
|
|
499
|
+
findings or [],
|
|
500
|
+
claim_results,
|
|
501
|
+
metadata_map,
|
|
502
|
+
ref_internal_scores=ref_internal_scores,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Group claims by reference for ref_scores
|
|
506
|
+
by_ref = _aggregate_ref_claims(claim_results)
|
|
507
|
+
ref_scores: list[RefScore] = []
|
|
508
|
+
for _key, claims in sorted(by_ref.items()):
|
|
509
|
+
ref_scores.append(_score_reference(claims))
|
|
510
|
+
|
|
511
|
+
if contribution_scores:
|
|
512
|
+
contribution = compute_contribution(contribution_scores, contribution_reasoning)
|
|
513
|
+
else:
|
|
514
|
+
# No contribution data → default to 1.0 (integrity only)
|
|
515
|
+
contribution = ContributionScores(overall=1.0)
|
|
516
|
+
|
|
517
|
+
scilint_score = (
|
|
518
|
+
integrity_result.internal_consistency
|
|
519
|
+
* integrity_result.referencing_quality
|
|
520
|
+
* contribution.overall
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
return SciLintScoreResult(
|
|
524
|
+
paper=paper_name,
|
|
525
|
+
scilint_score=scilint_score,
|
|
526
|
+
integrity_result=integrity_result,
|
|
527
|
+
contribution=contribution,
|
|
528
|
+
total_claims=len(claim_results),
|
|
529
|
+
total_refs_scored=len(ref_scores),
|
|
530
|
+
ref_scores=ref_scores,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
# CLI runner
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def run_contributions(
|
|
540
|
+
paper_name: str,
|
|
541
|
+
claims: list[dict[str, Any]],
|
|
542
|
+
findings_path: Path | None = None,
|
|
543
|
+
references_dir: Path | None = None,
|
|
544
|
+
contribution_scores: dict[str, float] | None = None,
|
|
545
|
+
contribution_reasoning: dict[str, str] | None = None,
|
|
546
|
+
output_dir: Path | None = None,
|
|
547
|
+
) -> SciLintScoreResult:
|
|
548
|
+
"""Compute SciLint Score from claims + findings + metadata.
|
|
549
|
+
|
|
550
|
+
SciLint Score = internal_consistency × referencing_quality × contribution
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
paper_name: Paper name.
|
|
554
|
+
claims: Claim result dicts (from workspace.db or pipeline).
|
|
555
|
+
findings_path: Optional path to check findings JSON.
|
|
556
|
+
references_dir: Optional per-paper workspace root for child reliability.
|
|
557
|
+
contribution_scores: Optional pre-computed contribution axis scores.
|
|
558
|
+
contribution_reasoning: Optional per-axis reasoning.
|
|
559
|
+
output_dir: Where to save results.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
SciLintScoreResult.
|
|
563
|
+
"""
|
|
564
|
+
findings: list[dict[str, Any]] = []
|
|
565
|
+
if findings_path and findings_path.exists():
|
|
566
|
+
findings = json.loads(findings_path.read_text(encoding="utf-8"))
|
|
567
|
+
|
|
568
|
+
# Load metadata for child integrity
|
|
569
|
+
metadata_map: dict[str, CitationMetadata] | None = None
|
|
570
|
+
if references_dir and references_dir.exists():
|
|
571
|
+
from sciwrite_lint.references.metadata import load_all_metadata
|
|
572
|
+
|
|
573
|
+
metadata_map = load_all_metadata(references_dir)
|
|
574
|
+
|
|
575
|
+
# Load per-ref internal scores from workspace.db
|
|
576
|
+
ref_internal_scores: dict[str, float] | None = None
|
|
577
|
+
if references_dir and references_dir.exists():
|
|
578
|
+
from sciwrite_lint.references.workspace_db import (
|
|
579
|
+
get_db,
|
|
580
|
+
load_all_ref_internal_scores,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
with get_db(references_dir) as conn:
|
|
584
|
+
scores = load_all_ref_internal_scores(conn)
|
|
585
|
+
if scores:
|
|
586
|
+
ref_internal_scores = scores
|
|
587
|
+
|
|
588
|
+
result = compute_scilint_score(
|
|
589
|
+
paper_name,
|
|
590
|
+
claims,
|
|
591
|
+
findings=findings,
|
|
592
|
+
metadata_map=metadata_map,
|
|
593
|
+
ref_internal_scores=ref_internal_scores,
|
|
594
|
+
contribution_scores=contribution_scores,
|
|
595
|
+
contribution_reasoning=contribution_reasoning,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
if not output_dir:
|
|
599
|
+
raise ValueError("output_dir is required")
|
|
600
|
+
|
|
601
|
+
# Save
|
|
602
|
+
out_dir = output_dir
|
|
603
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
604
|
+
out_path = out_dir / f"scilint_{paper_name}.json"
|
|
605
|
+
out_path.write_text(
|
|
606
|
+
json.dumps(result.to_dict(), indent=2, ensure_ascii=False) + "\n",
|
|
607
|
+
encoding="utf-8",
|
|
608
|
+
)
|
|
609
|
+
logger.info(f"SciLint Score saved to {out_path}")
|
|
610
|
+
|
|
611
|
+
return result
|