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,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