@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
@@ -83,6 +83,7 @@ the offset just past its last newline (`tezgah_integrity.committed_size`) - so a
83
83
  fragment a killed writer left behind is neither read as a record by the readers
84
84
  here nor appended beside as one.
85
85
  """
86
+ import decimal
86
87
  import fnmatch
87
88
  import json
88
89
  import math
@@ -150,7 +151,14 @@ PROTOCOL_RULES = 4
150
151
  SCOPE_RULES = 5
151
152
  HOLDOUT_RULES = 6
152
153
  EXPLORE_RULES = 7
153
- RULES = EXPLORE_RULES
154
+ # Rules 8 (plan 127): evidence captured by the tool and claims narrowed rather
155
+ # than lines refused.
156
+ EVIDENCE_RULES = 8
157
+ # Rules 9 (plan 136): a decision.md writes the line's own pick as `recommended:`;
158
+ # `chosen:` is the owner's, recorded by `choose` with an `owner:` line carrying
159
+ # the owner's words. `init` writes 9.
160
+ OWNER_CHOICE_RULES = 9
161
+ RULES = OWNER_CHOICE_RULES
154
162
  # How a rules-5 line answers its question. `qualitative` is a source or
155
163
  # reference review and owes no metric; `measured` runs experiments and locks
156
164
  # one. The question picks it, not the tooling: a qualitative ask answered with a
@@ -357,6 +365,27 @@ def slugs(repo):
357
365
  return sorted(seen)
358
366
 
359
367
 
368
+ def stray_dirs(repo):
369
+ """[(path, error)] for the hidden dirs under research/, its open/ or its done/:
370
+ `slugs` skips them, so a line written into one (a `.staging` beside a refusal)
371
+ is invisible to every check. `status` and the no-slug `check` report each."""
372
+ out = []
373
+ for folder in layout_roots(repo):
374
+ try:
375
+ names = sorted(os.listdir(folder))
376
+ except OSError:
377
+ continue
378
+ for name in names:
379
+ path = os.path.join(folder, name)
380
+ if name.startswith(".") and os.path.isdir(path):
381
+ rel = os.path.relpath(path, repo)
382
+ out.append((rel, "%s is not a research line: a line lives under "
383
+ "research/open or research/done; import it with "
384
+ "`tezgah-research init` then move its files, or "
385
+ "delete it" % rel))
386
+ return out
387
+
388
+
360
389
  def line_dir(repo, slug):
361
390
  """The one resolver: the first layout root that holds the line, else the
362
391
  `open/` path a new line is created at."""
@@ -485,6 +514,20 @@ def _ws_rel(repo, path):
485
514
  return rel if os.path.exists(os.path.join(ws, ".git")) else None
486
515
 
487
516
 
517
+ def folded(repo):
518
+ """True when `<repo>/.tezgah` is a folded workspace: a directory the project
519
+ itself tracks and that holds no repository of its own, so the project's
520
+ history is the one that carries it (`ensure_workspace` answers None for it by
521
+ design). A symlinked `.tezgah` and a tree with no `.git` are not folded: the
522
+ first is refused as repository-provided, the second has no history at all.
523
+ `workspace_tracked` wants positive evidence, so an index that cannot be read
524
+ is not folded either."""
525
+ ws = tp.workspace(repo)
526
+ return (os.path.isdir(ws) and not os.path.islink(ws)
527
+ and not os.path.lexists(os.path.join(ws, ".git"))
528
+ and tp.workspace_tracked(repo))
529
+
530
+
488
531
  def _ws_log(repo, path, *args):
489
532
  """(shas, error) of the private repository's `git log <args>` over `path`'s
490
533
  own layout names (`_own_names`) along its HEAD, ([], None) when `path` is
@@ -952,6 +995,13 @@ def _check_state(base, errors, warnings, strict):
952
995
  _check_derived_phase(base, state.get("phase"), state.get("direction"), warnings)
953
996
 
954
997
 
998
+ def _verdict_error(sid, verdict):
999
+ """The error a verdict outside `VERDICTS` gets; `broken_open_lines` builds the
1000
+ same text for a `pending` one, so an in-flight line is told from a broken one."""
1001
+ return "success criterion %s: verdict %r is not one of %s" % (
1002
+ sid, verdict, ", ".join(VERDICTS))
1003
+
1004
+
955
1005
  def _check_success(state, errors, warnings, strict):
956
1006
  """The ask contract, on a line that states an ask (rules 3 onward).
957
1007
 
@@ -983,8 +1033,7 @@ def _check_success(state, errors, warnings, strict):
983
1033
  if not sid or not criterion:
984
1034
  errors.append("a success criterion carries no id or no text")
985
1035
  if verdict and verdict not in VERDICTS:
986
- errors.append("success criterion %s: verdict %r is not one of %s"
987
- % (sid, verdict, ", ".join(VERDICTS)))
1036
+ errors.append(_verdict_error(sid, verdict))
988
1037
  if verdict in VERDICTS and not evidence:
989
1038
  errors.append("success criterion %s: a verdict with no evidence "
990
1039
  "pointer is an opinion" % sid)
@@ -992,7 +1041,7 @@ def _check_success(state, errors, warnings, strict):
992
1041
  unjudged = [sid for sid, _c, verdict, _e in rows if verdict not in VERDICTS]
993
1042
  if unjudged:
994
1043
  errors.append("concluded with no verdict for %s: judge each criterion "
995
- "(`tezgah-research verdict`) before concluding"
1044
+ "(`~/.config/tezgah/bin/tezgah-research verdict`) before concluding"
996
1045
  % ", ".join(unjudged))
997
1046
  if unanswered(state) and not (state.get("closed") or {}).get("ack") \
998
1047
  and not _user_event(state):
@@ -1237,7 +1286,7 @@ def _check_findings(base, errors, warnings, strict):
1237
1286
  missing = [s for s in FINDINGS_SECTIONS if ("## " + s) not in text]
1238
1287
  if missing:
1239
1288
  errors.append("findings.md does not answer: %s" % ", ".join(missing))
1240
- _check_patterns(text, errors, warnings, strict)
1289
+ _check_patterns(text, errors, warnings, strict, _evidence(_state(base)))
1241
1290
 
1242
1291
 
1243
1292
  PATTERNS_BULLET = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$")
@@ -1256,7 +1305,7 @@ def _section(text, name):
1256
1305
  return out
1257
1306
 
1258
1307
 
1259
- def _check_patterns(text, errors, warnings, strict):
1308
+ def _check_patterns(text, errors, warnings, strict, wrapped):
1260
1309
  """Every bullet under `## Patterns` names the source it came from.
1261
1310
 
1262
1311
  A pattern is the one finding a reader is meant to carry into other work, so
@@ -1265,9 +1314,17 @@ def _check_patterns(text, errors, warnings, strict):
1265
1314
  none is a guess wearing the clothes of a finding, which is exactly the
1266
1315
  failure the review exists to catch, and it is reported once per line with the
1267
1316
  count so a line with seventeen of them says so in one row rather than
1268
- seventeen."""
1269
- bullets = [m.group(1).strip() for line in _section(text, "Patterns")
1270
- for m in [PATTERNS_BULLET.match(line)] if m]
1317
+ seventeen. With `wrapped` (a rules-8 line, `_evidence`) a bullet is its first
1318
+ line plus every following indented, non-blank line before the next bullet
1319
+ (spec 4.7): a source wrapped onto a continuation line is still the bullet's
1320
+ source. An older line reads each bullet's first line, as it was opened under."""
1321
+ bullets = []
1322
+ for line in _section(text, "Patterns"):
1323
+ found = PATTERNS_BULLET.match(line)
1324
+ if found:
1325
+ bullets.append(found.group(1).strip())
1326
+ elif wrapped and bullets and line[:1].isspace() and line.strip():
1327
+ bullets[-1] += " " + line.strip()
1271
1328
  unsourced = [b for b in bullets if not PATTERNS_SOURCE.search(b)]
1272
1329
  if unsourced:
