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