capscorp 0.1.0__py3-none-any.whl
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.
- capscorp-0.1.0.dist-info/METADATA +201 -0
- capscorp-0.1.0.dist-info/RECORD +38 -0
- capscorp-0.1.0.dist-info/WHEEL +5 -0
- capscorp-0.1.0.dist-info/entry_points.txt +2 -0
- capscorp-0.1.0.dist-info/licenses/LICENSE +21 -0
- capscorp-0.1.0.dist-info/top_level.txt +1 -0
- capsule_corp/__init__.py +14 -0
- capsule_corp/checks.py +281 -0
- capsule_corp/cli.py +698 -0
- capsule_corp/doctor.py +134 -0
- capsule_corp/editor.py +64 -0
- capsule_corp/executors/__init__.py +64 -0
- capsule_corp/executors/base.py +66 -0
- capsule_corp/executors/local.py +105 -0
- capsule_corp/executors/modal_exec.py +157 -0
- capsule_corp/executors/slurm.py +185 -0
- capsule_corp/executors/ssh_docker.py +120 -0
- capsule_corp/executors/transport.py +97 -0
- capsule_corp/export.py +469 -0
- capsule_corp/index.py +117 -0
- capsule_corp/mcp_server.py +277 -0
- capsule_corp/models.py +230 -0
- capsule_corp/phases/__init__.py +7 -0
- capsule_corp/phases/design.py +129 -0
- capsule_corp/phases/implement.py +78 -0
- capsule_corp/phases/scaffold.py +134 -0
- capsule_corp/phases/verify.py +210 -0
- capsule_corp/progress.py +139 -0
- capsule_corp/prompts.py +210 -0
- capsule_corp/runners/__init__.py +10 -0
- capsule_corp/runners/base.py +76 -0
- capsule_corp/runners/pi.py +259 -0
- capsule_corp/settings.py +169 -0
- capsule_corp/store.py +470 -0
- capsule_corp/tui/__init__.py +5 -0
- capsule_corp/tui/app.py +397 -0
- capsule_corp/tui/code.py +160 -0
- capsule_corp/ui.py +39 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capscorp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal catalogue of reproducible, pre-registered research capsules
|
|
5
|
+
Author-email: Lenz Pracher <lenz.pracher@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lenzpracher/capsule-corp
|
|
8
|
+
Project-URL: Repository, https://github.com/lenzpracher/capsule-corp
|
|
9
|
+
Keywords: research,reproducibility,pre-registration,llm,tui
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: typer>=0.12
|
|
14
|
+
Requires-Dist: textual>=0.80
|
|
15
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
16
|
+
Requires-Dist: tomlkit>=0.13
|
|
17
|
+
Requires-Dist: rich>=13
|
|
18
|
+
Requires-Dist: platformdirs>=4
|
|
19
|
+
Requires-Dist: mcp<3,>=2.2
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
<p align="center">
|
|
23
|
+
<picture>
|
|
24
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
|
|
25
|
+
<img src="docs/assets/logo.svg" alt="capsule" width="440">
|
|
26
|
+
</picture>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
# capsule-corp
|
|
30
|
+
|
|
31
|
+
Packaging reproducible research questions.
|
|
32
|
+
|
|
33
|
+
A terminal catalogue where each **capsule** is one empirically testable research question,
|
|
34
|
+
packaged so that it stays reproducible and honest: the question, a frozen pre-registration,
|
|
35
|
+
the code, a pinned environment, the outputs, and a verification record.
|
|
36
|
+
|
|
37
|
+
Capsules are primarily written by an LLM. Every artifact is plain text and editable by hand.
|
|
38
|
+
|
|
39
|
+
## Why pre-registration
|
|
40
|
+
|
|
41
|
+
The failure mode of LLM-generated research is not bad code — it is code that quietly gets
|
|
42
|
+
rewritten until it agrees with the conclusion. capsule-corp splits a capsule into two phases
|
|
43
|
+
with a lock in between:
|
|
44
|
+
|
|
45
|
+
1. **Design.** An agent writes the question, the hypothesis, and a set of _machine-checkable_
|
|
46
|
+
assertions that would falsify it — before any implementation exists.
|
|
47
|
+
2. **Freeze.** `prereg.toml` is hashed into `.prereg.lock` and committed. From here the
|
|
48
|
+
predictions and the checks cannot change.
|
|
49
|
+
3. **Implement, run, verify.** A second agent writes the code. The hash is re-verified before
|
|
50
|
+
and after; if the pre-registration moved, the run fails.
|
|
51
|
+
|
|
52
|
+
Verification then has two independent layers: the deterministic checks (no LLM involved, so
|
|
53
|
+
they are a real gate), and a **blinded** LLM judge that sees the question, the code, and the
|
|
54
|
+
outputs — but not the write-up or any of the author's claims.
|
|
55
|
+
|
|
56
|
+
A capsule whose hypothesis is refuted is a _successful_ capsule. That outcome is recorded,
|
|
57
|
+
not treated as a failure.
|
|
58
|
+
|
|
59
|
+
## Status
|
|
60
|
+
|
|
61
|
+
Early but complete end to end: catalogue, agent loop, pre-registered verification, TUI,
|
|
62
|
+
remote execution, and the MCP server are all implemented. The Slurm, SSH and Modal
|
|
63
|
+
backends are unit-tested but have not yet been pointed at real infrastructure — see
|
|
64
|
+
[`docs/roadmap.md`](docs/roadmap.md).
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
curl -fsSL https://lenzpracher.github.io/capsule-corp/install.sh | sh
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This puts the `capsule` command in `~/.local/bin` using [uv](https://docs.astral.sh/uv/),
|
|
73
|
+
in its own isolated environment. No sudo. As with any installer of this shape, read
|
|
74
|
+
[the script](install.sh) before piping it to a shell.
|
|
75
|
+
|
|
76
|
+
Or, equivalently:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uv tool install capscorp
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The command is `capsule`; the package is `capscorp` and the repository is
|
|
83
|
+
`capsule-corp`. Only the first is something you type.
|
|
84
|
+
|
|
85
|
+
To hack on capsule-corp itself, clone it and install editable so the command tracks
|
|
86
|
+
your working copy:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
git clone https://github.com/lenzpracher/capsule-corp
|
|
90
|
+
cd capsule-corp
|
|
91
|
+
uv tool install --editable .
|
|
92
|
+
pixi install && pixi run postinstall # for the test and lint tasks
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then run `capsule doctor`. Two external tools do the real work:
|
|
96
|
+
[pi](https://github.com/earendil-works/pi) writes the capsules, and
|
|
97
|
+
[pixi](https://pixi.sh) manages each capsule's environment.
|
|
98
|
+
|
|
99
|
+
## Use
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
capsule doctor # check pi, pixi and the compute backends
|
|
103
|
+
capsule init ~/research # create a catalogue
|
|
104
|
+
capsule mkdir optimization # organise it however you like
|
|
105
|
+
|
|
106
|
+
capsule new "Does LR warmup lower final loss?" --folder optimization
|
|
107
|
+
capsule design 0001 # write the pre-registration; re-run to revise it
|
|
108
|
+
capsule freeze 0001 # lock it; predictions can no longer change
|
|
109
|
+
capsule implement 0001 # write the code
|
|
110
|
+
capsule run 0001 --on slurm # local (default), slurm, ssh, or modal
|
|
111
|
+
capsule verify 0001 # checks, then the blinded judge
|
|
112
|
+
|
|
113
|
+
capsule open 0001 # read the code in VS Code
|
|
114
|
+
capsule export 0001 # a supplementary-materials bundle for a paper
|
|
115
|
+
capsule tui # browse and drive it interactively
|
|
116
|
+
capsule mcp # serve the catalogue to any MCP client
|
|
117
|
+
capsule search warmup
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Commands are listed in `capsule --help` in the order you run them, grouped by purpose,
|
|
121
|
+
because that sequence is the method rather than an implementation detail.
|
|
122
|
+
|
|
123
|
+
## Interface
|
|
124
|
+
|
|
125
|
+
`capsule tui` opens a Textual interface over the same library the CLI uses: a folder
|
|
126
|
+
tree, a detail pane showing the pre-registration and the verdict, a settings view, and
|
|
127
|
+
`r`un / `v`erify / `d`esign / `f`reeze / `i`mplement keybinds. Press `e` for a built-in
|
|
128
|
+
file browser and editor with syntax highlighting, or `o` to open the capsule in
|
|
129
|
+
VS Code. Phases run in worker threads, so the interface stays responsive while a model
|
|
130
|
+
is working.
|
|
131
|
+
|
|
132
|
+
Long phases stream their progress as they run — each file the agent writes, each
|
|
133
|
+
command it runs, a live token count — so you can tell a working agent from a hung one.
|
|
134
|
+
|
|
135
|
+
## Attaching capsules to papers
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
capsule export 0001 # supplementary-materials zip
|
|
139
|
+
capsule export 0001 --format markdown # an appendix
|
|
140
|
+
capsule export 0001 --format html # one self-contained file
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Every export carries the pre-registration hash and the provenance, and discloses any
|
|
144
|
+
revision the registration went through after being frozen. See
|
|
145
|
+
[`docs/export.md`](docs/export.md).
|
|
146
|
+
|
|
147
|
+
## Running elsewhere
|
|
148
|
+
|
|
149
|
+
Capsules run locally by default, and unchanged on Slurm, any SSH host with Docker, or
|
|
150
|
+
Modal. See [`docs/compute.md`](docs/compute.md).
|
|
151
|
+
|
|
152
|
+
## Driving it from an MCP client
|
|
153
|
+
|
|
154
|
+
`capsule mcp` serves the catalogue over the Model Context Protocol, including
|
|
155
|
+
`capsule://<id>` resources so an agent can read prior capsules as context. See
|
|
156
|
+
[`docs/mcp.md`](docs/mcp.md).
|
|
157
|
+
|
|
158
|
+
## A capsule on disk
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
capsules/optimization/0001-lr-warmup/
|
|
162
|
+
capsule.toml manifest: id, status, provenance
|
|
163
|
+
QUESTION.md the research question
|
|
164
|
+
prereg.toml hypothesis, predictions, checks
|
|
165
|
+
.prereg.lock sha256 of prereg.toml at freeze time
|
|
166
|
+
AGENTS.md instructions for the implementing agent
|
|
167
|
+
.pi/settings.json per-capsule pinned agent config
|
|
168
|
+
pixi.toml per-capsule environment
|
|
169
|
+
src/ run.py the implementation
|
|
170
|
+
results/ results.json + figures/
|
|
171
|
+
runs/<timestamp>/ transcript and provenance for each run
|
|
172
|
+
verification.json check results + judge verdict
|
|
173
|
+
REPORT.md the write-up
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Files are the source of truth. The SQLite index is a cache and can be rebuilt at any time
|
|
177
|
+
with `capsule reindex`.
|
|
178
|
+
|
|
179
|
+
## Agent independence
|
|
180
|
+
|
|
181
|
+
The coding agent is [`pi`](https://github.com/earendil-works/pi), which is open source and
|
|
182
|
+
provider-agnostic — configure it for Claude, GPT, Gemini, or a local model. capsule-corp does
|
|
183
|
+
not pin a model; it inherits whatever `pi` is configured with and records what was actually
|
|
184
|
+
used in each capsule's manifest. The runner sits behind a protocol, so other agents can be
|
|
185
|
+
plugged in.
|
|
186
|
+
|
|
187
|
+
## Documentation
|
|
188
|
+
|
|
189
|
+
<https://lenzpracher.github.io/capsule-corp>
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
194
|
+
|
|
195
|
+
## Disclaimer
|
|
196
|
+
|
|
197
|
+
capsule-corp is an independent, unaffiliated open-source project. It is not
|
|
198
|
+
associated with, endorsed by, or sponsored by Bird Studio, Shueisha, Toei Animation,
|
|
199
|
+
or Capsule Corporation Tokyo. "Dragon Ball" and "Capsule Corporation" are trademarks
|
|
200
|
+
of their respective owners; the name and mark here are an affectionate nod, and no
|
|
201
|
+
rights-holder artwork is used.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
capscorp-0.1.0.dist-info/licenses/LICENSE,sha256=k5Wbwh0Aiw-enxdfTlAh_CUvtaxPuVecj2Vb21DrlUE,1068
|
|
2
|
+
capsule_corp/__init__.py,sha256=YHlveizhQS4fEpxxhp1WQnQbaecn2LSFlNUNy__WxiY,677
|
|
3
|
+
capsule_corp/checks.py,sha256=ooqj_KPEb-aEUV4yHUSOA4MBoZ1gqiK0NtUwWUhqpcY,10134
|
|
4
|
+
capsule_corp/cli.py,sha256=v1ugG6k4JD9wkZfBb3jk7N9qefdr9BUfgbF_6vDKIYA,27642
|
|
5
|
+
capsule_corp/doctor.py,sha256=VbOwSMra8MG_E_LkFLwwPCtdLbK3Wn_kpS06C_0mlnQ,4824
|
|
6
|
+
capsule_corp/editor.py,sha256=P13CwRk9IXG7Ep4l_uF1lVuQjZjKUzUvaph_dESC50g,2273
|
|
7
|
+
capsule_corp/export.py,sha256=X8su3HyFg39thCaPoyC08iy4ZzrAhw2nttYacnrQ-fM,17210
|
|
8
|
+
capsule_corp/index.py,sha256=wjT9oa72CfvLuFQQnLgl9YRVjbwga8TQBBEbWm7tyXY,4150
|
|
9
|
+
capsule_corp/mcp_server.py,sha256=amjR0V4IGVCmFAqoMKG0JjHVM2RYk5ZLaMAtn_vHTCI,10975
|
|
10
|
+
capsule_corp/models.py,sha256=esSrxjtH2Iqq7yCGkt9FuTNUe4Frl7hR6nXkp6z1Ng8,7547
|
|
11
|
+
capsule_corp/progress.py,sha256=6OZ4zZAWchHp6xq4H5m6p5ApLx2rTkqnKLLzdM6cvEY,4770
|
|
12
|
+
capsule_corp/prompts.py,sha256=JbGSMn5-i5P8as-wbF_agOcyH0_dJom649nskwg0hGg,8403
|
|
13
|
+
capsule_corp/settings.py,sha256=gH7mXZ22PDIwFKP5cp8HY8wIoARIs1m0ZNFv6n6qorU,5494
|
|
14
|
+
capsule_corp/store.py,sha256=YLJAg59OSRuBO3IRqjs1cyTiYlXUn0_hMyW5ERzURJQ,17723
|
|
15
|
+
capsule_corp/ui.py,sha256=MrvsxPPmTR4dHofjbLC9p01d8lBWv1xoCb7NJMHnvLU,1205
|
|
16
|
+
capsule_corp/executors/__init__.py,sha256=cBVaveJsVTk1bF_4xL4U_4pl33SqXXjjN3lGdHtbbgM,1992
|
|
17
|
+
capsule_corp/executors/base.py,sha256=MxCvN1b7Kfj-yUDQf6qdmskLp2QFkkXQXxxot08_EfQ,1728
|
|
18
|
+
capsule_corp/executors/local.py,sha256=4_iBbS9moW2a4OrFhoIO07t23QKsxQH7GPSoE5mvzqM,3433
|
|
19
|
+
capsule_corp/executors/modal_exec.py,sha256=UPe24ePkH1ptrIw-9Xu24Uf0FX4J78BstXyfoqwrmaE,5896
|
|
20
|
+
capsule_corp/executors/slurm.py,sha256=RPNB-W4wUG-436QINf_5UsyOAnhIhylppQ47_wrS5Tk,6722
|
|
21
|
+
capsule_corp/executors/ssh_docker.py,sha256=TOUIbOlZIUkpNW-3PAPnELWykBobz_X2V47C5bkU6BA,4203
|
|
22
|
+
capsule_corp/executors/transport.py,sha256=dunFZdUYRMaNp2BfIIrUtJixyum7XIDdVGjVFKIqiJQ,2831
|
|
23
|
+
capsule_corp/phases/__init__.py,sha256=XrKBbgS0W6Upzn8Ru4D6D_Z_9dGV91sS5aeDHLP5Cxo,283
|
|
24
|
+
capsule_corp/phases/design.py,sha256=jl6I2sqQcTDsivr8VGFeMpTQQ-iKfhcWRs8B6YHGWMo,4735
|
|
25
|
+
capsule_corp/phases/implement.py,sha256=uCp2Z93x6PjH3vwcQ-PwEntLa8YgsmIHmfr151dFqXY,2525
|
|
26
|
+
capsule_corp/phases/scaffold.py,sha256=wl-kI3w1JISTgWMhToA-bHkINOszDuasLqqzyjDrWJ0,4980
|
|
27
|
+
capsule_corp/phases/verify.py,sha256=vF-b5kXVSzQXCwYbthIsOaGyM8hinkfO81KnOjL5B6I,8038
|
|
28
|
+
capsule_corp/runners/__init__.py,sha256=g6kfDw4I9ploWfHzRrByIngJ5GBAv7HjNp4Z5-El2xk,446
|
|
29
|
+
capsule_corp/runners/base.py,sha256=TtREmBqW86PaX83PBoMQNPVYCf5oWc5lN_woLryyfu8,2179
|
|
30
|
+
capsule_corp/runners/pi.py,sha256=mL4r4zu8cZdfkZgXOLaFNe5kOiHJ2xmgacjF1wWMxvY,9064
|
|
31
|
+
capsule_corp/tui/__init__.py,sha256=JxCYwLUsaQSLP_eUkjY6avafR-fIxzloU4h-AKuJmTU,110
|
|
32
|
+
capsule_corp/tui/app.py,sha256=oBBAakTH8ke0roRQOllgv_nXcB0uypU-Ezz0cebrhJk,15768
|
|
33
|
+
capsule_corp/tui/code.py,sha256=XiUFuPPzAwTzC7UuRaJC-_h5mwfhTv3F7FN5Ll7ivaU,5989
|
|
34
|
+
capscorp-0.1.0.dist-info/METADATA,sha256=EI6t4o-wqTA56jCKYNPNA_cbmQT0xnLzo9hB8Mawtnk,7858
|
|
35
|
+
capscorp-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
36
|
+
capscorp-0.1.0.dist-info/entry_points.txt,sha256=cc6iHH8K3XJtx7_esaH3mZTiZ9EtHZrA228JP9DZTAA,50
|
|
37
|
+
capscorp-0.1.0.dist-info/top_level.txt,sha256=S9c9aYt2WucEqW3oQtWEX93-7pSkWrTc97iQ6-Ol20g,13
|
|
38
|
+
capscorp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lenzpracher
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
capsule_corp
|
capsule_corp/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""capsule-corp: a terminal catalogue of reproducible, pre-registered research capsules."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# The argument is the *distribution* name from pyproject.toml, not the import
|
|
7
|
+
# package (capsule_corp). Single source of truth is the [project] version; reading it
|
|
8
|
+
# back from installed metadata keeps a capsule's recorded provenance honest
|
|
9
|
+
# rather than reporting a constant that drifts from what was released.
|
|
10
|
+
__version__ = _version("capscorp")
|
|
11
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__"]
|
capsule_corp/checks.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Evaluation of pre-registered deterministic checks.
|
|
2
|
+
|
|
3
|
+
These checks are the hard gate: they decide whether a capsule's registered predictions
|
|
4
|
+
held, with no language model involved. That only works if the expressions are evaluated
|
|
5
|
+
safely and predictably, so this module walks the AST itself rather than calling
|
|
6
|
+
``eval``. There is no code path here that can import a module, call an arbitrary
|
|
7
|
+
function, or reach a dunder attribute.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import json
|
|
14
|
+
import operator
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from capsule_corp.models import Check, CheckKind, Prereg
|
|
21
|
+
|
|
22
|
+
# Only these builtins are reachable from a check expression.
|
|
23
|
+
ALLOWED_FUNCTIONS: dict[str, Callable[..., Any]] = {
|
|
24
|
+
"abs": abs,
|
|
25
|
+
"all": all,
|
|
26
|
+
"any": any,
|
|
27
|
+
"len": len,
|
|
28
|
+
"max": max,
|
|
29
|
+
"min": min,
|
|
30
|
+
"round": round,
|
|
31
|
+
"sum": sum,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_BIN_OPS: dict[type[ast.operator], Callable[[Any, Any], Any]] = {
|
|
35
|
+
ast.Add: operator.add,
|
|
36
|
+
ast.Sub: operator.sub,
|
|
37
|
+
ast.Mult: operator.mul,
|
|
38
|
+
ast.Div: operator.truediv,
|
|
39
|
+
ast.FloorDiv: operator.floordiv,
|
|
40
|
+
ast.Mod: operator.mod,
|
|
41
|
+
ast.Pow: operator.pow,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_COMPARE_OPS: dict[type[ast.cmpop], Callable[[Any, Any], Any]] = {
|
|
45
|
+
ast.Eq: operator.eq,
|
|
46
|
+
ast.NotEq: operator.ne,
|
|
47
|
+
ast.Lt: operator.lt,
|
|
48
|
+
ast.LtE: operator.le,
|
|
49
|
+
ast.Gt: operator.gt,
|
|
50
|
+
ast.GtE: operator.ge,
|
|
51
|
+
ast.In: lambda a, b: a in b,
|
|
52
|
+
ast.NotIn: lambda a, b: a not in b,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_UNARY_OPS: dict[type[ast.unaryop], Callable[[Any], Any]] = {
|
|
56
|
+
ast.USub: operator.neg,
|
|
57
|
+
ast.UAdd: operator.pos,
|
|
58
|
+
ast.Not: operator.not_,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
RESULTS_NAME = "results"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CheckEvaluationError(Exception):
|
|
65
|
+
"""A check could not be evaluated. Distinct from a check that evaluated to False."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class CheckResult:
|
|
70
|
+
"""Outcome of one pre-registered check."""
|
|
71
|
+
|
|
72
|
+
id: str
|
|
73
|
+
kind: CheckKind
|
|
74
|
+
passed: bool
|
|
75
|
+
description: str = ""
|
|
76
|
+
detail: str = ""
|
|
77
|
+
error: str | None = None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def errored(self) -> bool:
|
|
81
|
+
return self.error is not None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def evaluate_expression(expression: str, results: Any) -> Any:
|
|
85
|
+
"""Evaluate a restricted expression against loaded results."""
|
|
86
|
+
try:
|
|
87
|
+
tree = ast.parse(expression, mode="eval")
|
|
88
|
+
except SyntaxError as exc:
|
|
89
|
+
raise CheckEvaluationError(f"could not parse expression: {exc}") from exc
|
|
90
|
+
return _eval(tree.body, results)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _eval(node: ast.AST, results: Any) -> Any:
|
|
94
|
+
"""Evaluate one AST node.
|
|
95
|
+
|
|
96
|
+
Dispatch is table-driven and closed: a node type with no handler is refused rather
|
|
97
|
+
than falling through to something permissive. That is the property the safety of
|
|
98
|
+
this module rests on, so it is worth keeping the table easy to read.
|
|
99
|
+
"""
|
|
100
|
+
handler = _HANDLERS.get(type(node))
|
|
101
|
+
if handler is None:
|
|
102
|
+
raise CheckEvaluationError(f"{type(node).__name__} is not permitted in a check expression")
|
|
103
|
+
return handler(node, results)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _eval_constant(node: ast.Constant, _results: Any) -> Any:
|
|
107
|
+
return node.value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _eval_name(node: ast.Name, results: Any) -> Any:
|
|
111
|
+
if node.id == RESULTS_NAME:
|
|
112
|
+
return results
|
|
113
|
+
if node.id in ALLOWED_FUNCTIONS:
|
|
114
|
+
return ALLOWED_FUNCTIONS[node.id]
|
|
115
|
+
raise CheckEvaluationError(
|
|
116
|
+
f"unknown name {node.id!r}; a check may only reference '{RESULTS_NAME}' and "
|
|
117
|
+
f"{', '.join(sorted(ALLOWED_FUNCTIONS))}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _eval_attribute(node: ast.Attribute, results: Any) -> Any:
|
|
122
|
+
if node.attr.startswith("_"):
|
|
123
|
+
raise CheckEvaluationError(f"attribute {node.attr!r} is not permitted")
|
|
124
|
+
return _lookup(_eval(node.value, results), node.attr)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _eval_subscript(node: ast.Subscript, results: Any) -> Any:
|
|
128
|
+
return _lookup(_eval(node.value, results), _eval(node.slice, results))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _eval_compare(node: ast.Compare, results: Any) -> Any:
|
|
132
|
+
"""Evaluate a comparison, including chains such as ``10.2 < results.ratio < 12.4``."""
|
|
133
|
+
left = _eval(node.left, results)
|
|
134
|
+
for op, comparator in zip(node.ops, node.comparators, strict=True):
|
|
135
|
+
handler = _COMPARE_OPS.get(type(op))
|
|
136
|
+
if handler is None:
|
|
137
|
+
raise CheckEvaluationError(f"comparison {type(op).__name__} is not permitted")
|
|
138
|
+
right = _eval(comparator, results)
|
|
139
|
+
if not handler(left, right):
|
|
140
|
+
return False
|
|
141
|
+
left = right
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _eval_boolop(node: ast.BoolOp, results: Any) -> Any:
|
|
146
|
+
values = [_eval(value, results) for value in node.values]
|
|
147
|
+
return all(values) if isinstance(node.op, ast.And) else any(values)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _eval_unaryop(node: ast.UnaryOp, results: Any) -> Any:
|
|
151
|
+
handler = _UNARY_OPS.get(type(node.op))
|
|
152
|
+
if handler is None:
|
|
153
|
+
raise CheckEvaluationError(f"unary operator {type(node.op).__name__} is not permitted")
|
|
154
|
+
return handler(_eval(node.operand, results))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _eval_binop(node: ast.BinOp, results: Any) -> Any:
|
|
158
|
+
handler = _BIN_OPS.get(type(node.op))
|
|
159
|
+
if handler is None:
|
|
160
|
+
raise CheckEvaluationError(f"operator {type(node.op).__name__} is not permitted")
|
|
161
|
+
return handler(_eval(node.left, results), _eval(node.right, results))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _eval_list(node: ast.List, results: Any) -> Any:
|
|
165
|
+
return [_eval(element, results) for element in node.elts]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _eval_tuple(node: ast.Tuple, results: Any) -> Any:
|
|
169
|
+
return tuple(_eval(element, results) for element in node.elts)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _eval_dict(node: ast.Dict, results: Any) -> Any:
|
|
173
|
+
return {
|
|
174
|
+
_eval(key, results): _eval(value, results)
|
|
175
|
+
for key, value in zip(node.keys, node.values, strict=True)
|
|
176
|
+
if key is not None
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _eval_call(node: ast.Call, results: Any) -> Any:
|
|
181
|
+
"""Evaluate a call, which may only target one of the allowed builtins by name."""
|
|
182
|
+
if not isinstance(node.func, ast.Name):
|
|
183
|
+
raise CheckEvaluationError("only direct calls to the allowed helper functions are permitted")
|
|
184
|
+
if node.func.id not in ALLOWED_FUNCTIONS:
|
|
185
|
+
raise CheckEvaluationError(
|
|
186
|
+
f"function {node.func.id!r} is not permitted; allowed: {', '.join(sorted(ALLOWED_FUNCTIONS))}"
|
|
187
|
+
)
|
|
188
|
+
if node.keywords:
|
|
189
|
+
raise CheckEvaluationError("keyword arguments are not permitted in a check expression")
|
|
190
|
+
arguments = [_eval(argument, results) for argument in node.args]
|
|
191
|
+
try:
|
|
192
|
+
return ALLOWED_FUNCTIONS[node.func.id](*arguments)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
raise CheckEvaluationError(f"{node.func.id}() failed: {exc}") from exc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# The complete set of node types a check expression may contain. Anything absent here
|
|
198
|
+
# is rejected by _eval.
|
|
199
|
+
_HANDLERS: dict[type[ast.AST], Callable[[Any, Any], Any]] = {
|
|
200
|
+
ast.Attribute: _eval_attribute,
|
|
201
|
+
ast.BinOp: _eval_binop,
|
|
202
|
+
ast.BoolOp: _eval_boolop,
|
|
203
|
+
ast.Call: _eval_call,
|
|
204
|
+
ast.Compare: _eval_compare,
|
|
205
|
+
ast.Constant: _eval_constant,
|
|
206
|
+
ast.Dict: _eval_dict,
|
|
207
|
+
ast.List: _eval_list,
|
|
208
|
+
ast.Name: _eval_name,
|
|
209
|
+
ast.Subscript: _eval_subscript,
|
|
210
|
+
ast.Tuple: _eval_tuple,
|
|
211
|
+
ast.UnaryOp: _eval_unaryop,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _lookup(container: Any, key: Any) -> Any:
|
|
216
|
+
"""Look a key up in loaded JSON, with an error a researcher can act on."""
|
|
217
|
+
if isinstance(container, dict):
|
|
218
|
+
if key not in container:
|
|
219
|
+
available = ", ".join(sorted(str(k) for k in container)) or "(empty)"
|
|
220
|
+
raise CheckEvaluationError(f"results.json has no key {key!r}; available keys: {available}")
|
|
221
|
+
return container[key]
|
|
222
|
+
if isinstance(container, (list, tuple)) and isinstance(key, int):
|
|
223
|
+
try:
|
|
224
|
+
return container[key]
|
|
225
|
+
except IndexError as exc:
|
|
226
|
+
raise CheckEvaluationError(f"index {key} out of range (length {len(container)})") from exc
|
|
227
|
+
raise CheckEvaluationError(f"cannot look up {key!r} in a value of type {type(container).__name__}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------------- checks
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load_results(capsule_dir: Path) -> Any:
|
|
234
|
+
"""Load ``results/results.json`` from a capsule."""
|
|
235
|
+
path = capsule_dir / "results" / "results.json"
|
|
236
|
+
if not path.is_file():
|
|
237
|
+
raise CheckEvaluationError("results/results.json does not exist; run the capsule first")
|
|
238
|
+
try:
|
|
239
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
240
|
+
except json.JSONDecodeError as exc:
|
|
241
|
+
raise CheckEvaluationError(f"results/results.json is not valid JSON: {exc}") from exc
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def evaluate_check(check: Check, capsule_dir: Path, results: Any) -> CheckResult:
|
|
245
|
+
"""Evaluate one check, converting any failure into a reportable result."""
|
|
246
|
+
common: dict[str, Any] = {"id": check.id, "kind": check.kind, "description": check.description}
|
|
247
|
+
try:
|
|
248
|
+
if check.kind is CheckKind.EXPR:
|
|
249
|
+
assert check.expr is not None
|
|
250
|
+
value = evaluate_expression(check.expr, results)
|
|
251
|
+
return CheckResult(**common, passed=bool(value), detail=f"{check.expr} → {value!r}")
|
|
252
|
+
|
|
253
|
+
if check.kind is CheckKind.ARTIFACT:
|
|
254
|
+
assert check.path is not None
|
|
255
|
+
artifact = capsule_dir / check.path
|
|
256
|
+
if not artifact.is_file():
|
|
257
|
+
return CheckResult(**common, passed=False, detail=f"{check.path} does not exist")
|
|
258
|
+
if artifact.stat().st_size == 0:
|
|
259
|
+
return CheckResult(**common, passed=False, detail=f"{check.path} is empty")
|
|
260
|
+
return CheckResult(**common, passed=True, detail=f"{check.path} ({artifact.stat().st_size} bytes)")
|
|
261
|
+
|
|
262
|
+
return CheckResult(
|
|
263
|
+
**common,
|
|
264
|
+
passed=False,
|
|
265
|
+
detail="",
|
|
266
|
+
error="script checks are not implemented yet",
|
|
267
|
+
)
|
|
268
|
+
except CheckEvaluationError as exc:
|
|
269
|
+
return CheckResult(**common, passed=False, detail="", error=str(exc))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def evaluate_checks(prereg: Prereg, capsule_dir: Path) -> list[CheckResult]:
|
|
273
|
+
"""Evaluate every registered check against the capsule's results."""
|
|
274
|
+
try:
|
|
275
|
+
results = load_results(capsule_dir)
|
|
276
|
+
except CheckEvaluationError as exc:
|
|
277
|
+
return [
|
|
278
|
+
CheckResult(id=c.id, kind=c.kind, description=c.description, passed=False, error=str(exc))
|
|
279
|
+
for c in prereg.checks
|
|
280
|
+
]
|
|
281
|
+
return [evaluate_check(check, capsule_dir, results) for check in prereg.checks]
|