1273
1330
  _soft(errors, warnings, strict,
@@ -1707,14 +1764,17 @@ def _check_claims(base, errors, warnings, roots=(), strict=False):
1707
1764
  # held to their proof here. Warning on the historical statement too would
1708
1765
  # make a correction that rewords a figure impossible to land (it already
1709
1766
  # cannot edit the row), for a sentence no reader is meant to keep.
1710
- if cid not in superseded:
1767
+ # rules 8 replaces this rule by `untraced-number`, a step that never refuses
1768
+ if cid not in superseded and not _evidence(line_state):
1711
1769
  _check_claim_numbers(cid, claim, base, repo, errors, warnings, strict)
1712
1770
  _check_claim_raters(cid, claim, base, errors, warnings, strict)
1713
1771
  if sections and cid not in superseded:
1714
1772
  problem = _token_claim_problem(cid, claim, base)
1715
1773
  if problem:
1716
1774
  errors.append(problem)
1717
- _check_supersedes(parsed, errors, warnings, strict)
1775
+ _check_supersedes(parsed, errors, warnings, strict,
1776
+ _note_text(os.path.join(base, "to_human", "report.md"))
1777
+ if _evidence(line_state) else None)
1718
1778
  if not count:
1719
1779
  warnings.append("no claims recorded yet")
1720
1780
  return count
@@ -1738,7 +1798,7 @@ def _token_claim_problem(cid, claim, base):
1738
1798
  return None
1739
1799
 
1740
1800
 
1741
- def _check_supersedes(parsed, errors, warnings, strict):
1801
+ def _check_supersedes(parsed, errors, warnings, strict, report=None):
1742
1802
  """A claim that supersedes another names one this line holds, and says so.
1743
1803
 
1744
1804
  The record is append-only: a claim whose number went stale is corrected by a
@@ -1760,6 +1820,10 @@ def _check_supersedes(parsed, errors, warnings, strict):
1760
1820
  errors.append("claim %s supersedes %s, and no claim in this line "
1761
1821
  "carries that id" % (cid, ref))
1762
1822
  continue
1823
+ # rules 8 (spec 4.6): a report line naming both ids tells the reader
1824
+ if report is not None and any(_mentions(cid, line) and _mentions(ref, line)
1825
+ for line in report.splitlines()):
1826
+ continue
1763
1827
  _soft(errors, warnings, strict,
1764
1828
  "claim %s supersedes %s: a reader who opens %s alone reads the "
1765
1829
  "statement this line has since replaced" % (cid, ref, ref))
@@ -1787,6 +1851,11 @@ def _supersedes(claim):
1787
1851
  return ids, None
1788
1852
 
1789
1853
 
1854
+ def _superseded(rows):
1855
+ """The claim ids some row supersedes (`_supersedes`): the historical rows."""
1856
+ return {ref for row in rows for ref in _supersedes(row)[0]}
1857
+
1858
+
1790
1859
  def _claim_ids(base, handle=None):
1791
1860
  """The ids `claims.jsonl` already carries, for the rule that a claim may only
1792
1861
  supersede one this line holds. A row that does not parse is skipped here: the
@@ -2027,6 +2096,12 @@ def claim_problems(claim, base, repo, held=None):
2027
2096
  if scopes and all(s == "fixture" for s in scopes):
2028
2097
  problems.append("claim %s declares scope real and every row it rests on "
2029
2098
  "records fixture" % cid)
2099
+ # rules 8: the claim refusals `_core_rules8` applies, from the same functions, and
2100
+ # like it never to a claim a row supersedes (the correction is that row)
2101
+ if _evidence(_state(base)) and cid not in _superseded(
2102
+ _row_objects(os.path.join(base, "claims.jsonl"))):
2103
+ problems.extend(derived_problems(cid, claim, base))
2104
+ problems.extend(fired_problems(cid, claim, base))
2030
2105
  return problems
2031
2106
 
2032
2107
 
@@ -2329,10 +2404,17 @@ def _check_tracking(repo, base, errors, warnings, strict):
2329
2404
  if not pattern:
2330
2405
  continue
2331
2406
  rel = _ws_rel(repo, path)
2332
- fix = ("`git -C .tezgah add -f %s`" % rel if rel is not None else
2333
- "`.tezgah` has no git repository of its own - `tezgah-research "
2334
- "commit %s \"<message>\"` creates it and commits the line there"
2335
- % os.path.basename(base))
2407
+ if rel is not None:
2408
+ fix = "`git -C .tezgah add -f %s`" % rel
2409
+ elif folded(repo):
2410
+ # a forced add of a `.tezgah/` path is the control rule's to refuse
2411
+ fix = ("`.tezgah` is tracked by the project - drop that ignore line, "
2412
+ "then `~/.config/tezgah/bin/tezgah-research commit %s \"<message>\"` commits the "
2413
+ "line in the project's history" % os.path.basename(base))
2414
+ else:
2415
+ fix = ("`.tezgah` has no git repository of its own - `~/.config/tezgah/bin/tezgah-research "
2416
+ "commit %s \"<message>\"` creates it and commits the line there"
2417
+ % os.path.basename(base))
2336
2418
  _soft(errors, warnings, strict,
2337
2419
  "experiment %s: %s is ignored by %s, so no commit can show the "
2338
2420
  "protocol predates the results; %s"
@@ -2532,7 +2614,7 @@ def _check_literature(base, errors, warnings, strict):
2532
2614
  _soft(errors, warnings, note in legacy or newer,
2533
2615
  "literature/%s is not in INDEX.jsonl: a note the index does not "
2534
2616
  "name is a source no reader can weigh" % note)
2535
- _index_fields(rows, errors, warnings, strict)
2617
+ _index_fields(rows, errors, warnings, strict, _evidence(_state(base)))
2536
2618
 
2537
2619
 
2538
2620
  def _read_index(index, literature, errors, warnings, strict):
@@ -2572,9 +2654,10 @@ def _read_index(index, literature, errors, warnings, strict):
2572
2654
  return rows, named
2573
2655
 
2574
2656
 
2575
- def _index_fields(rows, errors, warnings, strict):
2657
+ def _index_fields(rows, errors, warnings, strict, evidence=False):
2576
2658
  """The index entries the rules read: a grey source's quality, a verification
2577
- against two records, and the three fields a note has to carry."""
2659
+ against two records (rules 8 reads its receipts instead), and the three fields a
2660
+ note has to carry."""
2578
2661
  total = len(rows)
2579
2662
  if not total:
2580
2663
  return
@@ -2585,8 +2668,8 @@ def _index_fields(rows, errors, warnings, strict):
2585
2668
  "literature/INDEX.jsonl: %d of %d rows are a grey source with no "
2586
2669
  "quality note, so nothing says how good the practice evidence is"
2587
2670
  % (len(grey), total))
2588
- verified = [r for r in rows
2589
- if not isinstance(r.get("verified"), list) or len(r["verified"]) < 2]
2671
+ verified = [] if evidence else [
2672
+ r for r in rows if not isinstance(r.get("verified"), list) or len(r["verified"]) < 2]
2590
2673
  if verified:
2591
2674
  _soft(errors, warnings, strict,
2592
2675
  "literature/INDEX.jsonl: %d of %d rows are verified by fewer than "
@@ -2761,6 +2844,11 @@ def _rules(state):
2761
2844
  return value if isinstance(value, int) and not isinstance(value, bool) else 1
2762
2845
 
2763
2846
 
2847
+ def _evidence(state):
2848
+ """True for a line opened under the evidence rules (`EVIDENCE_RULES`)."""
2849
+ return _rules(state) >= EVIDENCE_RULES
2850
+
2851
+
2764
2852
  def _state(base):
2765
2853
  """state.json as an object, or {} when it is missing or unreadable."""
2766
2854
  state, exc = _read_json(os.path.join(base, "state.json"))
@@ -2770,10 +2858,10 @@ def _state(base):
2770
2858
  def newer_rules(state):
2771
2859
  """The refusal for a line a later tezgah wrote, or None.
2772
2860
 
2773
- A `rules` above `RULES` names rules this code does not implement. Reading the
2774
- line by the rules it does know would pass what the later set refuses, and a
2775
- writer would append rows judged by the older set. So the line is refused by
2776
- name, before any rule reads it."""
2861
+ A `rules` above `RULES` names rules this code does not implement.
2862
+ Reading the line by the rules it does know would pass what the later set
2863
+ refuses, and a writer would append rows judged by the older set. So the line
2864
+ is refused by name, before any rule reads it."""
2777
2865
  found = _rules(state)
2778
2866
  if found <= RULES:
2779
2867
  return None
@@ -2791,20 +2879,22 @@ def _concluding(base, state):
2791
2879
  or os.path.isfile(os.path.join(base, "to_human", "report.md")))
2792
2880
 
2793
2881
 
2794
- def file_versions(repo, path):
2882
+ def file_versions(repo, path, every_ref=True):
2795
2883
  """[(sha, text)] of every committed version of `path`, oldest first; [] when git
2796
2884
  cannot say. The append-only rule compares versions, which the added/last-touched
2797
2885
  pair cannot. The project's history is read first and the private `.tezgah`
2798
2886
  repository's after it, like `added_commits`: a line committed in the project
2799
2887
  before the move has its older versions there.
2800
2888
 
2801
- The order rules read HEAD's lineage; this reads every ref of the private
2802
- repository (`--all`), because it looks for a rewrite and not an order: a row
2803
- amended away while a tag keeps the old commit is a result rewritten after the
2804
- run, which HEAD alone no longer shows (an internal research decision). A side
2805
- ref can only add a version, so it can refuse a line and never pass one."""
2889
+ By default this reads every ref of the private repository (`--all`), because
2890
+ the results rule looks for a rewrite: a row amended away while a tag keeps the
2891
+ old commit is a result rewritten after the run, which HEAD alone no longer
2892
+ shows (an internal research decision); the append-only capture ledgers read it
2893
+ the same way. `every_ref=False` reads HEAD's lineage only, for the order rule of
2894
+ the captures: a backdated side branch read through `--all` supplied the oldest
2895
+ version and satisfied the stopping-rule order."""
2806
2896
  return [v for top, rel in _histories(repo, path)
2807
- for v in _versions(top, rel, ("--all",) if top != repo else ())]
2897
+ for v in _versions(top, rel, ("--all",) if every_ref and top != repo else ())]
2808
2898
 
2809
2899
 
2810
2900
  def _versions(top, rel, extra=()):
@@ -2890,19 +2980,22 @@ def _evaluation_commit(repo, path):
2890
2980
  return {top: _first_lock(top, rel) for top, rel in _histories(repo, path)}
2891
2981
 
2892
2982
 
2983
+ def _orders_lock(state):
2984
+ """True when a line's results are ordered against its evaluation lock: the
2985
+ arena is an exploratory line's lock, and `_check_arena_order` orders every
2986
+ results.jsonl against it instead; a state with no evaluation has none."""
2987
+ return not _exploratory(state) and isinstance(state.get("evaluation"), dict)
2988
+
2989
+
2893
2990
  def _check_evaluation_order(repo, base, state, errors, warnings, strict):
2894
2991
  """The evaluation is locked before the first result, and `locked_at` names a
2895
2992
  commit the history holds. `_check_evaluation` reads that the fields are filled;
2896
2993
  this reads when: a metric and a baseline committed after the results are the
2897
2994
  criterion the results were measured against after the fact."""
2898
- if _exploratory(state):
2899
- # the arena is an exploratory line's lock, and `_check_arena_order` orders
2900
- # every results.jsonl against it the way this orders them against a metric
2995
+ if not _orders_lock(state):
2901
2996
  return
2902
2997
  hard = strict or _rules(state) >= STANDARDS_RULES
2903
- evaluation = state.get("evaluation")
2904
- if not isinstance(evaluation, dict):
2905
- return
2998
+ evaluation = state["evaluation"]
2906
2999
  for token in SHA_TOKEN.findall(str(evaluation.get("locked_at", ""))):
2907
3000
  if is_ancestor(repo, token, "HEAD") is not True:
2908
3001
  _soft(errors, warnings, hard,
@@ -3181,6 +3274,34 @@ def _mentions(token, text):
3181
3274
  return re.search(r"(?<![\w.-])%s(?![\w.-])" % re.escape(token), text) is not None
3182
3275
 
3183
3276
 
3277
+ # A decision.md's pick: `chosen:` is the owner's, `recommended:` the line's own
3278
+ # (rules 9); every reader takes `chosen:` when present, else `recommended:`.
3279
+ PICK = r"(?im)^\W*%s\W*:\s*`?([\w.-]+)"
3280
+ # The `owner:` line `choose` writes: a date and the owner's words, quoted.
3281
+ OWNER_WORDS = re.compile(r'(?im)^\W*owner\W*:[^\n]*"[^"\n]*\S[^"\n]*"')
3282
+
3283
+
3284
+ def decision_pick(body):
3285
+ """(variant, "chosen" or "recommended") for a decision.md body, else (None, None)."""
3286
+ for field in ("chosen", "recommended"):
3287
+ found = re.search(PICK % field, body)
3288
+ if found:
3289
+ return found.group(1), field
3290
+ return None, None
3291
+
3292
+
3293
+ def line_pick(body):
3294
+ """The line's own pick, which the line's rules judge (rejected variants,
3295
+ dominance, post-hoc): `recommended:`, else a `chosen:` with no owner's words.
3296
+ An owner's `chosen:` is the owner's call, so a dominated or post-hoc owner
3297
+ choice is never the line's error."""
3298
+ found = re.search(PICK % "recommended", body)
3299
+ if found:
3300
+ return found.group(1)
3301
+ found = re.search(PICK % "chosen", body)
3302
+ return found.group(1) if found and not OWNER_WORDS.search(body) else None
3303
+
3304
+
3184
3305
  def _check_decision(repo, base, name, ddir, need, errors, warnings, strict, git,
3185
3306
  concluding):
3186
3307
  label = "decisions/%s" % name
@@ -3264,13 +3385,19 @@ def _check_decision(repo, base, name, ddir, need, errors, warnings, strict, git,
3264
3385
  "says what ruled it out" % label)
3265
3386
  return
3266
3387
  body = _note_text(os.path.join(ddir, "decision.md"))
3267
- found = re.search(r"(?im)^\W*chosen\W*:\s*`?([\w.-]+)", body)
3268
- chosen = found.group(1) if found else None
3388
+ chosen, field = decision_pick(body)
3269
3389
  if chosen not in produced:
3270
3390
  errors.append("%s: decision.md chosen %r is not a produced variant"
3271
3391
  % (label, chosen))
3392
+ if field == "chosen" and _rules(_state(base)) >= OWNER_CHOICE_RULES \
3393
+ and not OWNER_WORDS.search(body):
3394
+ errors.append("%s: decision.md chosen %s names no owner choice: the line "
3395
+ "writes recommended:, and the owner's pick is recorded with "
3396
+ "tezgah-research choose" % (label, chosen))
3397
+ pick = line_pick(body)
3398
+ owned = chosen if field == "chosen" and OWNER_WORDS.search(body) else None
3272
3399
  for vid in produced:
3273
- if vid == chosen:
3400
+ if vid in (pick, owned):
3274
3401
  continue
3275
3402
  lines = [line for line in body.splitlines() if _mentions(vid, line)]
3276
3403
  if not any(_mentions(cid, line) for line in lines for cid in criteria):
@@ -3279,12 +3406,12 @@ def _check_decision(repo, base, name, ddir, need, errors, warnings, strict, git,
3279
3406
  if not re.search(r"(?im)^\W*flip\W*:", body):
3280
3407
  errors.append("%s: decision.md names no `flip:` condition - the result that "
3281
3408
  "would reverse the choice" % label)
3282
- if chosen in produced:
3283
- better = dominated_by(chosen, produced, criteria, cells)
3409
+ if pick in produced:
3410
+ better = dominated_by(pick, produced, criteria, cells)
3284
3411
  if better:
3285
3412
  errors.append("%s: the chosen variant %s is dominated by %s, which is at "
3286
3413
  "least as good on every criterion and better on one"
3287
- % (label, chosen, better))
3414
+ % (label, pick, better))
3288
3415
 
3289
3416
 
3290
3417
  def _deliverable_path(state):
@@ -3533,7 +3660,8 @@ def _check_review_integrity(base, errors, warnings, strict):
3533
3660
  "to_human/review.json names no %s: a review says who produced the line "
3534
3661
  "and who judged it, and those are two readers"
3535
3662
  % ("reviewer" if not reviewer else "producer"))
3536
- elif reviewer == producer:
3663
+ elif reviewer == producer or (_evidence(state) and re.sub(r"\s+", "", reviewer).casefold()
3664
+ == re.sub(r"\s+", "", producer).casefold()):
3537
3665
  errors.append("to_human/review.json reviewer %r is the producer: a line graded "
3538
3666
  "by the session that wrote it is not reviewed" % reviewer)
3539
3667
  if review.get("findings") == []:
@@ -3551,14 +3679,977 @@ def _check_review_integrity(base, errors, warnings, strict):
3551
3679
  % (" and ".join(high), len(order), order[0][:80]))
3552
3680
 
3553
3681
 
