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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ capsule = capsule_corp.cli:main
@@ -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
@@ -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]