@r1z4x/tezgah 1.9.2 → 1.13.0

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 (445) hide show
  1. package/CHANGELOG.md +415 -1
  2. package/MANIFEST +404 -144
  3. package/README.de.md +1 -1
  4. package/README.es.md +1 -1
  5. package/README.fr.md +1 -1
  6. package/README.md +5 -4
  7. package/README.tr.md +1 -1
  8. package/bin/codegen +5 -2
  9. package/bin/tezgah-design +288 -1
  10. package/bin/tezgah-docs +1 -1
  11. package/bin/tezgah-doctor +39 -2
  12. package/bin/tezgah-import-ai-research +26 -12
  13. package/bin/tezgah-ocr-review +296 -0
  14. package/bin/tezgah-research +255 -44
  15. package/bin/tezgah-review-quote +185 -0
  16. package/bin/tezgah-setup +1365 -55
  17. package/bin/tezgah-skill +1 -1
  18. package/bin/tezgah-status +201 -3
  19. package/bin/tezgah-task +258 -20
  20. package/commands/adhd.md +2 -2
  21. package/commands/ponytail.md +4 -4
  22. package/docs/architecture.md +2 -2
  23. package/docs/citations-baseline.json +3 -3
  24. package/docs/clarity-baseline.json +11 -5
  25. package/docs/contract.md +91 -43
  26. package/docs/environment.md +5 -2
  27. package/docs/evidence.md +109 -17
  28. package/docs/feature-audit.md +2 -2
  29. package/docs/gate.md +60 -33
  30. package/docs/glossary.md +6 -3
  31. package/docs/hosts.md +313 -17
  32. package/docs/index.json +34 -8
  33. package/docs/judge.md +4 -2
  34. package/docs/layers.md +4 -4
  35. package/docs/models.md +37 -9
  36. package/docs/operations.md +141 -5
  37. package/docs/research.md +815 -8
  38. package/docs/skills.md +137 -13
  39. package/docs/status-line.md +121 -1
  40. package/docs/testing.md +3 -1
  41. package/hooks/hooks.json +2 -2
  42. package/hooks/lang/en.json +21 -0
  43. package/hooks/lang/tr.json +22 -1
  44. package/hooks/projects-stop.py +2 -1
  45. package/hooks/tezgah_agents.py +65 -25
  46. package/hooks/tezgah_apps.py +18 -0
  47. package/hooks/tezgah_attest.py +79 -10
  48. package/hooks/tezgah_components.py +4 -0
  49. package/hooks/tezgah_context.py +207 -55
  50. package/hooks/tezgah_extension.py +337 -0
  51. package/hooks/tezgah_gate.py +267 -79
  52. package/hooks/tezgah_integrity.py +864 -143
  53. package/hooks/tezgah_judge.py +6 -4
  54. package/hooks/tezgah_models.py +59 -19
  55. package/hooks/tezgah_orca.py +1 -1
  56. package/hooks/tezgah_paths.py +145 -8
  57. package/hooks/tezgah_policy.py +128 -57
  58. package/hooks/tezgah_rank.py +9 -6
  59. package/hooks/tezgah_replay.py +12 -6
  60. package/hooks/tezgah_research.py +1414 -76
  61. package/hooks/tezgah_research_capture.py +1544 -0
  62. package/hooks/tezgah_research_evidence.py +842 -0
  63. package/hooks/tezgah_research_stats.py +258 -0
  64. package/hooks/tezgah_skill_pick.py +408 -106
  65. package/hooks/tezgah_stack.py +236 -1
  66. package/hooks/tezgah_stopspec.py +8 -1
  67. package/hooks/tezgah_task.py +14 -5
  68. package/hooks/tezgah_taste_ledger.py +1 -1
  69. package/hosts/amp/hook.py +217 -0
  70. package/hosts/amp/tezgah-plugin.ts.in +386 -0
  71. package/hosts/antigravity/hook.py +255 -0
  72. package/hosts/codex/hook.py +2 -1
  73. package/hosts/commandcode/hook.py +239 -0
  74. package/hosts/copilot/hook.py +284 -0
  75. package/hosts/cursor/hook.py +31 -3
  76. package/hosts/droid/hook.py +259 -0
  77. package/hosts/dsh/hooks.json +1 -1
  78. package/hosts/gemini/hook.py +213 -0
  79. package/hosts/goose/hook.py +145 -0
  80. package/hosts/omp/hook.py +4 -1
  81. package/hosts/omp/tezgah-hook.ts.in +30 -7
  82. package/hosts/opencode/plugins/tezgah.js +309 -48
  83. package/hosts/opencode/tui/cli/tui.tsx +100 -0
  84. package/hosts/opencode/tui/tezgah-tui.tsx +2 -1
  85. package/hosts/qwen/hook.py +206 -0
  86. package/output-styles/tezgah.md +20 -15
  87. package/package.json +1 -1
  88. package/skills/ai-research/SKILL.md +21 -14
  89. package/skills/ai-research/SOURCE +7 -5
  90. package/skills/ai-research/index/1-frame.md +6 -6
  91. package/skills/ai-research/index/2-data.md +9 -9
  92. package/skills/ai-research/index/3-train.md +36 -36
  93. package/skills/ai-research/index/4-measure.md +13 -13
  94. package/skills/ai-research/index/5-run.md +11 -11
  95. package/skills/ai-research/index/6-write.md +23 -23
  96. package/skills/ai-research/library.json +104 -104
  97. package/skills/analyze-app/SKILL.md +17 -12
  98. package/skills/design-contract/SKILL.md +42 -7
  99. package/skills/design-contract/data-views.md +2 -2
  100. package/skills/design-library/EVALS.md +1 -1
  101. package/skills/design-library/INDEX.md +39 -39
  102. package/skills/design-library/SKILL.md +19 -13
  103. package/skills/design-library/SOURCE +45 -43
  104. package/skills/feature-audit/SKILL.md +20 -14
  105. package/skills/hallmark/LICENSE +21 -0
  106. package/skills/hallmark/SKILL.md +154 -0
  107. package/skills/hallmark/SOURCE +207 -0
  108. package/skills/hallmark/agents/openai.yaml +13 -0
  109. package/skills/hallmark/references/anatomy.md +71 -0
  110. package/skills/hallmark/references/brand-truth.md +40 -0
  111. package/skills/hallmark/references/comp.md +104 -0
  112. package/skills/hallmark/references/craft.md +116 -0
  113. package/skills/hallmark/references/dark-mode.md +49 -0
  114. package/skills/hallmark/references/data-viz.md +48 -0
  115. package/skills/hallmark/references/design-md.md +53 -0
  116. package/skills/hallmark/references/direction.md +52 -0
  117. package/skills/hallmark/references/export-formats.md +80 -0
  118. package/skills/hallmark/references/flow.md +131 -0
  119. package/skills/hallmark/references/fonts.md +58 -0
  120. package/skills/hallmark/references/gates.md +243 -0
  121. package/skills/hallmark/references/genres/atmospheric.md +24 -0
  122. package/skills/hallmark/references/genres/editorial.md +24 -0
  123. package/skills/hallmark/references/genres/modern-minimal.md +24 -0
  124. package/skills/hallmark/references/genres/playful.md +24 -0
  125. package/skills/hallmark/references/imagery.md +99 -0
  126. package/skills/hallmark/references/interaction-and-states.md +89 -0
  127. package/skills/hallmark/references/layout.md +104 -0
  128. package/skills/hallmark/references/macrostructures/01-bento-grid.md +21 -0
  129. package/skills/hallmark/references/macrostructures/02-long-document.md +35 -0
  130. package/skills/hallmark/references/macrostructures/03-marquee-hero.md +21 -0
  131. package/skills/hallmark/references/macrostructures/04-stat-led.md +21 -0
  132. package/skills/hallmark/references/macrostructures/05-workbench.md +21 -0
  133. package/skills/hallmark/references/macrostructures/06-conversational-faq.md +28 -0
  134. package/skills/hallmark/references/macrostructures/07-manifesto.md +28 -0
  135. package/skills/hallmark/references/macrostructures/08-photographic.md +21 -0
  136. package/skills/hallmark/references/macrostructures/09-quote-led.md +21 -0
  137. package/skills/hallmark/references/macrostructures/10-specimen.md +21 -0
  138. package/skills/hallmark/references/macrostructures/11-catalogue.md +21 -0
  139. package/skills/hallmark/references/macrostructures/12-letter.md +21 -0
  140. package/skills/hallmark/references/macrostructures/13-index-first.md +21 -0
  141. package/skills/hallmark/references/macrostructures/14-narrative-workflow.md +21 -0
  142. package/skills/hallmark/references/macrostructures/15-split-studio.md +21 -0
  143. package/skills/hallmark/references/macrostructures/16-feature-stack.md +23 -0
  144. package/skills/hallmark/references/macrostructures/17-type-specimen.md +19 -0
  145. package/skills/hallmark/references/macrostructures/18-portfolio-grid.md +21 -0
  146. package/skills/hallmark/references/macrostructures/19-map-diagram.md +21 -0
  147. package/skills/hallmark/references/macrostructures/20-ecosystem-index.md +19 -0
  148. package/skills/hallmark/references/macrostructures/21-component-playground.md +21 -0
  149. package/skills/hallmark/references/macrostructures.md +119 -0
  150. package/skills/hallmark/references/microinteractions.md +80 -0
  151. package/skills/hallmark/references/patterns.md +202 -0
  152. package/skills/hallmark/references/redesign.md +88 -0
  153. package/skills/hallmark/references/responsive.md +46 -0
  154. package/skills/hallmark/references/review-finish.md +19 -0
  155. package/skills/hallmark/references/review-hero.md +20 -0
  156. package/skills/hallmark/references/scroll-choreography.md +110 -0
  157. package/skills/hallmark/references/texture.md +90 -0
  158. package/skills/hallmark/references/themes/README.md +58 -0
  159. package/skills/hallmark/references/themes/almanac.jpg +0 -0
  160. package/skills/hallmark/references/themes/almanac.md +103 -0
  161. package/skills/hallmark/references/themes/arcade.jpg +0 -0
  162. package/skills/hallmark/references/themes/arcade.md +102 -0
  163. package/skills/hallmark/references/themes/atelier.jpg +0 -0
  164. package/skills/hallmark/references/themes/atelier.md +98 -0
  165. package/skills/hallmark/references/themes/bloom.jpg +0 -0
  166. package/skills/hallmark/references/themes/bloom.md +102 -0
  167. package/skills/hallmark/references/themes/blueprint.jpg +0 -0
  168. package/skills/hallmark/references/themes/blueprint.md +103 -0
  169. package/skills/hallmark/references/themes/broadside.jpg +0 -0
  170. package/skills/hallmark/references/themes/broadside.md +108 -0
  171. package/skills/hallmark/references/themes/brutal.jpg +0 -0
  172. package/skills/hallmark/references/themes/brutal.md +95 -0
  173. package/skills/hallmark/references/themes/carnival.jpg +0 -0
  174. package/skills/hallmark/references/themes/carnival.md +104 -0
  175. package/skills/hallmark/references/themes/cobalt.jpg +0 -0
  176. package/skills/hallmark/references/themes/cobalt.md +101 -0
  177. package/skills/hallmark/references/themes/console.jpg +0 -0
  178. package/skills/hallmark/references/themes/console.md +99 -0
  179. package/skills/hallmark/references/themes/coral.jpg +0 -0
  180. package/skills/hallmark/references/themes/coral.md +102 -0
  181. package/skills/hallmark/references/themes/darkroom.jpg +0 -0
  182. package/skills/hallmark/references/themes/darkroom.md +99 -0
  183. package/skills/hallmark/references/themes/dose.jpg +0 -0
  184. package/skills/hallmark/references/themes/dose.md +98 -0
  185. package/skills/hallmark/references/themes/dusk.jpg +0 -0
  186. package/skills/hallmark/references/themes/dusk.md +105 -0
  187. package/skills/hallmark/references/themes/editorial.jpg +0 -0
  188. package/skills/hallmark/references/themes/editorial.md +97 -0
  189. package/skills/hallmark/references/themes/formwork.jpg +0 -0
  190. package/skills/hallmark/references/themes/formwork.md +107 -0
  191. package/skills/hallmark/references/themes/furrow.jpg +0 -0
  192. package/skills/hallmark/references/themes/furrow.md +98 -0
  193. package/skills/hallmark/references/themes/garden.jpg +0 -0
  194. package/skills/hallmark/references/themes/garden.md +97 -0
  195. package/skills/hallmark/references/themes/grid.jpg +0 -0
  196. package/skills/hallmark/references/themes/grid.md +101 -0
  197. package/skills/hallmark/references/themes/halftone.jpg +0 -0
  198. package/skills/hallmark/references/themes/halftone.md +106 -0
  199. package/skills/hallmark/references/themes/hedgerow.jpg +0 -0
  200. package/skills/hallmark/references/themes/hedgerow.md +104 -0
  201. package/skills/hallmark/references/themes/hum.jpg +0 -0
  202. package/skills/hallmark/references/themes/hum.md +99 -0
  203. package/skills/hallmark/references/themes/index.json +2191 -0
  204. package/skills/hallmark/references/themes/ink.jpg +0 -0
  205. package/skills/hallmark/references/themes/ink.md +106 -0
  206. package/skills/hallmark/references/themes/kiln.jpg +0 -0
  207. package/skills/hallmark/references/themes/kiln.md +108 -0
  208. package/skills/hallmark/references/themes/loom.jpg +0 -0
  209. package/skills/hallmark/references/themes/loom.md +106 -0
  210. package/skills/hallmark/references/themes/lumen.jpg +0 -0
  211. package/skills/hallmark/references/themes/lumen.md +103 -0
  212. package/skills/hallmark/references/themes/manifesto.jpg +0 -0
  213. package/skills/hallmark/references/themes/manifesto.md +101 -0
  214. package/skills/hallmark/references/themes/midnight.jpg +0 -0
  215. package/skills/hallmark/references/themes/midnight.md +97 -0
  216. package/skills/hallmark/references/themes/newsprint.jpg +0 -0
  217. package/skills/hallmark/references/themes/newsprint.md +102 -0
  218. package/skills/hallmark/references/themes/overprint.jpg +0 -0
  219. package/skills/hallmark/references/themes/overprint.md +101 -0
  220. package/skills/hallmark/references/themes/pasteup.jpg +0 -0
  221. package/skills/hallmark/references/themes/pasteup.md +100 -0
  222. package/skills/hallmark/references/themes/piste.jpg +0 -0
  223. package/skills/hallmark/references/themes/piste.md +99 -0
  224. package/skills/hallmark/references/themes/ribbon.jpg +0 -0
  225. package/skills/hallmark/references/themes/ribbon.md +106 -0
  226. package/skills/hallmark/references/themes/rim.jpg +0 -0
  227. package/skills/hallmark/references/themes/rim.md +96 -0
  228. package/skills/hallmark/references/themes/riso.jpg +0 -0
  229. package/skills/hallmark/references/themes/riso.md +98 -0
  230. package/skills/hallmark/references/themes/solstice.jpg +0 -0
  231. package/skills/hallmark/references/themes/solstice.md +104 -0
  232. package/skills/hallmark/references/themes/specimen.jpg +0 -0
  233. package/skills/hallmark/references/themes/specimen.md +100 -0
  234. package/skills/hallmark/references/themes/spore.jpg +0 -0
  235. package/skills/hallmark/references/themes/spore.md +104 -0
  236. package/skills/hallmark/references/themes/sport.jpg +0 -0
  237. package/skills/hallmark/references/themes/sport.md +101 -0
  238. package/skills/hallmark/references/themes/squeeze.jpg +0 -0
  239. package/skills/hallmark/references/themes/squeeze.md +99 -0
  240. package/skills/hallmark/references/themes/sticker.jpg +0 -0
  241. package/skills/hallmark/references/themes/sticker.md +106 -0
  242. package/skills/hallmark/references/themes/studio.jpg +0 -0
  243. package/skills/hallmark/references/themes/studio.md +99 -0
  244. package/skills/hallmark/references/themes/terminal.jpg +0 -0
  245. package/skills/hallmark/references/themes/terminal.md +96 -0
  246. package/skills/hallmark/references/themes/terrazzo.jpg +0 -0
  247. package/skills/hallmark/references/themes/terrazzo.md +106 -0
  248. package/skills/hallmark/references/themes/ultraviolet.jpg +0 -0
  249. package/skills/hallmark/references/themes/ultraviolet.md +106 -0
  250. package/skills/hallmark/references/themes/vellum.jpg +0 -0
  251. package/skills/hallmark/references/themes/vellum.md +105 -0
  252. package/skills/hallmark/references/themes/wayfind.jpg +0 -0
  253. package/skills/hallmark/references/themes/wayfind.md +105 -0
  254. package/skills/hallmark/references/themes/werkstatt.jpg +0 -0
  255. package/skills/hallmark/references/themes/werkstatt.md +105 -0
  256. package/skills/hallmark/scripts/capture.mjs +125 -0
  257. package/skills/hallmark/scripts/fixtures/exemplars/atmospheric.html +324 -0
  258. package/skills/hallmark/scripts/fixtures/exemplars/editorial.html +363 -0
  259. package/skills/hallmark/scripts/fixtures/exemplars/modern-minimal.html +527 -0
  260. package/skills/hallmark/scripts/fixtures/exemplars/playful.html +353 -0
  261. package/skills/hallmark/scripts/fixtures/eyebrow-fail/index.html +118 -0
  262. package/skills/hallmark/scripts/fixtures/eyebrow-pass/index.html +137 -0
  263. package/skills/hallmark/scripts/fixtures/kept/after.html +27 -0
  264. package/skills/hallmark/scripts/fixtures/kept/before.html +26 -0
  265. package/skills/hallmark/scripts/fixtures/slopped.html +145 -0
  266. package/skills/hallmark/scripts/fixtures/tailwind-fail/Feature.vue +14 -0
  267. package/skills/hallmark/scripts/fixtures/tailwind-fail/Hero.tsx +33 -0
  268. package/skills/hallmark/scripts/fixtures/tailwind-fail/index.html +74 -0
  269. package/skills/hallmark/scripts/fixtures/tailwind-fail/style.css +6 -0
  270. package/skills/hallmark/scripts/fixtures/tailwind-pass/Hero.tsx +33 -0
  271. package/skills/hallmark/scripts/fixtures/tailwind-pass/index.html +57 -0
  272. package/skills/hallmark/scripts/fixtures/tailwind-pass/style.css +15 -0
  273. package/skills/hallmark/scripts/fixtures/waiver/index.html +54 -0
  274. package/skills/hallmark/scripts/imagegen.mjs +85 -0
  275. package/skills/hallmark/scripts/install-hook.mjs +70 -0
  276. package/skills/hallmark/scripts/install.mjs +141 -0
  277. package/skills/hallmark/scripts/lint-hook.mjs +49 -0
  278. package/skills/hallmark/scripts/photos.json +1 -0
  279. package/skills/hallmark/scripts/picture.mjs +71 -0
  280. package/skills/hallmark/scripts/refcheck.mjs +352 -0
  281. package/skills/hallmark/scripts/sloplint-render.mjs +1252 -0
  282. package/skills/hallmark/scripts/sloplint.mjs +4422 -0
  283. package/skills/hallmark/scripts/theme.mjs +650 -0
  284. package/skills/harness/SKILL.md +17 -8
  285. package/skills/i-have-adhd/SKILL.md +32 -12
  286. package/skills/no-ai-slop/SKILL.md +15 -1
  287. package/skills/plan-add/SKILL.md +38 -27
  288. package/skills/plan-status/SKILL.md +13 -7
  289. package/skills/plan-sync/SKILL.md +18 -12
  290. package/skills/pm-frameworks/SKILL.md +19 -10
  291. package/skills/pm-frameworks/SOURCE +7 -5
  292. package/skills/ponytail/SKILL.md +19 -14
  293. package/skills/product-analysis/SKILL.md +22 -18
  294. package/skills/product-flow/SKILL.md +14 -9
  295. package/skills/research/SKILL.md +97 -48
  296. package/skills/rl-env/SKILL.md +25 -13
  297. package/skills/rl-env/SOURCE +8 -6
  298. package/skills/tezgah-contract/SKILL.md +65 -47
  299. package/statusline.py +20 -9
  300. package/workflows/graph-review.js +14 -2
  301. package/bin/tezgah-skill.py +0 -59
  302. /package/skills/ai-research/0-autoresearch-skill/{SKILL.md → ENTRY.md} +0 -0
  303. /package/skills/ai-research/01-model-architecture/litgpt/{SKILL.md → ENTRY.md} +0 -0
  304. /package/skills/ai-research/01-model-architecture/mamba/{SKILL.md → ENTRY.md} +0 -0
  305. /package/skills/ai-research/01-model-architecture/nanogpt/{SKILL.md → ENTRY.md} +0 -0
  306. /package/skills/ai-research/01-model-architecture/rwkv/{SKILL.md → ENTRY.md} +0 -0
  307. /package/skills/ai-research/01-model-architecture/torchtitan/{SKILL.md → ENTRY.md} +0 -0
  308. /package/skills/ai-research/02-tokenization/huggingface-tokenizers/{SKILL.md → ENTRY.md} +0 -0
  309. /package/skills/ai-research/02-tokenization/sentencepiece/{SKILL.md → ENTRY.md} +0 -0
  310. /package/skills/ai-research/03-fine-tuning/axolotl/{SKILL.md → ENTRY.md} +0 -0
  311. /package/skills/ai-research/03-fine-tuning/llama-factory/{SKILL.md → ENTRY.md} +0 -0
  312. /package/skills/ai-research/03-fine-tuning/peft/{SKILL.md → ENTRY.md} +0 -0
  313. /package/skills/ai-research/03-fine-tuning/unsloth/{SKILL.md → ENTRY.md} +0 -0
  314. /package/skills/ai-research/04-mechanistic-interpretability/nnsight/{SKILL.md → ENTRY.md} +0 -0
  315. /package/skills/ai-research/04-mechanistic-interpretability/pyvene/{SKILL.md → ENTRY.md} +0 -0
  316. /package/skills/ai-research/04-mechanistic-interpretability/saelens/{SKILL.md → ENTRY.md} +0 -0
  317. /package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/{SKILL.md → ENTRY.md} +0 -0
  318. /package/skills/ai-research/05-data-processing/nemo-curator/{SKILL.md → ENTRY.md} +0 -0
  319. /package/skills/ai-research/05-data-processing/ray-data/{SKILL.md → ENTRY.md} +0 -0
  320. /package/skills/ai-research/06-post-training/grpo-rl-training/{SKILL.md → ENTRY.md} +0 -0
  321. /package/skills/ai-research/06-post-training/miles/{SKILL.md → ENTRY.md} +0 -0
  322. /package/skills/ai-research/06-post-training/openrlhf/{SKILL.md → ENTRY.md} +0 -0
  323. /package/skills/ai-research/06-post-training/simpo/{SKILL.md → ENTRY.md} +0 -0
  324. /package/skills/ai-research/06-post-training/slime/{SKILL.md → ENTRY.md} +0 -0
  325. /package/skills/ai-research/06-post-training/torchforge/{SKILL.md → ENTRY.md} +0 -0
  326. /package/skills/ai-research/06-post-training/trl-fine-tuning/{SKILL.md → ENTRY.md} +0 -0
  327. /package/skills/ai-research/06-post-training/verl/{SKILL.md → ENTRY.md} +0 -0
  328. /package/skills/ai-research/07-safety-alignment/constitutional-ai/{SKILL.md → ENTRY.md} +0 -0
  329. /package/skills/ai-research/07-safety-alignment/llamaguard/{SKILL.md → ENTRY.md} +0 -0
  330. /package/skills/ai-research/07-safety-alignment/nemo-guardrails/{SKILL.md → ENTRY.md} +0 -0
  331. /package/skills/ai-research/07-safety-alignment/prompt-guard/{SKILL.md → ENTRY.md} +0 -0
  332. /package/skills/ai-research/08-distributed-training/accelerate/{SKILL.md → ENTRY.md} +0 -0
  333. /package/skills/ai-research/08-distributed-training/deepspeed/{SKILL.md → ENTRY.md} +0 -0
  334. /package/skills/ai-research/08-distributed-training/megatron-core/{SKILL.md → ENTRY.md} +0 -0
  335. /package/skills/ai-research/08-distributed-training/pytorch-fsdp2/{SKILL.md → ENTRY.md} +0 -0
  336. /package/skills/ai-research/08-distributed-training/pytorch-lightning/{SKILL.md → ENTRY.md} +0 -0
  337. /package/skills/ai-research/08-distributed-training/ray-train/{SKILL.md → ENTRY.md} +0 -0
  338. /package/skills/ai-research/09-infrastructure/lambda-labs/{SKILL.md → ENTRY.md} +0 -0
  339. /package/skills/ai-research/09-infrastructure/modal/{SKILL.md → ENTRY.md} +0 -0
  340. /package/skills/ai-research/09-infrastructure/skypilot/{SKILL.md → ENTRY.md} +0 -0
  341. /package/skills/ai-research/10-optimization/awq/{SKILL.md → ENTRY.md} +0 -0
  342. /package/skills/ai-research/10-optimization/bitsandbytes/{SKILL.md → ENTRY.md} +0 -0
  343. /package/skills/ai-research/10-optimization/flash-attention/{SKILL.md → ENTRY.md} +0 -0
  344. /package/skills/ai-research/10-optimization/gguf/{SKILL.md → ENTRY.md} +0 -0
  345. /package/skills/ai-research/10-optimization/gptq/{SKILL.md → ENTRY.md} +0 -0
  346. /package/skills/ai-research/10-optimization/hqq/{SKILL.md → ENTRY.md} +0 -0
  347. /package/skills/ai-research/10-optimization/ml-training-recipes/{SKILL.md → ENTRY.md} +0 -0
  348. /package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/{SKILL.md → ENTRY.md} +0 -0
  349. /package/skills/ai-research/11-evaluation/lm-evaluation-harness/{SKILL.md → ENTRY.md} +0 -0
  350. /package/skills/ai-research/11-evaluation/nemo-evaluator/{SKILL.md → ENTRY.md} +0 -0
  351. /package/skills/ai-research/12-inference-serving/llama-cpp/{SKILL.md → ENTRY.md} +0 -0
  352. /package/skills/ai-research/12-inference-serving/sglang/{SKILL.md → ENTRY.md} +0 -0
  353. /package/skills/ai-research/12-inference-serving/tensorrt-llm/{SKILL.md → ENTRY.md} +0 -0
  354. /package/skills/ai-research/12-inference-serving/vllm/{SKILL.md → ENTRY.md} +0 -0
  355. /package/skills/ai-research/13-mlops/mlflow/{SKILL.md → ENTRY.md} +0 -0
  356. /package/skills/ai-research/13-mlops/swanlab/{SKILL.md → ENTRY.md} +0 -0
  357. /package/skills/ai-research/13-mlops/tensorboard/{SKILL.md → ENTRY.md} +0 -0
  358. /package/skills/ai-research/13-mlops/weights-and-biases/{SKILL.md → ENTRY.md} +0 -0
  359. /package/skills/ai-research/14-agents/a-evolve/{SKILL.md → ENTRY.md} +0 -0
  360. /package/skills/ai-research/14-agents/autogpt/{SKILL.md → ENTRY.md} +0 -0
  361. /package/skills/ai-research/14-agents/crewai/{SKILL.md → ENTRY.md} +0 -0
  362. /package/skills/ai-research/14-agents/langchain/{SKILL.md → ENTRY.md} +0 -0
  363. /package/skills/ai-research/14-agents/llamaindex/{SKILL.md → ENTRY.md} +0 -0
  364. /package/skills/ai-research/15-rag/chroma/{SKILL.md → ENTRY.md} +0 -0
  365. /package/skills/ai-research/15-rag/faiss/{SKILL.md → ENTRY.md} +0 -0
  366. /package/skills/ai-research/15-rag/pinecone/{SKILL.md → ENTRY.md} +0 -0
  367. /package/skills/ai-research/15-rag/qdrant/{SKILL.md → ENTRY.md} +0 -0
  368. /package/skills/ai-research/15-rag/sentence-transformers/{SKILL.md → ENTRY.md} +0 -0
  369. /package/skills/ai-research/16-prompt-engineering/dspy/{SKILL.md → ENTRY.md} +0 -0
  370. /package/skills/ai-research/16-prompt-engineering/guidance/{SKILL.md → ENTRY.md} +0 -0
  371. /package/skills/ai-research/16-prompt-engineering/instructor/{SKILL.md → ENTRY.md} +0 -0
  372. /package/skills/ai-research/16-prompt-engineering/outlines/{SKILL.md → ENTRY.md} +0 -0
  373. /package/skills/ai-research/17-observability/langsmith/{SKILL.md → ENTRY.md} +0 -0
  374. /package/skills/ai-research/17-observability/phoenix/{SKILL.md → ENTRY.md} +0 -0
  375. /package/skills/ai-research/18-multimodal/audiocraft/{SKILL.md → ENTRY.md} +0 -0
  376. /package/skills/ai-research/18-multimodal/blip-2/{SKILL.md → ENTRY.md} +0 -0
  377. /package/skills/ai-research/18-multimodal/clip/{SKILL.md → ENTRY.md} +0 -0
  378. /package/skills/ai-research/18-multimodal/cosmos-policy/{SKILL.md → ENTRY.md} +0 -0
  379. /package/skills/ai-research/18-multimodal/llava/{SKILL.md → ENTRY.md} +0 -0
  380. /package/skills/ai-research/18-multimodal/openpi/{SKILL.md → ENTRY.md} +0 -0
  381. /package/skills/ai-research/18-multimodal/openvla-oft/{SKILL.md → ENTRY.md} +0 -0
  382. /package/skills/ai-research/18-multimodal/segment-anything/{SKILL.md → ENTRY.md} +0 -0
  383. /package/skills/ai-research/18-multimodal/stable-diffusion/{SKILL.md → ENTRY.md} +0 -0
  384. /package/skills/ai-research/18-multimodal/whisper/{SKILL.md → ENTRY.md} +0 -0
  385. /package/skills/ai-research/19-emerging-techniques/knowledge-distillation/{SKILL.md → ENTRY.md} +0 -0
  386. /package/skills/ai-research/19-emerging-techniques/long-context/{SKILL.md → ENTRY.md} +0 -0
  387. /package/skills/ai-research/19-emerging-techniques/model-merging/{SKILL.md → ENTRY.md} +0 -0
  388. /package/skills/ai-research/19-emerging-techniques/model-pruning/{SKILL.md → ENTRY.md} +0 -0
  389. /package/skills/ai-research/19-emerging-techniques/moe-training/{SKILL.md → ENTRY.md} +0 -0
  390. /package/skills/ai-research/19-emerging-techniques/speculative-decoding/{SKILL.md → ENTRY.md} +0 -0
  391. /package/skills/ai-research/20-ml-paper-writing/academic-plotting/{SKILL.md → ENTRY.md} +0 -0
  392. /package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/{SKILL.md → ENTRY.md} +0 -0
  393. /package/skills/ai-research/20-ml-paper-writing/presenting-conference-talks/{SKILL.md → ENTRY.md} +0 -0
  394. /package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/{SKILL.md → ENTRY.md} +0 -0
  395. /package/skills/ai-research/21-research-ideation/brainstorming-research-ideas/{SKILL.md → ENTRY.md} +0 -0
  396. /package/skills/ai-research/21-research-ideation/creative-thinking-for-research/{SKILL.md → ENTRY.md} +0 -0
  397. /package/skills/ai-research/22-agent-native-research-artifact/compiler/{SKILL.md → ENTRY.md} +0 -0
  398. /package/skills/ai-research/22-agent-native-research-artifact/research-manager/{SKILL.md → ENTRY.md} +0 -0
  399. /package/skills/ai-research/22-agent-native-research-artifact/rigor-reviewer/{SKILL.md → ENTRY.md} +0 -0
  400. /package/skills/design-library/adaptive-interfaces/skills/colour-independence/{SKILL.md → ENTRY.md} +0 -0
  401. /package/skills/design-library/adaptive-interfaces/skills/flexible-typography/{SKILL.md → ENTRY.md} +0 -0
  402. /package/skills/design-library/adaptive-interfaces/skills/information-density/{SKILL.md → ENTRY.md} +0 -0
  403. /package/skills/design-library/adaptive-interfaces/skills/preference-audit/{SKILL.md → ENTRY.md} +0 -0
  404. /package/skills/design-library/adaptive-interfaces/skills/responsive-accessibility/{SKILL.md → ENTRY.md} +0 -0
  405. /package/skills/design-library/adaptive-interfaces/skills/responsive-review/{SKILL.md → ENTRY.md} +0 -0
  406. /package/skills/design-library/adaptive-interfaces/skills/simplified-views/{SKILL.md → ENTRY.md} +0 -0
  407. /package/skills/design-library/adaptive-interfaces/skills/specify/{SKILL.md → ENTRY.md} +0 -0
  408. /package/skills/design-library/adaptive-interfaces/skills/user-preference-respect/{SKILL.md → ENTRY.md} +0 -0
  409. /package/skills/design-library/cognitive-accessibility/skills/adaptive-personalisation/{SKILL.md → ENTRY.md} +0 -0
  410. /package/skills/design-library/cognitive-accessibility/skills/assess-load/{SKILL.md → ENTRY.md} +0 -0
  411. /package/skills/design-library/cognitive-accessibility/skills/cognitive-load-assessment/{SKILL.md → ENTRY.md} +0 -0
  412. /package/skills/design-library/cognitive-accessibility/skills/contextual-help-design/{SKILL.md → ENTRY.md} +0 -0
  413. /package/skills/design-library/cognitive-accessibility/skills/error-prevention-recovery/{SKILL.md → ENTRY.md} +0 -0
  414. /package/skills/design-library/cognitive-accessibility/skills/focus-attention-design/{SKILL.md → ENTRY.md} +0 -0
  415. /package/skills/design-library/cognitive-accessibility/skills/memory-load-reduction/{SKILL.md → ENTRY.md} +0 -0
  416. /package/skills/design-library/cognitive-accessibility/skills/plain-language-design/{SKILL.md → ENTRY.md} +0 -0
  417. /package/skills/design-library/cognitive-accessibility/skills/review/{SKILL.md → ENTRY.md} +0 -0
  418. /package/skills/design-library/cognitive-accessibility/skills/simplify/{SKILL.md → ENTRY.md} +0 -0
  419. /package/skills/design-library/cognitive-accessibility/skills/wayfinding-navigation/{SKILL.md → ENTRY.md} +0 -0
  420. /package/skills/design-library/design-research/skills/behavioural-analytics/{SKILL.md → ENTRY.md} +0 -0
  421. /package/skills/design-library/design-research/skills/qual-quant-triangulation/{SKILL.md → ENTRY.md} +0 -0
  422. /package/skills/design-library/inclusive-interaction/skills/audit/{SKILL.md → ENTRY.md} +0 -0
  423. /package/skills/design-library/inclusive-interaction/skills/design-flow/{SKILL.md → ENTRY.md} +0 -0
  424. /package/skills/design-library/inclusive-interaction/skills/feedback-and-status/{SKILL.md → ENTRY.md} +0 -0
  425. /package/skills/design-library/inclusive-interaction/skills/gesture-alternatives/{SKILL.md → ENTRY.md} +0 -0
  426. /package/skills/design-library/inclusive-interaction/skills/keyboard-navigation/{SKILL.md → ENTRY.md} +0 -0
  427. /package/skills/design-library/inclusive-interaction/skills/keyboard-review/{SKILL.md → ENTRY.md} +0 -0
  428. /package/skills/design-library/inclusive-interaction/skills/motion-sensitivity/{SKILL.md → ENTRY.md} +0 -0
  429. /package/skills/design-library/inclusive-interaction/skills/multi-modal-input/{SKILL.md → ENTRY.md} +0 -0
  430. /package/skills/design-library/inclusive-interaction/skills/touch-target-design/{SKILL.md → ENTRY.md} +0 -0
  431. /package/skills/design-library/inclusive-interaction/skills/voice-interaction/{SKILL.md → ENTRY.md} +0 -0
  432. /package/skills/design-library/visual-critique/skills/critique-affordance/{SKILL.md → ENTRY.md} +0 -0
  433. /package/skills/design-library/visual-critique/skills/critique-brand-consistency/{SKILL.md → ENTRY.md} +0 -0
  434. /package/skills/design-library/visual-critique/skills/critique-color/{SKILL.md → ENTRY.md} +0 -0
  435. /package/skills/design-library/visual-critique/skills/critique-composition/{SKILL.md → ENTRY.md} +0 -0
  436. /package/skills/design-library/visual-critique/skills/critique-information-density/{SKILL.md → ENTRY.md} +0 -0
  437. /package/skills/design-library/visual-critique/skills/critique-typography/{SKILL.md → ENTRY.md} +0 -0
  438. /package/skills/design-library/visual-critique/skills/critique-visual-hierarchy/{SKILL.md → ENTRY.md} +0 -0
  439. /package/skills/pm-frameworks/intended-vs-implemented/{SKILL.md → ENTRY.md} +0 -0
  440. /package/skills/pm-frameworks/opportunity-solution-tree/{SKILL.md → ENTRY.md} +0 -0
  441. /package/skills/rl-env/generate-nemo-gym-env/{SKILL.md → ENTRY.md} +0 -0
  442. /package/skills/rl-env/generate-openenv-env/{SKILL.md → ENTRY.md} +0 -0
  443. /package/skills/rl-env/generate-ors-env/{SKILL.md → ENTRY.md} +0 -0
  444. /package/skills/rl-env/generate-verifiers-env/{SKILL.md → ENTRY.md} +0 -0
  445. /package/skills/rl-env/rl-env-from-description/{SKILL.md → ENTRY.md} +0 -0