3554
- def check_line(repo, slug, git=True, strict=False, code=None):
3682
+ # --- rules 8 (plan 127): steps, routing and the execution trail ----------------
3683
+ # A line opened under `EVIDENCE_RULES` is read for the steps that narrow each
3684
+ # claim's certainty (spec section 2) beside its refusals. `_check_rules8` threads
3685
+ # one `steps` dict - claim id -> step names in the order found - through the
3686
+ # capture module, the checks below and the evidence module, which turns the steps
3687
+ # into levels. Keys starting with `#` are the channel, never claim ids: `#flags`
3688
+ # (what `check` was asked), `#report` (the report lines `check` prints and never
3689
+ # counts), `#certainty` (each claim's level, for `check --json`) and `#stats`
3690
+ # ("experiment <h>" -> the statistics error `_check_rules8` refuses).
3691
+
3692
+ QUESTION_CLASSES = tuple("R%d" % n for n in range(13))
3693
+ CLASS_METHODS = dict(
3694
+ [(c, ("qualitative",)) for c in ("R1", "R2", "R7", "R8", "R9", "R10")]
3695
+ + [(c, ("qualitative", "measured")) for c in ("R3", "R12")]
3696
+ + [(c, ("measured",)) for c in ("R4", "R5", "R6")] + [("R11", ("exploratory",))])
3697
+ SEARCH_CLASSES = ("R1", "R2", "R7", "R8", "R9", "R10")
3698
+ # sources / searches / runs per tier: starting values, to re-measure (spec 3.3)
3699
+ BUDGETS = {"quick": (10, 10, 10), "study": (30, 40, 40), "program": (100, 150, 200)}
3700
+ LITERATURE_STEPS = ("header-mismatch", "unindexed-source", "online-drift",
3701
+ "abstract-only", "source-not-captured", "one-provider")
3702
+ SEARCH_STEPS = ("one-connector", "no-counter-search", "no-stopping-rule",
3703
+ "stopping-not-met", "known-item-missed", "screened-outside-search")
3704
+ EXPERIMENT_STEPS = ("no-replication", "no-power-plan", "below-planned-n", "no-interval",
3705
+ "interval-includes-zero", "below-achieved-mde",
3706
+ "no-multiplicity-correction", "fails-multiplicity", "no-sesoi",
3707
+ "tost-not-passed", "instrument-unvalidated", "instrument-rejected",
3708
+ "judged-row-incomplete", "falsifier-not-evaluable")
3709
+ CLASS_STEPS = {"R2": ("no-grey-quality",), "R3": ("no-population",),
3710
+ "R4": ("no-confounders",),
3711
+ "R5": ("fewer-than-two-rivals", "no-positive-control"),
3712
+ "R6": ("fewer-than-two-rivals",),
3713
+ "R7": ("no-dual-screening", "no-recall-stopping"),
3714
+ "R8": ("no-dual-screening",), "R9": ("no-amstar",), "R10": ("no-codebook",),
3715
+ "R12": ("fewer-than-two-approaches",)}
3716
+ TERMINAL_STATUSES = ("supported", "refuted", "weakened", "revised", "untested",
3717
+ "inconclusive")
3718
+ DEAD_ROWS = ("aborted", "failed")
3719
+ # The comparator words match ASCII-case-insensitively (`(?a:...)`): a Unicode
3720
+ # IGNORECASE lets U+017F match `s`, a spelling FALSIFIER_OPS holds no key for
3721
+ # (review round two R2-2).
3722
+ FALSIFIER_COMPARATOR = re.compile(
3723
+ r"(?i)(?:<=|>=|≤|≥|<|>|=|\b(?a:at most|at least|above|below|less than|more than"
3724
+ r"|fewer than|greater than|exceeds?|under|over))\s*-?\d")
3725
+ FALSIFIER_TEST = re.compile(
3726
+ r"(?i)\b(?:t-test|welch|wilcoxon|mann-whitney|chi-square|fisher|binomial|tost)\b")
3727
+ FALSIFIER_ALPHA = re.compile(r"(?i)\balpha\s*=?\s*0?\.\d+")
3728
+ # The structured form (spec 2.2) takes every comparator the grammar above names; each
3729
+ # maps to the operator it means. Anything else stays unevaluated (review D3-2).
3730
+ FALSIFIER_OPS = {"<=": "<=", ">=": ">=", "≤": "<=", "≥": ">=", "<": "<", ">": ">", "=": "=",
3731
+ "at most": "<=", "at least": ">=", "above": ">", "below": "<",
3732
+ "less than": "<", "more than": ">", "fewer than": "<", "greater than": ">",
3733
+ "exceeds": ">", "exceed": ">", "under": "<", "over": ">"}
3734
+ FALSIFIER_FORM = re.compile(
3735
+ r"(?i)^([\w.-]+)(?:\s*(<=|>=|≤|≥|<|>|=)|\s+(?a:(at\s+most|at\s+least|above|below"
3736
+ r"|less\s+than|more\s+than|fewer\s+than|greater\s+than|exceeds?|under|over)))"
3737
+ r"\s*(-?\d+(?:\.\d+)?)(?:\s+on\s+(\S+))?\s*$")
3738
+ # A protocol number (spec 2.3): `0.05`, `.05`, `5%` (0.05), `1e-1`.
3739
+ PROTOCOL_NUMBER = re.compile(r"([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)(\s*%)?")
3740
+ # Each checked protocol number's open range (lower, upper; None = unbounded).
3741
+ PROTOCOL_RANGES = {"alpha": (0.0, 1.0), "power": (0.0, 1.0), "sesoi": (0.0, None),
3742
+ "mde": (0.0, None)}
3743
+ COMPARE_FORM = re.compile(
3744
+ r"(?i)^(\S+)\s+vs\s+(\S+)\s+on\s+([\w.-]+)\s*,\s*(lower|higher)\s+is\s+better")
3745
+ CLAIM_NUMBER = re.compile(r"(?<![\w.])-?\d+(?:\.\d+)?(?![\w.])")
3746
+ DERIVED_REF = re.compile(r"^([\w.-]+)/(#\d+|[\w.-]+):([\w.-]+)$")
3747
+ UNIVERSAL_WORDS = re.compile(r"(?i)\b(every|all|always|never|any)\b")
3748
+ CAUSAL_WORDS = re.compile(r"(?i)\b(causes|caused|cause|leads to|due to|because of)\b")
3749
+ CODE_SUFFIXES = (".py", ".sh", ".js", ".ts", ".rs", ".go")
3750
+
3751
+
3752
+ def obligations(cls, method=""):
3753
+ """The step names of spec section 2 a line of class `cls` can trigger."""
3754
+ out = ["no-question-class", "class-unreviewed", "method-not-allowed",
3755
+ "untraced-number", "uncalibrated-verifier"] + list(LITERATURE_STEPS)
3756
+ if cls in SEARCH_CLASSES or method == "qualitative":
3757
+ out += SEARCH_STEPS
3758
+ if method == "measured" or "measured" in CLASS_METHODS.get(cls, ()):
3759
+ out += EXPERIMENT_STEPS
3760
+ return out + list(CLASS_STEPS.get(cls, ()))
3761
+
3762
+
3763
+ def _step(steps, cid, name):
3764
+ """Name `name` once for claim `cid`, in the order found."""
3765
+ found = steps.setdefault(cid, [])
3766
+ if name not in found:
3767
+ found.append(name)
3768
+
3769
+
3770
+ def _fields(text):
3771
+ """{key: [values]} of every column-0 `key: value` line of a protocol."""
3772
+ out = {}
3773
+ for line in text.lstrip("\ufeff").splitlines():
3774
+ found = PROTOCOL_LABEL.match(line)
3775
+ if found:
3776
+ out.setdefault(found.group(1).lower(), []).append(found.group(2).strip())
3777
+ return out
3778
+
3779
+
3780
+ def _first(fields, key):
3781
+ return (fields.get(key) or [""])[0]
3782
+
3783
+
3784
+ def _number(value):
3785
+ """The first number of a protocol value (`PROTOCOL_NUMBER`), a percent read as its
3786
+ fraction; None without one."""
3787
+ found = PROTOCOL_NUMBER.search(value or "")
3788
+ if not found:
3789
+ return None
3790
+ number = float(found.group(1))
3791
+ return number / 100 if found.group(2) else number
3792
+
3793
+
3794
+ def protocol_number(key, value):
3795
+ """(number, problem) of the protocol line `key: value`, `key` one of PROTOCOL_RANGES:
3796
+ the one reader `check`, `claim`, the session context and the instruments share.
3797
+ (None, None) when the line is absent or empty; a value with no number, or one
3798
+ outside its range (alpha, power in (0, 1); sesoi, mde above 0), is an invalid
3799
+ record: (None, the refusal naming the key and the value). So is an alpha so close
3800
+ to 0 that alpha/2 or 1 - alpha/2, the quantiles the intervals read, is 0 or 1 as a
3801
+ float (`1e-20`, review round two R2-1)."""
3802
+ value = (value or "").strip()
3803
+ if not value:
3804
+ return None, None
3805
+ number = _number(value)
3806
+ lo, hi = PROTOCOL_RANGES[key]
3807
+ if number is not None and math.isfinite(number) and number > lo \
3808
+ and (hi is None or number < hi):
3809
+ if key == "alpha" and not (number / 2 > 0 and 1 - number / 2 < 1):
3810
+ return None, "protocol.md alpha: %s leaves a quantile at 0 or 1" % value
3811
+ return number, None
3812
+ return None, "protocol.md %s: %s is not a number %s" % (
3813
+ key, value, "above 0" if hi is None else "in (0, 1)")
3814
+
3815
+
3816
+ def _numeric(value):
3817
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
3818
+
3819
+
3820
+ def _live(rows):
3821
+ """Rows that count. A refile is a correction: the latest row filed for the same
3822
+ `run`, `arm`, `item` and `instrument` stands for it, so one run measured over
3823
+ several arms or items keeps a row for each; a row with no `run` counts on its
3824
+ own. An `aborted` or `failed` run stays a row and counts nowhere."""
3825
+ key = lambda r: tuple(str(r.get(k)) for k in ("run", "arm", "item", "instrument")) # noqa: E731
3826
+ last = {key(r): n for n, r in enumerate(rows) if r.get("run") is not None}
3827
+ return [r for n, r in enumerate(rows)
3828
+ if (r.get("run") is None or last[key(r)] == n)
3829
+ and r.get("status") not in DEAD_ROWS]
3830
+
3831
+
3832
+ def _groups(rows):
3833
+ """{group: rows}: by `arm`, else `phase`, else one group `all`."""
3834
+ live = _live(rows)
3835
+ key = next((k for k in ("arm", "phase") if any(k in r for r in live)), None)
3836
+ out = {}
3837
+ for row in live:
3838
+ out.setdefault(str(row[key]) if key in row else "all", []).append(row)
3839
+ return out
3840
+
3841
+
3842
+ def _paired(rows_a, rows_b):
3843
+ """The two groups aligned by `item` when both carry the same item set, else None."""
3844
+ items_a, items_b = [r.get("item") for r in rows_a], [r.get("item") for r in rows_b]
3845
+ if None in items_a or None in items_b or len(set(map(str, items_a))) != len(items_a):
3846
+ return None
3847
+ if sorted(map(str, items_a)) != sorted(map(str, items_b)):
3848
+ return None
3849
+ order = {str(i): n for n, i in enumerate(items_a)}
3850
+ return rows_a, sorted(rows_b, key=lambda r: order[str(r.get("item"))])
3851
+
3852
+
3853
+ def _effect(fields, groups, alpha, power):
3854
+ """The `compare:` line's effect (spec 2.3), or None without one."""
3855
+ import tezgah_research_stats as stats
3856
+ found = COMPARE_FORM.match(_first(fields, "compare"))
3857
+ if not found:
3858
+ return None
3859
+ t, c, field, better = found.groups()
3860
+ rows_t, rows_c = groups.get(t, []), groups.get(c, [])
3861
+ out = {"t": t, "c": c, "field": field, "n": (len(rows_t), len(rows_c)),
3862
+ "effect": None, "ci": None, "mde": None, "p": None, "a": [], "b": [],
3863
+ "paired": False}
3864
+ rows_t = [r for r in rows_t if _numeric(r.get(field))]
3865
+ rows_c = [r for r in rows_c if _numeric(r.get(field))]
3866
+ if better.lower() == "lower":
3867
+ rows_t, rows_c = rows_c, rows_t
3868
+ aligned = _paired(rows_t, rows_c) if len(rows_t) >= 2 else None
3869
+ if aligned:
3870
+ rows_t, rows_c = aligned
3871
+ a, b = [float(r[field]) for r in rows_t], [float(r[field]) for r in rows_c]
3872
+ out.update(a=a, b=b, paired=bool(aligned))
3873
+ if a and b:
3874
+ out["effect"] = sum(a) / len(a) - sum(b) / len(b)
3875
+ if len(a) >= 2 and len(b) >= 2:
3876
+ diff = stats.mean_diff(a, b, alpha, paired=bool(aligned))
3877
+ out.update(effect=diff["effect"], ci=(diff["lo"], diff["hi"]), p=diff["p"],
3878
+ mde=stats.achieved_mde(diff["se"], alpha, power))
3879
+ return out
3880
+
3881
+
3882
+ def _stats_failed(exp, exc):
3883
+ """Keep an experiment's first statistics error (ValueError, ArithmeticError);
3884
+ `_check_rules8` turns it into the experiment's one refusal (review round two R2-1)."""
3885
+ if not exp.get("stats_error"):
3886
+ exp["stats_error"] = str(exc) or type(exc).__name__
3887
+
3888
+
3889
+ def experiment_record(base, h):
3890
+ """One experiment as the rules-8 checks read it: protocol fields, rows, groups,
3891
+ the plan's numbers and the `compare:` effect. `invalid` holds the refusal of each
3892
+ protocol number outside its range; an invalid alpha or power is None and leaves
3893
+ the effect uncomputed, so no statistic runs on it. A statistics error leaves the
3894
+ effect None and lands in `stats_error`."""
3895
+ d = os.path.join(base, "experiments", h)
3896
+ fields = _fields(_note_text(os.path.join(d, "protocol.md")))
3897
+ rows = _row_objects(os.path.join(d, "results.jsonl"))
3898
+ groups = _groups(rows)
3899
+ plan = {k: protocol_number(k, _first(fields, k)) for k in PROTOCOL_RANGES}
3900
+ alpha, power = (None if plan[k][1] else plan[k][0] or default
3901
+ for k, default in (("alpha", 0.05), ("power", 0.80)))
3902
+ exp = {"h": h, "dir": d, "fields": fields, "rows": rows, "groups": groups,
3903
+ "alpha": alpha, "power": power, "sesoi": plan["sesoi"][0],
3904
+ "invalid": [problem for _, problem in plan.values() if problem],
3905
+ "effect": None, "stats_error": None}
3906
+ if alpha is not None and power is not None:
3907
+ try:
3908
+ exp["effect"] = _effect(fields, groups, alpha, power)
3909
+ except (ValueError, ArithmeticError) as exc:
3910
+ _stats_failed(exp, exc)
3911
+ return exp
3912
+
3913
+
3914
+ def falsifier_unscoped(text, groups):
3915
+ """True when a structured falsifier names no `on <group>` while the rows hold
3916
+ more than one group: its mean would pool the control with the arm it judges."""
3917
+ found = FALSIFIER_FORM.match(text)
3918
+ return bool(found and not found.group(5) and len(groups) > 1)
3919
+
3920
+
3921
+ def falsifier_fired(exp):
3922
+ """(falsifier, mean) when the structured `falsifier:` holds over its rows, else
3923
+ None - also when the group holds no row carrying the field, and when it names no
3924
+ group over several (`falsifier_unscoped`)."""
3925
+ text = _first(exp["fields"], "falsifier")
3926
+ found = FALSIFIER_FORM.match(text)
3927
+ if not found or falsifier_unscoped(text, exp["groups"]):
3928
+ return None
3929
+ field, symbol, words, number, group = found.groups()
3930
+ op = FALSIFIER_OPS.get(" ".join((symbol or words).casefold().split()))
3931
+ if op is None:
3932
+ return None
3933
+ rows = exp["groups"].get(group, []) if group else _live(exp["rows"])
3934
+ values = [float(r[field]) for r in rows if _numeric(r.get(field))]
3935
+ if not values:
3936
+ return None
3937
+ mean, x = sum(values) / len(values), float(number)
3938
+ holds = {"<": mean < x, "<=": mean <= x, ">": mean > x, ">=": mean >= x,
3939
+ "=": mean == x}[op]
3940
+ return (text, mean) if holds else None
3941
+
3942
+
3943
+ def _claim_experiments(claim, base):
3944
+ return [h for h in _experiments_named(_cited(claim.get("proof")))
3945
+ if os.path.isdir(os.path.join(base, "experiments", h))]
3946
+
3947
+
3948
+ def fired_problems(cid, claim, base):
3949
+ """The refusal for a `supported` claim whose cited experiment's falsifier fired."""
3950
+ if claim.get("status") != "supported":
3951
+ return []
3952
+ out = []
3953
+ for h in _claim_experiments(claim, base):
3954
+ fired = falsifier_fired(experiment_record(base, h))
3955
+ if fired:
3956
+ out.append("claim %s is supported while its falsifier fired: experiments/%s "
3957
+ "`falsifier: %s` holds at a mean of %s" % (cid, h, fired[0],
3958
+ "%g" % fired[1]))
3959
+ return out
3960
+
3961
+
3962
+ def _ref_value(base, ref):
3963
+ """The number `<h>/<run>:<field>` or `<h>/#<row>:<field>` names, as the Decimal of
3964
+ the value written in the row (`repr` gives back a JSON float's shortest digits), or
3965
+ None."""
3966
+ found = DERIVED_REF.match(str(ref).strip())
3967
+ if not found:
3968
+ return None
3969
+ h, which, field = found.groups()
3970
+ rows = _row_objects(os.path.join(base, "experiments", h, "results.jsonl"))
3971
+ if which.startswith("#"):
3972
+ k = int(which[1:])
3973
+ row = rows[k - 1] if 1 <= k <= len(rows) else None
3974
+ else:
3975
+ row = next((r for r in reversed(rows) if str(r.get("run")) == which), None)
3976
+ value = row.get(field) if row else None
3977
+ if not _numeric(value) or (isinstance(value, float) and not math.isfinite(value)):
3978
+ return None
3979
+ return decimal.Decimal(repr(value))
3980
+
3981
+
3982
+ def _recompute(spec, base):
3983
+ if not isinstance(spec, dict) or not isinstance(spec.get("of"), list):
3984
+ return None
3985
+ op, refs = spec.get("op"), spec["of"]
3986
+ if op == "count":
3987
+ return decimal.Decimal(len(refs))
3988
+ values = [_ref_value(base, ref) for ref in refs]
3989
+ if not values or None in values:
3990
+ return None
3991
+ if op in ("diff", "ratio"):
3992
+ if len(values) != 2 or (op == "ratio" and values[1] == 0):
3993
+ return None
3994
+ return values[0] - values[1] if op == "diff" else values[0] / values[1]
3995
+ return {"mean": lambda v: sum(v) / len(v), "sum": sum, "min": min,
3996
+ "max": max}.get(op, lambda v: None)(values)
3997
+
3998
+
3999
+ def _rounds_to(value, written):
4000
+ """True when `value` (a Decimal) rounded half-up or half-even at the precision of the
4001
+ number as written equals it."""
4002
+ try:
4003
+ target = decimal.Decimal(written)
4004
+ return any(value.quantize(target, rounding=mode) == target
4005
+ for mode in (decimal.ROUND_HALF_UP, decimal.ROUND_HALF_EVEN))
4006
+ except decimal.DecimalException:
4007
+ return False
4008
+
4009
+
4010
+ def derived_problems(cid, claim, base):
4011
+ """The refusals for `derived_from` entries whose recomputed value differs from
4012
+ the number as written (spec 2.4); writer and checker read this one rule. The
4013
+ arithmetic is decimal over the rows' written values, so 2.675 is 2.675, not the
4014
+ binary float below it."""
4015
+ derived = claim.get("derived_from")
4016
+ if derived is None:
4017
+ return []
4018
+ if not isinstance(derived, dict):
4019
+ return ["claim %s derived_from is not an object of number -> {op, of}" % cid]
4020
+ out = []
4021
+ with decimal.localcontext() as ctx:
4022
+ ctx.prec = 50
4023
+ for written, spec in derived.items():
4024
+ value = _recompute(spec, base)
4025
+ if value is None or not _rounds_to(value, written):
4026
+ out.append("claim %s derived_from %s does not recompute: %s gives %s"
4027
+ % (cid, written, json.dumps(spec),
4028
+ "nothing" if value is None else "%g" % value))
4029
+ return out
4030
+
4031
+
4032
+ def _log_tokens(exp, row, cache):
4033
+ rel = row.get("log")
4034
+ if not isinstance(rel, str) or not rel:
4035
+ return set()
4036
+ for root in (exp["dir"], os.path.dirname(os.path.dirname(exp["dir"]))):
4037
+ path = os.path.join(root, rel)
4038
+ if os.path.isfile(path):
4039
+ if path not in cache:
4040
+ cache[path] = set(CLAIM_NUMBER.findall(_note_text(path)))
4041
+ return cache[path]
4042
+ return set()
4043
+
4044
+
4045
+ def _untraced(claim, cited):
4046
+ """The numbers of an evidence claim no cited row holds with its log, and no
4047
+ `derived_from` entry names (spec 2.4)."""
4048
+ derived = claim.get("derived_from")
4049
+ derived = derived if isinstance(derived, dict) else {}
4050
+ cache, out = {}, []
4051
+ for token in CLAIM_NUMBER.findall(str(claim.get("statement", ""))):
4052
+ if token in derived:
4053
+ continue
4054
+ value = float(token)
4055
+ if not any(token in _log_tokens(exp, row, cache)
4056
+ and any(_numeric(v) and float(v) == value for v in row.values())
4057
+ for exp in cited for row in _live(exp["rows"])):
4058
+ out.append(token)
4059
+ return out
4060
+
4061
+
4062
+ def _rivals(fields):
4063
+ return [v for v in fields.get("rival", [])
4064
+ if re.match(r"\S.*\sexcludes:\s*\S", v)]
4065
+
4066
+
4067
+ def _observational(fields):
4068
+ design = _first(fields, "design").lower()
4069
+ return design == "observational" or (not design and not _first(fields, "change"))
4070
+
4071
+
4072
+ def _experiment_steps(claim, cited, cls, measured, rejected):
4073
+ """The section-2 steps an experiment claim takes from the experiments it cites."""
4074
+ import tezgah_research_stats as stats
4075
+ out = []
4076
+ no_difference = claim.get("effect") == "no-difference"
4077
+ for exp in cited:
4078
+ fields, groups, eff = exp["fields"], exp["groups"], exp["effect"]
4079
+ if cls in ("R5", "R6") and len(_rivals(fields)) < 2:
4080
+ out.append("fewer-than-two-rivals")
4081
+ if not groups or any(len(rows) < 2 for rows in groups.values()):
4082
+ out.append("no-replication")
4083
+ if measured and not all(_first(fields, k) for k in ("alpha", "power", "mde", "n")):
4084
+ out.append("no-power-plan")
4085
+ falsifier = _first(fields, "falsifier")
4086
+ if measured and falsifier and (not falsifier_testable(falsifier)
4087
+ or falsifier_unscoped(falsifier, groups)):
4088
+ out.append("falsifier-not-evaluable")
4089
+ if eff:
4090
+ planned = _number(_first(fields, "n"))
4091
+ if planned is not None and min(eff["n"]) < planned:
4092
+ out.append("below-planned-n")
4093
+ if eff["ci"] is None:
4094
+ out.append("no-interval")
4095
+ elif not no_difference:
4096
+ if eff["ci"][0] <= 0:
4097
+ out.append("interval-includes-zero")
4098
+ if eff["effect"] < eff["mde"]:
4099
+ out.append("below-achieved-mde")
4100
+ correction = _first(fields, "correction")
4101
+ if len(groups) > 2 and not correction:
4102
+ out.append("no-multiplicity-correction")
4103
+ if correction and exp["h"] not in rejected:
4104
+ out.append("fails-multiplicity")
4105
+ if no_difference:
4106
+ sesoi = exp["sesoi"]
4107
+ if sesoi is None:
4108
+ out.append("no-sesoi")
4109
+ else:
4110
+ try:
4111
+ passed = bool(eff and eff["ci"] and stats.tost(
4112
+ eff["a"], eff["b"], sesoi, exp["alpha"], eff["paired"]))
4113
+ except (ValueError, ArithmeticError) as exc:
4114
+ _stats_failed(exp, exc)
4115
+ passed = False
4116
+ if not passed:
4117
+ out.append("tost-not-passed")
4118
+ return out
4119
+
4120
+
4121
+ def _multiplicity(base, exps):
4122
+ """The experiments their declared `correction:` rejects, corrected together."""
4123
+ import tezgah_research_stats as stats
4124
+ tests = {"bh": stats.bh, "holm": stats.holm, "bonferroni": stats.bonferroni}
4125
+ by = {}
4126
+ for h, exp in sorted(exps.items()):
4127
+ method = _first(exp["fields"], "correction").strip().lower()
4128
+ if method and exp["effect"]:
4129
+ by.setdefault(method, []).append(h)
4130
+ rejected = set()
4131
+ for method, hs in by.items():
4132
+ alpha = min(exps[h]["alpha"] for h in hs)
4133
+ try:
4134
+ if method == "e-bh":
4135
+ values = [_number(_first(_fields(_note_text(os.path.join(
4136
+ exps[h]["dir"], "analysis.md"))), "e-value")) or 0.0 for h in hs]
4137
+ flags = stats.ebh(values, alpha)
4138
+ elif method in tests:
4139
+ p = [exps[h]["effect"]["p"] for h in hs]
4140
+ flags = tests[method]([1.0 if v is None else v for v in p], alpha)
4141
+ else:
4142
+ continue
4143
+ except (ValueError, ArithmeticError) as exc:
4144
+ for h in hs:
4145
+ _stats_failed(exps[h], exc)
4146
+ continue
4147
+ rejected.update(h for h, flag in zip(hs, flags) if flag)
4148
+ return rejected
4149
+
4150
+
4151
+ def _format_ci(lo, hi):
4152
+ return "[%.4f, %.4f]" % (lo, hi)
4153
+
4154
+
4155
+ def falsifier_testable(text):
4156
+ """True when a falsifier holds a comparator and a number, or a named test with its
4157
+ alpha (spec 2.2)."""
4158
+ return bool(FALSIFIER_COMPARATOR.search(text)
4159
+ or (FALSIFIER_TEST.search(text) and FALSIFIER_ALPHA.search(text)))
4160
+
4161
+
4162
+ # A `metric:` list splits at the commas that separate names: never inside parentheses
4163
+ # (`(ms, median of 5 runs)`), never between digit groups (`10,000-piece`), and never
4164
+ # inside the locked name, which is one name (plan 128 K1, K3).
4165
+ METRIC_COMMA = re.compile(r",(?![^()]*\))(?!(?<=\d,)\d)")
4166
+ # A word of a metric name, for the reworded comparison: hyphen and underscore split,
4167
+ # digits kept (`p95`), and the joining words a reworded name may drop or add
4168
+ # (`of`, `the`, `per`) ignored.
4169
+ METRIC_WORD = re.compile(r"[0-9a-z]+")
4170
+ METRIC_JOIN = {"a", "an", "the", "of", "for", "in", "on", "to", "per", "by", "with"}
4171
+
4172
+
4173
+ def _metric_key(name):
4174
+ """A metric name as the rules compare it: casefolded, whitespace collapsed, a
4175
+ trailing parenthetical (`(ms)`) dropped."""
4176
+ return " ".join(re.sub(r"\s*\([^)]*\)\s*$", "", str(name or "")).casefold().split())
4177
+
4178
+
4179
+ def _metric_words(key):
4180
+ return {w for w in METRIC_WORD.findall(key) if w not in METRIC_JOIN}
4181
+
4182
+
4183
+ def _same_metric(a, b):
4184
+ """True when one metric key is the other or a word-boundary prefix of it, either
4185
+ way (plan 128 K2), or a rewording of it: every word of `b` sits in `a`, in any
4186
+ order and with the joining words ignored (`read-path p95 latency` is
4187
+ `p95 latency of the read path`, K6 raters X6 and Y6)."""
4188
+ if a == b or a.startswith(b + " ") or b.startswith(a + " "):
4189
+ return True
4190
+ words = _metric_words(b)
4191
+ return bool(words) and words <= _metric_words(a)
4192
+
4193
+
4194
+ def _registered(state, fields):
4195
+ """(names, keys, locked key): the metrics a protocol's `metric:` lines register,
4196
+ split at METRIC_COMMA with the locked name kept whole, and the line's lock."""
4197
+ evaluation = state.get("evaluation") if isinstance(state.get("evaluation"), dict) else {}
4198
+ locked = _metric_key(evaluation.get("metric"))
4199
+ whole = re.compile(r"\s*,\s*".join(r"\s+".join(map(re.escape, part.split()))
4200
+ for part in locked.split(",")), re.I) \
4201
+ if "," in locked else None
4202
+ names = []
4203
+ for value in fields.get("metric", []):
4204
+ if whole:
4205
+ value = whole.sub(lambda m: m.group(0).replace(",", "\0"), value)
4206
+ names += [m.strip().replace("\0", ",") for m in METRIC_COMMA.split(value)
4207
+ if m.strip()]
4208
+ return names, [_metric_key(m) for m in names], locked
4209
+
4210
+
4211
+ def metric_problem(state, fields):
4212
+ """The locked-metric refusal of a protocol's `metric:` lines, or None (spec 2.2):
4213
+ one of them has to be the locked metric (`_same_metric`). `source` asks it
4214
+ before a rules-8 capture freezes the protocol (plan 128 K4)."""
4215
+ names, keys, locked = _registered(state, fields)
4216
+ if locked and keys and not any(_same_metric(k, locked) for k in keys):
4217
+ return ("protocol.md registers a metric other than the locked one (%s, locked: %s)"
4218
+ % (", ".join(names), locked))
4219
+ return None
4220
+
4221
+
4222
+ def _check_experiment8(repo, slug, base, state, exp, report, errors, warnings, strict,
4223
+ git, lines, code=None):
4224
+ """One experiment's rules-8 refusals (2.2, 2.4) and report lines (2.3)."""
4225
+ import tezgah_research_stats as stats
4226
+ h, fields = exp["h"], exp["fields"]
4227
+ label = "experiment %s" % h
4228
+ errors.extend("%s: %s" % (label, problem) for problem in exp["invalid"])
4229
+ problem = metric_problem(state, fields)
4230
+ if problem:
4231
+ errors.append("%s: %s" % (label, problem))
4232
+ if report is not None:
4233
+ metrics, norm, locked = _registered(state, fields)
4234
+ excused = {_metric_key(v.split(":", 1)[0])
4235
+ for v in fields.get("not_reported", []) if ":" in v}
4236
+ text = " ".join(report.casefold().split())
4237
+ # protocol.md is frozen once the run is filed (`changed after the run`), so
4238
+ # `not_reported:` is an exit only before it (plan 128 K5)
4239
+ exit_ = ("the run is filed, so protocol.md is frozen: name it in to_human/report.md"
4240
+ if exp["rows"] else
4241
+ "name it in to_human/report.md, or before the run mark it "
4242
+ "`not_reported: <metric>: <reason>` in protocol.md")
4243
+ for metric, key in zip(metrics, norm):
4244
+ # a registered metric that is the locked one (the rule above's
4245
+ # equivalence) is reported where the report names the locked metric
4246
+ refines = locked and _same_metric(key, locked)
4247
+ if (key not in text and not (refines and locked in text)
4248
+ and key not in excused):
4249
+ errors.append("%s: registered metric %r is neither reported nor marked "
4250
+ "not_reported - %s" % (label, metric, exit_))
4251
+ evaluator = _first(fields, "evaluator")
4252
+ if evaluator:
4253
+ _check_evaluator(repo, base, state, exp, evaluator, label, errors, git, code)
4254
+ select = re.match(r"(?i)^(min|max)\s+([\w.-]+)", _first(fields, "select"))
4255
+ if select:
4256
+ field = select.group(2)
4257
+ values = {g: [float(r[field]) for r in rows if _numeric(r.get(field))]
4258
+ for g, rows in exp["groups"].items()}
4259
+ means = {g: sum(v) / len(v) for g, v in values.items() if v}
4260
+ if means:
4261
+ pick = (min if select.group(1).lower() == "min" else max)(
4262
+ sorted(means), key=means.get)
4263
+ found = re.search(r"(?im)^\W*selected\W*:\s*`?([\w.-]+)",
4264
+ _note_text(os.path.join(exp["dir"], "analysis.md")))
4265
+ if not found:
4266
+ _soft(errors, warnings, strict,
4267
+ "%s: analysis states no selected group, and `select:` picks %s"
4268
+ % (label, pick))
4269
+ elif found.group(1) != pick:
4270
+ errors.append("%s: select rule picks %s, and analysis.md selected: %s"
4271
+ % (label, pick, found.group(1)))
4272
+ for group, rows in exp["groups"].items():
4273
+ # binary: every outcome 0 or 1, a JSON boolean counting as 1/0 (True == 1)
4274
+ if rows and exp["alpha"] is not None and all(r.get("outcome") in (0, 1)
4275
+ for r in rows):
4276
+ k, n = sum(int(r["outcome"]) for r in rows), len(rows)
4277
+ try:
4278
+ wilson = _format_ci(*stats.wilson(k, n, exp["alpha"]))
4279
+ cp = _format_ci(*stats.clopper_pearson(k, n, exp["alpha"]))
4280
+ except (ValueError, ArithmeticError) as exc:
4281
+ _stats_failed(exp, exc)
4282
+ continue
4283
+ lines.append("stats %s: %s %s n=%d k=%d wilson=%s cp=%s"
4284
+ % (slug, h, group, n, k, wilson, cp))
4285
+ eff = exp["effect"]
4286
+ if eff:
4287
+ lines.append("effect %s: %s %s vs %s on %s effect=%s ci=%s mde=%s"
4288
+ % (slug, h, eff["t"], eff["c"], eff["field"],
4289
+ "none" if eff["effect"] is None else "%.4f" % eff["effect"],
4290
+ _format_ci(*eff["ci"]) if eff["ci"] else "none",
4291
+ "none" if eff["mde"] is None else "%.4f" % eff["mde"]))
4292
+
4293
+
4294
+ def evaluator_file(repo, base, code, rel):
4295
+ """(path, root): an `evaluator:` path looked up in the line, the repository and
4296
+ the checkout `check` runs from (`code`, plan 128 K9), with the repository whose
4297
+ history holds it; (None, None) when none holds the file."""
4298
+ for root, at in ((repo, base), (repo, repo), (code, code)):
4299
+ if at and os.path.isfile(os.path.join(at, rel)):
4300
+ return os.path.join(at, rel), root
4301
+ return None, None
4302
+
4303
+
4304
+ def _commit_time(repo, path, sha):
4305
+ """The commit time of `sha` in the first history of `path` that holds it, or ""."""
4306
+ stamp = ""
4307
+ for top, _rel in _histories(repo, path):
4308
+ out, err = _git_out(top, "show", "-s", "--format=%cI", sha)
4309
+ stamp = stamp or ("" if err else out.strip())
4310
+ return stamp
4311
+
4312
+
4313
+ def _text_at(root, path, sha, stamp):
4314
+ """`path`'s committed text at commit `sha` - in a history that does not hold that
4315
+ commit (a project file read against a line in `.tezgah`'s repository), its latest
4316
+ version not after `stamp` - or None."""
4317
+ found = None
4318
+ for top, rel in _histories(root, path):
4319
+ rev = sha if _holds(top, sha) else None
4320
+ if rev is None and stamp:
4321
+ out, err = _git_out(top, "log", "-1", "--format=%H", "--until=" + stamp,
4322
+ "--", rel)
4323
+ rev = None if err else out.strip()
4324
+ if rev:
4325
+ out, err = _git_out(top, "show", "%s:%s" % (rev, rel))
4326
+ found = found if err else out
4327
+ return found
4328
+
4329
+
4330
+ def _sealed_evaluator(repo, base, state, h, root, path, git):
4331
+ """The evaluator's sha256 at the line's conclusion, or None for a line not
4332
+ concluded (plan 128 K8): the hash `conclude` sealed, and for a seal older than
4333
+ that field the evaluator at the first committed state.json holding the seal."""
4334
+ import hashlib
4335
+ seal = state.get(SEAL)
4336
+ if not isinstance(seal, dict):
4337
+ return None
4338
+ rows = seal.get("experiments") if isinstance(seal.get("experiments"), dict) else {}
4339
+ row = rows.get(h) if isinstance(rows.get(h), dict) else {}
4340
+ if SHA256_HEX.fullmatch(str(row.get("evaluator") or "")):
4341
+ return row["evaluator"]
4342
+ if not git:
4343
+ return None
4344
+ state_path = os.path.join(base, "state.json")
4345
+ for top, rel in _histories(repo, state_path):
4346
+ for sha, text in _versions(top, rel):
4347
+ try:
4348
+ held = SEAL in json.loads(text)
4349
+ except (ValueError, TypeError):
4350
+ held = False
4351
+ if held:
4352
+ at = _text_at(root, path, sha, _commit_time(repo, state_path, sha))
4353
+ return None if at is None else hashlib.sha256(at.encode("utf-8")).hexdigest()
4354
+ return None
4355
+
4356
+
4357
+ def _check_evaluator(repo, base, state, exp, evaluator, label, errors, git, code=None):
4358
+ """An `evaluator:` file holds the bytes it held at the protocol's first commit -
4359
+ in a history that does not hold that commit (a project evaluator of a line in
4360
+ `.tezgah`'s repository), its latest version not after that commit's time - and
4361
+ its declared `sha256:` (spec 2.4). A concluded line reads the evaluator as it was
4362
+ at the conclusion (`_sealed_evaluator`), so a later project edit is not its own."""
4363
+ import hashlib
4364
+ parts = evaluator.split()
4365
+ path, root = evaluator_file(repo, base, code, parts[0])
4366
+ declared = next((p.split(":", 1)[1] for p in parts[1:] if p.startswith("sha256:")), "")
4367
+ if path is None:
4368
+ errors.append("%s: evaluator %s is not a file in the line, the repository or the "
4369
+ "checkout" % (label, parts[0]))
4370
+ return
4371
+ now = _sealed_evaluator(repo, base, state, exp["h"], root, path, git) or _digest(path)
4372
+ if declared and now != declared.lower():
4373
+ errors.append("%s: evaluator file changed after lock: %s no longer hashes to its "
4374
+ "declared sha256" % (label, parts[0]))
4375
+ return
4376
+ if not git:
4377
+ return
4378
+ proto_path = os.path.join(exp["dir"], "protocol.md")
4379
+ proto = file_versions(repo, proto_path)
4380
+ if not proto:
4381
+ return
4382
+ sha = proto[0][0]
4383
+ locked = _text_at(root, path, sha, _commit_time(repo, proto_path, sha))
4384
+ if locked is None:
4385
+ # committed only after the lock: its first version is the nearest to it
4386
+ versions = file_versions(root, path)
4387
+ locked = versions[0][1] if versions else None
4388
+ if locked is not None and hashlib.sha256(locked.encode("utf-8")).hexdigest() != now:
4389
+ errors.append("%s: evaluator file changed after lock: %s differs from its state "
4390
+ "at the protocol's first commit" % (label, parts[0]))
4391
+
4392
+
4393
+ def _overclaim(cid, claim, cited, warnings):
4394
+ """Universal words over fixture evidence, causal words over an observational or
4395
+ single-group experiment (spec 4.4). Warns in both modes."""
4396
+ text = str(claim.get("statement", ""))
4397
+ rows = [r for exp in cited for r in exp["rows"]]
4398
+ words = []
4399
+ if claim.get("scope") == "fixture" or (rows and all(r.get("scope") == "fixture"
4400
+ for r in rows)):
4401
+ words += UNIVERSAL_WORDS.findall(text)
4402
+ if any(_observational(exp["fields"]) or len(exp["groups"]) < 2 for exp in cited):
4403
+ words += CAUSAL_WORDS.findall(text)
4404
+ if words:
4405
+ warnings.append("claim %s overclaims: %s - the evidence it cites does not reach "
4406
+ "that far; narrow the claim to what was measured"
4407
+ % (cid, ", ".join('"%s"' % w for w in dict.fromkeys(words))))
4408
+
4409
+
4410
+ def _routing_steps(base, state, exps, review):
4411
+ """The 3.3 steps that touch every claim of the line."""
4412
+ cls = state.get("question_class")
4413
+ out = []
4414
+ if not cls:
4415
+ out.append("no-question-class")
4416
+ if review is not None and review.get("question_class") != cls:
4417
+ out.append("class-unreviewed")
4418
+ if cls in CLASS_METHODS and state.get("method") not in CLASS_METHODS[cls]:
4419
+ out.append("method-not-allowed")
4420
+ index = _row_objects(os.path.join(base, "literature", "INDEX.jsonl"))
4421
+ protocols = [exp["fields"] for exp in exps.values()]
4422
+ if cls in ("R7", "R8"):
4423
+ screeners, dropped = {}, set()
4424
+ for row in _row_objects(os.path.join(base, "searches", "screening.jsonl")):
4425
+ rid = str(row.get("id"))
4426
+ if rid not in screeners and row.get("decision") == "dropped":
4427
+ dropped.add(rid)
4428
+ screeners.setdefault(rid, set()).add(
4429
+ re.sub(r"\s+", "", str(row.get("screener") or "")).casefold())
4430
+ dual = {rid for rid, who in screeners.items() if len(who - {""}) >= 2}
4431
+ if cls == "R7":
4432
+ thin = not screeners or len(dual) < len(screeners)
4433
+ else:
4434
+ thin = (not screeners or 5 * len(dual) < len(screeners)
4435
+ or bool(dropped - dual))
4436
+ if thin:
4437
+ out.append("no-dual-screening")
4438
+ stopping = state.get("stopping")
4439
+ if cls == "R7" and not (isinstance(stopping, dict)
4440
+ and stopping.get("rule") == "hypergeometric"):
4441
+ out.append("no-recall-stopping")
4442
+ if cls == "R5" and state.get("method") == "measured" and any(
4443
+ not _first(f, "control") for f in protocols):
4444
+ out.append("no-positive-control")
4445
+ if cls == "R4" and any(not _first(f, "confounders") for f in protocols):
4446
+ out.append("no-confounders")
4447
+ if cls == "R3" and not (state.get("population") and state.get("n")):
4448
+ out.append("no-population")
4449
+ if cls == "R2" and any(r.get("class") == "grey" and not str(r.get("quality", "")).strip()
4450
+ for r in index):
4451
+ out.append("no-grey-quality")
4452
+ if cls == "R9" and any(not str(r.get("amstar", "")).strip() for r in index):
4453
+ out.append("no-amstar")
4454
+ if cls == "R10" and not os.path.isfile(os.path.join(base, "codebook.md")):
4455
+ out.append("no-codebook")
4456
+ approaches = state.get("approaches")
4457
+ if cls == "R12" and sum(1 for a in approaches or [] if isinstance(a, dict)
4458
+ and a.get("biases")) < 2:
4459
+ out.append("fewer-than-two-approaches")
4460
+ return out
4461
+
4462
+
4463
+ def budget(slug, base, state):
4464
+ """(report line, [what is over]) of the tier's budget (spec 3.3)."""
4465
+ caps = BUDGETS.get(state.get("tier"), BUDGETS[DEFAULT_TIER])
4466
+ used = (len(_row_objects(os.path.join(base, "literature", "INDEX.jsonl"))),
4467
+ len(_row_objects(os.path.join(base, "searches", "SEARCHES.jsonl"))),
4468
+ sum(1 for h in _experiment_names(base)
4469
+ for r in _row_objects(os.path.join(base, "experiments", h, "results.jsonl"))
4470
+ if str(r.get("source", "")).startswith("orx:")))
4471
+ names = ("sources", "searches", "runs")
4472
+ line = "budget %s: %s" % (slug, " ".join("%s %d/%d" % (n, u, c)
4473
+ for n, u, c in zip(names, used, caps)))
4474
+ return line, ["%s %d/%d" % (n, u, c) for n, u, c in zip(names, used, caps) if u > c]
4475
+
4476
+
4477
+ def hypothesis_problems(state):
4478
+ """A concluded line ends every hypothesis in a terminal status (spec 2.2)."""
4479
+ out = []
4480
+ for n, entry in enumerate(state.get("hypotheses") or [], 1):
4481
+ status = entry.get("status") if isinstance(entry, dict) else None
4482
+ if status not in TERMINAL_STATUSES:
4483
+ hid = entry.get("id") if isinstance(entry, dict) and entry.get("id") else n
4484
+ out.append("state.json hypothesis %s has no terminal status (%r): a concluded "
4485
+ "line ends each one %s" % (hid, status, ", ".join(TERMINAL_STATUSES)))
4486
+ return out
4487
+
4488
+
4489
+ def overlaps(repo, question, slug=None):
4490
+ """[(line, claim, status, similarity)] of the done lines' claims whose
4491
+ `similarity_matrix` value against `question` is >= 0.5, over the corpus of the
4492
+ question plus every done line's claim statements (spec 4.2)."""
4493
+ done = [(name, row) for name in slugs(repo) if name != slug and sealed(repo, name)
4494
+ for row in _row_objects(os.path.join(line_dir(repo, name), "claims.jsonl"))
4495
+ if str(row.get("statement", "")).strip()]
4496
+ if not done or not str(question or "").strip():
4497
+ return []
4498
+ _backend, matrix = similarity_matrix([question] + [str(r["statement"]) for _, r in done])
4499
+ return [(name, row.get("id"), row.get("status"), matrix[0][n])
4500
+ for n, (name, row) in enumerate(done, 1) if matrix[0][n] >= 0.5]
4501
+
4502
+
4503
+ def _post_hoc(repo, ddir):
4504
+ """The variant ids whose variants.jsonl row was first committed after the first
4505
+ comparison.jsonl cell (spec 4.5)."""
4506
+ paths = [os.path.join(ddir, f) for f in ("variants.jsonl", "comparison.jsonl")]
4507
+ top, _rel = _histories(repo, paths[0])[-1]
4508
+ order, err = _git_out(top, "rev-list", "--reverse", "HEAD")
4509
+ if err:
4510
+ return set()
4511
+ index = {sha: n for n, sha in enumerate(order.split())}
4512
+ firsts = []
4513
+ for path in paths:
4514
+ seen = {}
4515
+ for sha, text in _versions(top, _histories(repo, path)[-1][1]):
4516
+ for line in text.splitlines():
4517
+ if line.strip() and sha in index:
4518
+ seen.setdefault(line.strip(), index[sha])
4519
+ firsts.append(seen)
4520
+ if not firsts[1]:
4521
+ return set()
4522
+ first_cell = min(firsts[1].values())
4523
+ out = set()
4524
+ for line, n in firsts[0].items():
4525
+ if n > first_cell:
4526
+ try:
4527
+ row = json.loads(line)
4528
+ except ValueError:
4529
+ continue
4530
+ if isinstance(row, dict) and row.get("id"):
4531
+ out.add(str(row["id"]))
4532
+ return out
4533
+
4534
+
4535
+ def _check_review8(base, claims, review, errors, warnings, strict):
4536
+ """4.1: families compared normalised, and a review of evidence claims that reads
4537
+ a raw log or code."""
4538
+ if review is None:
4539
+ return
4540
+ family = [re.sub(r"\s+", "", str(review.get(k) or "")).casefold()
4541
+ for k in ("producer_family", "reviewer_family")]
4542
+ if family[0] and family[0] == family[1]:
4543
+ warnings.append("to_human/review.json: same-family review (%s): producer and "
4544
+ "reviewer share a model family, so they share its blind spots"
4545
+ % family[0])
4546
+ if any(c.get("kind") == "evidence" for c in claims):
4547
+ targets = [str(f.get("target", "")).strip() for f in review.get("findings") or []
4548
+ if isinstance(f, dict)]
4549
+ if not any(re.match(r"experiments/[^/]+/raw/", t) or t.endswith(CODE_SUFFIXES)
4550
+ for t in targets):
4551
+ _soft(errors, warnings, strict,
4552
+ "to_human/review.json: review quotes no raw log or code, so no finding "
4553
+ "reads the bytes an evidence claim rests on")
4554
+
4555
+
4556
+ def _core_rules8(repo, slug, base, state, errors, warnings, strict, git, steps, code=None):
4557
+ rows = _row_objects(os.path.join(base, "claims.jsonl"))
4558
+ claims = [c for c in rows if isinstance(c.get("id"), str) and c["id"]]
4559
+ superseded = _superseded(rows)
4560
+ exps = {h: experiment_record(base, h) for h in _experiment_names(base)}
4561
+ report_path = os.path.join(base, "to_human", "report.md")
4562
+ report = _note_text(report_path) if os.path.isfile(report_path) else None
4563
+ review_path = os.path.join(base, "to_human", "review.json")
4564
+ review, exc = _read_json(review_path) if os.path.isfile(review_path) else (None, None)
4565
+ review = review if isinstance(review, dict) and not exc else None
4566
+ lines = steps.setdefault("#report", [])
4567
+ for h in sorted(exps):
4568
+ _check_experiment8(repo, slug, base, state, exps[h], report, errors, warnings,
4569
+ strict, git, lines, code)
4570
+ rejected = _multiplicity(base, exps)
4571
+ every = _routing_steps(base, state, exps, review)
4572
+ cls, measured = state.get("question_class"), state.get("method") == "measured"
4573
+ for claim in claims:
4574
+ cid = claim["id"]
4575
+ cited = [exps[h] for h in _experiments_named(_cited(claim.get("proof")))
4576
+ if h in exps]
4577
+ if cid not in superseded:
4578
+ errors.extend(derived_problems(cid, claim, base))
4579
+ errors.extend(fired_problems(cid, claim, base))
4580
+ found = _experiment_steps(claim, cited, cls, measured, rejected) if cited else []
4581
+ if claim.get("kind") == "evidence" and _untraced(claim, cited):
4582
+ found.append("untraced-number")
4583
+ for name in found + every:
4584
+ _step(steps, cid, name)
4585
+ _overclaim(cid, claim, cited, warnings)
4586
+ failed = steps.setdefault("#stats", {})
4587
+ for h in sorted(exps):
4588
+ if exps[h]["stats_error"]:
4589
+ failed.setdefault("experiment %s" % h, exps[h]["stats_error"])
4590
+ _check_review8(base, claims, review, errors, warnings, strict)
4591
+ if state.get("phase") == "concluded":
4592
+ errors.extend(hypothesis_problems(state))
4593
+ line, over = budget(slug, base, state)
4594
+ lines.append(line)
4595
+ if over:
4596
+ warnings.append("over budget: %s (tier %s)" % (", ".join(over),
4597
+ state.get("tier") or DEFAULT_TIER))
4598
+ if git:
4599
+ for name, ddir in _decision_dirs(base):
4600
+ body = _note_text(os.path.join(ddir, "decision.md"))
4601
+ picked = line_pick(body)
4602
+ if picked and picked in _post_hoc(repo, ddir) \
4603
+ and "post-hoc" not in body and "post_hoc" not in body:
4604
+ errors.append("decisions/%s: decision.md chooses %s, a variant first "
4605
+ "committed after the first comparison cell, without saying "
4606
+ "post-hoc" % (name, picked))
4607
+ related = {(str(r.get("line")), str(r.get("claim")))
4608
+ for r in _row_objects(os.path.join(base, "related.jsonl"))
4609
+ if r.get("relation") in ("builds-on", "dismissed")}
4610
+ for line_name, cid, status, _sim in overlaps(repo, state.get("question"), slug):
4611
+ if status == "refuted" and (line_name, str(cid)) not in related:
4612
+ warnings.append("overlaps refuted claim %s/%s: record in related.jsonl "
4613
+ "whether this line builds on it or dismisses it, and why"
4614
+ % (line_name, cid))
4615
+
4616
+
4617
+ def _check_rules8(repo, slug, base, state, errors, warnings, strict, git, flags=None,
4618
+ code=None):
4619
+ """steps of a rules-8 line: the capture checks, the core ones, then the evidence
4620
+ module, which reads every step found and holds the certainty engine. A module
4621
+ that cannot be imported is an error on a rules-8 line, never a silent pass. A
4622
+ statistics computation's ValueError or ArithmeticError, kept in `#stats` by
4623
+ the module it ran in, is one refusal per experiment (review round two R2-1)."""
4624
+ steps = {"#flags": {"online": bool((flags or {}).get("online")),
4625
+ "orx": bool((flags or {}).get("orx"))}, "#stats": {}}
4626
+ import importlib
4627
+ for name in ("tezgah_research_capture", None, "tezgah_research_evidence"):
4628
+ if name is None:
4629
+ _core_rules8(repo, slug, base, state, errors, warnings, strict, git, steps, code)
4630
+ continue
4631
+ try:
4632
+ module = importlib.import_module(name)
4633
+ except ImportError as exc:
4634
+ errors.append("rules 8: %s cannot be imported (%s), so its checks did not run"
4635
+ % (name, exc))
4636
+ continue
4637
+ module.check(repo, slug, base, state, errors, warnings, strict, git, steps)
4638
+ errors.extend("%s: statistics could not be computed: %s" % (label, error)
4639
+ for label, error in sorted(steps["#stats"].items()))
4640
+ return steps
4641
+
4642
+
4643
+ def check_line(repo, slug, git=True, strict=False, code=None, flags=None, extra=None):
3555
4644
  """(errors, warnings) for one research line. `git=False` skips the checks that
3556
4645
  need git history, for callers that must stay cheap (the session context).
3557
4646
  `strict=True` turns every guarantee the checker cannot decide into an error.
3558
4647
  `code` is the checkout the check runs in when it is not `repo`: the line
3559
4648
  lives in the main checkout's workspace (plan 097) and its citations and
3560
4649
  proofs resolve against the checkout whose branch holds the code; unnamed, a
3561
- bare repository's worktree holding the cwd (`tp.work_tree`, plan 098)."""
4650
+ bare repository's worktree holding the cwd (`tp.work_tree`, plan 098).
4651
+ A rules-8 line (`_evidence`) also runs `_check_rules8` with `flags` (`online`,
4652
+ `orx`); a dict `extra` receives its `report` lines and per-claim `certainty`."""
3562
4653
  code = code or tp.work_tree(repo)
3563
4654
  base = line_dir(repo, slug)
3564
4655
  errors, warnings = [], []
@@ -3586,6 +4677,13 @@ def check_line(repo, slug, git=True, strict=False, code=None):
3586
4677
  if git:
3587
4678
  _check_tracking(repo, base, errors, warnings, strict)
3588
4679
  _check_review_integrity(base, errors, warnings, strict)
4680
+ state = _state(base)
4681
+ if _evidence(state):
4682
+ steps = _check_rules8(repo, slug, base, state, errors, warnings, strict, git, flags,
4683
+ code)
4684
+ if extra is not None:
4685
+ extra["report"] = list(steps.get("#report", []))
4686
+ extra["certainty"] = dict(steps.get("#certainty", {}))
3589
4687
  return errors, warnings
3590
4688
 
3591
4689
 
@@ -3692,22 +4790,29 @@ def _check_derived_phase(base, phase, direction, warnings):
3692
4790
  "stopped describing the line" % (direction, derived, want))
3693
4791
 
3694
4792
 
3695
- def check(repo, slug=None, git=True, strict=False, all_lines=True, code=None):
3696
- """{slug: {"errors": [...], "warnings": [...]}} for one line or every line.
4793
+ def check(repo, slug=None, git=True, strict=False, all_lines=True, code=None, flags=None):
4794
+ """{slug: {"errors": [...], "warnings": [...]}} for one line or every line; a
4795
+ rules-8 line adds "report" (the lines `check` prints, never counted) and
4796
+ "certainty" ({claim id: {level, start, steps}}).
3697
4797
 
3698
4798
  With `all_lines=False` and no slug, a line under `done/` is read by its order
3699
4799
  seal alone (`done_seal`), not re-checked, and appears only when that read
3700
4800
  fails: the default no-slug `check` and the session note report the open
3701
- lines, and `check --all-lines` re-runs the closed ones (ADR 015)."""
3702
- out = {}
4801
+ lines, and `check --all-lines` re-runs the closed ones (ADR 015). With no
4802
+ slug, each hidden dir under research/ (`stray_dirs`) is a row of its own,
4803
+ keyed by its path."""
4804
+ out = {} if slug else {path: {"errors": [error], "warnings": []}
4805
+ for path, error in stray_dirs(repo)}
3703
4806
  for name in ([slug] if slug else slugs(repo)):
3704
4807
  if not slug and not all_lines and sealed(repo, name):
3705
4808
  errors = done_seal(repo, name)[0]
3706
4809
  if errors:
3707
4810
  out[name] = {"errors": errors, "warnings": []}
3708
4811
  continue
3709
- errors, warnings = check_line(repo, name, git=git, strict=strict, code=code)
3710
- out[name] = {"errors": errors, "warnings": warnings}
4812
+ extra = {}
4813
+ errors, warnings = check_line(repo, name, git=git, strict=strict, code=code,
4814
+ flags=flags, extra=extra)
4815
+ out[name] = dict({"errors": errors, "warnings": warnings}, **extra)
3711
4816
  return out
3712
4817
 
3713
4818
 
@@ -3789,7 +4894,9 @@ def _last_add(repo, path):
3789
4894
 
3790
4895
  def _seal_rows(repo, base):
3791
4896
  """{experiment: seal row}: the blob hashes of every experiment holding a
