sciwrite-lint 0.4.0__tar.gz → 0.5.0__tar.gz

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 (163) hide show
  1. {sciwrite_lint-0.4.0/sciwrite_lint.egg-info → sciwrite_lint-0.5.0}/PKG-INFO +14 -3
  2. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/README.md +13 -2
  3. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/pyproject.toml +3 -1
  4. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/__init__.py +1 -1
  5. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/__main__.py +52 -56
  6. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/parse.py +1 -1
  7. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/verify.py +12 -5
  8. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/eval_claims.py +107 -89
  9. sciwrite_lint-0.5.0/sciwrite_lint/llm/manuscript_cache.py +173 -0
  10. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pdf/grobid.py +24 -8
  11. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/checks.py +85 -5
  12. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/claims.py +0 -1
  13. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/__init__.py +13 -0
  14. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/_core.py +43 -5
  15. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/claim_results.py +8 -3
  16. sciwrite_lint-0.5.0/sciwrite_lint/references/workspace_db/manuscript_check_cache.py +103 -0
  17. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0/sciwrite_lint.egg-info}/PKG-INFO +14 -3
  18. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint.egg-info/SOURCES.txt +2 -1
  19. sciwrite_lint-0.4.0/sciwrite_lint/claude_backend.py +0 -94
  20. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/LICENSE +0 -0
  21. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/_network.py +0 -0
  22. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/api.py +0 -0
  23. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/api_keys.py +0 -0
  24. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/__init__.py +0 -0
  25. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/_diagnostics.py +0 -0
  26. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/_section_utils.py +0 -0
  27. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/cite_purpose.py +0 -0
  28. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/claim_support.py +0 -0
  29. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/cross_section_consistency.py +0 -0
  30. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/dangling_cite.py +0 -0
  31. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/dangling_ref.py +0 -0
  32. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/full_paper_consistency.py +0 -0
  33. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/internal_consistency_pairs.py +0 -0
  34. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/prose_quality.py +0 -0
  35. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/ref_internal_checks.py +0 -0
  36. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/reference_accuracy.py +0 -0
  37. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/reference_exists.py +0 -0
  38. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/reference_unreliable.py +0 -0
  39. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/registry.py +0 -0
  40. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/retracted_cite.py +0 -0
  41. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/structure_promises.py +0 -0
  42. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/checks/unreferenced_figure.py +0 -0
  43. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/claims.py +0 -0
  44. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/claude_cli.py +0 -0
  45. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/__init__.py +0 -0
  46. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/_common.py +0 -0
  47. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/check.py +0 -0
  48. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/config.py +0 -0
  49. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/fetch.py +0 -0
  50. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/__init__.py +0 -0
  51. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/_monitor.py +0 -0
  52. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/containers.py +0 -0
  53. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/dismiss_claim.py +0 -0
  54. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/grobid.py +0 -0
  55. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/init.py +0 -0
  56. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/override.py +0 -0
  57. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/vision.py +0 -0
  58. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/misc/vllm.py +0 -0
  59. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cli/rank.py +0 -0
  60. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/config.py +0 -0
  61. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/cross_paper.py +0 -0
  62. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/exceptions.py +0 -0
  63. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/footnote_urls.py +0 -0
  64. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/__init__.py +0 -0
  65. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/_common.py +0 -0
  66. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/_download.py +0 -0
  67. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/_orchestrator.py +0 -0
  68. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/_search.py +0 -0
  69. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/_validation.py +0 -0
  70. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/arxiv.py +0 -0
  71. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/biorxiv.py +0 -0
  72. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/core.py +0 -0
  73. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/eric.py +0 -0
  74. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/europepmc.py +0 -0
  75. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/hal.py +0 -0
  76. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/ideas.py +0 -0
  77. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/nasa_ads.py +0 -0
  78. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/nber.py +0 -0
  79. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/osf.py +0 -0
  80. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/pmc.py +0 -0
  81. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/s2.py +0 -0
  82. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/fulltext/unpaywall.py +0 -0
  83. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/latex_to_markdown.py +0 -0
  84. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/__init__.py +0 -0
  85. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/__init__.py +0 -0
  86. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/compute_cap.py +0 -0
  87. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/controller.py +0 -0
  88. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/decide.py +0 -0
  89. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/host_metrics.py +0 -0
  90. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/metrics_probe.py +0 -0
  91. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/registry.py +0 -0
  92. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/telemetry.py +0 -0
  93. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/vllm_metrics.py +0 -0
  94. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm/concurrency_optimizer/wiring.py +0 -0
  95. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/llm_utils.py +0 -0
  96. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/local_sources.py +0 -0
  97. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/manuscript_store.py +0 -0
  98. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/mhtml.py +0 -0
  99. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/models.py +0 -0
  100. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/oa/__init__.py +0 -0
  101. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/oa/_models.py +0 -0
  102. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/oa/_pdf.py +0 -0
  103. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/oa/_search.py +0 -0
  104. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/oa/_web.py +0 -0
  105. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pdf/__init__.py +0 -0
  106. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pdf/pdf_download.py +0 -0
  107. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/__init__.py +0 -0
  108. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/embeddings.py +0 -0
  109. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/fetch.py +0 -0
  110. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/orchestration.py +0 -0
  111. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/parse.py +0 -0
  112. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/pdf_context.py +0 -0
  113. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/ref_internal.py +0 -0
  114. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/runners.py +0 -0
  115. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/staged.py +0 -0
  116. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/swap.py +0 -0
  117. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/tracking.py +0 -0
  118. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/unreliable.py +0 -0
  119. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/verify.py +0 -0
  120. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/pipeline/vision.py +0 -0
  121. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/prompt_safety.py +0 -0
  122. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/py.typed +0 -0
  123. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/rate_limiter.py +0 -0
  124. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/__init__.py +0 -0
  125. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/_embed_timing.py +0 -0
  126. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/chain.py +0 -0
  127. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/citations.py +0 -0
  128. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/crossref.py +0 -0
  129. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/embedding_store.py +0 -0
  130. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/matching.py +0 -0
  131. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/metadata.py +0 -0
  132. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/reference_store.py +0 -0
  133. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/retraction_watch.py +0 -0
  134. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/bib_checks.py +0 -0
  135. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/citation_metadata.py +0 -0
  136. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/manuscript_citations.py +0 -0
  137. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/parse_cache.py +0 -0
  138. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/pipeline_stage.py +0 -0
  139. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/query_vectors.py +0 -0
  140. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/ref_internal.py +0 -0
  141. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/registry.py +0 -0
  142. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/references/workspace_db/vision_cache.py +0 -0
  143. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/report.py +0 -0
  144. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/schemas.py +0 -0
  145. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/scoring/__init__.py +0 -0
  146. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/scoring/contribution.py +0 -0
  147. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/scoring/scilint_score.py +0 -0
  148. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/tex_parser.py +0 -0
  149. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/usage.py +0 -0
  150. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/vision/__init__.py +0 -0
  151. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/vision/cache.py +0 -0
  152. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/vision/describe.py +0 -0
  153. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/vision/image_extraction.py +0 -0
  154. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/vision/pipeline.py +0 -0
  155. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/vllm/__init__.py +0 -0
  156. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/vllm/metrics.py +0 -0
  157. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/vllm/vllm_server.py +0 -0
  158. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint/web.py +0 -0
  159. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint.egg-info/dependency_links.txt +0 -0
  160. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint.egg-info/entry_points.txt +0 -0
  161. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint.egg-info/requires.txt +0 -0
  162. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/sciwrite_lint.egg-info/top_level.txt +0 -0
  163. {sciwrite_lint-0.4.0 → sciwrite_lint-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sciwrite-lint
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A linter for scientific manuscripts — reference verification, consistency checking, and structural validation.
5
5
  Author: Sergey Samsonau
6
6
  Maintainer-email: Authentic Research Partners LLC <info@arpconnect.com>
@@ -147,7 +147,7 @@ sciwrite-lint contributions paper.pdf --format json
147
147
 
148
148
  ### Optimizations
149
149
 
150
- Three models — a vision model (Qwen3-VL-2B default, or 8B FP8 via `--vision-backend vllm` for +15% accuracy), an embedding model (Arctic Embed), and an 8B reasoning LLM (Qwen3 via vLLM) — share a single consumer GPU. The pipeline runs each in its own stage and explicitly stops one before starting the next, so only one ever owns GPU memory at a time (same code path on WSL2 and native Linux). FP8 weights and KV cache (Ada Lovelace+) and per-paper SQLite caching with hash-based invalidation are baseline. On top of that:
150
+ Three models — a vision model (Qwen3-VL-2B default, or 8B FP8 via `--vision-backend vllm` for +15% accuracy), an embedding model (Arctic Embed), and an 8B reasoning LLM (Qwen3 via vLLM) — share a single consumer GPU. The pipeline runs each in its own stage and explicitly stops one before starting the next, so only one ever owns GPU memory at a time (same code path on WSL2 and native Linux). FP8 weights and KV cache (Ada Lovelace+) and per-paper SQLite caching are baseline; every cache layer pins its inputs by hash so editing the paper, replacing a figure, or refreshing a reference recomputes only what's affected (see the *Iterative editing* subsection under Usage — `--fresh` is not part of the normal loop). On top of that:
151
151
 
152
152
  - **Cost-aware verify-claim ladder** — sentence chunk → paragraph chunk → whole section. Each level fans out top-N candidates in parallel; stops on a conclusive verdict. Most claims resolve at the cheap sentence level (~200-token prompts) instead of paying for whole sections (~5K tokens), with full-section escalation reserved for hard cases
153
153
  - **Prefix-first prompt structure** — shared context placed before variable content in all prompts, maximizing vLLM's automatic prefix caching
@@ -282,7 +282,18 @@ sciwrite-lint contributions paper.pdf # standalone file scoring
282
282
 
283
283
  `check` runs the full pipeline in one command: text checks → figure analysis → LLM consistency → reference verification via APIs → download and parse cited papers → claim verification → consistency checks on cited papers → bibliography verification → aggregate reliability scores → SciLint Score. An initial run on a 50-reference paper takes up to 30 minutes (dominated by downloads and claim verification); subsequent cached runs complete in minutes.
284
284
 
285
- Use `--fresh` to start from scratch (backs up the existing workspace before recreating it).
285
+ ### Iterative editing no `--fresh` needed
286
+
287
+ `sciwrite-lint check` invalidates cached results whenever the inputs they were derived from change, so rerunning after an edit just works. Concretely:
288
+
289
+ - **Edit a sentence or paragraph** → the manuscript-LLM checks (prose-quality, cross-section consistency, etc.) re-run only on the touched sentences and their paragraph siblings; untouched prose stays cached
290
+ - **Edit a paragraph around a citation** → the claim verifier reruns on the edited prose
291
+ - **Replace a figure** (overwrite the PNG/PDF in your tree) → the vision cache re-describes it
292
+ - **Swap the local LLM model** (e.g. `qwen3` ↔ `gemma3` via `--model`) → manuscript-check rows and claim verdicts no longer count as cache hits
293
+ - **Overwrite a reference PDF** in your drop folder, or **re-fetch** an open-access PDF that has new bytes → the parser, embeddings, and claim verifier all rerun against the new content
294
+ - **Upgrade a summary `.md` to a real PDF** in your drop folder → claims are re-verified against the richer evidence
295
+
296
+ Use `--fresh` only when you want to wipe the workspace deliberately (it backs up the existing workspace before recreating it). It is not the answer to "I edited the paper and a finding looks stale" — if you ever see that, file an issue; a cache invariant is wrong and we'll fix it.
286
297
 
287
298
  Use `--checks ID[,ID...]` to run only the listed checks (comma-separated), disabling the rest for this invocation. Useful for fast iteration during editing (`--checks prose-quality` runs just the prose review), for debugging a single check, or for scripting a two-pass workflow. Unknown check IDs fail loudly — run `sciwrite-lint checks` to list available IDs.
288
299
 
@@ -81,7 +81,7 @@ sciwrite-lint contributions paper.pdf --format json
81
81
 
82
82
  ### Optimizations
83
83
 
84
- Three models — a vision model (Qwen3-VL-2B default, or 8B FP8 via `--vision-backend vllm` for +15% accuracy), an embedding model (Arctic Embed), and an 8B reasoning LLM (Qwen3 via vLLM) — share a single consumer GPU. The pipeline runs each in its own stage and explicitly stops one before starting the next, so only one ever owns GPU memory at a time (same code path on WSL2 and native Linux). FP8 weights and KV cache (Ada Lovelace+) and per-paper SQLite caching with hash-based invalidation are baseline. On top of that:
84
+ Three models — a vision model (Qwen3-VL-2B default, or 8B FP8 via `--vision-backend vllm` for +15% accuracy), an embedding model (Arctic Embed), and an 8B reasoning LLM (Qwen3 via vLLM) — share a single consumer GPU. The pipeline runs each in its own stage and explicitly stops one before starting the next, so only one ever owns GPU memory at a time (same code path on WSL2 and native Linux). FP8 weights and KV cache (Ada Lovelace+) and per-paper SQLite caching are baseline; every cache layer pins its inputs by hash so editing the paper, replacing a figure, or refreshing a reference recomputes only what's affected (see the *Iterative editing* subsection under Usage — `--fresh` is not part of the normal loop). On top of that:
85
85
 
86
86
  - **Cost-aware verify-claim ladder** — sentence chunk → paragraph chunk → whole section. Each level fans out top-N candidates in parallel; stops on a conclusive verdict. Most claims resolve at the cheap sentence level (~200-token prompts) instead of paying for whole sections (~5K tokens), with full-section escalation reserved for hard cases
87
87
  - **Prefix-first prompt structure** — shared context placed before variable content in all prompts, maximizing vLLM's automatic prefix caching
@@ -216,7 +216,18 @@ sciwrite-lint contributions paper.pdf # standalone file scoring
216
216
 
217
217
  `check` runs the full pipeline in one command: text checks → figure analysis → LLM consistency → reference verification via APIs → download and parse cited papers → claim verification → consistency checks on cited papers → bibliography verification → aggregate reliability scores → SciLint Score. An initial run on a 50-reference paper takes up to 30 minutes (dominated by downloads and claim verification); subsequent cached runs complete in minutes.
218
218
 
219
- Use `--fresh` to start from scratch (backs up the existing workspace before recreating it).
219
+ ### Iterative editing no `--fresh` needed
220
+
221
+ `sciwrite-lint check` invalidates cached results whenever the inputs they were derived from change, so rerunning after an edit just works. Concretely:
222
+
223
+ - **Edit a sentence or paragraph** → the manuscript-LLM checks (prose-quality, cross-section consistency, etc.) re-run only on the touched sentences and their paragraph siblings; untouched prose stays cached
224
+ - **Edit a paragraph around a citation** → the claim verifier reruns on the edited prose
225
+ - **Replace a figure** (overwrite the PNG/PDF in your tree) → the vision cache re-describes it
226
+ - **Swap the local LLM model** (e.g. `qwen3` ↔ `gemma3` via `--model`) → manuscript-check rows and claim verdicts no longer count as cache hits
227
+ - **Overwrite a reference PDF** in your drop folder, or **re-fetch** an open-access PDF that has new bytes → the parser, embeddings, and claim verifier all rerun against the new content
228
+ - **Upgrade a summary `.md` to a real PDF** in your drop folder → claims are re-verified against the richer evidence
229
+
230
+ Use `--fresh` only when you want to wipe the workspace deliberately (it backs up the existing workspace before recreating it). It is not the answer to "I edited the paper and a finding looks stale" — if you ever see that, file an issue; a cache invariant is wrong and we'll fix it.
220
231
 
221
232
  Use `--checks ID[,ID...]` to run only the listed checks (comma-separated), disabling the rest for this invocation. Useful for fast iteration during editing (`--checks prose-quality` runs just the prose review), for debugging a single check, or for scripting a two-pass workflow. Unknown check IDs fail loudly — run `sciwrite-lint checks` to list available IDs.
222
233
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sciwrite-lint"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "A linter for scientific manuscripts — reference verification, consistency checking, and structural validation."
9
9
  requires-python = ">=3.13,<3.14"
10
10
  license = "MIT"
@@ -86,3 +86,5 @@ sciwrite-lint = "sciwrite_lint.__main__:main"
86
86
  [tool.setuptools.packages.find]
87
87
  include = ["sciwrite_lint*"]
88
88
 
89
+ [tool.setuptools.package-data]
90
+
@@ -2,7 +2,7 @@
2
2
 
3
3
  from sciwrite_lint.exceptions import LLMConnectionError, SciWriteLintError
4
4
 
5
- __version__ = "0.4.0"
5
+ __version__ = "0.5.0"
6
6
 
7
7
  from sciwrite_lint.oa import (
8
8
  DownloadResult,
@@ -14,8 +14,6 @@ import argparse
14
14
  import sys
15
15
  from pathlib import Path
16
16
 
17
- from loguru import logger
18
-
19
17
  from sciwrite_lint import __version__
20
18
  from sciwrite_lint.cli._common import (
21
19
  _classify_verify_issue as _classify_verify_issue,
@@ -42,6 +40,31 @@ _REPLACE_HELP = (
42
40
  # ---------------------------------------------------------------------------
43
41
 
44
42
 
43
+ def _register_optional_extensions(
44
+ sub: argparse._SubParsersAction, # type: ignore[type-arg]
45
+ ) -> None:
46
+ """Load CLI extensions discovered under ``sciwrite_lint``.
47
+
48
+ An underscore-prefixed subpackage is a CLI extension when its
49
+ ``__init__.py`` defines ``register_cli(sub)``. Discovery walks
50
+ ``sciwrite_lint.__path__`` via ``pkgutil``, so no extension package
51
+ names appear in this source — adding or removing one is a filesystem
52
+ change.
53
+ """
54
+ import importlib
55
+ import pkgutil
56
+
57
+ import sciwrite_lint as _pkg
58
+
59
+ for _finder, name, ispkg in pkgutil.iter_modules(_pkg.__path__):
60
+ if not ispkg or not name.startswith("_"):
61
+ continue
62
+ ext = importlib.import_module(f"{_pkg.__name__}.{name}")
63
+ register = getattr(ext, "register_cli", None)
64
+ if register is not None:
65
+ register(sub)
66
+
67
+
45
68
  def main(argv: list[str] | None = None) -> int:
46
69
  from sciwrite_lint.cli.check import run_check, run_checks_list
47
70
  from sciwrite_lint.cli.config import run_config
@@ -125,47 +148,8 @@ def main(argv: list[str] | None = None) -> int:
125
148
  p_checks = sub.add_parser("checks", help="List all registered checks")
126
149
  p_checks.set_defaults(func=run_checks_list)
127
150
 
128
- # --- aicode-lint (optional only registered when the WIP module is installed) ---
129
- # ``sciwrite_lint.aicode_lint`` is excluded from the public release rsync
130
- # while it stabilizes. Use ``importlib.util.find_spec`` to check whether
131
- # the module is present rather than try/except ImportError (separately
132
- # banned). Public installs (pip from PyPI) won't have it; dev installs
133
- # do. Check the parent ``sciwrite_lint.aicode_lint`` first because
134
- # ``find_spec`` raises ``ModuleNotFoundError`` on a missing intermediate
135
- # path; once the parent is confirmed present, the child lookup is safe.
136
- import importlib.util as _importlib_util
137
-
138
- if _importlib_util.find_spec("sciwrite_lint.aicode_lint") is not None:
139
- from sciwrite_lint.aicode_lint.cli import run_aicode_lint
140
-
141
- p_aicode = sub.add_parser(
142
- "aicode-lint",
143
- help="LLM code review on Python sources via local vLLM (semantic antipatterns)",
144
- )
145
- p_aicode.add_argument(
146
- "paths",
147
- nargs="*",
148
- help="Files, directories, or globs to scan (default: sciwrite_lint/**/*.py)",
149
- )
150
- p_aicode.add_argument(
151
- "--rules",
152
- default=None,
153
- metavar="ID[,ID...]",
154
- help="Run only the listed rule IDs (comma-separated). Default: all built-in rules.",
155
- )
156
- p_aicode.add_argument(
157
- "--list-rules",
158
- action="store_true",
159
- help="Print the rule registry and exit.",
160
- )
161
- p_aicode.add_argument(
162
- "--format",
163
- choices=["terminal", "json"],
164
- default=None,
165
- help="Output format (default: terminal).",
166
- )
167
- p_aicode.add_argument("--config", help="Path to .sciwrite-lint.toml")
168
- p_aicode.set_defaults(func=run_aicode_lint)
151
+ # Load optional underscore-prefixed CLI extension subpackages (if any).
152
+ _register_optional_extensions(sub)
169
153
 
170
154
  # --- init ---
171
155
  p_init = sub.add_parser(
@@ -270,12 +254,6 @@ def main(argv: list[str] | None = None) -> int:
270
254
  # --- verify-claims ---
271
255
  p_vc = sub.add_parser("verify-claims", help="Verify claims against cited sources")
272
256
  p_vc.add_argument("--paper", required=True, help="Paper to verify")
273
- p_vc.add_argument(
274
- "--backend",
275
- choices=["vllm", "claude"],
276
- default="vllm",
277
- help="vllm (local LLM, fast) or claude (Claude CLI, deep)",
278
- )
279
257
  p_vc.add_argument(
280
258
  "--model",
281
259
  choices=_TEXT_MODELS,
@@ -499,14 +477,32 @@ def main(argv: list[str] | None = None) -> int:
499
477
  p_vllm.print_help()
500
478
  return 0
501
479
 
502
- # Set up file logging from config (stderr sink stays at INFO by default)
503
- try:
504
- config = load_config(
505
- Path(args.config) if getattr(args, "config", None) else None
506
- )
507
- except Exception as e:
508
- logger.debug("Config load failed, using defaults: {}", e)
480
+ # Set up file logging from config (stderr sink stays at INFO by default).
481
+ # ``load_config`` already returns ``LintConfig()`` when no config file is
482
+ # discovered; reaching the exception path means a file WAS found (or
483
+ # explicitly passed via --config) but failed to parse or validate.
484
+ # Surface that instead of silently substituting hardcoded defaults — a
485
+ # broken TOML otherwise produces results computed against the wrong
486
+ # workspace, paper list, or API keys.
487
+ #
488
+ # The ``init`` command is the one exception: its job is to scaffold a
489
+ # config, so it must run against a missing, broken, or sentinel TOML
490
+ # without aborting. Use ``LintConfig()`` for logging setup only and let
491
+ # init proceed to (over)write the file.
492
+ if args.command == "init":
509
493
  config = LintConfig()
494
+ else:
495
+ try:
496
+ config = load_config(
497
+ Path(args.config) if getattr(args, "config", None) else None
498
+ )
499
+ except Exception as e:
500
+ cfg_arg = getattr(args, "config", None)
501
+ cfg_hint = f" ({cfg_arg})" if cfg_arg else " (.sciwrite-lint.toml)"
502
+ raise SystemExit(
503
+ f"ERROR: failed to load configuration{cfg_hint}: {type(e).__name__}: {e}\n"
504
+ " Fix the TOML or remove the file to use built-in defaults."
505
+ ) from e
510
506
  _setup_logging(config)
511
507
 
512
508
  return args.func(args)
@@ -84,7 +84,7 @@ def run_parse(args: argparse.Namespace) -> int:
84
84
  n = compute_and_store_embeddings(key, text, refs_dir)
85
85
  print(f" {key}: {n} chunks")
86
86
  except Exception as e:
87
- print(f" {key}: error {e}")
87
+ logger.warning("parse: embedding failed for {}: {}", key, e)
88
88
 
89
89
  print(f"\n Summary: {cached} cached, {parsed} parsed, {failed} failed")
90
90
  if failed:
@@ -335,6 +335,7 @@ def run_ref_health(args: argparse.Namespace) -> int:
335
335
  academic_unmatched: list[Path] = []
336
336
  web_matched: dict[str, Path] = {}
337
337
  web_unmatched: list[Path] = []
338
+ local_match_error: str | None = None
338
339
  try:
339
340
  if tex_file:
340
341
  from sciwrite_lint.config import load_config as _load_cfg
@@ -356,8 +357,13 @@ def run_ref_health(args: argparse.Namespace) -> int:
356
357
  _cfg.local_web_dir, titles, WEB_SUFFIXES
357
358
  )
358
359
  except Exception as e:
359
- logger.debug(
360
- f"ref-health: local source matching skipped ({type(e).__name__}: {e})"
360
+ # Record the failure so the report renders a visible notice instead
361
+ # of silently producing a ref-health output with the local-source
362
+ # section missing. Log at WARNING (not DEBUG) so the operator sees
363
+ # it even when running with the default log level.
364
+ local_match_error = f"{type(e).__name__}: {e}"
365
+ logger.warning(
366
+ "ref-health: local source matching skipped ({})", local_match_error
361
367
  )
362
368
 
363
369
  # --- Output ---
@@ -398,7 +404,9 @@ def run_ref_health(args: argparse.Namespace) -> int:
398
404
  if web_count:
399
405
  print(f" ({web_count} web resources)")
400
406
 
401
- # Local sources — academic (PDFs + MD summaries) + web (MD + MHTML)
407
+ # Local sources — academic (PDFs + MD summaries) + web (MD + MHTML).
408
+ # Matching failures are surfaced via logger.warning (above) so they
409
+ # appear in the operator's terminal regardless of report formatting.
402
410
  if academic_matched or academic_unmatched:
403
411
  print("\n LOCAL ACADEMIC SOURCES")
404
412
  if academic_matched:
@@ -425,7 +433,7 @@ def run_ref_health(args: argparse.Namespace) -> int:
425
433
 
426
434
 
427
435
  def run_verify_claims(args: argparse.Namespace) -> int:
428
- """Verify claims against cited sources using local LLM or Claude Opus."""
436
+ """Verify claims against cited sources using local vLLM."""
429
437
  from sciwrite_lint.cli._common import _load_config, _resolve_paper
430
438
 
431
439
  config = _load_config(args)
@@ -446,7 +454,6 @@ def run_verify_claims(args: argparse.Namespace) -> int:
446
454
  refs_dir,
447
455
  config=config,
448
456
  bib_path=pc.bib,
449
- backend=args.backend,
450
457
  model=args.model,
451
458
  key_filter=args.key,
452
459
  limit=args.limit,
@@ -5,8 +5,8 @@ cited source (PDF via GROBID or .md), splits into sections, and checks
5
5
  each section in parallel against the claim using a local LLM via vLLM.
6
6
 
7
7
  Only works for T1 citations (full text available). Skips T2/T3.
8
- Uses only local vLLM — no cloud services. For the Claude Opus backend
9
- (deep analysis, expensive), see sciwrite_lint.evals.eval_claims_opus.
8
+ Uses only local vLLM — no cloud services. Manuscript text never leaves
9
+ the local machine.
10
10
  """
11
11
 
12
12
  from __future__ import annotations
@@ -1421,16 +1421,16 @@ async def run_claim_verification(
1421
1421
  config: LintConfig | None = None,
1422
1422
  bib_format: str = "auto",
1423
1423
  bib_path: Path | None = None,
1424
- backend: str = "vllm",
1425
1424
  model: str = "",
1426
1425
  key_filter: str | None = None,
1427
1426
  limit: int | None = None,
1428
1427
  rerun: bool = False,
1429
1428
  ) -> list[dict]:
1430
- """Run claim verification for a paper.
1431
-
1432
- backend: "vllm" (local LLM, default) or "claude" (Claude CLI, deep).
1433
- """
1429
+ """Run claim verification for a paper against the local vLLM backend."""
1430
+ # ``backend`` is fixed to "vllm". The column is preserved on
1431
+ # ``claim_results`` to correctly invalidate any pre-0.5.0 rows that
1432
+ # stored a different value.
1433
+ backend = "vllm"
1434
1434
  from sciwrite_lint.references.citations import check_local_sources, extract_bibitems
1435
1435
  from sciwrite_lint.references.metadata import load_all_metadata
1436
1436
 
@@ -1449,7 +1449,7 @@ async def run_claim_verification(
1449
1449
  # Standalone PDF (no prior build_pdf_context call)
1450
1450
  from sciwrite_lint.pipeline import build_pdf_context, citations_from_pdf_context
1451
1451
 
1452
- asyncio.run(build_pdf_context(tex_path, config))
1452
+ await build_pdf_context(tex_path, config)
1453
1453
  citations = citations_from_pdf_context(config)
1454
1454
  else:
1455
1455
  citations = extract_bibitems(tex_path, bib_format, bib_path=bib_path)
@@ -1532,42 +1532,52 @@ async def run_claim_verification(
1532
1532
  # contexts from workspace.db. The standalone path has no findings
1533
1533
  # list to populate, so any build-warning Finding is logged and
1534
1534
  # dropped — pipeline runs route it to system_issues correctly.
1535
- if backend != "claude":
1536
- from sciwrite_lint.pipeline import (
1537
- ensure_claim_query_vectors,
1538
- persist_manuscript_citations,
1539
- )
1535
+ from sciwrite_lint.pipeline import (
1536
+ ensure_claim_query_vectors,
1537
+ persist_manuscript_citations,
1538
+ )
1540
1539
 
1541
- _, build_finding = persist_manuscript_citations(
1542
- config, references_dir, tex_path=tex_path
1540
+ _, build_finding = persist_manuscript_citations(
1541
+ config, references_dir, tex_path=tex_path
1542
+ )
1543
+ if build_finding is not None:
1544
+ logger.warning(
1545
+ "Manuscript-context build warning surfaced (standalone "
1546
+ "verify-claims; not surfaced as system issue): {}",
1547
+ build_finding.context,
1543
1548
  )
1544
- if build_finding is not None:
1545
- logger.warning(
1546
- "Manuscript-context build warning surfaced (standalone "
1547
- "verify-claims; not surfaced as system issue): {}",
1548
- build_finding.context,
1549
- )
1550
- ensure_claim_query_vectors(references_dir, config)
1549
+ ensure_claim_query_vectors(references_dir, config)
1551
1550
 
1552
- if backend == "claude":
1553
- vllm_model = ""
1554
- model_id = "claude"
1555
- logger.info("Backend: Claude Sonnet (via claude CLI)")
1556
- else:
1557
- vllm_model = model or config.llm_model or VLLM_DEFAULT_MODEL
1558
- model_id = f"vllm:{vllm_model}"
1559
- logger.info(f"Backend: vLLM ({VLLM_MODELS[vllm_model]['model']})")
1551
+ vllm_model = model or config.llm_model or VLLM_DEFAULT_MODEL
1552
+ model_id = f"vllm:{vllm_model}"
1553
+ logger.info(f"Backend: vLLM ({VLLM_MODELS[vllm_model]['model']})")
1560
1554
 
1561
1555
  ref_paths: dict[str, Path | None] = {}
1562
1556
  ref_types: dict[str, str] = {}
1557
+ ref_src_hashes: dict[str, str] = {}
1563
1558
  ref_sections: dict[str, list[Section]] = {}
1564
1559
  for key, local in local_files.items():
1565
1560
  ref_paths[key] = _resolve_reference_path(local, references_dir)
1566
1561
  meta = all_meta.get(key)
1567
1562
  if meta:
1568
1563
  ref_types[key] = meta.access.get("local_type", "none")
1564
+ ref_src_hashes[key] = meta.access.get("local_file_src_hash", "")
1569
1565
  else:
1570
1566
  ref_types[key] = "pdf" if local.endswith(".pdf") else "summary"
1567
+ ref_src_hashes[key] = ""
1568
+
1569
+ # parse_cache.pdf_hash moves whenever the GROBID-parsed PDF bytes
1570
+ # change — covers OA-fetched refs (which have no drop-folder src
1571
+ # hash) on top of the ref_src_hash signal for drop-folder refs.
1572
+ from sciwrite_lint.references.workspace_db import (
1573
+ get_db,
1574
+ load_all_parse_cache,
1575
+ )
1576
+
1577
+ ref_parse_hashes: dict[str, str] = {}
1578
+ with get_db(references_dir) as _pc_conn:
1579
+ for ref_key, row in load_all_parse_cache(_pc_conn).items():
1580
+ ref_parse_hashes[ref_key] = row.get("pdf_hash", "")
1571
1581
 
1572
1582
  # Load previous results from workspace.db
1573
1583
  from sciwrite_lint.references.workspace_db import (
@@ -1603,8 +1613,35 @@ async def run_claim_verification(
1603
1613
 
1604
1614
  for i, claim in enumerate(verifiable):
1605
1615
  pk = (claim.key, claim.line)
1606
- if pk in previous:
1607
- results[i] = previous[pk]
1616
+ cached = previous.get(pk)
1617
+ # Reuse a cached verdict only when every input that could change
1618
+ # it still matches: surrounding context, the backend/model that
1619
+ # produced it, the kind of local source consulted, and the
1620
+ # source-file hash (catches drop-folder PDF/MHTML re-ingests).
1621
+ # Empty cached ref_src_hash is treated as "no info" — we don't
1622
+ # invalidate on it alone, since OA-fetched refs have no ingest
1623
+ # hash to compare.
1624
+ cur_src_hash = ref_src_hashes.get(claim.key, "")
1625
+ cur_parse_hash = ref_parse_hashes.get(claim.key, "")
1626
+ cur_ref_type = ref_types.get(claim.key, "paper")
1627
+ if (
1628
+ cached is not None
1629
+ and cached.get("context", "") == claim.context
1630
+ and cached.get("backend", "") == backend
1631
+ and cached.get("model", "") == model_id
1632
+ and cached.get("ref_type", "") == cur_ref_type
1633
+ and (
1634
+ not cached.get("ref_src_hash", "")
1635
+ or not cur_src_hash
1636
+ or cached.get("ref_src_hash", "") == cur_src_hash
1637
+ )
1638
+ and (
1639
+ not cached.get("ref_parse_hash", "")
1640
+ or not cur_parse_hash
1641
+ or cached.get("ref_parse_hash", "") == cur_parse_hash
1642
+ )
1643
+ ):
1644
+ results[i] = cached
1608
1645
  skipped += 1
1609
1646
  elif not ref_paths.get(claim.key):
1610
1647
  results[i] = {
@@ -1628,11 +1665,6 @@ async def run_claim_verification(
1628
1665
  ref_sections[claim.key] = split_sections(ref_text) if ref_text else []
1629
1666
 
1630
1667
  _CLAIM_CONCURRENCY = 5
1631
- # Use the dynamic controller only when the vllm backend is active.
1632
- # Claude CLI has its own server-side admission, so a static
1633
- # semaphore is the right tool for it. ``concurrency_slot`` handles
1634
- # both branches uniformly so the loop body doesn't care which
1635
- # backend is in play.
1636
1668
  from sciwrite_lint.llm.concurrency_optimizer import (
1637
1669
  ControllerParams,
1638
1670
  concurrency_slot,
@@ -1643,7 +1675,7 @@ async def run_claim_verification(
1643
1675
  target_kv_grow=config.concurrency_target_kv_grow,
1644
1676
  target_kv_hi=config.concurrency_target_kv_hi,
1645
1677
  )
1646
- _use_dynamic = backend == "vllm" and config.use_dynamic_concurrency
1678
+ _use_dynamic = config.use_dynamic_concurrency
1647
1679
  _slot_static_cap = (
1648
1680
  config.llm_max_concurrency if _use_dynamic else _CLAIM_CONCURRENCY
1649
1681
  )
@@ -1670,42 +1702,31 @@ async def run_claim_verification(
1670
1702
  f"{ref_path.name} is a web page summary, not the actual paper"
1671
1703
  )
1672
1704
 
1673
- if backend == "claude":
1674
- # Claude CLI has its own server-side admission; use the slot
1675
- # as an outer per-claim throttle.
1676
- async with _slot():
1677
- from sciwrite_lint.claude_backend import verify_claim_claude
1678
-
1679
- logger.debug(f"Reference: {ref_path.name}")
1680
- logger.info("Sending to Claude CLI...")
1681
- project_dir = config.project_dir if config else None
1682
- verdict = verify_claim_claude(claim, ref_path, project_dir=project_dir)
1683
- else:
1684
- # vLLM: the slot wraps each individual LLM call inside
1685
- # verify_claim_vllm, so the controller's dynamic cap bounds
1686
- # the actual vLLM request volume regardless of how many
1687
- # claims and ladder fan-outs run in parallel.
1688
- sections = ref_sections[claim.key]
1689
- if not sections:
1690
- results[idx] = {
1691
- "key": claim.key,
1692
- "line": claim.line,
1693
- "context": claim.context,
1694
- "verdict": "CANNOT_DETERMINE",
1695
- "explanation": "Could not read reference",
1696
- }
1697
- return
1698
-
1699
- logger.debug(f"Reference: {ref_path.name} ({len(sections)} sections)")
1700
- verdict = await verify_claim_vllm(
1701
- claim,
1702
- sections,
1703
- config=config,
1704
- model_name=vllm_model,
1705
- references_dir=references_dir,
1706
- client=vllm_client,
1707
- slot=_slot,
1708
- )
1705
+ # vLLM: the slot wraps each individual LLM call inside
1706
+ # verify_claim_vllm, so the controller's dynamic cap bounds
1707
+ # the actual vLLM request volume regardless of how many
1708
+ # claims and ladder fan-outs run in parallel.
1709
+ sections = ref_sections[claim.key]
1710
+ if not sections:
1711
+ results[idx] = {
1712
+ "key": claim.key,
1713
+ "line": claim.line,
1714
+ "context": claim.context,
1715
+ "verdict": "CANNOT_DETERMINE",
1716
+ "explanation": "Could not read reference",
1717
+ }
1718
+ return
1719
+
1720
+ logger.debug(f"Reference: {ref_path.name} ({len(sections)} sections)")
1721
+ verdict = await verify_claim_vllm(
1722
+ claim,
1723
+ sections,
1724
+ config=config,
1725
+ model_name=vllm_model,
1726
+ references_dir=references_dir,
1727
+ client=vllm_client,
1728
+ slot=_slot,
1729
+ )
1709
1730
 
1710
1731
  if verdict:
1711
1732
  verdict["key"] = claim.key
@@ -1714,6 +1735,8 @@ async def run_claim_verification(
1714
1735
  verdict["backend"] = backend
1715
1736
  verdict["model"] = model_id
1716
1737
  verdict["ref_type"] = ref_type
1738
+ verdict["ref_src_hash"] = ref_src_hashes.get(claim.key, "")
1739
+ verdict["ref_parse_hash"] = ref_parse_hashes.get(claim.key, "")
1717
1740
  if ref_type == "web_page" and verdict.get("verdict") == "NOT_SUPPORTED":
1718
1741
  verdict["verdict"] = "CANNOT_DETERMINE"
1719
1742
  verdict["explanation"] = (
@@ -1744,23 +1767,18 @@ async def run_claim_verification(
1744
1767
  label="claim-verify",
1745
1768
  params=_ctrl_params,
1746
1769
  ) as _slot:
1747
- if backend == "claude":
1770
+ # Shared vLLM client for all claim verifications — avoids
1771
+ # creating and tearing down a connection per claim.
1772
+ from openai import AsyncOpenAI
1773
+
1774
+ async with AsyncOpenAI(
1775
+ base_url=config.llm_endpoint,
1776
+ api_key="dummy",
1777
+ timeout=config.llm_timeout,
1778
+ ) as vllm_client:
1748
1779
  await asyncio.gather(
1749
- *[_verify_one(idx, c, None) for idx, c in to_verify]
1780
+ *[_verify_one(idx, c, vllm_client) for idx, c in to_verify]
1750
1781
  )
1751
- else:
1752
- # Shared vLLM client for all claim verifications — avoids
1753
- # creating and tearing down a connection per claim.
1754
- from openai import AsyncOpenAI
1755
-
1756
- async with AsyncOpenAI(
1757
- base_url=config.llm_endpoint,
1758
- api_key="dummy",
1759
- timeout=config.llm_timeout,
1760
- ) as vllm_client:
1761
- await asyncio.gather(
1762
- *[_verify_one(idx, c, vllm_client) for idx, c in to_verify]
1763
- )
1764
1782
 
1765
1783
  # Flatten — any None slots are claims that fell through (shouldn't happen)
1766
1784
  verified_results = [r for r in results if r is not None]