@@ -0,0 +1,1544 @@
1
+ """Captured evidence for research rules 8 (plan 127, spec 1.1-1.5).
2
+
3
+ The tool, not the session, fetches what a line cites: `cite` stores each registry's
4
+ raw answer as a receipt, `fetch` stores a source's extracted text and the passages
5
+ around every quote the line makes of it, `search` stores each connector's raw
6
+ output. `check` re-derives every receipt and search from those bytes offline,
7
+ refuses a capture that changed after its first commit, and names the certainty
8
+ steps a missing capture costs.
9
+
10
+ Interface (plan 127):
11
+ - COMMANDS = {"cite", "fetch", "search"}: handler(repo, args) -> exit code, prints.
12
+ - check(repo, slug, base, state, errors, warnings, strict, git, steps): reads
13
+ steps["#flags"] ({"online", "orx"}), appends report lines to steps["#report"] and
14
+ step names to steps[<claim id>].
15
+ - fetch_url(url, body=None, headers=None) -> (status, bytes): the one network call;
16
+ TEZGAH_CAPTURE_MAP answers instead of the network when set.
17
+ - write_capture(base, rel, data) / write_captures(base, {rel: data}) -> problem|None.
18
+ - append_rows(base, rel, rows); commit_capture(repo, base, rels, command, slug) -> error|None.
19
+ - engine_runs(state) -> (runs|None, error): `orx runs` scoped by experiment.
20
+ - citation_problem(row) / quote_problem(quote, text) -> str|None: release-check's scorers.
21
+ """
22
+ import decimal
23
+ import hashlib
24
+ import html
25
+ import json
26
+ import os
27
+ import re
28
+ import subprocess
29
+ import sys
30
+ import time
31
+ import unicodedata
32
+ import urllib.error
33
+ import urllib.parse
34
+ import urllib.request
35
+ import xml.etree.ElementTree as ET
36
+
37
+ import tezgah_paths as tp
38
+ import tezgah_research as tr
39
+ import tezgah_research_stats as st
40
+
41
+ PROVIDERS = ("arxiv", "crossref", "openalex", "semanticscholar", "pubmed", "scopus",
42
+ "acm")
43
+ ORX_CONNECTORS = ("discover", "paper")
44
+ TAGS = ("primary", "snowball", "counter", "known")
45
+ SEARCH_CLASSES = ("R1", "R2", "R7", "R8", "R9", "R10")
46
+ STOPWORDS = {"a", "an", "the", "of", "and", "for", "in", "on", "to", "with"}
47
+ ATOM = "{http://www.w3.org/2005/Atom}"
48
+ ARXIV_API = "https://export.arxiv.org/api/query?id_list=%s&max_results=1"
49
+ S2_PAPER = ("https://api.semanticscholar.org/graph/v1/paper/%s"
50
+ "?fields=title,authors,year,externalIds,abstract")
51
+ SEARCH_URLS = {
52
+ "arxiv": "https://export.arxiv.org/api/query?search_query=all:%s&max_results=50",
53
+ "crossref": "https://api.crossref.org/works?query=%s&rows=50",
54
+ "openalex": "https://api.openalex.org/works?search=%s&per-page=50",
55
+ "semanticscholar": ("https://api.semanticscholar.org/graph/v1/paper/search?query=%s"
56
+ "&limit=50&fields=title,externalIds,year"),
57
+ "pubmed": ("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed"
58
+ "&retmax=50&retmode=json&term=%s"),
59
+ "scopus": "https://api.elsevier.com/content/search/scopus?query=%s&count=50",
60
+ # del.acm.org answers a browser check (403), so ACM DL is read through Crossref's
61
+ # member 320 (ACM), which holds the same DOIs
62
+ "acm": "https://api.crossref.org/works?query=%s&rows=50&filter=member:320"}
63
+ PUBMED_SUMMARY = ("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed"
64
+ "&retmode=json&id=%s")
65
+ ORX_ARXIV = re.compile(r"(?<![\d.])(\d{4}\.\d{4,5})(?:v\d+)?(?!\d)")
66
+ ORX_DOI = re.compile(r"10\.\d{4,9}/[^\s\"'<>]+")
67
+ ID_ORDER = ("arXiv:", "doi:", "openalex:", "s2:", "pubmed:")
68
+
69
+ INDEX = "literature/INDEX.jsonl"
70
+ RECEIPTS = "literature/.receipts/RECEIPTS.jsonl"
71
+ SOURCES = "literature/.sources/SOURCES.jsonl"
72
+ SEARCHES = "searches/SEARCHES.jsonl"
73
+ SCREENING = "searches/screening.jsonl"
74
+ CAPTURE = re.compile(r"^(?:literature/\.receipts/|literature/\.sources/|searches/raw/"
75
+ r"|experiments/[^/]+/raw/|instruments/gold/)")
76
+ LEDGER = re.compile(r"^(?:literature/\.receipts/RECEIPTS|literature/\.sources/SOURCES"
77
+ r"|searches/SEARCHES|instruments/[^/]+)\.jsonl$")
78
+ # A number as `source --value` takes it: `12`, `-0.5`, `1.2e-3`.
79
+ VALUE_NUMBER = r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?"
80
+ # A number as a run log writes it: VALUE_NUMBER, or one grouped by thousands (`12,345`),
81
+ # which is read whole, so its tail is never a number of its own (K6 rater Y2). A unit
82
+ # glued on after it - letters or `%` up to a word end (`22.3ms`, `512MB`, `3.2x`, `45%`)
83
+ # - leaves it that number (plan 128 K6); letters in front (`r1`, `h2`) or digits after
84
+ # the letters (a sha such as `2fb1370`) leave it no number.
85
+ # ponytail: a CSV row of integers whose later fields have three digits (`5,300`) reads
86
+ # as one grouped number; a log that means two fields there is the ceiling.
87
+ LOG_NUMBER = re.compile(r"(?<![\w.])-?(?:[1-9]\d{0,2}(?:,\d{3})+|\d+)(?:\.\d+)?"
88
+ r"(?:[eE][-+]?\d+)?(?=(?:[^\W\d_]+|%)?(?![\w]|\.\d))")
89
+
90
+ NO_REGISTRY = "resolves in no registry"
91
+ NOT_IN_SOURCE = "quote not in the captured source text"
92
+ # The receipt and search parsers' version (`parse_receipt`, `receipt_ids`, `receipt_urls`,
93
+ # `schema_ok`, `search_records`), recorded as a row's `parser` (absent: 0). Every row is
94
+ # re-derived under it; a newer one is refused (`_newer`). A bump adds its own hold.
95
+ PARSER = 1
96
+ # An open-access page is a work's body when its text holds this many distinct
97
+ # sentences of BODY_WORDS or more words: the repository landing pages of K6 rater Y4
98
+ # (metadata, the abstract, its citation exports) held 41 and 47, the arXiv HTML and
99
+ # PDF bodies beside them 497 to 814.
100
+ BODY_SENTENCES = 100
101
+ BODY_WORDS = 10
102
+
103
+
104
+ def log_numbers(text):
105
+ """Every number of a run log, as Decimals: what a `--value` has to equal."""
106
+ return {decimal.Decimal(t.replace(",", "")) for t in LOG_NUMBER.findall(text)}
107
+
108
+
109
+ # --- the shared capture helpers -------------------------------------------------
110
+ def fetch_url(url, body=None, headers=None):
111
+ """(status, bytes) of one GET (or a POST when `body` is bytes). The only network
112
+ call of the capture commands: with TEZGAH_CAPTURE_MAP set, the map answers - a GET
113
+ keyed by its URL, a POST by `POST <url> <sha256 of body>`, an unknown key 404."""
114
+ cmap = os.environ.get("TEZGAH_CAPTURE_MAP")
115
+ if cmap:
116
+ key = url if body is None else "POST %s %s" % (url, hashlib.sha256(body).hexdigest())
117
+ try:
118
+ with open(cmap, encoding="utf-8") as fh:
119
+ table = json.load(fh)
120
+ except (OSError, ValueError):
121
+ table = {}
122
+ entry = table.get(key) if isinstance(table, dict) else None
123
+ if entry is None:
124
+ return 404, b""
125
+ status, rel = ((200, entry) if isinstance(entry, str)
126
+ else (int(entry.get("status", 200)), entry.get("body")))
127
+ if not rel:
128
+ return status, b""
129
+ try:
130
+ with open(os.path.join(os.path.dirname(os.path.abspath(cmap)), rel), "rb") as fh:
131
+ return status, fh.read()
132
+ except OSError:
133
+ return status, b""
134
+ request = urllib.request.Request(url, data=body, headers=dict(
135
+ {"User-Agent": "tezgah-research"}, **(headers or {})))
136
+ try:
137
+ with urllib.request.urlopen(request, timeout=30) as resp:
138
+ return resp.status, resp.read()
139
+ except urllib.error.HTTPError as exc:
140
+ return exc.code, exc.read()
141
+ except (urllib.error.URLError, OSError, ValueError):
142
+ return 0, b""
143
+
144
+
145
+ def run_orx(args):
146
+ """(exit code or None when orx is missing, stdout bytes)."""
147
+ orx = tp.orx_bin()
148
+ if not orx:
149
+ return None, b""
150
+ try:
151
+ proc = subprocess.run([orx] + list(args), capture_output=True, timeout=600)
152
+ except (OSError, subprocess.TimeoutExpired):
153
+ return None, b""
154
+ return proc.returncode, proc.stdout
155
+
156
+
157
+ def engine_runs(state):
158
+ """(runs, error): `orx runs <orx_project>`, scoped by `--experiment <orx_experiment>`
159
+ when the line names one, as a list of objects; (None, None) without a project."""
160
+ project = state.get("orx_project")
161
+ if not isinstance(project, str) or not project.strip():
162
+ return None, None
163
+ argv = ["runs", project]
164
+ if isinstance(state.get("orx_experiment"), str) and state["orx_experiment"].strip():
165
+ argv += ["--experiment", state["orx_experiment"]]
166
+ code, out = run_orx(argv)
167
+ if code != 0:
168
+ return None, "orx %s failed" % " ".join(argv)
169
+ try:
170
+ doc = json.loads(out.decode("utf-8"))
171
+ except (UnicodeDecodeError, ValueError):
172
+ # orx 0.2.20 has no `--json` and prints a table (K6 rater Y5)
173
+ runs = _runs_table(out.decode("utf-8", "replace"))
174
+ if runs is None:
175
+ return None, "orx %s printed neither JSON nor a run table" % " ".join(argv)
176
+ return runs, None
177
+ runs = doc.get("runs") if isinstance(doc, dict) else doc
178
+ return [r for r in runs if isinstance(r, dict)] if isinstance(runs, list) else [], None
179
+
180
+
181
+ ORX_RUNS_HEAD = re.compile(r"^ID\s+STATUS\s+EXPERIMENT\b")
182
+ ORX_RUNS_RULE = re.compile(r"^[\s\u2500-\u257f-]+$")
183
+
184
+
185
+ def _runs_table(out):
186
+ """The rows of orx 0.2.20's `orx runs` table as objects (id, status, experiment,
187
+ commit, duration, updated), or None when the answer is not that table. Cells sit
188
+ two or more spaces apart, so an experiment name with single spaces stays whole."""
189
+ lines = out.splitlines()
190
+ if not any(ORX_RUNS_HEAD.match(line) for line in lines):
191
+ return None
192
+ runs = []
193
+ for line in lines:
194
+ if not line.strip() or ORX_RUNS_HEAD.match(line) or ORX_RUNS_RULE.match(line):
195
+ continue
196
+ cells = [c.strip() for c in re.split(r"\s{2,}", line.strip())]
197
+ if len(cells) < 2 or not cells[0]:
198
+ continue
199
+ cells += [""] * (6 - len(cells))
200
+ runs.append(dict(zip(("id", "status", "experiment", "commit", "duration",
201
+ "updated"), cells[:6])))
202
+ return runs
203
+
204
+
205
+ def run_id(run):
206
+ return run.get("id") or run.get("runId")
207
+
208
+
209
+ def write_captures(base, files):
210
+ """Write every `{rel: bytes}` under `base`, or nothing: a target that exists with
211
+ other bytes is the `capture exists` refusal (identical bytes are already there)."""
212
+ for rel, data in sorted(files.items()):
213
+ path = os.path.join(base, rel)
214
+ if os.path.lexists(path):
215
+ try:
216
+ with open(path, "rb") as fh:
217
+ if fh.read() == data:
218
+ continue
219
+ except OSError:
220
+ pass
221
+ return "%s: capture exists with other bytes; a capture is written once" % rel
222
+ for rel, data in files.items():
223
+ path = os.path.join(base, rel)
224
+ if not os.path.exists(path):
225
+ os.makedirs(os.path.dirname(path), exist_ok=True)
226
+ with open(path, "wb") as fh:
227
+ fh.write(data)
228
+ return None
229
+
230
+
231
+ def write_capture(base, rel, data):
232
+ return write_captures(base, {rel: data})
233
+
234
+
235
+ def append_rows(base, rel, rows):
236
+ """Append JSON rows to a ledger, repairing an unterminated tail first."""
237
+ path = os.path.join(base, rel)
238
+ os.makedirs(os.path.dirname(path), exist_ok=True)
239
+ with open(path, "a+b") as fh:
240
+ tr.truncate_to_committed(fh)
241
+ fh.write(b"".join(json.dumps(r).encode("utf-8") + b"\n" for r in rows))
242
+
243
+
244
+ def commit_capture(repo, base, rels, command, slug):
245
+ """Commit exactly `rels` (line-relative) as `capture: <command> <slug>` in
246
+ `.tezgah`'s repository, or the project's when `.tezgah` is folded; error or None."""
247
+ ws = tp.ensure_workspace(repo)
248
+ if ws is None and tr.folded(repo):
249
+ top = repo
250
+
251
+ def git(*args):
252
+ return subprocess.run(["git", "-C", repo] + list(args), capture_output=True,
253
+ text=True)
254
+ elif ws is None:
255
+ return "no repository to commit the capture into (.tezgah could not be set up)"
256
+ else:
257
+ top = ws
258
+
259
+ def git(*args):
260
+ return tp.ws_git(repo, *args)
261
+ names = [os.path.relpath(os.path.realpath(os.path.join(base, r)), os.path.realpath(top))
262
+ for r in rels]
263
+ try:
264
+ done = git("add", "--", *names)
265
+ if not done.returncode:
266
+ done = git("commit", "-q", "-m", "capture: %s %s" % (command, slug), "--", *names)
267
+ except OSError as exc:
268
+ return "commit failed: %s" % exc
269
+ if done.returncode:
270
+ return "commit failed: %s" % (done.stderr or done.stdout).strip()
271
+ return None
272
+
273
+
274
+ def _now():
275
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
276
+
277
+
278
+ def _sha(data):
279
+ return hashlib.sha256(data).hexdigest()
280
+
281
+
282
+ def _rows(base, rel):
283
+ return tr._row_objects(os.path.join(base, rel))
284
+
285
+
286
+ def _read(base, rel):
287
+ """Bytes of a line-relative file, or None (also for a path leaving the line)."""
288
+ if not isinstance(rel, str) or not rel or os.path.isabs(rel) or ".." in rel.split("/"):
289
+ return None
290
+ try:
291
+ with open(os.path.join(base, rel), "rb") as fh:
292
+ return fh.read()
293
+ except OSError:
294
+ return None
295
+
296
+
297
+ def _misuse(message):
298
+ print("tezgah-research: %s" % message, file=sys.stderr)
299
+ return 2
300
+
301
+
302
+ def _line(repo, slug):
303
+ return tr.line_dir(repo, slug) if slug in tr.slugs(repo) else None
304
+
305
+
306
+ # --- text and ids -----------------------------------------------------------------
307
+ QUOTE_MARKS = str.maketrans({"\u2018": "'", "\u2019": "'", "\u201a": "'", "\u201b": "'",
308
+ "\u201c": '"', "\u201d": '"', "\u201e": '"', "\u201f": '"',
309
+ "\u2013": "-", "\u2014": "-", "\u223c": "~"})
310
+ # A TeX command and the token it sits in: LaTeXML's annotation beside rendered math
311
+ # (`∼ \sim`, `𝐱 \mathbf{x}`) reads as the rendered symbol alone.
312
+ TEX_TOKEN = re.compile(r"\S*\\[A-Za-z]+\S*")
313
+ # A space beside a math symbol (`∼ 100B` against `~100B`): rendered math has none.
314
+ MATH_SPACE = re.compile(r" ?([%s]) ?" % re.escape("".join(
315
+ sorted(set("+<=>|~\u00ac\u00b1\u00d7\u00f7") | {chr(c) for c in range(0x2000, 0x2c00)
316
+ if unicodedata.category(chr(c)) == "Sm"}))))
317
+
318
+
319
+ def normalise(text):
320
+ """Spec 0's text normalisation, for quotes, passages and titles."""
321
+ text = unicodedata.normalize("NFKC", str(text or ""))
322
+ text = TEX_TOKEN.sub(" ", text)
323
+ # a marker split by tags or spaces (LaTeXML's `[<a>12</a>]` reads `[ 12 ]`) too
324
+ text = re.sub(r"\[\s*\d+(?:\s*[,\u2013-]\s*\d+)*\s*\]", "", text).translate(QUOTE_MARKS)
325
+ # an editorial change keeps its letter and loses only its brackets: a quote that
326
+ # prints `[T]he` cites the work's `The` (K6 rater Y6's probe)
327
+ text = re.sub(r"\[([^\]]{1,3})\]", r"\1", text)
328
+ text = re.sub(r"-\s+", "", text)
329
+ # a hyphen between two letters reads as a space: `tail-latency` quotes `tail latency`
330
+ text = re.sub(r"(?<=[^\W\d_])-(?=[^\W\d_])", " ", text)
331
+ # inner quotation marks are optional, in the quote and in the text (plan 128 C4)
332
+ text = text.replace('"', "").replace("'", "")
333
+ return MATH_SPACE.sub(r"\1", " ".join(text.split())).casefold().strip()
334
+
335
+
336
+ def body_sentences(text):
337
+ """The distinct sentences of >= BODY_WORDS words in `text`: a work's body holds
338
+ BODY_SENTENCES of them, a landing page (metadata and abstract) fewer."""
339
+ return len({s for s in re.split(r"(?<=[.!?])\s+", normalise(text))
340
+ if len(s.split()) >= BODY_WORDS})
341
+
342
+
343
+ # A bare arXiv id: new style `YYMM.NNNN[N]`, old style `archive[.SUB]/YYMMNNN`
344
+ ARXIV_BARE = re.compile(r"((?:\d{2}(?:0[1-9]|1[0-2])\.\d{4,5})|(?:[a-z-]+(?:\.[A-Z]{2})?/"
345
+ r"\d{2}(?:0[1-9]|1[0-2])\d{3}))(?:v\d+)?$")
346
+
347
+
348
+ def norm_id(raw):
349
+ """The normalised id (`arXiv:`, `doi:`, `openalex:W`, `s2:`, `pubmed:`), or None. A
350
+ bare arXiv
351
+ id is read from a string only: a JSON number is a number, never an id."""
352
+ s = str(raw or "").strip()
353
+ low = s.lower()
354
+ if low.startswith(("https://", "http://", "arxiv.org/", "www.arxiv.org/")):
355
+ m = ARXIV_URL.search(s)
356
+ if m:
357
+ return "arXiv:" + m.group(1)
358
+ m = ARXIV_BARE.match(s) if isinstance(raw, str) else None
359
+ if m:
360
+ return "arXiv:" + m.group(1)
361
+ if re.match(r"10\.\d{4,9}/\S", low):
362
+ s = low = "doi:" + low
363
+ for prefix in ("https://doi.org/", "http://doi.org/", "https://dx.doi.org/"):
364
+ if low.startswith(prefix):
365
+ s = low = "doi:" + low[len(prefix):]
366
+ if low.startswith(("https://openalex.org/", "http://openalex.org/")):
367
+ return "openalex:" + s.rsplit("/", 1)[1]
368
+ m = re.match(r"(?i)arxiv:\s*(\S+?)(?:v\d+)?$", s)
369
+ if m:
370
+ return "arXiv:" + m.group(1)
371
+ if low.startswith("doi:") and low[4:].strip():
372
+ doi = low[4:].strip()
373
+ if doi.startswith("10.48550/arxiv."):
374
+ return "arXiv:" + re.sub(r"v\d+$", "", doi[len("10.48550/arxiv."):])
375
+ return "doi:" + doi
376
+ for prefix in ("openalex:", "s2:", "pubmed:"):
377
+ if low.startswith(prefix) and s[len(prefix):].strip():
378
+ return prefix + s[len(prefix):].strip()
379
+ return None
380
+
381
+
382
+ def identity(ids):
383
+ """A record's identity: its first id in the order arXiv, doi, openalex, s2."""
384
+ for prefix in ID_ORDER:
385
+ for i in ids:
386
+ if i.startswith(prefix):
387
+ return i
388
+ return ids[0] if ids else None
389
+
390
+
391
+ def _bare(rid):
392
+ return rid.split(":", 1)[1] if rid and ":" in rid else rid
393
+
394
+
395
+ def _json(data):
396
+ try:
397
+ return json.loads(data.decode("utf-8"))
398
+ except (UnicodeDecodeError, ValueError):
399
+ return None
400
+
401
+
402
+ def _dig(obj, *path):
403
+ for key in path:
404
+ if isinstance(key, int):
405
+ obj = obj[key] if isinstance(obj, list) and len(obj) > key else None
406
+ else:
407
+ obj = obj.get(key) if isinstance(obj, dict) else None
408
+ return obj
409
+
410
+
411
+ def _text(value):
412
+ return " ".join(value.split()) if isinstance(value, str) and value.strip() else None
413
+
414
+
415
+ def _last_word(name):
416
+ words = str(name).split() if isinstance(name, str) else []
417
+ return words[-1] if words else None
418
+
419
+
420
+ def _year(value):
421
+ if isinstance(value, bool):
422
+ return None
423
+ try:
424
+ return int(str(value)[:4]) if isinstance(value, str) else int(value)
425
+ except (TypeError, ValueError):
426
+ return None
427
+
428
+
429
+ def _atom_entries(data):
430
+ try:
431
+ root = ET.fromstring(data)
432
+ except ET.ParseError:
433
+ return []
434
+ out = []
435
+ for entry in root.iter(ATOM + "entry"):
436
+ def field(tag, el=entry):
437
+ found = el.find(tag)
438
+ return found.text if found is not None and found.text else ""
439
+ out.append({"id": field(ATOM + "id"), "title": field(ATOM + "title"),
440
+ "published": field(ATOM + "published"),
441
+ "summary": field(ATOM + "summary"),
442
+ "author": field("%sauthor/%sname" % (ATOM, ATOM))})
443
+ return out
444
+
445
+
446
+ def html_to_text(data):
447
+ """Tags dropped (script and style with their content), entities unescaped."""
448
+ text = data.decode("utf-8", "replace")
449
+ text = re.sub(r"(?is)<(script|style)\b.*?</\1\s*>", " ", text)
450
+ return html.unescape(re.sub(r"(?s)<[^>]*>", " ", text))
451
+
452
+
453
+ # --- 1.1 receipts -----------------------------------------------------------------
454
+ def receipt_urls(rid):
455
+ """[(provider, url)] queried for a normalised id; [] for an id `cite` skips."""
456
+ if rid and rid.startswith("arXiv:"):
457
+ a = rid[6:]
458
+ return [("arxiv", ARXIV_API % a),
459
+ ("crossref", "https://api.crossref.org/works/10.48550/arXiv." + a),
460
+ ("openalex", "https://api.openalex.org/works/doi:10.48550/arXiv." + a),
461
+ ("semanticscholar", S2_PAPER % ("arXiv:" + a))]
462
+ if rid and rid.startswith("doi:"):
463
+ d = rid[4:]
464
+ return [("crossref", "https://api.crossref.org/works/" + d),
465
+ ("openalex", "https://api.openalex.org/works/doi:" + d),
466
+ ("semanticscholar", S2_PAPER % ("DOI:" + d))]
467
+ if rid and rid.startswith("openalex:"):
468
+ return [("openalex", "https://api.openalex.org/works/" + rid[9:])]
469
+ if rid and rid.startswith("s2:"):
470
+ return [("semanticscholar", S2_PAPER % rid[3:])]
471
+ if rid and rid.startswith("pubmed:"):
472
+ return [("pubmed", PUBMED_SUMMARY % rid[7:])]
473
+ return []
474
+
475
+
476
+ ARXIV_URL = re.compile(r"(?i)arxiv\.org/(?:abs|pdf|html)/(\S+?)(?:v\d+)?(?:\.pdf)?/?$")
477
+
478
+
479
+ def _url_id(value):
480
+ if not isinstance(value, str):
481
+ return None
482
+ m = ARXIV_URL.search(value.strip())
483
+ return "arXiv:" + m.group(1) if m else norm_id(value)
484
+
485
+
486
+ def receipt_ids(provider, data):
487
+ """The normalised ids a provider's answer names: arXiv entry ids, Crossref
488
+ `message.DOI`, OpenAlex `id`/`doi`/`ids` and location URLs (a merged work keeps an
489
+ arXiv preprint there), S2 `paperId`/`externalIds`."""
490
+ if provider == "arxiv":
491
+ values = [e["id"] for e in _atom_entries(data or b"")]
492
+ else:
493
+ doc = _json(data or b"")
494
+ doc = doc if isinstance(doc, dict) else {}
495
+ values = []
496
+ if provider == "crossref":
497
+ values = [("doi:", _dig(doc, "message", "DOI"))]
498
+ elif provider == "openalex":
499
+ ids = doc.get("ids") if isinstance(doc.get("ids"), dict) else {}
500
+ places = [doc.get("primary_location"), doc.get("best_oa_location")]
501
+ places += doc.get("locations") if isinstance(doc.get("locations"), list) else []
502
+ values = [doc.get("id"), doc.get("doi")] + list(ids.values()) + [
503
+ p.get(k) for p in places if isinstance(p, dict)
504
+ for k in ("landing_page_url", "pdf_url")]
505
+ elif provider == "semanticscholar":
506
+ ext = doc.get("externalIds") if isinstance(doc.get("externalIds"), dict) else {}
507
+ values = [("s2:", doc.get("paperId")), ("doi:", ext.get("DOI")),
508
+ ("arXiv:", ext.get("ArXiv"))]
509
+ elif provider == "pubmed":
510
+ result = doc.get("result") if isinstance(doc.get("result"), dict) else {}
511
+ values = [("pubmed:", k) for k in result if k != "uids"]
512
+ values += [("doi:", a.get("value")) for r in result.values()
513
+ if isinstance(r, dict) for a in r.get("articleids") or ()
514
+ if isinstance(a, dict) and a.get("idtype") == "doi"]
515
+ values = [v[0] + v[1] if isinstance(v, tuple) and isinstance(v[1], str) else v
516
+ for v in values]
517
+ return {i for i in map(_url_id, values) if i}
518
+
519
+
520
+ def parse_receipt(provider, rid, status, data):
521
+ """{resolved, title, first_author, year, abstract} of one provider's answer, by the
522
+ provider field table of spec 1.1; parsed fields None when absent."""
523
+ out = {"resolved": False, "title": None, "first_author": None, "year": None,
524
+ "abstract": None}
525
+ if status != 200 or not data:
526
+ return out
527
+ if provider == "arxiv":
528
+ aid = _bare(rid) or ""
529
+ hit = re.compile(r"(?<![\d.])%s(?:v\d+)?/?$" % re.escape(aid))
530
+ for entry in _atom_entries(data):
531
+ title = _text(entry["title"])
532
+ if aid and hit.search(entry["id"].strip()) and title and title != "Error":
533
+ out.update(title=title, first_author=_last_word(entry["author"]),
534
+ year=_year(entry["published"]), abstract=_text(entry["summary"]))
535
+ break
536
+ else:
537
+ doc = _json(data)
538
+ if not isinstance(doc, dict):
539
+ return out
540
+ if provider == "crossref":
541
+ msg = doc.get("message")
542
+ abstract = _dig(msg, "abstract")
543
+ out.update(title=_text(_dig(msg, "title", 0)),
544
+ first_author=_text(_dig(msg, "author", 0, "family")),
545
+ year=_year(_dig(msg, "issued", "date-parts", 0, 0)),
546
+ abstract=_text(re.sub(r"<[^>]+>", " ", abstract))
547
+ if isinstance(abstract, str) else None)
548
+ elif provider == "openalex":
549
+ index = doc.get("abstract_inverted_index")
550
+ words = {}
551
+ if isinstance(index, dict):
552
+ for word, places in index.items():
553
+ for place in places if isinstance(places, list) else ():
554
+ if isinstance(place, int):
555
+ words[place] = word
556
+ out.update(title=_text(doc.get("title")),
557
+ first_author=_last_word(_dig(doc, "authorships", 0, "author",
558
+ "display_name")),
559
+ year=_year(doc.get("publication_year")),
560
+ abstract=_text(" ".join(words[k] for k in sorted(words))))
561
+ elif provider == "semanticscholar":
562
+ out.update(title=_text(doc.get("title")),
563
+ first_author=_last_word(_dig(doc, "authors", 0, "name")),
564
+ year=_year(doc.get("year")), abstract=_text(doc.get("abstract")))
565
+ elif provider == "pubmed":
566
+ uid = str(rid or "")[7:]
567
+ out.update(title=_text(_dig(doc, "result", uid, "title")),
568
+ first_author=_last_word(_dig(doc, "result", uid, "sortfirstauthor")),
569
+ year=_year(_dig(doc, "result", uid, "pubdate")))
570
+ out["resolved"] = bool(out["title"])
571
+ return out
572
+
573
+
574
+ def _title_words(title):
575
+ return set(re.findall(r"[a-z0-9]+", normalise(title))) - STOPWORDS
576
+
577
+
578
+ def title_agrees(index_title, receipt_title):
579
+ """citecheck's measure: max(|a&b|/|a|b|, |a&b|/|b|) >= 0.5 over title words."""
580
+ a, b = _title_words(index_title), _title_words(receipt_title)
581
+ if not a or not b:
582
+ return False
583
+ both = len(a & b)
584
+ return max(both / len(a | b), both / len(b)) >= 0.5
585
+
586
+
587
+ def header_agrees(row, rec):
588
+ year, theirs = _year(row.get("year")), rec.get("year")
589
+ author = str(row.get("first_author") or "").strip()
590
+ return (title_agrees(row.get("title"), rec.get("title")) and year is not None
591
+ and theirs is not None and abs(year - theirs) <= 1
592
+ and (not author or author.casefold()
593
+ == str(rec.get("first_author") or "").casefold()))
594
+
595
+
596
+ def _provider(name):
597
+ name = str(name or "").lower()
598
+ return "arxiv" if name == "alphaxiv" or name.startswith("orx") else name
599
+
600
+
601
+ def citation_verdict(row, recs):
602
+ """The 1.1 refusal substring, the step name, or None, for an INDEX row and its
603
+ [(provider, parsed receipt)]: one provider is the `one-provider` step."""
604
+ resolved = [(p, r) for p, r in recs if r.get("resolved")]
605
+ if not resolved:
606
+ reason = row.get("unindexed_reason")
607
+ return "unindexed-source" if isinstance(reason, str) and reason.strip() else NO_REGISTRY
608
+ if not any(header_agrees(row, r) for _p, r in resolved):
609
+ return "header-mismatch"
610
+ if len({_provider(p) for p, _r in resolved}) < 2:
611
+ return "one-provider"
612
+ return None
613
+
614
+
615
+ def citation_problem(row):
616
+ """release-check's scorer: the 1.1 verdict of one INDEX-like row, its four
617
+ providers queried in memory (no file written)."""
618
+ rid = norm_id(row.get("id"))
619
+ return citation_verdict(row, [(p, parse_receipt(p, rid, *fetch_url(url)))
620
+ for p, url in receipt_urls(rid)])
621
+
622
+
623
+ def cmd_cite(repo, args):
624
+ if len(args) != 1:
625
+ return _misuse("cite <slug>")
626
+ slug = args[0]
627
+ base = _line(repo, slug)
628
+ if base is None:
629
+ return _misuse("no such research line: %s" % slug)
630
+ files, receipts, asked = {}, [], False
631
+ for row in _rows(base, INDEX):
632
+ if row.get("class") != "formal":
633
+ continue
634
+ rid = norm_id(row.get("id"))
635
+ urls = receipt_urls(rid)
636
+ if not urls:
637
+ print("skipped %s: id %r is not an arXiv, DOI, OpenAlex, Semantic Scholar "
638
+ "or PubMed id"
639
+ % (row.get("note"), row.get("id")))
640
+ continue
641
+ for provider, url in urls:
642
+ asked = True
643
+ status, data = fetch_url(url)
644
+ if status != 200:
645
+ # an answer other than 200 (a 404, a 429 rate limit) is no receipt: a
646
+ # later `check --online` would read the provider's answer as drift
647
+ print("receipt %s %s: status %d, not recorded" % (rid, provider, status))
648
+ continue
649
+ parsed = parse_receipt(provider, rid, status, data)
650
+ rel = "literature/.receipts/%s.raw" % _sha(data) if data else None
651
+ if rel:
652
+ files[rel] = data
653
+ receipts.append(dict({"id": rid, "provider": provider, "url": url,
654
+ "status": status, "resolved": parsed["resolved"],
655
+ "file": rel, "sha256": _sha(data) if data else None,
656
+ "fetched_at": _now(), "parser": PARSER},
657
+ **{k: parsed[k] for k in ("title", "first_author", "year",
658
+ "abstract")}))
659
+ print("receipt %s %s: status %d, %s" % (rid, provider, status, "resolved"
660
+ if parsed["resolved"] else "unresolved"))
661
+ if not receipts:
662
+ print("cite %s: %s" % (slug, "no registry answered 200, so nothing was written"
663
+ if asked else "no formal row with an arXiv, DOI, OpenAlex, "
664
+ "Semantic Scholar or PubMed id to cite"))
665
+ return 0
666
+ return _store(repo, base, slug, "cite", files, RECEIPTS, receipts)
667
+
668
+
669
+ def _store(repo, base, slug, command, files, ledger, rows):
670
+ problem = write_captures(base, files)
671
+ if problem:
672
+ print("FAIL %s: %s" % (slug, problem))
673
+ return 1
674
+ append_rows(base, ledger, rows)
675
+ problem = commit_capture(repo, base, sorted(files) + [ledger], command, slug)
676
+ if problem:
677
+ print("FAIL %s: %s" % (slug, problem))
678
+ return 1
679
+ return 0
680
+
681
+
682
+ # --- 1.2 sources --------------------------------------------------------------------
683
+ QUOTED = re.compile(r"[\"\u201c]([^\"\u201c\u201d]+)[\"\u201d]")
684
+ BLOCKQUOTE = re.compile(r"(?m)^\s*>\s*[\"\u201c](.+?)[\"\u201d]")
685
+
686
+
687
+ def quote_fragments(quote):
688
+ """A quote split on `...`, `…` and `[...]`, normalised; fragments of >= 3 words."""
689
+ parts = re.split(r"\[(?:\.\.\.|\u2026)\]|\.\.\.|\u2026", str(quote))
690
+ return [f for f in (normalise(p) for p in parts) if len(f.split()) >= 3]
691
+
692
+
693
+ def _locate(fragments, text):
694
+ """[(fragment, index or -1)], each searched after the previous match.
695
+
696
+ A fragment that is not there verbatim is read again with its spaces optional: an
697
+ extraction that dropped one (`human-inthe-loop` for `human-in-the-loop`) still
698
+ carries the fragment's letters in order (K6 rater Y6's probe)."""
699
+ out, pos = [], 0
700
+ for frag in fragments:
701
+ i = text.find(frag, pos)
702
+ if i < 0 and not frag.isalnum():
703
+ found = re.compile(r"(?<!\w)" + r"\s*".join(map(re.escape, frag.split()))
704
+ + r"(?!\w)").search(text, pos)
705
+ i = found.start() if found else -1
706
+ out.append((frag, i))
707
+ if i >= 0:
708
+ pos = i + len(frag)
709
+ return out
710
+
711
+
712
+ def quote_problem(quote, text):
713
+ """release-check's scorer: the 1.2 refusal when a fragment of `quote` is absent
714
+ from `text` (in order), else None."""
715
+ found = _locate(quote_fragments(quote), normalise(text))
716
+ return NOT_IN_SOURCE if any(i < 0 for _f, i in found) else None
717
+
718
+
719
+ def _claims(base):
720
+ return [c for c in _rows(base, "claims.jsonl") if isinstance(c.get("id"), str)]
721
+
722
+
723
+ def cited_tokens(proof):
724
+ """The path tokens a proof cites, trailing sentence punctuation stripped: one
725
+ reading for the capture rules (`_citing`) and the start level (`start_level`),
726
+ so `see literature/smith2020.` cites `smith2020` for both."""
727
+ return {t.rstrip(".,;:)") for t in tr._cited(proof)}
728
+
729
+
730
+ def _citing(claims, note):
731
+ want = {"literature/" + note, note}
732
+ return [c for c in claims if want & cited_tokens(c.get("proof"))]
733
+
734
+
735
+ def claim_quotes(claim):
736
+ """A claim's quotes: its `quote` field and every double-quoted span of >= 3 words
737
+ in its statement."""
738
+ quote = claim.get("quote")
739
+ out = [q for q in (quote if isinstance(quote, list) else [quote]) if isinstance(q, str)]
740
+ out += [q for q in QUOTED.findall(str(claim.get("statement") or "")) if len(q.split()) >= 3]
741
+ return list(dict.fromkeys(q for q in out if q.strip()))
742
+
743
+
744
+ def note_quotes(base, note):
745
+ """The note's own `> "..."` blockquotes."""
746
+ data = _read(base, "literature/" + note)
747
+ return [q for q in BLOCKQUOTE.findall(data.decode("utf-8", "replace")) if q.strip()] \
748
+ if data else []
749
+
750
+
751
+ def gather_quotes(base, note, claims):
752
+ """The quotes the line makes of `note`: those of each claim citing it, and the
753
+ note's own blockquotes."""
754
+ return list(dict.fromkeys([q for c in _citing(claims, note) for q in claim_quotes(c)]
755
+ + note_quotes(base, note)))
756
+
757
+
758
+ def _index_row(base, note):
759
+ return next((r for r in _rows(base, INDEX) if r.get("note") == note), None)
760
+
761
+
762
+ def _receipts_for(base, rid):
763
+ return [r for r in _rows(base, RECEIPTS) if norm_id(r.get("id")) == rid]
764
+
765
+
766
+ def cmd_fetch(repo, args):
767
+ usage = 'fetch <slug> <note> [--file <path> --ack "<the user\'s words>" | --orx]'
768
+ if len(args) < 2:
769
+ return _misuse(usage)
770
+ slug, note, rest = args[0], args[1], list(args[2:])
771
+ path = ack = None
772
+ use_orx = False
773
+ while rest:
774
+ flag = rest.pop(0)
775
+ if flag == "--file" and rest:
776
+ path = rest.pop(0)
777
+ elif flag == "--ack" and rest:
778
+ ack = rest.pop(0)
779
+ elif flag == "--orx":
780
+ use_orx = True
781
+ else:
782
+ return _misuse(usage)
783
+ if path and not (ack or "").strip():
784
+ return _misuse('fetch --file needs --ack "<the user\'s words>"')
785
+ if (path and use_orx) or (ack and not path):
786
+ return _misuse(usage)
787
+ base = _line(repo, slug)
788
+ if base is None:
789
+ return _misuse("no such research line: %s" % slug)
790
+ row = _index_row(base, note)
791
+ if row is None:
792
+ return _misuse("literature/INDEX.jsonl names no note %s" % note)
793
+ rid = norm_id(row.get("id"))
794
+ receipts = _receipts_for(base, rid) if rid else []
795
+ data = text = url = None
796
+ access = "full"
797
+ if path:
798
+ try:
799
+ with open(path, "rb") as fh:
800
+ data = fh.read()
801
+ except OSError as exc:
802
+ return _misuse("cannot read --file %s: %s" % (path, exc))
803
+ text = (html_to_text(data) if path.lower().endswith((".html", ".htm"))
804
+ else data.decode("utf-8", "replace"))
805
+ access = "user"
806
+ tried_orx = False
807
+ orx_text = False
808
+ if text is None and use_orx and rid:
809
+ tried_orx = True
810
+ data, text, url = _orx_paper(rid)
811
+ orx_text = text is not None
812
+ if text is None and rid and rid.startswith("arXiv:"):
813
+ url = "https://arxiv.org/html/%s" % rid[6:]
814
+ status, body = fetch_url(url)
815
+ if status == 200 and body.strip():
816
+ data, text = body, html_to_text(body)
817
+ landing = None
818
+ if text is None and rid and rid.startswith("doi:"):
819
+ url = _oa_url(base, receipts)
820
+ status, body = fetch_url(url) if url else (0, b"")
821
+ if status == 200 and body.strip() and not body.startswith(b"%PDF"):
822
+ page = html_to_text(body)
823
+ if body_sentences(page) >= BODY_SENTENCES:
824
+ data, text = body, page
825
+ else:
826
+ landing = url, body, page
827
+ if text is None and rid and not tried_orx:
828
+ data, text, url = _orx_paper(rid)
829
+ orx_text = text is not None
830
+ reason, norm = None, None
831
+ if (text is not None and orx_text and access == "full"
832
+ and body_sentences(text) < BODY_SENTENCES):
833
+ # the same body test the open-access page gets (K6 rater Y5 and Y6): orx answers
834
+ # with metadata and an abstract for a paper it cannot read whole. A short text
835
+ # that holds one of the line's quotes still backs it, so it stays full.
836
+ norm = normalise(text)
837
+ frags = [f for q in gather_quotes(base, note, _claims(base))
838
+ for f in quote_fragments(q)]
839
+ if not any(f in norm for f in frags):
840
+ access = "abstract"
841
+ reason = ("orx held %d distinct sentences of %d+ words, under the %d of a "
842
+ "work's body, and none of the line's %d quotes: metadata and "
843
+ "abstract, not full text" % (
844
+ body_sentences(text), BODY_WORDS, BODY_SENTENCES, len(frags)))
845
+ if text is None and landing:
846
+ # a repository landing page holds the metadata and the abstract, not the body
847
+ # (K6 rater Y4's Tilburg and EUR Pure pages): the abstract-only step
848
+ (url, data, text), access = landing, "abstract"
849
+ reason = ("the open-access page holds %d distinct sentences of %d+ words, under "
850
+ "the %d of a work's body: a landing page" % (
851
+ body_sentences(text), BODY_WORDS, BODY_SENTENCES))
852
+ elif text is None:
853
+ rec = next((r for r in receipts if r.get("resolved") and _text(r.get("abstract"))),
854
+ None)
855
+ if rec:
856
+ access, url, text = "abstract", rec.get("url"), rec["abstract"]
857
+ data = text.encode("utf-8")
858
+ else:
859
+ access, url = "none", None
860
+ reason = ("no step yielded text: no arXiv HTML, open-access copy, orx full text "
861
+ "or receipt abstract")
862
+ norm = norm if norm is not None else normalise(text or "")
863
+ passages = []
864
+ for quote in gather_quotes(base, note, _claims(base)):
865
+ for frag, i in _locate(quote_fragments(quote), norm):
866
+ passages.append({"quote": quote, "found": i >= 0,
867
+ "text": norm[max(0, i - 300):i + len(frag) + 300] if i >= 0
868
+ else ""})
869
+ record = {"note": note, "id": rid, "url": url, "access": access, "reason": reason,
870
+ "ack": ack, "sha256": _sha(data) if data is not None else None,
871
+ "chars": len(text or ""), "passages": passages}
872
+ files = {}
873
+ if text is not None:
874
+ # the whole extracted text, write-once beside its record: `check` finds a quote
875
+ # written after the capture in it, with no refetch
876
+ whole = text.encode("utf-8")
877
+ record.update(text_file="literature/.sources/%s.txt" % _sha(whole),
878
+ text_sha256=_sha(whole))
879
+ files[record["text_file"]] = whole
880
+ body = (json.dumps(record, indent=1) + "\n").encode("utf-8")
881
+ rel = "literature/.sources/%s.json" % _sha(body)
882
+ files[rel] = body
883
+ code = _store(repo, base, slug, "fetch", files, SOURCES, [
884
+ {"note": note, "file": rel, "sha256": _sha(body), "access": access, "url": url,
885
+ "fetched_at": _now()}])
886
+ if not code:
887
+ print("fetch %s: access %s%s%s" % (note, access, " (%s)" % url if url else "",
888
+ " - %s" % reason if reason else ""))
889
+ return code
890
+
891
+
892
+ def _orx_paper(rid):
893
+ code, out = run_orx(["paper", "--full", _bare(rid)])
894
+ if code == 0 and out.strip():
895
+ return out, out.decode("utf-8", "replace"), "orx paper --full %s" % _bare(rid)
896
+ return None, None, None
897
+
898
+
899
+ def _oa_url(base, receipts):
900
+ for rec in receipts:
901
+ if rec.get("provider") == "openalex" and rec.get("resolved"):
902
+ url = _dig(_json(_read(base, rec.get("file")) or b""), "open_access", "oa_url")
903
+ if isinstance(url, str) and url.startswith(("http://", "https://")):
904
+ return url
905
+ return None
906
+
907
+
908
+ # --- 1.3 searches -------------------------------------------------------------------
909
+ def _allowed(connector, argv):
910
+ return connector in PROVIDERS or (connector == "orx" and isinstance(argv, list)
911
+ and argv[1:2] in ([c] for c in ORX_CONNECTORS))
912
+
913
+
914
+ def schema_ok(connector, data):
915
+ """True when a search's raw bytes parse under its connector's response schema: an
916
+ Atom feed (arXiv), a JSON object holding `message.items` (Crossref and ACM),
917
+ `results` (OpenAlex), `data`/`total` (Semantic Scholar, which omits `data` at 0
918
+ hits), `esearchresult.idlist` (PubMed) or `search-results.entry` (Scopus), UTF-8
919
+ text (orx). A capture filed under another connector is not derived from it."""
920
+ if connector == "arxiv":
921
+ try:
922
+ return ET.fromstring(data).tag == ATOM + "feed"
923
+ except ET.ParseError:
924
+ return False
925
+ if connector == "orx":
926
+ try:
927
+ data.decode("utf-8")
928
+ except UnicodeDecodeError:
929
+ return False
930
+ return True
931
+ doc = _json(data)
932
+ if not isinstance(doc, dict):
933
+ return False
934
+ if connector in ("crossref", "acm"):
935
+ return isinstance(_dig(doc, "message", "items"), list)
936
+ if connector == "pubmed":
937
+ return isinstance(_dig(doc, "esearchresult", "idlist"), list)
938
+ if connector == "scopus":
939
+ return isinstance(_dig(doc, "search-results", "entry"), list)
940
+ if connector == "openalex":
941
+ return isinstance(doc.get("results"), list)
942
+ return connector == "semanticscholar" and (isinstance(doc.get("data"), list)
943
+ or "total" in doc)
944
+
945
+
946
+ def search_records(connector, data):
947
+ """[[normalised ids]] - one list per record - derived from a search's raw output."""
948
+ records = []
949
+ if connector == "arxiv":
950
+ for entry in _atom_entries(data):
951
+ m = re.search(r"abs/(\S+?)(?:v\d+)?/?$", entry["id"].strip())
952
+ records.append([norm_id("arXiv:" + m.group(1))] if m else [])
953
+ elif connector == "orx":
954
+ text = data.decode("utf-8", "replace")
955
+ found = [(m.start(), "arXiv:" + m.group(1)) for m in ORX_ARXIV.finditer(text)]
956
+ found += [(m.start(), "doi:" + m.group(0)) for m in ORX_DOI.finditer(text)]
957
+ ids = list(dict.fromkeys(norm_id(i) for _pos, i in sorted(found)))
958
+ records = [[i] for i in ids if i]
959
+ else:
960
+ doc = _json(data)
961
+ if connector == "pubmed":
962
+ ids = _dig(doc, "esearchresult", "idlist")
963
+ records = [["pubmed:%s" % i] for i in ids if isinstance(i, (str, int))] \
964
+ if isinstance(ids, list) else []
965
+ elif connector == "scopus":
966
+ entries = _dig(doc, "search-results", "entry")
967
+ records = [[norm_id("doi:%s" % e["prism:doi"])]
968
+ for e in entries if isinstance(e, dict) and e.get("prism:doi")] \
969
+ if isinstance(entries, list) else []
970
+ elif connector in ("crossref", "acm"):
971
+ items = _dig(doc, "message", "items")
972
+ records = [[norm_id("doi:%s" % i.get("DOI"))] if i.get("DOI") else []
973
+ for i in items if isinstance(i, dict)] if isinstance(items, list) else []
974
+ elif connector == "openalex":
975
+ results = _dig(doc, "results")
976
+ records = [[norm_id(r.get(k)) for k in ("id", "doi") if r.get(k)]
977
+ for r in results if isinstance(r, dict)] \
978
+ if isinstance(results, list) else []
979
+ elif connector == "semanticscholar":
980
+ data_rows = _dig(doc, "data")
981
+ for r in data_rows if isinstance(data_rows, list) else ():
982
+ if isinstance(r, dict):
983
+ ext = r.get("externalIds") if isinstance(r.get("externalIds"), dict) else {}
984
+ records.append([i for i in (
985
+ norm_id("s2:%s" % r["paperId"]) if r.get("paperId") else None,
986
+ norm_id("arXiv:%s" % ext["ArXiv"]) if ext.get("ArXiv") else None,
987
+ norm_id("doi:%s" % ext["DOI"]) if ext.get("DOI") else None) if i])
988
+ return [[i for i in r if i] for r in records if any(r)]
989
+
990
+
991
+ def cmd_search(repo, args):
992
+ usage = ('search <slug> --tag primary|snowball|counter|known [--item "<scope.in item>"] '
993
+ '-- <connector> <args...>')
994
+ if "--" not in args:
995
+ return _misuse(usage)
996
+ cut = args.index("--")
997
+ head, tail = list(args[:cut]), list(args[cut + 1:])
998
+ if not head or not tail:
999
+ return _misuse(usage)
1000
+ slug, tag, item = head.pop(0), None, None
1001
+ while head:
1002
+ flag = head.pop(0)
1003
+ if flag == "--tag" and head:
1004
+ tag = head.pop(0)
1005
+ elif flag == "--item" and head:
1006
+ item = head.pop(0)
1007
+ else:
1008
+ return _misuse(usage)
1009
+ if tag not in TAGS:
1010
+ return _misuse(usage)
1011
+ base = _line(repo, slug)
1012
+ if base is None:
1013
+ return _misuse("no such research line: %s" % slug)
1014
+ if tag == "counter" and item not in tr._scope_items(tr._state(base)):
1015
+ return _misuse("search --tag counter needs an --item equal to a scope.in entry")
1016
+ connector, words = tail[0], tail[1:]
1017
+ argv = url = None
1018
+ if connector == "orx" and words[:1] in ([c] for c in ORX_CONNECTORS):
1019
+ argv = ["orx"] + words
1020
+ code, data = run_orx(words)
1021
+ status = 200 if code == 0 else code
1022
+ elif connector in PROVIDERS:
1023
+ if not " ".join(words).strip():
1024
+ return _misuse(usage)
1025
+ if connector == "scopus" and not os.environ.get("TEZGAH_SCOPUS_KEY"):
1026
+ return _misuse("the scopus connector needs TEZGAH_SCOPUS_KEY set to an "
1027
+ "Elsevier API key (https://dev.elsevier.com/apikey/manage)")
1028
+ url = SEARCH_URLS[connector] % urllib.parse.quote_plus(" ".join(words))
1029
+ status, data = fetch_url(url, headers={"X-ELS-APIKey": os.environ["TEZGAH_SCOPUS_KEY"]}
1030
+ if connector == "scopus" else None)
1031
+ else:
1032
+ print("FAIL %s: %s: connector not on the allowlist (%s, orx discover, orx paper)"
1033
+ % (slug, connector, ", ".join(PROVIDERS)))
1034
+ return 1
1035
+ if status != 200:
1036
+ print("FAIL %s: the %s search answered %s; nothing was written"
1037
+ % (slug, connector, "status %s" % status if url else "exit %s" % status))
1038
+ return 1
1039
+ if not schema_ok(connector, data):
1040
+ print("FAIL %s: the %s answer does not parse as a %s response; nothing was written"
1041
+ % (slug, connector, connector))
1042
+ return 1
1043
+ records = search_records(connector, data)
1044
+ rows = _rows(base, SEARCHES)
1045
+ rel = "searches/raw/%s.raw" % _sha(data)
1046
+ twin = next((r for r in rows if r.get("file") == rel), None)
1047
+ if twin:
1048
+ print("search: the %s answer is byte-identical to search %s's capture; one capture "
1049
+ "backs one row, so no row was written" % (connector, twin.get("n")))
1050
+ return 0
1051
+ n = 1 + max([r["n"] for r in rows if isinstance(r.get("n"), int)] or [0])
1052
+ row = {"n": n, "tag": tag, "item": item, "connector": connector, "query": " ".join(words)}
1053
+ row.update({"argv": argv} if argv else {"url": url})
1054
+ row.update({"file": rel, "sha256": _sha(data), "hits": len(records),
1055
+ "ids": [identity(r) for r in records], "searched_at": _now(),
1056
+ "parser": PARSER})
1057
+ code = _store(repo, base, slug, "search", {rel: data}, SEARCHES, [row])
1058
+ if not code:
1059
+ print("search %d: %s, %d hit(s)" % (n, connector, len(records)))
1060
+ return code
1061
+
1062
+
1063
+ COMMANDS = {"cite": cmd_cite, "fetch": cmd_fetch, "search": cmd_search}
1064
+
1065
+
1066
+ # --- check ----------------------------------------------------------------------------
1067
+ def _step(steps, claims, name):
1068
+ for claim in claims:
1069
+ names = steps.setdefault(claim["id"], [])
1070
+ if name not in names:
1071
+ names.append(name)
1072
+
1073
+
1074
+ def check(repo, slug, base, state, errors, warnings, strict, git, steps):
1075
+ """Spec 1.1-1.5 on one rules-8 line: refusals into `errors`, soft rules into
1076
+ `warnings`, steps per claim id, report lines into steps["#report"]."""
1077
+ flags = steps.get("#flags") or {}
1078
+ report = steps.setdefault("#report", [])
1079
+ claims = _claims(base)
1080
+ if git:
1081
+ _check_write_once(repo, base, errors)
1082
+ _check_ledger_files(base, errors)
1083
+ _check_receipts(slug, base, claims, errors, warnings, steps, report, flags.get("online"))
1084
+ _check_sources(base, state, claims, errors, warnings, strict, steps)
1085
+ _check_searches(repo, slug, base, state, claims, errors, warnings, steps, report, git)
1086
+ if flags.get("orx"):
1087
+ _census(base, state, warnings)
1088
+
1089
+
1090
+ def _line_key(path, names):
1091
+ for name in names:
1092
+ if path.lower().startswith(name.lower() + "/"):
1093
+ return path[len(name) + 1:]
1094
+ return None
1095
+
1096
+
1097
+ def _check_write_once(repo, base, errors):
1098
+ """1.5: a capture file differs from the blob its first commit added (in a later
1099
+ commit or the working tree), or a ledger row present at a commit changed later."""
1100
+ seen = set()
1101
+ for top, rel in tr._histories(repo, base):
1102
+ names = tr._own_names(rel)
1103
+ out, err = tr._git_out(top, "log", "--root", "--format=%x00%H", "--raw",
1104
+ "--no-abbrev", "--no-renames", "--", *names)
1105
+ if err:
1106
+ continue
1107
+ history = {}
1108
+ for chunk in out.split("\x00")[1:]:
1109
+ lines = chunk.splitlines()
1110
+ for ln in lines[1:]:
1111
+ if not ln.startswith(":") or "\t" not in ln:
1112
+ continue
1113
+ meta, path = ln.split("\t", 1)
1114
+ blob, key = meta.split()[3], _line_key(path, names)
1115
+ if key and CAPTURE.match(key) and not LEDGER.match(key) and blob.strip("0"):
1116
+ history.setdefault(key, []).append((lines[0].strip(), blob))
1117
+ disk = sorted(k for k in history if os.path.isfile(os.path.join(base, k)))
1118
+ blobs = {}
1119
+ if disk:
1120
+ hashed, herr = tr._git_out(top, "hash-object", "--",
1121
+ *[os.path.join(base, k) for k in disk])
1122
+ if not herr:
1123
+ blobs = dict(zip(disk, hashed.split()))
1124
+ for key, versions in sorted(history.items()):
1125
+ first_sha, first = versions[-1]
1126
+ if key not in seen and (any(b != first for _s, b in versions)
1127
+ or blobs.get(key, first) != first):
1128
+ seen.add(key)
1129
+ errors.append("%s changed after its first commit (%s): a capture is written "
1130
+ "once, so a new capture is a new file" % (key, first_sha[:12]))
1131
+ for rel in _ledgers(base):
1132
+ path = os.path.join(base, rel)
1133
+ # the results reading, every ref: a row a tag still holds was committed
1134
+ versions = [text for _sha_, text in tr.file_versions(repo, path)]
1135
+ current, exc = tr._committed_text(path)
1136
+ if not exc:
1137
+ versions.append(current)
1138
+ # each version's rows stay in the latest in their order, so two branches that
1139
+ # appended and merged pass (K6 rater X3's probe-merge) and a reorder does not:
1140
+ # the readers take the latest SOURCES record, the first label, the first draw.
1141
+ # ponytail: a branch not merged yet reads every row it appended as gone from the
1142
+ # latest; ancestry per version pair is the fuller rule.
1143
+ latest = [ln for ln in (versions or [""])[-1].splitlines() if ln.strip()]
1144
+ if not all(_subsequence([ln for ln in older.splitlines() if ln.strip()], latest)
1145
+ for older in versions[:-1]):
1146
+ errors.append("%s: a row changed, moved or went missing after its first commit; "
1147
+ "a ledger is append-only" % rel)
1148
+
1149
+
1150
+ def _subsequence(rows, latest):
1151
+ """`rows` is a subsequence of `latest`: each in it, in the same order."""
1152
+ rest = iter(latest)
1153
+ return all(row in rest for row in rows)
1154
+
1155
+
1156
+ def _ledgers(base):
1157
+ out = [r for r in (RECEIPTS, SOURCES, SEARCHES) if os.path.isfile(os.path.join(base, r))]
1158
+ try:
1159
+ names = sorted(os.listdir(os.path.join(base, "instruments")))
1160
+ except OSError:
1161
+ names = []
1162
+ return out + ["instruments/" + n for n in names if n.endswith(".jsonl")]
1163
+
1164
+
1165
+ def _check_ledger_files(base, errors):
1166
+ """Every ledger row's `file` exists and hashes to its `sha256`; a SEARCHES or
1167
+ SOURCES row with no `file` has no capture."""
1168
+ for rel in _ledgers(base):
1169
+ for n, row in enumerate(_rows(base, rel), 1):
1170
+ if not row.get("file"):
1171
+ if rel in (SEARCHES, SOURCES):
1172
+ errors.append("%s row %d names no file, so it has no capture" % (rel, n))
1173
+ continue
1174
+ data = _read(base, row["file"])
1175
+ if data is None:
1176
+ errors.append("%s row %d names %s, which has no capture" % (rel, n, row["file"]))
1177
+ elif row.get("sha256") and _sha(data) != row["sha256"]:
1178
+ errors.append("%s row %d: hash mismatch - %s does not hash to its sha256"
1179
+ % (rel, n, row["file"]))
1180
+
1181
+
1182
+ def _notes_by_id(base):
1183
+ out = {}
1184
+ for row in _rows(base, INDEX):
1185
+ rid = norm_id(row.get("id"))
1186
+ if rid and isinstance(row.get("note"), str):
1187
+ out.setdefault(rid, []).append(row["note"])
1188
+ return out
1189
+
1190
+
1191
+ def _newer(errors, rel, n, row):
1192
+ """A row naming a parser newer than this one: a later tezgah wrote it, and this
1193
+ check cannot re-derive it. The row's `parser` is read for this alone: an older
1194
+ or missing one is re-derived under this parser like any other row."""
1195
+ version = row.get("parser", 0)
1196
+ if isinstance(version, int) and version > PARSER:
1197
+ errors.append("%s row %d names parser version %d, newer than this check's %d: "
1198
+ "a later tezgah wrote it, so it is not derived here"
1199
+ % (rel, n, version, PARSER))
1200
+ return True
1201
+ return False
1202
+
1203
+
1204
+ def _check_receipts(slug, base, claims, errors, warnings, steps, report, online):
1205
+ # a receipt is judged for an id the INDEX holds now: a corrected id, cited again,
1206
+ # leaves the old id's rows in the append-only ledger unjudged
1207
+ held = {norm_id(r.get("id")) for r in _rows(base, INDEX) if r.get("class") == "formal"}
1208
+ parsed = []
1209
+ for n, row in enumerate(_rows(base, RECEIPTS), 1):
1210
+ rid, provider = norm_id(row.get("id")), row.get("provider")
1211
+ data = _read(base, row.get("file")) if row.get("file") else b""
1212
+ if data is None or not rid or rid not in held:
1213
+ continue
1214
+ if _newer(errors, RECEIPTS, n, row):
1215
+ continue
1216
+ got = parse_receipt(provider, rid, row.get("status"), data)
1217
+ wrong = [k for k in ("resolved", "title", "first_author", "year") if got[k] != row.get(k)]
1218
+ if wrong:
1219
+ errors.append("%s row %d (%s %s) is not derived from its capture: %s differ from "
1220
+ "a re-parse of its raw bytes" % (RECEIPTS, n, rid, provider,
1221
+ ", ".join(wrong)))
1222
+ named = receipt_ids(provider, data)
1223
+ got["id"] = rid if rid in named else None
1224
+ if got["resolved"]:
1225
+ url = dict(receipt_urls(rid)).get(provider)
1226
+ if not got["id"]:
1227
+ # a registry answering an alias with the work's other id: no receipt for
1228
+ # this id, and no refusal - the other providers resolve it, or none does
1229
+ note = ("%s: the %s receipt for %s names %s, not that id, so it does not "
1230
+ "count for it" % (RECEIPTS, provider, rid,
1231
+ ", ".join(sorted(named)) or "no id"))
1232
+ if note not in warnings:
1233
+ warnings.append(note)
1234
+ continue
1235
+ if row.get("url") != url:
1236
+ errors.append("%s row %d (%s %s): %s is not the %s receipt URL for that id "
1237
+ "(%s)" % (RECEIPTS, n, rid, provider, row.get("url"), provider,
1238
+ url))
1239
+ continue
1240
+ parsed.append((row, rid, got))
1241
+ for row in _rows(base, INDEX):
1242
+ if row.get("class") != "formal":
1243
+ continue
1244
+ rid = norm_id(row.get("id")) or row.get("id")
1245
+ note = str(row.get("note"))
1246
+ verdict = citation_verdict(row, [(r.get("provider"), g) for r, i, g in parsed
1247
+ if rid and i == rid])
1248
+ if verdict == NO_REGISTRY:
1249
+ errors.append("literature/%s: %s %s - no receipt resolved it (run `cite`, or "
1250
+ "record `unindexed_reason`)" % (note, row.get("id") or "an empty id",
1251
+ NO_REGISTRY))
1252
+ elif verdict:
1253
+ _step(steps, _citing(claims, note), verdict)
1254
+ if not online:
1255
+ return
1256
+ # only a receipt recorded from a 200 has an answer to drift from (a 429 row of an
1257
+ # older `cite` read as drift once the provider answered)
1258
+ targets = [(r, i, g) for r, i, g in parsed if r.get("status") == 200]
1259
+ if len(targets) > 10:
1260
+ targets = sorted(targets, key=lambda t: str(t[0].get("sha256") or ""))[:10]
1261
+ notes = _notes_by_id(base)
1262
+ for row, rid, got in targets:
1263
+ provider = row.get("provider")
1264
+ status, data = fetch_url(dict(receipt_urls(rid)).get(provider)
1265
+ or str(row.get("url") or ""))
1266
+ now = parse_receipt(provider, rid, status, data)
1267
+ now["id"] = rid if rid in receipt_ids(provider, data) else None
1268
+ for field in ("id", "title", "first_author", "year"):
1269
+ if now[field] != got[field]:
1270
+ report.append("online %s: %s %s %s drifted" % (slug, rid, provider, field))
1271
+ for note in notes.get(rid, ()):
1272
+ _step(steps, _citing(claims, note), "online-drift")
1273
+
1274
+
1275
+ def _source_records(base):
1276
+ """{note: [(ledger row, record)]} in ledger order. A row counts only when its
1277
+ `file` is a `.sources/` record for that note: a row with no file, or naming a
1278
+ receipt or another note's record, sets no access."""
1279
+ out = {}
1280
+ for row in _rows(base, SOURCES):
1281
+ note, rel = row.get("note"), row.get("file")
1282
+ if not (isinstance(note, str) and isinstance(rel, str)
1283
+ and rel.startswith("literature/.sources/") and rel.endswith(".json")):
1284
+ continue
1285
+ record = _json(_read(base, rel) or b"")
1286
+ if isinstance(record, dict) and record.get("note") == note:
1287
+ out.setdefault(note, []).append((row, record))
1288
+ return out
1289
+
1290
+
1291
+ def _access(entry):
1292
+ return entry[1].get("access")
1293
+
1294
+
1295
+ def _in_order(frags, texts):
1296
+ """Fragment k in texts[k], and no adjacent pair reversed in a window holding both."""
1297
+ if len(texts) != len(frags) or not all(f in t for f, t in zip(frags, texts)):
1298
+ return False
1299
+ return not any(frags[k] in t and frags[k + 1] in t and _locate(frags[k:k + 2], t)[1][1] < 0
1300
+ for k in range(len(frags) - 1) for t in texts[k:k + 2])
1301
+
1302
+
1303
+ def _found(quote, records, texts=()):
1304
+ """1.2 at `check`: every fragment of `quote` found in order - all in one whole
1305
+ captured text (normalised `texts`: the sources' extracted text, the receipts'
1306
+ abstracts) or one passage text of the captures, or fragment k in the k-th passage
1307
+ a capture stored for this quote (fragments far apart sit in separate windows)."""
1308
+ frags = quote_fragments(quote)
1309
+ if not frags:
1310
+ return True
1311
+ stored = [[p for p in r.get("passages") or () if isinstance(p, dict)] for r in records]
1312
+ if any(all(i >= 0 for _f, i in _locate(frags, t))
1313
+ for t in list(texts) + [str(p.get("text") or "") for ps in stored for p in ps]):
1314
+ return True
1315
+ return any(_in_order(frags, [str(p.get("text") or "") for p in ps if p.get("quote") == quote])
1316
+ for ps in stored)
1317
+
1318
+
1319
+ def _source_text(base, record, errors):
1320
+ """The whole extracted text a `fetch` record names, or None; a named text file that
1321
+ is gone or hashes otherwise is refused like a ledger's capture."""
1322
+ rel = record.get("text_file")
1323
+ if not rel:
1324
+ return None
1325
+ data = _read(base, rel) if str(rel).startswith("literature/.sources/") else None
1326
+ if data is None:
1327
+ problem = "a source record names %s, which has no capture" % rel
1328
+ elif _sha(data) != record.get("text_sha256"):
1329
+ problem = "hash mismatch - %s does not hash to its text_sha256" % rel
1330
+ else:
1331
+ return data.decode("utf-8", "replace")
1332
+ if problem not in errors:
1333
+ errors.append(problem)
1334
+ return None
1335
+
1336
+
1337
+ def _receipt_abstracts(base, rid):
1338
+ """The abstracts re-parsed from the raw receipts that name `rid`."""
1339
+ out = []
1340
+ for row in _receipts_for(base, rid) if rid else ():
1341
+ provider, data = row.get("provider"), _read(base, row.get("file"))
1342
+ if data and rid in receipt_ids(provider, data):
1343
+ abstract = parse_receipt(provider, rid, row.get("status"), data)["abstract"]
1344
+ out += [abstract] if abstract else []
1345
+ return out
1346
+
1347
+
1348
+ def _quote_missing(errors, where, quote, notes, searched):
1349
+ """1.2's refusal: `quote` found in the captured text of none of `notes`."""
1350
+ if not any(_found(quote, *searched[n]) for n in notes):
1351
+ errors.append('%s: "%s": %s (searched %s; if it is not a quote, rephrase it without '
1352
+ 'quotation marks; if it is, `fetch --file` the version quoted)'
1353
+ % (where, quote, NOT_IN_SOURCE,
1354
+ ", ".join("literature/" + n for n in notes)))
1355
+
1356
+
1357
+ def _check_sources(base, state, claims, errors, warnings, strict, steps):
1358
+ captures = _source_records(base)
1359
+ index = _rows(base, INDEX)
1360
+ texts = {n: [_source_text(base, rec, errors) for _row, rec in c]
1361
+ for n, c in captures.items()}
1362
+ access, searched = {}, {}
1363
+ for row in index:
1364
+ note = row.get("note")
1365
+ if not isinstance(note, str):
1366
+ continue
1367
+ mine = captures.get(note, [])
1368
+ access[note] = _access(mine[-1]) if mine else None
1369
+ citing = _citing(claims, note)
1370
+ if access[note] in (None, "none"):
1371
+ _step(steps, citing, "source-not-captured")
1372
+ elif access[note] == "abstract":
1373
+ _step(steps, citing, "abstract-only")
1374
+ elif access[note] in ("full", "user"):
1375
+ records = [rec for _row, rec in mine if rec.get("access") in ("full", "user")]
1376
+ whole = [normalise(t) for (_row, rec), t in zip(mine, texts.get(note, []))
1377
+ if t is not None and rec.get("access") in ("full", "user")]
1378
+ whole += [normalise(a) for a in _receipt_abstracts(base, norm_id(row.get("id")))]
1379
+ searched[note] = (records, whole)
1380
+ for quote in note_quotes(base, note):
1381
+ _quote_missing(errors, "literature/" + note, quote, [note], searched)
1382
+ # a claim's quote is searched in every note it cites, and refused only when every one
1383
+ # of them holds full text and none holds the quote: a cited note without full text
1384
+ # already narrows the claim by its step. A claim another row supersedes is history.
1385
+ superseded = tr._superseded(claims)
1386
+ for claim in claims:
1387
+ notes = [n for n in access if _citing([claim], n)]
1388
+ if claim["id"] in superseded or not notes or not all(n in searched for n in notes):
1389
+ continue
1390
+ for quote in claim_quotes(claim):
1391
+ _quote_missing(errors, "claim " + claim["id"], quote, notes, searched)
1392
+ formal = [r.get("note") for r in index if r.get("class") == "formal"]
1393
+ if not formal or not tr._concluding(base, state):
1394
+ return
1395
+ latest = {n: _access(c[-1]) for n, c in captures.items() if c}
1396
+ closed = sum(1 for n in formal if latest.get(n) in (None, "abstract", "none"))
1397
+ user = sum(1 for n in formal if latest.get(n) == "user")
1398
+ text = (_read(base, "to_human/report.md") or b"").decode("utf-8", "replace")
1399
+ # the share counts every source without full text, closed and abstract-only alike,
1400
+ # under any of the words a report may use for it
1401
+ shares = ("closed-access", "not full text", "abstract-only")
1402
+ if not any(re.search(r"%s:\s*%d\s+of\s+%d\b" % (re.escape(w), closed, len(formal)), text)
1403
+ for w in shares):
1404
+ tr._soft(errors, warnings, strict, "report states no closed-access share: %d of %d "
1405
+ "sources have no full text (%s)" % (closed, len(formal), " or ".join(
1406
+ "`%s: %d of %d`" % (w, closed, len(formal)) for w in shares)))
1407
+ if user and not re.search(r"user-supplied:\s*%d\s+of\s+%d\s+sources" % (user, len(formal)),
1408
+ text):
1409
+ tr._soft(errors, warnings, strict, "report states no user-supplied share "
1410
+ "(`user-supplied: %d of %d sources`)" % (user, len(formal)))
1411
+
1412
+
1413
+ def _search_line(state):
1414
+ cls = state.get("question_class")
1415
+ return cls is None or cls in SEARCH_CLASSES or state.get("method") == "qualitative"
1416
+
1417
+
1418
+ def _valid_stopping(stop):
1419
+ if not isinstance(stop, dict):
1420
+ return False
1421
+
1422
+ def unit(v):
1423
+ return isinstance(v, (int, float)) and not isinstance(v, bool) and 0 < v < 1
1424
+
1425
+ rule = stop.get("rule")
1426
+ if rule == "hypergeometric":
1427
+ return unit(stop.get("recall")) and unit(stop.get("confidence"))
1428
+ if rule == "saturation":
1429
+ run = stop.get("run")
1430
+ return isinstance(run, int) and not isinstance(run, bool) and run > 0
1431
+ return rule == "pragmatic" and isinstance(stop.get("risk"), str) and bool(
1432
+ stop["risk"].strip())
1433
+
1434
+
1435
+ def _stopping_first(repo, base):
1436
+ """True when a valid `state.stopping` was committed strictly before the commit
1437
+ that added the first SEARCHES row (or when no search row is committed yet)."""
1438
+ searches = tr.file_versions(repo, os.path.join(base, SEARCHES), every_ref=False)
1439
+ if not searches:
1440
+ return True
1441
+ first = next((sha for sha, text in tr.file_versions(repo, os.path.join(base, "state.json"),
1442
+ every_ref=False)
1443
+ if _valid_stopping(_dig(_json(text.encode("utf-8")), "stopping"))), None)
1444
+ return bool(first) and first != searches[0][0] and tr.is_ancestor(
1445
+ repo, first, searches[0][0]) is True
1446
+
1447
+
1448
+ def _check_searches(repo, slug, base, state, claims, errors, warnings, steps, report, git):
1449
+ rows = _rows(base, SEARCHES)
1450
+ per_row, counted, backs = [], [], {}
1451
+ for n, row in enumerate(rows, 1):
1452
+ connector, rel = row.get("connector"), row.get("file")
1453
+ # only a row whose file is a search capture, backing no earlier row, counts
1454
+ # (connector, tag, records)
1455
+ data = None
1456
+ if rel and not str(rel).startswith("searches/raw/"):
1457
+ errors.append("%s row %d names %s, which is not under searches/raw/, so it has "
1458
+ "no capture" % (SEARCHES, n, rel))
1459
+ elif rel in backs:
1460
+ errors.append("%s row %d names %s, which backs row %d: one capture backs one "
1461
+ "row, so it has no capture" % (SEARCHES, n, rel, backs[rel]))
1462
+ elif rel:
1463
+ backs[rel] = n
1464
+ data = _read(base, rel)
1465
+ if not _allowed(connector, row.get("argv")):
1466
+ errors.append("%s row %d names %r: connector not on the allowlist"
1467
+ % (SEARCHES, n, connector))
1468
+ data = None
1469
+ if data is not None and _newer(errors, SEARCHES, n, row):
1470
+ data = None
1471
+ elif data is not None and not schema_ok(connector, data):
1472
+ errors.append("%s row %d is not derived from its capture: %s does not parse as a "
1473
+ "%s response" % (SEARCHES, n, rel, connector))
1474
+ data = None
1475
+ else:
1476
+ records = search_records(connector, data) if data is not None else []
1477
+ if data is not None and (row.get("hits") != len(records)
1478
+ or row.get("ids") != [identity(r) for r in records]):
1479
+ errors.append("%s row %d is not derived from its capture: hits/ids differ "
1480
+ "from a re-parse of %s" % (SEARCHES, n, rel))
1481
+ if data is not None:
1482
+ counted.append(row)
1483
+ per_row.append(records)
1484
+ owner = {}
1485
+ for records in per_row:
1486
+ for rec in records:
1487
+ for i in rec:
1488
+ owner.setdefault(i, identity(rec))
1489
+ literature = [c for c in claims if any(t.startswith("literature/")
1490
+ for t in tr._cited(c.get("proof")))]
1491
+ labels, decided = [], set()
1492
+ for row in _rows(base, SCREENING):
1493
+ sid = norm_id(row.get("id")) or row.get("id")
1494
+ if sid not in owner:
1495
+ # a hand search's record: screened, but no capture shows a search returned it
1496
+ _step(steps, literature, "screened-outside-search")
1497
+ continue
1498
+ if owner[sid] not in decided and row.get("decision") in ("kept", "dropped"):
1499
+ decided.add(owner[sid])
1500
+ labels.append(1 if row["decision"] == "kept" else 0)
1501
+ if not _search_line(state):
1502
+ return
1503
+ connectors = {(r.get("connector"), tuple(r.get("argv") or ())[1:2]) for r in counted}
1504
+ if len(connectors) < 2:
1505
+ _step(steps, literature, "one-connector")
1506
+ for item in tr._scope_items(state):
1507
+ if not any(r.get("tag") == "counter" and r.get("item") == item for r in counted):
1508
+ _step(steps, literature, "no-counter-search")
1509
+ stop = state.get("stopping")
1510
+ if not _valid_stopping(stop) or (git and not _stopping_first(repo, base)):
1511
+ _step(steps, literature, "no-stopping-rule")
1512
+ if _valid_stopping(stop):
1513
+ if stop["rule"] == "hypergeometric":
1514
+ p = st.hypergeometric_p(labels, len(set(owner.values())), stop["recall"])
1515
+ met = p < 1 - stop["confidence"]
1516
+ report.append("stopping %s: hypergeometric p=%.6f %s"
1517
+ % (slug, p, "met" if met else "not met"))
1518
+ else:
1519
+ met = stop["rule"] == "pragmatic" or st.saturated(
1520
+ [[identity(r) for r in records] for records in per_row], stop["run"])
1521
+ report.append("stopping %s: %s %s" % (slug, stop["rule"], "met" if met
1522
+ else "not met"))
1523
+ if not met:
1524
+ _step(steps, literature, "stopping-not-met")
1525
+ known = state.get("known_items")
1526
+ for item in known if isinstance(known, list) else ():
1527
+ if (norm_id(item) or item) not in owner:
1528
+ _step(steps, literature, "known-item-missed")
1529
+
1530
+
1531
+ def _census(base, state, warnings):
1532
+ """1.4: every engine run of this line's experiment has a results row."""
1533
+ runs, err = engine_runs(state)
1534
+ if err:
1535
+ warnings.append("%s, so the run census was not taken" % err)
1536
+ if not runs:
1537
+ return
1538
+ filed = set()
1539
+ for h in tr._experiment_names(base):
1540
+ filed |= {r.get("source") for r in _rows(base, "experiments/%s/results.jsonl" % h)}
1541
+ for run in runs:
1542
+ rid = run_id(run)
1543
+ if rid and "orx:%s" % rid not in filed:
1544
+ warnings.append("orx run %s has no results row" % rid)