3792
- protocol.md, and the add commits of a run one, informational only."""
4897
+ protocol.md, and the add commits of a run one, informational only. A protocol
4898
+ naming an `evaluator:` also seals the evaluator's hash, the version `check`
4899
+ reads for the concluded line (`_sealed_evaluator`, plan 128 K8)."""
3793
4900
  out = {}
3794
4901
  for h, (proto, results) in _seal_files(base).items():
3795
4902
  row = {"protocol": _digest(proto),
@@ -3797,6 +4904,11 @@ def _seal_rows(repo, base):
3797
4904
  if results:
3798
4905
  row["commits"] = {"protocol": _last_add(repo, proto),
3799
4906
  "results": _last_add(repo, results)}
4907
+ words = _first(_fields(_note_text(proto)), "evaluator").split()
4908
+ evaluator = evaluator_file(repo, base, tp.work_tree(repo), words[0])[0] \
4909
+ if words else None
4910
+ if evaluator:
4911
+ row["evaluator"] = _digest(evaluator)
3800
4912
  out[h] = row
3801
4913
  return out
3802
4914
 
@@ -3982,7 +5094,7 @@ def import_line(repo, source, slug=None, allow_open=None, date=""):
3982
5094
  lines, here = _committed_lines(ws, rev), slugs(repo)
3983
5095
  if slug is not None and slug not in lines:
3984
5096
  return [], ("%s is not committed in %s, so it has no history to import; "
3985
- "commit it there (`tezgah-research commit`) - a plain copy is "
5097
+ "commit it there (`~/.config/tezgah/bin/tezgah-research commit`) - a plain copy is "
3986
5098
  "refused because it loses the order proof" % (slug, src))
3987
5099
  wanted = [slug] if slug is not None else sorted(s for s in lines if s not in here)
3988
5100
  clash = [s for s in wanted if s in here]
@@ -3999,14 +5111,14 @@ def import_line(repo, source, slug=None, allow_open=None, date=""):
3999
5111
  unfinished = open_lines(repo) if arriving else []
4000
5112
  if (unfinished or len(arriving) > 1) and allow_open is None:
4001
5113
  return [], ("%s would open beside unfinished line(s) %s; say why with "
4002
- "--allow-open \"<reason>\"" % (
5114
+ "--allow-open \"<reason>\"; %s" % (
4003
5115
  ", ".join(arriving),
4004
5116
  "; ".join("%s (%s)" % (n, "; ".join(r)) for n, r in unfinished)
4005
- or ", ".join(arriving)))
5117
+ or ", ".join(arriving), OWNER_ASK))
4006
5118
  broken = broken_open_lines(repo) if unfinished else []
