sciwrite-lint 0.3.0__tar.gz → 0.4.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 (167) hide show
  1. {sciwrite_lint-0.3.0/sciwrite_lint.egg-info → sciwrite_lint-0.4.0}/PKG-INFO +25 -6
  2. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/README.md +13 -5
  3. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/pyproject.toml +17 -42
  4. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/__init__.py +1 -1
  5. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/__main__.py +57 -94
  6. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/api.py +51 -6
  7. sciwrite_lint-0.4.0/sciwrite_lint/api_keys.py +93 -0
  8. sciwrite_lint-0.4.0/sciwrite_lint/checks/_diagnostics.py +237 -0
  9. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/_section_utils.py +27 -9
  10. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/cite_purpose.py +5 -0
  11. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/cross_section_consistency.py +45 -19
  12. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/dangling_cite.py +1 -1
  13. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/full_paper_consistency.py +88 -32
  14. sciwrite_lint-0.4.0/sciwrite_lint/checks/internal_consistency_pairs.py +473 -0
  15. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/prose_quality.py +38 -14
  16. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/ref_internal_checks.py +103 -23
  17. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/reference_unreliable.py +9 -3
  18. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/registry.py +1 -0
  19. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/structure_promises.py +23 -12
  20. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/claims.py +13 -4
  21. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/claude_cli.py +8 -21
  22. sciwrite_lint-0.4.0/sciwrite_lint/cli/_common.py +106 -0
  23. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/check.py +86 -127
  24. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/config.py +11 -81
  25. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/fetch.py +2 -18
  26. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/_monitor.py +517 -9
  27. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/containers.py +3 -2
  28. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/dismiss_claim.py +1 -1
  29. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/override.py +1 -1
  30. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/parse.py +5 -10
  31. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/vision.py +1 -1
  32. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/vllm.py +6 -1
  33. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/rank.py +6 -2
  34. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/verify.py +4 -4
  35. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/config.py +220 -12
  36. sciwrite_lint-0.4.0/sciwrite_lint/cross_paper.py +59 -0
  37. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/eval_claims.py +699 -264
  38. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/footnote_urls.py +5 -1
  39. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/_orchestrator.py +12 -19
  40. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/_validation.py +52 -4
  41. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/ideas.py +1 -16
  42. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/latex_to_markdown.py +2 -2
  43. sciwrite_lint-0.4.0/sciwrite_lint/llm/__init__.py +5 -0
  44. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/__init__.py +60 -0
  45. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/compute_cap.py +84 -0
  46. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/controller.py +464 -0
  47. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/decide.py +462 -0
  48. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/host_metrics.py +113 -0
  49. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/metrics_probe.py +70 -0
  50. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/registry.py +199 -0
  51. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/telemetry.py +414 -0
  52. sciwrite_lint-0.3.0/sciwrite_lint/vllm/metrics.py → sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/vllm_metrics.py +34 -38
  53. sciwrite_lint-0.4.0/sciwrite_lint/llm/concurrency_optimizer/wiring.py +119 -0
  54. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/llm_utils.py +292 -72
  55. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/local_sources.py +5 -3
  56. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/manuscript_store.py +162 -6
  57. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/mhtml.py +17 -5
  58. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/models.py +0 -4
  59. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/oa/_web.py +2 -2
  60. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pdf/grobid.py +137 -34
  61. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pdf/pdf_download.py +2 -12
  62. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/__init__.py +8 -4
  63. sciwrite_lint-0.4.0/sciwrite_lint/pipeline/checks.py +195 -0
  64. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/claims.py +2 -2
  65. sciwrite_lint-0.4.0/sciwrite_lint/pipeline/embeddings.py +350 -0
  66. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/fetch.py +17 -3
  67. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/orchestration.py +38 -11
  68. sciwrite_lint-0.4.0/sciwrite_lint/pipeline/parse.py +153 -0
  69. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/runners.py +193 -38
  70. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/staged.py +31 -25
  71. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/swap.py +117 -89
  72. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/tracking.py +2 -1
  73. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/verify.py +18 -5
  74. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/vision.py +106 -13
  75. sciwrite_lint-0.4.0/sciwrite_lint/references/_embed_timing.py +63 -0
  76. {sciwrite_lint-0.3.0/sciwrite_lint/scoring → sciwrite_lint-0.4.0/sciwrite_lint/references}/chain.py +3 -1
  77. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/citations.py +57 -55
  78. sciwrite_lint-0.4.0/sciwrite_lint/references/embedding_store.py +429 -0
  79. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/matching.py +8 -34
  80. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/metadata.py +6 -15
  81. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/reference_store.py +420 -102
  82. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/retraction_watch.py +4 -3
  83. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/__init__.py +15 -0
  84. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/_core.py +55 -3
  85. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/claim_results.py +26 -6
  86. sciwrite_lint-0.4.0/sciwrite_lint/references/workspace_db/manuscript_citations.py +89 -0
  87. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/report.py +14 -5
  88. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/schemas.py +222 -6
  89. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/scoring/contribution.py +2 -2
  90. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/scoring/scilint_score.py +32 -10
  91. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/usage.py +4 -1
  92. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/vision/cache.py +60 -24
  93. sciwrite_lint-0.4.0/sciwrite_lint/vision/describe.py +1095 -0
  94. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/vision/pipeline.py +1 -0
  95. sciwrite_lint-0.4.0/sciwrite_lint/vllm/metrics.py +42 -0
  96. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/vllm/vllm_server.py +289 -20
  97. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/web.py +16 -7
  98. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0/sciwrite_lint.egg-info}/PKG-INFO +25 -6
  99. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint.egg-info/SOURCES.txt +18 -1
  100. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint.egg-info/requires.txt +11 -0
  101. sciwrite_lint-0.3.0/sciwrite_lint/cross_paper.py +0 -174
  102. sciwrite_lint-0.3.0/sciwrite_lint/pipeline/checks.py +0 -64
  103. sciwrite_lint-0.3.0/sciwrite_lint/pipeline/embeddings.py +0 -234
  104. sciwrite_lint-0.3.0/sciwrite_lint/pipeline/parse.py +0 -57
  105. sciwrite_lint-0.3.0/sciwrite_lint/references/embedding_store.py +0 -389
  106. sciwrite_lint-0.3.0/sciwrite_lint/vision/describe.py +0 -632
  107. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/LICENSE +0 -0
  108. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/_network.py +0 -0
  109. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/__init__.py +0 -0
  110. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/claim_support.py +0 -0
  111. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/dangling_ref.py +0 -0
  112. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/reference_accuracy.py +0 -0
  113. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/reference_exists.py +0 -0
  114. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/retracted_cite.py +0 -0
  115. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/checks/unreferenced_figure.py +0 -0
  116. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/claude_backend.py +0 -0
  117. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/__init__.py +0 -0
  118. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/__init__.py +0 -0
  119. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/grobid.py +0 -0
  120. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/cli/misc/init.py +0 -0
  121. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/exceptions.py +0 -0
  122. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/__init__.py +0 -0
  123. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/_common.py +0 -0
  124. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/_download.py +0 -0
  125. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/_search.py +0 -0
  126. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/arxiv.py +0 -0
  127. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/biorxiv.py +0 -0
  128. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/core.py +0 -0
  129. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/eric.py +0 -0
  130. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/europepmc.py +0 -0
  131. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/hal.py +0 -0
  132. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/nasa_ads.py +0 -0
  133. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/nber.py +0 -0
  134. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/osf.py +0 -0
  135. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/pmc.py +0 -0
  136. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/s2.py +0 -0
  137. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/fulltext/unpaywall.py +0 -0
  138. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/oa/__init__.py +0 -0
  139. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/oa/_models.py +0 -0
  140. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/oa/_pdf.py +0 -0
  141. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/oa/_search.py +0 -0
  142. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pdf/__init__.py +0 -0
  143. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/pdf_context.py +0 -0
  144. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/ref_internal.py +0 -0
  145. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/pipeline/unreliable.py +0 -0
  146. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/prompt_safety.py +0 -0
  147. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/py.typed +0 -0
  148. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/rate_limiter.py +0 -0
  149. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/__init__.py +0 -0
  150. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/crossref.py +0 -0
  151. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/bib_checks.py +0 -0
  152. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/citation_metadata.py +0 -0
  153. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/parse_cache.py +0 -0
  154. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/pipeline_stage.py +0 -0
  155. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/query_vectors.py +0 -0
  156. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/ref_internal.py +0 -0
  157. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/registry.py +0 -0
  158. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/references/workspace_db/vision_cache.py +0 -0
  159. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/scoring/__init__.py +0 -0
  160. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/tex_parser.py +0 -0
  161. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/vision/__init__.py +0 -0
  162. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/vision/image_extraction.py +0 -0
  163. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint/vllm/__init__.py +0 -0
  164. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint.egg-info/dependency_links.txt +0 -0
  165. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint.egg-info/entry_points.txt +0 -0
  166. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/sciwrite_lint.egg-info/top_level.txt +0 -0
  167. {sciwrite_lint-0.3.0 → sciwrite_lint-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sciwrite-lint
3
- Version: 0.3.0
3
+ Version: 0.4.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>
@@ -48,9 +48,20 @@ Requires-Dist: pdf2image>=1.17
48
48
  Requires-Dist: lxml>=6.1
49
49
  Requires-Dist: pypandoc-binary>=1.15
50
50
  Requires-Dist: rfc3986>=2.0
51
+ Requires-Dist: psutil>=5.9
52
+ Requires-Dist: annotated-types>=0.6
51
53
  Provides-Extra: dev
52
54
  Requires-Dist: pytest>=9.0.3; extra == "dev"
53
55
  Requires-Dist: pytest-xdist>=3.0; extra == "dev"
56
+ Requires-Dist: ruff>=0.15; extra == "dev"
57
+ Requires-Dist: mypy>=1.18; extra == "dev"
58
+ Requires-Dist: ast-grep-cli>=0.35; extra == "dev"
59
+ Requires-Dist: deptry>=0.25; extra == "dev"
60
+ Requires-Dist: bandit>=1.9; extra == "dev"
61
+ Requires-Dist: pip-audit>=2.10; extra == "dev"
62
+ Requires-Dist: pip-licenses>=5.5; extra == "dev"
63
+ Requires-Dist: twine>=6.0; extra == "dev"
64
+ Requires-Dist: build>=1.0; extra == "dev"
54
65
  Dynamic: license-file
55
66
 
56
67
  # sciwrite-lint
@@ -136,22 +147,22 @@ sciwrite-lint contributions paper.pdf --format json
136
147
 
137
148
  ### Optimizations
138
149
 
139
- 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. Models run in separate pipeline stages; on WSL2, CUDA memory virtualization pages idle allocations to system RAM, letting all three share physical VRAM. 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 with hash-based invalidation are baseline. On top of that:
140
151
 
141
- - **Semantic section filtering** — embedding-based KNN retrieval sends only the ~5 most relevant sections per claim to the LLM, reducing LLM calls ~4x
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
142
153
  - **Prefix-first prompt structure** — shared context placed before variable content in all prompts, maximizing vLLM's automatic prefix caching
143
- - **Per-call-site thinking budgets** — each LLM call site has empirically tuned (max_tokens, thinking_preset) pairs, measured via grid search to maximize detection quality
154
+ - **Per-call-site thinking budgets** — each LLM call site has its own (max_tokens, thinking_preset) pair tuned for that check's prompt and output shape
144
155
  - **Adaptive embedding batches** — token-aware batch sizing gives ~50x speedup over CPU while staying within VRAM limits
145
156
  - **Batch-staged multi-paper pipeline** — when checking 2+ papers, GPU models load once per batch (vision/embedding/cited-vision) and vLLM/network stages run concurrently, giving a meaningful speedup over sequential per-paper runs. Tune via `--concurrency` (default 2, validated up to 4 on a single consumer GPU)
146
157
  - **Phased API resolution** — citations flow through OpenAlex → Semantic Scholar → CrossRef → Open Library/LoC, each phase only processing what previous phases didn't resolve
147
158
  - **14-source full-text cascade** — early exit on first successful download across arXiv, Semantic Scholar, OpenAlex, PubMed Central, Europe PMC, Unpaywall, bioRxiv, NBER, RePEc/IDEAS, HAL, ERIC, NASA ADS, OSF Preprints, CORE
148
159
  - **Live monitoring** *(advanced)* — `sciwrite-lint containers monitor` shows service health, VRAM usage, and KV cache utilization in a terminal dashboard
149
160
 
150
- Full pipeline on a 50-reference paper: ~30 minutes initial (dominated by downloads and claim verification), minutes on cached reruns. On native Linux, the pipeline automatically swaps vLLM containers to free GPU for embedding and vision stages (~50x faster than CPU). *(Native Linux GPU swap is preliminary — tested on WSL2 only; expected to work, may need minor fixes.)*
161
+ Full pipeline on a 50-reference paper: ~30 minutes initial (dominated by downloads and claim verification), minutes on cached reruns. The pipeline automatically swaps vLLM containers to free GPU for embedding and vision stages (~50× faster than CPU) same code path on WSL2 and native Linux.
151
162
 
152
163
  ## Install
153
164
 
154
- **Assumed setup:** A workstation with an NVIDIA GPU (16+ GB VRAM). Developed and tested on Windows (WSL2). Native Linux is likely to work with GPU memory allocation tuning (see [docs/services.md](https://github.com/authentic-research-partners/sciwrite-lint/blob/main/docs/services.md#embedding-device)). Not tested on macOS.
165
+ **Assumed setup:** A workstation with an NVIDIA GPU (16+ GB VRAM). Tested on WSL2 with NVIDIA driver 546.01+. Native Linux is likely to work with GPU memory allocation tuning (see [docs/services.md](https://github.com/authentic-research-partners/sciwrite-lint/blob/main/docs/services.md#embedding-device)). Not tested on macOS.
155
166
 
156
167
  Requires [uv](https://docs.astral.sh/uv/getting-started/installation/), a container runtime (podman or docker), CUDA drivers, and [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html).
157
168
 
@@ -196,6 +207,14 @@ sciwrite-lint containers start # start GROBID + vLLM (needs GPU for vLLM
196
207
  sciwrite-lint containers monitor # live dashboard: service health, VRAM, KV cache
197
208
  ```
198
209
 
210
+ ### WSL2: disable CUDA Sysmem Fallback (recommended)
211
+
212
+ By default Windows silently spills GPU overflow into system RAM via PCIe (30–100× slower than VRAM); you'll see this as a sudden throughput collapse during the heaviest pipeline stages with no error in the logs. Tell the NVIDIA driver to fail loudly instead:
213
+
214
+ > NVIDIA Control Panel → 3D Settings → Manage 3D Settings → *CUDA - Sysmem Fallback Policy* → **Prefer No Sysmem Fallback** → Apply.
215
+
216
+ Driver 546.01+. After this, WSL2 behaves like native Linux on overflow (CUDA OOM error). The pipeline already sequences GPU consumers so OOM should not happen in normal use; the setting just prevents silent slowdowns when something does go wrong. No setting needed on native Linux — `cudaMalloc` is already loud there.
217
+
199
218
  ![Monitor dashboard](https://github.com/authentic-research-partners/sciwrite-lint/raw/main/docs/monitor.png)
200
219
 
201
220
  `init` detects `.tex` files and their `.bib` references and generates a `.sciwrite-lint.toml` config. Review to confirm the right files were detected.
@@ -81,22 +81,22 @@ 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. Models run in separate pipeline stages; on WSL2, CUDA memory virtualization pages idle allocations to system RAM, letting all three share physical VRAM. 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 with hash-based invalidation are baseline. On top of that:
85
85
 
86
- - **Semantic section filtering** — embedding-based KNN retrieval sends only the ~5 most relevant sections per claim to the LLM, reducing LLM calls ~4x
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
88
- - **Per-call-site thinking budgets** — each LLM call site has empirically tuned (max_tokens, thinking_preset) pairs, measured via grid search to maximize detection quality
88
+ - **Per-call-site thinking budgets** — each LLM call site has its own (max_tokens, thinking_preset) pair tuned for that check's prompt and output shape
89
89
  - **Adaptive embedding batches** — token-aware batch sizing gives ~50x speedup over CPU while staying within VRAM limits
90
90
  - **Batch-staged multi-paper pipeline** — when checking 2+ papers, GPU models load once per batch (vision/embedding/cited-vision) and vLLM/network stages run concurrently, giving a meaningful speedup over sequential per-paper runs. Tune via `--concurrency` (default 2, validated up to 4 on a single consumer GPU)
91
91
  - **Phased API resolution** — citations flow through OpenAlex → Semantic Scholar → CrossRef → Open Library/LoC, each phase only processing what previous phases didn't resolve
92
92
  - **14-source full-text cascade** — early exit on first successful download across arXiv, Semantic Scholar, OpenAlex, PubMed Central, Europe PMC, Unpaywall, bioRxiv, NBER, RePEc/IDEAS, HAL, ERIC, NASA ADS, OSF Preprints, CORE
93
93
  - **Live monitoring** *(advanced)* — `sciwrite-lint containers monitor` shows service health, VRAM usage, and KV cache utilization in a terminal dashboard
94
94
 
95
- Full pipeline on a 50-reference paper: ~30 minutes initial (dominated by downloads and claim verification), minutes on cached reruns. On native Linux, the pipeline automatically swaps vLLM containers to free GPU for embedding and vision stages (~50x faster than CPU). *(Native Linux GPU swap is preliminary — tested on WSL2 only; expected to work, may need minor fixes.)*
95
+ Full pipeline on a 50-reference paper: ~30 minutes initial (dominated by downloads and claim verification), minutes on cached reruns. The pipeline automatically swaps vLLM containers to free GPU for embedding and vision stages (~50× faster than CPU) same code path on WSL2 and native Linux.
96
96
 
97
97
  ## Install
98
98
 
99
- **Assumed setup:** A workstation with an NVIDIA GPU (16+ GB VRAM). Developed and tested on Windows (WSL2). Native Linux is likely to work with GPU memory allocation tuning (see [docs/services.md](https://github.com/authentic-research-partners/sciwrite-lint/blob/main/docs/services.md#embedding-device)). Not tested on macOS.
99
+ **Assumed setup:** A workstation with an NVIDIA GPU (16+ GB VRAM). Tested on WSL2 with NVIDIA driver 546.01+. Native Linux is likely to work with GPU memory allocation tuning (see [docs/services.md](https://github.com/authentic-research-partners/sciwrite-lint/blob/main/docs/services.md#embedding-device)). Not tested on macOS.
100
100
 
101
101
  Requires [uv](https://docs.astral.sh/uv/getting-started/installation/), a container runtime (podman or docker), CUDA drivers, and [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html).
102
102
 
@@ -141,6 +141,14 @@ sciwrite-lint containers start # start GROBID + vLLM (needs GPU for vLLM
141
141
  sciwrite-lint containers monitor # live dashboard: service health, VRAM, KV cache
142
142
  ```
143
143
 
144
+ ### WSL2: disable CUDA Sysmem Fallback (recommended)
145
+
146
+ By default Windows silently spills GPU overflow into system RAM via PCIe (30–100× slower than VRAM); you'll see this as a sudden throughput collapse during the heaviest pipeline stages with no error in the logs. Tell the NVIDIA driver to fail loudly instead:
147
+
148
+ > NVIDIA Control Panel → 3D Settings → Manage 3D Settings → *CUDA - Sysmem Fallback Policy* → **Prefer No Sysmem Fallback** → Apply.
149
+
150
+ Driver 546.01+. After this, WSL2 behaves like native Linux on overflow (CUDA OOM error). The pipeline already sequences GPU consumers so OOM should not happen in normal use; the setting just prevents silent slowdowns when something does go wrong. No setting needed on native Linux — `cudaMalloc` is already loud there.
151
+
144
152
  ![Monitor dashboard](https://github.com/authentic-research-partners/sciwrite-lint/raw/main/docs/monitor.png)
145
153
 
146
154
  `init` detects `.tex` files and their `.bib` references and generates a `.sciwrite-lint.toml` config. Review to confirm the right files were detected.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sciwrite-lint"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -49,6 +49,8 @@ dependencies = [
49
49
  "lxml>=6.1",
50
50
  "pypandoc-binary>=1.15",
51
51
  "rfc3986>=2.0",
52
+ "psutil>=5.9",
53
+ "annotated-types>=0.6",
52
54
  ]
53
55
 
54
56
  [project.urls]
@@ -59,9 +61,23 @@ Issues = "https://github.com/authentic-research-partners/sciwrite-lint/issues"
59
61
  Paper = "https://arxiv.org/abs/2604.08501"
60
62
 
61
63
  [project.optional-dependencies]
64
+ # `pip install -e ".[dev]"` brings in every tool used by the mandatory checks
65
+ # and the release pipeline. Exception: the `claude` CLI is a Node app —
66
+ # install separately per https://docs.anthropic.com/en/docs/claude-code.
62
67
  dev = [
68
+ # Mandatory checks — run after every code change
63
69
  "pytest>=9.0.3",
64
70
  "pytest-xdist>=3.0",
71
+ "ruff>=0.15",
72
+ "mypy>=1.18",
73
+ "ast-grep-cli>=0.35",
74
+ # Release pipeline (scripts/release.sh phases 3–6)
75
+ "deptry>=0.25",
76
+ "bandit>=1.9",
77
+ "pip-audit>=2.10",
78
+ "pip-licenses>=5.5",
79
+ "twine>=6.0",
80
+ "build>=1.0",
65
81
  ]
66
82
 
67
83
  [project.scripts]
@@ -70,44 +86,3 @@ sciwrite-lint = "sciwrite_lint.__main__:main"
70
86
  [tool.setuptools.packages.find]
71
87
  include = ["sciwrite_lint*"]
72
88
 
73
- [tool.pytest.ini_options]
74
- testpaths = ["tests"]
75
- pythonpath = ["."]
76
- markers = [
77
- "integration: tests that read actual paper files",
78
- "network: tests that make real network connections (slow)",
79
- ]
80
- # PID-unique basetemp set in conftest.py — prevents FileExistsError
81
- # when multiple pytest processes run concurrently.
82
- addopts = "-n auto -p no:cacheprovider"
83
-
84
- [tool.bandit]
85
- skips = [
86
- "B101", # assert — used for type narrowing and internal invariants
87
- "B104", # bind 0.0.0.0 — container start commands for GROBID/vLLM
88
- "B314", # xml.etree.fromstring — parsing trusted GROBID TEI output
89
- "B404", # subprocess import — CLI tool, subprocess is core functionality
90
- "B405", # xml.etree import — parsing trusted GROBID TEI output
91
- "B603", # subprocess call — CLI invokes grobid/vllm/claude by design
92
- "B607", # partial path — CLI tools resolved via PATH
93
- "B608", # SQL string — internal usage.db, no user input in queries
94
- "B615", # HF from_pretrained without revision pin — trusted publisher (Qwen), research tool
95
- ]
96
-
97
- [tool.deptry]
98
- optional_dependencies_dev_groups = ["dev"]
99
-
100
- [tool.deptry.per_rule_ignores]
101
- # Self-imports of shipped non-package directories
102
- DEP001 = ["evals", "eval_real_world", "bench"]
103
- # Lazy/conditional imports in optional groups — deptry can't detect these
104
- DEP002 = ["bibtexparser", "pdfplumber", "sentence-transformers", "numpy", "sqlite-vec", "grobid-tei-xml", "torch", "transformers", "Pillow", "pypdf", "pdf2image"]
105
- # Intra-package imports
106
- DEP003 = ["sciwrite_lint", "evals", "eval_real_world"]
107
-
108
- [tool.mypy]
109
- plugins = ["pydantic.mypy"]
110
- [[tool.mypy.overrides]]
111
- # No py.typed marker or typeshed stubs available
112
- module = ["bibtexparser", "sqlite_vec", "pypdf", "pypdfium2", "pypdfium2.raw", "transformers", "pdf2image", "lxml", "lxml.html", "lxml.html.etree", "rfc3986", "pypandoc"]
113
- ignore_missing_imports = true
@@ -2,7 +2,7 @@
2
2
 
3
3
  from sciwrite_lint.exceptions import LLMConnectionError, SciWriteLintError
4
4
 
5
- __version__ = "0.3.0"
5
+ __version__ = "0.4.0"
6
6
 
7
7
  from sciwrite_lint.oa import (
8
8
  DownloadResult,
@@ -17,105 +17,24 @@ from pathlib import Path
17
17
  from loguru import logger
18
18
 
19
19
  from sciwrite_lint import __version__
20
- from sciwrite_lint.config import LintConfig, PaperConfig, load_config
20
+ from sciwrite_lint.cli._common import (
21
+ _classify_verify_issue as _classify_verify_issue,
22
+ _load_config as _load_config,
23
+ _paper_names as _paper_names,
24
+ _resolve_input_files as _resolve_input_files,
25
+ _resolve_paper as _resolve_paper,
26
+ _setup_logging,
27
+ )
28
+ from sciwrite_lint.config import LintConfig, load_config
21
29
  from sciwrite_lint.vllm.vllm_server import MODELS as _VLLM_MODELS
22
30
 
23
31
  _ALL_MODELS = sorted(_VLLM_MODELS.keys())
24
32
  _TEXT_MODELS = [k for k, v in _VLLM_MODELS.items() if v["kind"] == "text"]
25
33
 
26
-
27
- # ---------------------------------------------------------------------------
28
- # Helpers (used by CLI submodules)
29
- # ---------------------------------------------------------------------------
30
-
31
-
32
- def _load_config(args: argparse.Namespace) -> LintConfig:
33
- """Load config from --config flag or auto-discovery."""
34
- if getattr(args, "config", None):
35
- return load_config(Path(args.config))
36
-
37
- config = load_config(None)
38
- if config.config_path is None:
39
- from sciwrite_lint.config import _detect_papers
40
-
41
- logger.error("No .sciwrite-lint.toml found.")
42
- detected = _detect_papers()
43
- if detected:
44
- logger.error(" Detected .tex files:")
45
- for p in detected:
46
- bib = f" (bib: {p['bib']})" if p.get("bib") else ""
47
- logger.error(f" {p['file_path']}{bib}")
48
- logger.error(" Run: sciwrite-lint init")
49
- logger.error(" Then review .sciwrite-lint.toml before running checks.")
50
- return config
51
-
52
-
53
- def _resolve_paper(config: LintConfig, name: str) -> PaperConfig | None:
54
- """Resolve a paper name to its config. Print error if not found."""
55
- pc = config.get_paper(name)
56
- if not pc:
57
- if config.papers:
58
- names = ", ".join(p.name for p in config.papers)
59
- logger.error(f"Unknown paper '{name}'. Registered: {names}")
60
- else:
61
- logger.error(f"Unknown paper '{name}'. No papers registered.")
62
- logger.error(
63
- f" Add [[papers]] to {config.config_path or '.sciwrite-lint.toml'}"
64
- )
65
- return pc
66
-
67
-
68
- def _paper_names(config: LintConfig) -> list[str]:
69
- return [p.name for p in config.papers]
70
-
71
-
72
- def _resolve_input_files(
73
- args: argparse.Namespace, config: LintConfig
74
- ) -> list[tuple[str, Path]]:
75
- """Resolve which files to check (.tex or .pdf).
76
-
77
- Priority: positional file > --paper (from config) > all papers in config.
78
- Returns list of (name, path) pairs.
79
- """
80
- if hasattr(args, "file") and args.file:
81
- p = Path(args.file)
82
- return [(p.stem, p)]
83
-
84
- paper_filter = getattr(args, "paper", None)
85
- if paper_filter:
86
- pc = _resolve_paper(config, paper_filter)
87
- if not pc:
88
- return []
89
- return [(pc.name, pc.file_path)]
90
-
91
- if config.papers:
92
- return [(pc.name, pc.file_path) for pc in config.papers]
93
-
94
- logger.error("No papers registered. Either:")
95
- logger.error(" sciwrite-lint check <file.tex|file.pdf> — check a specific file")
96
- logger.error(
97
- " sciwrite-lint init — set up project with [[papers]]"
98
- )
99
- return []
100
-
101
-
102
- def _classify_verify_issue(issue: str) -> tuple[str, str]:
103
- """Classify a verify issue string into (level, rule_id) for findings."""
104
- from sciwrite_lint.pipeline import _classify_verify_issue as _classify
105
-
106
- return _classify(issue)
107
-
108
-
109
- def _setup_logging(config: LintConfig) -> None:
110
- """Configure loguru rotating file sink from config."""
111
- logger.add(
112
- "logs/sciwrite-lint.log",
113
- rotation="10 MB",
114
- retention="30 days",
115
- compression="gz",
116
- level=config.log_level,
117
- format="{time:YYYY-MM-DD HH:mm:ss} | {level:<8} | {name}:{function}:{line} | {message}",
118
- )
34
+ _REPLACE_HELP = (
35
+ "Stop any other running vLLM container first (two vLLMs can't "
36
+ "share the GPU safely; without this flag the start is refused)"
37
+ )
119
38
 
120
39
 
121
40
  # ---------------------------------------------------------------------------
@@ -206,6 +125,48 @@ def main(argv: list[str] | None = None) -> int:
206
125
  p_checks = sub.add_parser("checks", help="List all registered checks")
207
126
  p_checks.set_defaults(func=run_checks_list)
208
127
 
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)
169
+
209
170
  # --- init ---
210
171
  p_init = sub.add_parser(
211
172
  "init", help="Initialize project: config + references directory"
@@ -457,6 +418,7 @@ def main(argv: list[str] | None = None) -> int:
457
418
  action="store_true",
458
419
  help="Also start vision vLLM (qwen3-vl-8b-fp8 on port 5002)",
459
420
  )
421
+ p_containers.add_argument("--replace", action="store_true", help=_REPLACE_HELP)
460
422
  p_containers.add_argument("--config", help="Path to .sciwrite-lint.toml")
461
423
  p_containers.set_defaults(func=run_containers)
462
424
 
@@ -488,6 +450,7 @@ def main(argv: list[str] | None = None) -> int:
488
450
  action="store_true",
489
451
  help="Pull latest vLLM image before starting",
490
452
  )
453
+ p_vllm_start.add_argument("--replace", action="store_true", help=_REPLACE_HELP)
491
454
  p_vllm_start.add_argument("--config", help="Path to .sciwrite-lint.toml")
492
455
  p_vllm_start.set_defaults(func=run_vllm)
493
456
 
@@ -116,8 +116,14 @@ class CitationAPI:
116
116
  client=self._client,
117
117
  )
118
118
  except Exception as e:
119
+ # The waterfall caller distinguishes ``{"error": …}`` from
120
+ # ``None`` (not-found): error dicts mean "lookup itself
121
+ # failed" and stay visible, ``None`` means "no result for
122
+ # this citation". All API lookups in this module return the
123
+ # error shape so transient network failures don't masquerade
124
+ # as misses.
119
125
  logger.debug("CrossRef lookup failed for {}: {}", citation.key, e)
120
- return None
126
+ return {"error": str(e), "source": "crossref"}
121
127
 
122
128
  # ------------------------------------------------------------------
123
129
  # OpenAlex
@@ -502,6 +508,13 @@ async def batch_openalex(
502
508
  label="OpenAlex batch",
503
509
  )
504
510
  if resp.status_code != 200:
511
+ # API failure ≠ "no matches". Log at WARNING so the operator
512
+ # sees the API was unreachable; the verify-cascade still
513
+ # falls through to S2/CrossRef per the pipeline contract.
514
+ logger.warning(
515
+ "OpenAlex batch returned status {}; cascading to next provider",
516
+ resp.status_code,
517
+ )
505
518
  return results
506
519
  works = resp.json().get("results", [])
507
520
  for work in works:
@@ -552,7 +565,7 @@ async def batch_s2(
552
565
  async with tracked("semantic_scholar", ids=len(ids)):
553
566
  try:
554
567
  resp = await retry_on_transient(
555
- lambda: client.post(
568
+ lambda ids=ids: client.post(
556
569
  "https://api.semanticscholar.org/graph/v1/paper/batch",
557
570
  json={"ids": ids},
558
571
  params={"fields": _S2_FIELDS},
@@ -560,13 +573,27 @@ async def batch_s2(
560
573
  label="S2 batch",
561
574
  )
562
575
  if resp.status_code != 200:
576
+ # API failure ≠ "no matches". Log at WARNING so the
577
+ # operator sees the API was unreachable.
578
+ logger.warning(
579
+ "S2 batch returned status {} for {} IDs; "
580
+ "cascading to next provider",
581
+ resp.status_code,
582
+ len(ids),
583
+ )
563
584
  continue
564
585
  papers = resp.json()
565
586
  for paper, (key, _s2_id) in zip(papers, batch):
566
587
  if paper: # S2 returns null for not-found
567
588
  results[key] = _parse_s2(paper)
568
589
  except httpx.HTTPError as e:
569
- logger.debug("S2 batch request failed: {}", e)
590
+ # Network error "no matches". WARNING so the operator
591
+ # can see API was unreachable; cascade continues.
592
+ logger.warning(
593
+ "S2 batch request failed ({}: {}); cascading to next provider",
594
+ type(e).__name__,
595
+ e,
596
+ )
570
597
  continue
571
598
 
572
599
  return results
@@ -606,10 +633,28 @@ async def parallel_crossref(
606
633
  polite_email=config.polite_email,
607
634
  client=client,
608
635
  )
609
- if result and not result.get("error"):
610
- results[c.key] = result
636
+ if result is None:
637
+ return # not-found — fine, next provider handles
638
+ if result.get("error"):
639
+ # API failure ≠ "no match". Log at WARNING so
640
+ # the operator sees CrossRef was unreachable;
641
+ # don't propagate the error dict into ``results``
642
+ # because callers expect parsed-result shape.
643
+ logger.warning(
644
+ "CrossRef lookup failed for {}: {}",
645
+ c.key,
646
+ result["error"],
647
+ )
648
+ return
649
+ results[c.key] = result
611
650
  except Exception as e:
612
- logger.debug("CrossRef lookup failed for {}: {}", c.key, e)
651
+ # Network error "no match". WARNING so visible.
652
+ logger.warning(
653
+ "CrossRef lookup raised for {} ({}: {})",
654
+ c.key,
655
+ type(e).__name__,
656
+ e,
657
+ )
613
658
 
614
659
  await asyncio.gather(*[_lookup_one(c) for c in citations])
615
660
  return results
@@ -0,0 +1,93 @@
1
+ """Optional-API-key registry, on-disk reader, and config validator.
2
+
3
+ Holds the cross-layer surface that both the CLI (``cli/config.py`` for
4
+ show / set-key / remove-key commands) and the pipeline preflight
5
+ (``pipeline/orchestration.py``) need to share. Living here keeps the
6
+ pipeline layer from importing the CLI layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from loguru import logger
15
+
16
+ if TYPE_CHECKING:
17
+ from sciwrite_lint.config import LintConfig
18
+
19
+
20
+ # Services that accept API keys, stored in ~/.sciwrite-lint/<filename>
21
+ _API_KEY_SERVICES: dict[str, dict[str, str]] = {
22
+ "semantic-scholar": {
23
+ "file": "s2_api_key",
24
+ "description": "Semantic Scholar (1 → 100 req/s)",
25
+ "url": "https://www.semanticscholar.org/product/api#api-key",
26
+ },
27
+ "ncbi": {
28
+ "file": "ncbi_api_key",
29
+ "description": "NCBI / PubMed Central (3 → 10 req/s)",
30
+ "url": "https://www.ncbi.nlm.nih.gov/account/settings/",
31
+ },
32
+ "core": {
33
+ "file": "core_api_key",
34
+ "description": "CORE (institutional repository access)",
35
+ "url": "https://core.ac.uk/services/api",
36
+ },
37
+ "nasa-ads": {
38
+ "file": "nasa_ads_api_key",
39
+ "description": "NASA ADS (astronomy/astrophysics; 5000 req/day per token)",
40
+ "url": "https://ui.adsabs.harvard.edu/user/settings/token",
41
+ },
42
+ }
43
+
44
+ _KEY_DIR = Path.home() / ".sciwrite-lint"
45
+
46
+
47
+ def _read_key(filename: str) -> str | None:
48
+ """Read an API key from ~/.sciwrite-lint/{filename}."""
49
+ path = _KEY_DIR / filename
50
+ if path.exists():
51
+ val = path.read_text().strip()
52
+ return val or None
53
+ return None
54
+
55
+
56
+ def check_api_config(config: "LintConfig", needs_email: bool = False) -> list[str]:
57
+ """Validate API configuration. Returns list of errors (hard stops).
58
+
59
+ Also logs warnings for missing optional API keys.
60
+
61
+ Args:
62
+ config: Resolved LintConfig.
63
+ needs_email: If True, missing polite_email is an error (not just a warning).
64
+ """
65
+ errors: list[str] = []
66
+
67
+ # polite_email: required for Unpaywall + Retraction Watch
68
+ if not config.polite_email:
69
+ if needs_email:
70
+ errors.append(
71
+ "polite_email not set — Unpaywall and Retraction Watch will not work. "
72
+ "Set with: sciwrite-lint config set-email you@example.com"
73
+ )
74
+ else:
75
+ logger.warning(
76
+ "polite_email not set — Unpaywall and Retraction Watch disabled. "
77
+ "Set with: sciwrite-lint config set-email you@example.com"
78
+ )
79
+
80
+ # Optional API keys: warn about rate limit benefits
81
+ missing_keys: list[str] = []
82
+ for service, info in _API_KEY_SERVICES.items():
83
+ if not _read_key(info["file"]):
84
+ missing_keys.append(f"{service} ({info['description']})")
85
+
86
+ if missing_keys:
87
+ logger.info(
88
+ "Optional API keys not configured (slower rate limits): {}. "
89
+ "See: sciwrite-lint config show",
90
+ ", ".join(missing_keys),
91
+ )
92
+
93
+ return errors