4007
5119
  if broken:
4008
5120
  return [], ("--allow-open does not open a line beside an open line check "
4009
- "refuses: %s" % ", ".join(n for n, _e in broken))
5121
+ "refuses: %s; %s" % (", ".join(n for n, _e in broken), OWNER_ASK))
4010
5122
  if fresh:
4011
5123
  done = tp.ws_git(repo, "commit", "-q", "--allow-empty", "-m",
4012
5124
  "research: workspace root")
@@ -4134,10 +5246,16 @@ def _variants_owed(base, state):
4134
5246
  def broken_open_lines(repo):
4135
5247
  """[(slug, errors)] for the open lines `check` refuses: what `init
4136
5248
  --allow-open` will not open a new line beside, because a hatch past broken
4137
- lines is how they stayed broken."""
5249
+ lines is how they stayed broken. A criterion whose verdict is still
5250
+ `pending` is a line in flight, not a broken one, so that error alone does
5251
+ not block: another session may be running the line."""
4138
5252
  out = []
4139
5253
  for slug, _reasons in open_lines(repo):
4140
5254
  errors, _ = check_line(repo, slug)
5255
+ pending = {_verdict_error(sid, verdict) for sid, _c, verdict, _e
5256
+ in success_rows(line_state(repo, slug).get("success"))
5257
+ if verdict == "pending"}
5258
+ errors = [e for e in errors if e not in pending]
4141
5259
  if errors:
4142
5260
  out.append((slug, errors))
4143
5261
  return out
@@ -4220,8 +5338,20 @@ def conclude_line(repo, slug, date=""):
4220
5338
  "(state.json `success`) before concluding")
4221
5339
  if unjudged:
4222
5340
  problems.append("no verdict for %s: record it with "
4223
- "`tezgah-research verdict <slug> <id> met|not-met|unanswerable "
5341
+ "`~/.config/tezgah/bin/tezgah-research verdict <slug> <id> met|not-met|unanswerable "
4224
5342
  "--evidence <ref>`" % ", ".join(unjudged))
5343
+ if _evidence(state):
5344
+ # the rules `check` refuses a concluded rules-8 line for, asked before the
5345
+ # line is concluded: every hypothesis terminal, every rejected instrument
5346
+ # switched or narrowed (the evidence module's rule)
5347
+ problems.extend(hypothesis_problems(state))
5348
+ try:
5349
+ import tezgah_research_evidence as evidence
5350
+ except ImportError as exc:
5351
+ problems.append("rules 8: tezgah_research_evidence cannot be imported (%s)"
5352
+ % exc)
5353
+ else:
5354
+ problems.extend(evidence.conclude_problems(repo, slug, base, state))
4225
5355
  if problems:
4226
5356
  return problems, None
4227
5357
  state.update(phase="concluded", direction="conclude")
@@ -4238,6 +5368,75 @@ def conclude_line(repo, slug, date=""):
4238
5368
  return ([problem] if problem else []), state
4239
5369
 
4240
5370
 
5371
+ # The line every refusal over open lines carries (`init`, `import`): deciding
5372
+ # about another session's work (its line, plan or branch) is the owner's.
5373
+ OWNER_ASK = ("a line another session is running is not this session's to fix or "
5374
+ "close: stop and ask the owner (a decision only the owner makes), never "
5375
+ "a way around a tezgah refusal (another folder, a hidden dir)")
5376
+
5377
+
5378
+ def choose_line(repo, slug, decision, variant, ack, date=""):
5379
+ """The problem that stopped the owner's choice being written, or None.
5380
+
5381
+ A line recommends; the owner chooses (rules 9). `chosen: <variant>` and
5382
+ `owner: <date> "<words>"` replace any earlier pair in the decision's
5383
+ decision.md; the line's own pick stays as written, and a pick a rules-8 line
5384
+ wrote as `chosen:` is renamed `recommended:`. log.md gets one line. A done
5385
+ line takes it too: its order seal hashes the experiments alone, so the
5386
+ decision note stays writable."""
5387
+ words = " ".join(str(ack or "").replace('"', "'").split())
5388
+ if not words:
5389
+ return ("choose needs --ack \"<the owner's words>\": choosing a variant or "
5390
+ "option a research, analysis or review recommends is the owner's call")
5391
+ base = line_dir(repo, slug)
5392
+ newer = newer_rules(_state(base))
5393
+ if newer:
5394
+ return newer
5395
+ ddir = dict(_decision_dirs(base)).get(decision)
5396
+ path = os.path.join(ddir or "", "decision.md")
5397
+ if not ddir or not os.path.isfile(path):
5398
+ return "decisions/%s has no decision.md in %s" % (decision, slug)
5399
+ _criteria, variants, _problems = load_decision(ddir)
5400
+ produced = sorted(v for v, row in variants.items() if row.get("status") == "produced")
5401
+ if variant not in produced:
5402
+ return "variant %s is not produced in decisions/%s (produced: %s)" % (
5403
+ variant, decision, ", ".join(produced) or "none")
5404
+ body = _note_text(path)
5405
+ replace = OWNER_WORDS.search(body) or re.search(PICK % "recommended", body)
5406
+ kept = []
5407
+ for line in body.splitlines():
5408
+ if re.match(r"(?i)\W*owner\W*:", line):
5409
+ continue
5410
+ if re.match(r"(?i)\W*chosen\W*:", line):
5411
+ if replace:
5412
+ continue
5413
+ # the line wrote its own pick as `chosen:` (rules 8): it stays the
5414
+ # line's pick, for the line's rules to judge
5415
+ line = re.sub(r"(?i)chosen", "recommended", line, count=1)
5416
+ kept.append(line)
5417
+ try:
5418
+ with open(path, "w", encoding="utf-8") as fh:
5419
+ fh.write("\n".join(kept + ["chosen: %s" % variant,
5420
+ 'owner: %s "%s"' % (date, words)]) + "\n")
5421
+ with open(os.path.join(base, "log.md"), "a", encoding="utf-8") as fh:
5422
+ fh.write('- %s owner chose %s in decisions/%s: "%s"\n'
5423
+ % (date, variant, decision, words))
5424
+ except OSError as exc:
5425
+ return str(exc)
5426
+ return None
5427
+
5428
+
5429
+ def owner_waits(repo, slug):
5430
+ """[(decision, variant)] for each decision.md that recommends a variant and
5431
+ records no owner choice: what `conclude` tells the session to report."""
5432
+ out = []
5433
+ for name, ddir in _decision_dirs(line_dir(repo, slug)):
5434
+ picked, field = decision_pick(_note_text(os.path.join(ddir, "decision.md")))
5435
+ if field == "recommended":
5436
+ out.append((name, picked))
5437
+ return out
5438
+
5439
+
4241
5440
  def open_lines(repo):
4242
5441
  """[(slug, [reason, ...])] for every line that is not finished, for `init`.
4243
5442
 
@@ -4296,6 +5495,7 @@ def summary(repo):
4296
5495
  if fixtures:
4297
5496
  note += ", %d fixture-scoped claim(s): %s" % (len(fixtures), ", ".join(fixtures))
4298
5497
  out.append("%s: %s" % (slug, note))
5498
+ out.extend("FAIL %s" % error for _path, error in stray_dirs(repo))
4299
5499
  unfinished = open_note(repo)
4300
5500
  if unfinished:
4301
5501
  out.append(unfinished)
@@ -4678,7 +5878,51 @@ def urlsplit_host(source):
4678
5878
  return host[4:] if host.startswith("www.") else host
4679
5879
 
4680
5880
 
4681
- def source_run(repo, slug, hypothesis, run, orx, command="", scope="", fixture=""):
5881
+ # A run log above 16 MiB is refused rather than committed as a receipt unseen; the
5882
+ # refusal names `--ack`, which files it whole when the owner says so (K6 rater X6).
5883
+ RUN_LOG_BOUND = 16 * 1024 * 1024
5884
+ # What orx 0.2.20's `orx logs` prints instead of the log: the path, the size, a tail.
5885
+ ORX_LOG_SUMMARY = re.compile(r"\s*This is the path to the full log file: (.+?)[ \t]*\n"
5886
+ r"\s*It is ([\d,]+) bytes\.")
5887
+
5888
+
5889
+ def _bound_problem(verb, run, size, bound):
5890
+ return ("orx logs %s %s %d bytes, above the %d-byte bound a receipt is committed "
5891
+ "under - file it with --ack \"<the user's words>\" to keep it whole"
5892
+ % (run, verb, size, bound))
5893
+
5894
+
5895
+ def _full_log(repo, run, out, bound=RUN_LOG_BOUND):
5896
+ """(bytes, problem): the run log `orx logs` printed - its output, or, when that is
5897
+ orx's summary, the file the summary names, held to the size it printed (K6 rater
5898
+ Y2: the summary's 500-character tail is not the log). Either is held to `bound`
5899
+ (`RUN_LOG_BOUND`; None when the owner acknowledged the size, which reads it whole)."""
5900
+ found = ORX_LOG_SUMMARY.match(out)
5901
+ if not found:
5902
+ if out.lstrip().startswith("This is the path to the full log file:"):
5903
+ return None, ("orx logs %s printed the log's path without its size, so the "
5904
+ "file cannot be held to what orx saw" % run)
5905
+ data = out.encode("utf-8")
5906
+ if bound is not None and len(data) > bound:
5907
+ return None, _bound_problem("printed", run, len(data), bound)
5908
+ return data, None
5909
+ path, size = os.path.join(repo, found.group(1)), int(found.group(2).replace(",", ""))
5910
+ if bound is not None and size > bound:
5911
+ return None, _bound_problem("said", run, size, bound)
5912
+ try:
5913
+ with open(path, "rb") as fh:
5914
+ data = fh.read(-1 if bound is None else bound + 1)
5915
+ except OSError as exc:
5916
+ return None, "cannot read the log orx logs %s names: %s" % (run, exc)
5917
+ if len(data) != size:
5918
+ return None, ("orx logs %s said %d bytes and %s holds %d: the run is still "
5919
+ "writing it or it changed since, so it is not the log orx saw - "
5920
+ "file the run once its log is complete" % (run, size, path, len(data)))
5921
+ return data, None
5922
+
5923
+
5924
+ def source_run(repo, slug, hypothesis, run, orx, command="", scope="", fixture="",
5925
+ arm="", item="", values=(), instrument="", ack=""):
4682
5926
  """(path, problems): file one orx run as this experiment's receipt.
4683
5927
 
4684
5928
  `orx logs` is read here rather than by the session, so the log lands in the
@@ -4700,37 +5944,129 @@ def source_run(repo, slug, hypothesis, run, orx, command="", scope="", fixture="
4700
5944
  without it files the row and says which field is still missing rather than
4701
5945
  refusing a row a session may have a reason to file; a value that is not a
4702
5946
  string, or one that is empty, is the mistake the field exists to catch and is
4703
- refused."""
5947
+ refused.
5948
+
5949
+ `values` are (field, number as written) pairs: each lands in the row as a
5950
+ number, and one equal to no number of the stored log is refused - equal as a
5951
+ number, so `1.5` matches a logged `1.50` (review round two K-6). `instrument`,
5952
+ `arm` and `item` land as strings, so the instrument ledger (`gold`, `label`)
5953
+ reads the row (K-7). A run already filed keeps its log; the new one is
5954
+ `raw/<run>.<n>.log` (a capture is written once). A rules-8 line's row also
5955
+ names its `run`, the engine's
5956
+ `status` when the line names an orx project, and the log and row are committed
5957
+ as `capture: source <slug>` (plan 127, spec 1.4).
5958
+
5959
+ `ack` is the owner's words for a log above `RUN_LOG_BOUND`: the log is stored
5960
+ whole and the row records the words (K6 rater X6)."""
4704
5961
  if scope and scope not in SCOPES:
4705
5962
  return None, ["scope %r is not one of %s" % (scope, ", ".join(SCOPES))]
4706
5963
  if fixture and (not isinstance(fixture, str) or not fixture.strip()):
4707
5964
  return None, ["fixture %r does not name what was generated" % (fixture,)]
4708
- newer = newer_rules(_state(line_dir(repo, slug)))
5965
+ base = line_dir(repo, slug)
5966
+ state = _state(base)
5967
+ newer = newer_rules(state)
4709
5968
  if newer:
4710
5969
  return None, [newer]
4711
5970
  experiment = os.path.join(line_dir(repo, slug), "experiments", hypothesis)
4712
5971
  if not os.path.isfile(os.path.join(experiment, "protocol.md")):
4713
5972
  return None, ["experiments/%s holds no protocol.md: write and commit the "
4714
5973
  "plan before filing the run it predicts" % hypothesis]
4715
- log, err = _run([orx, "logs", run], cwd=repo)
5974
+ if _evidence(state) and (_orders_lock(state) or _exploratory(state)):
5975
+ # the capture is committed at once, after HEAD of the history carrying the
5976
+ # line, so an uncommitted lock would follow its results for good: the order
5977
+ # `_check_evaluation_order` reads - an exploratory line's lock is its arena,
5978
+ # ordered by `_check_arena_order` (plan 128 K12) - asked of that HEAD before
5979
+ # anything is written
5980
+ lock_path = os.path.join(base, "state.json")
5981
+ top = _histories(repo, lock_path)[-1][0]
5982
+ arena = _exploratory(state)
5983
+ firsts = _arena_lock(repo, base)[0] if arena else _evaluation_commit(repo, lock_path)
5984
+ if _answers(repo, firsts, {top: "HEAD"}).get(top) is not True:
5985
+ return None, ["%s is not committed: no committed state.json holding %s "
5986
+ "precedes this capture, so its results would come before the "
5987
+ "lock for good - commit the line first (`tezgah-research commit "
5988
+ "%s \"<message>\"`); nothing was written"
5989
+ % ((("the arena", "the arena") if arena else
5990
+ ("the evaluation lock", "the metric and the baseline"))
5991
+ + (slug,))]
5992
+ if _evidence(state):
5993
+ # the same reason as the lock: what the capture commits next would leave the
5994
+ # protocol refused for good - entered after its results, or frozen invalid
5995
+ # (left, refused; fixed, `changed after the run`) - K6 rater X2
5996
+ commit = 'commit the line first (`tezgah-research commit %s "<message>"`); ' \
5997
+ 'nothing was written' % slug
5998
+ record = experiment_record(base, hypothesis)
5999
+ if record["invalid"]:
6000
+ return None, ["experiments/%s/protocol.md carries an invalid record (%s): the "
6001
+ "capture commits its results at once, after which the protocol "
6002
+ "cannot be fixed - fix it, then %s"
6003
+ % (hypothesis, "; ".join(record["invalid"]), commit)]
6004
+ metric = metric_problem(state, record["fields"])
6005
+ if metric:
6006
+ return None, ["experiments/%s/%s: the capture commits its results at once, "
6007
+ "after which the protocol cannot be fixed - name the locked "
6008
+ "metric in its `metric:` line, then %s" % (hypothesis, metric,
6009
+ commit)]
6010
+ if not _git_out(repo, "rev-parse", "--is-inside-work-tree")[1] and not any(
6011
+ _oldest_adds(repo, os.path.join(experiment, "protocol.md"))[0].values()):
6012
+ return None, ["experiments/%s/protocol.md is not committed: the capture commits "
6013
+ "its results at once, so the protocol would enter the history "
6014
+ "after them for good - %s" % (hypothesis, commit)]
6015
+ out, err = _run([orx, "logs", run], cwd=repo)
4716
6016
  if err:
4717
6017
  return None, ["orx logs %s failed: %s" % (run, err)]
4718
- if not log.strip():
6018
+ data, problem = _full_log(repo, run, out, None if ack else RUN_LOG_BOUND)
6019
+ if problem:
6020
+ return None, [problem]
6021
+ if not data.strip():
4719
6022
  return None, ["orx logs %s returned nothing, so there is no receipt to "
4720
6023
  "file" % run]
6024
+ import tezgah_research_capture as cap # deferred: it imports this module
6025
+ logged = cap.log_numbers(data.decode("utf-8", "replace"))
6026
+ for field, written in values:
6027
+ if decimal.Decimal(written) not in logged:
6028
+ return None, ["%s=%s: value not in the run log (orx logs %s holds no number "
6029
+ "equal to %s)" % (field, written, run, written)]
4721
6030
  raw = os.path.join(experiment, "raw")
4722
6031
  os.makedirs(raw, exist_ok=True)
4723
- path = os.path.join(raw, run + ".log")
4724
- with open(path, "w", encoding="utf-8") as fh:
4725
- fh.write(log)
4726
- row = {"source": "orx:%s" % run, "log": "raw/%s.log" % run}
6032
+ name, n = run + ".log", 0
6033
+ while os.path.lexists(os.path.join(raw, name)):
6034
+ n += 1
6035
+ name = "%s.%d.log" % (run, n)
6036
+ path = os.path.join(raw, name)
6037
+ with open(path, "wb") as fh:
6038
+ fh.write(data)
6039
+ row = {"source": "orx:%s" % run, "log": "raw/%s" % name}
6040
+ if _evidence(state):
6041
+ row["run"] = run
6042
+ if instrument:
6043
+ row["instrument"] = instrument
6044
+ if arm:
6045
+ row["arm"] = arm
6046
+ if item:
6047
+ row["item"] = item
6048
+ for field, written in values:
6049
+ row[field] = int(written) if re.fullmatch(r"-?\d+", written) else float(written)
4727
6050
  if command:
4728
6051
  row["command"] = command
6052
+ if ack:
6053
+ row["ack"] = ack
4729
6054
  if scope:
4730
6055
  row["scope"] = scope
4731
6056
  if fixture:
4732
6057
  row["fixture"] = fixture
4733
- problems = _append_row(os.path.join(experiment, "results.jsonl"), row)
6058
+ if _evidence(state):
6059
+ runs, _err = cap.engine_runs(state)
6060
+ status = next((r.get("status") for r in runs or () if cap.run_id(r) == run), None)
6061
+ if isinstance(status, str) and status:
6062
+ row["status"] = status
6063
+ results = os.path.join(experiment, "results.jsonl")
6064
+ problems = _append_row(results, row)
6065
+ if not problems and _evidence(state):
6066
+ err = cap.commit_capture(repo, base, [os.path.relpath(path, base),
6067
+ os.path.relpath(results, base)],
6068
+ "source", slug)
6069
+ problems = [err] if err else []
4734
6070
  return path, problems
4735
6071
 
4736
6072
 
@@ -4899,7 +6235,7 @@ def verdict_problems(state, rows):
4899
6235
 
4900
6236
 
4901
6237
  def init(repo, slug, question="", created="", supersedes=None, ask="", tier=DEFAULT_TIER,
4902
- method="", scope_in=(), scope_out=(), arena=None):
6238
+ method="", scope_in=(), scope_out=(), arena=None, question_class=""):
4903
6239
  """Scaffold a research line. Returns the paths created (never overwrites).
4904
6240
 
4905
6241
  Refuses a slug `slugs()` cannot list - see `valid_slug` - so no caller can
@@ -4914,6 +6250,8 @@ def init(repo, slug, question="", created="", supersedes=None, ask="", tier=DEFA
4914
6250
  os.makedirs(path, exist_ok=True)
4915
6251
  state = dict(STATE_TEMPLATE)
4916
6252
  state["method"] = method
6253
+ if question_class:
6254
+ state["question_class"] = question_class
4917
6255
  state["scope"] = {"in": list(scope_in), "out": list(scope_out)}
4918
6256
  state["question"] = question
4919
6257
  state["created"] = created
@@ -6239,9 +7577,9 @@ def bradley_terry(ids, matches):
6239
7577
 
6240
7578
  def _next_action(slug, iid, stage, history, record, arena):
6241
7579
  """The one action an idea is owed next, as the command that does it."""
6242
- cmd = "tezgah-research stage %s %s" % (slug, iid)
7580
+ cmd = "~/.config/tezgah/bin/tezgah-research stage %s %s" % (slug, iid)
6243
7581
  if stage == "proposed":
6244
- return ("dedup (`tezgah-research dedup %s`), then `%s prior-art --evidence "
7582
+ return ("dedup (`~/.config/tezgah/bin/tezgah-research dedup %s`), then `%s prior-art --evidence "
6245
7583
  "'{\"verdict\": \"novel|incremental|known\", \"nearest\": [3 indexed "
6246
7584
  "notes], \"delta\": \"<one sentence>\"}'`" % (slug, cmd))
6247
7585
  if stage == "duplicate":
@@ -6254,7 +7592,7 @@ def _next_action(slug, iid, stage, history, record, arena):
6254
7592
  if stage == "prior-art":
6255
7593
  if record["judges"] >= 2 and record["orders"] >= 2:
6256
7594
  return "`%s screened --evidence tournament.jsonl`" % cmd
6257
- return ("judge it: `tezgah-research match %s` (2+ judges of a family that "
7595
+ return ("judge it: `~/.config/tezgah/bin/tezgah-research match %s` (2+ judges of a family that "
6258
7596
  "generated neither idea, both orders)" % slug)
6259
7597
  if stage == "screened":
6260
7598
  return ("pilot: commit experiments/<h>/protocol.md naming %s and its kill "
@@ -6318,7 +7656,7 @@ def board(repo, slug):
6318
7656
  live = [e for e in entries if e["stage"] not in ("confirmed", "killed")]
6319
7657
  live.sort(key=lambda e: (-STAGES.index(e["stage"]), -e["score"], _idea_number(e["id"])))
6320
7658
  nxt = ("%s - %s" % (live[0]["id"], live[0]["next"]) if live else
6321
- ("record ideas: `tezgah-research idea %s`" % slug) if not ideas else
7659
+ ("record ideas: `~/.config/tezgah/bin/tezgah-research idea %s`" % slug) if not ideas else
6322
7660
  "nothing left: every idea is confirmed or killed - write the report")
6323
7661
  evaluator = ("none (review only)" if not _text(arena.get("evaluator")) else
6324
7662
  "%s %s" % (arena["evaluator"], "changed" if evaluator_problem(repo, state)
@@ -6444,7 +7782,7 @@ def dedup_text(report, slug):
6444
7782
  rep = c["members"][0]
6445
7783
  out.append("cluster %s: %s" % (rep, ", ".join(
6446
7784
  "%s (%.2f)" % (m, c["similarity"][m]) for m in c["members"][1:])))
6447
- out.extend(" tezgah-research stage %s %s duplicate --evidence '%s'"
7785
+ out.extend(" ~/.config/tezgah/bin/tezgah-research stage %s %s duplicate --evidence '%s'"
6448
7786
  % (slug, m, json.dumps({"of": rep, "similarity": c["similarity"][m],
6449
7787
  "backend": report["backend"]}))
6450
7788
  for m in c["members"][1:])