results-cli 0.5.2__tar.gz → 0.5.4__tar.gz
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.
- {results_cli-0.5.2 → results_cli-0.5.4}/PKG-INFO +73 -3
- {results_cli-0.5.2 → results_cli-0.5.4}/README.md +72 -2
- {results_cli-0.5.2 → results_cli-0.5.4}/plugin/.claude-plugin/plugin.json +1 -1
- {results_cli-0.5.2 → results_cli-0.5.4}/plugin/skills/results/SKILL.md +24 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/pyproject.toml +1 -1
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/audit.py +86 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/cli.py +50 -1
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/record.py +165 -4
- results_cli-0.5.4/tests/test_annotate.py +244 -0
- results_cli-0.5.4/tests/test_review.py +152 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/.gitignore +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/LICENSE +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/plugin/README.md +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/plugin/commands/results-check.md +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/plugin/hooks/hooks.json +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/plugin/hooks/unbound_numbers.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/__init__.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/ledger.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/manuscript.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/paths.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/stamps.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/src/results/timeline.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_cli.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_cli_help.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_concurrency.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_coverage_and_hook.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_ledger.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_manuscript.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_tamper.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_timeline.py +0 -0
- {results_cli-0.5.2 → results_cli-0.5.4}/tests/test_timestamp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: results-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.4
|
|
4
4
|
Summary: Seal a run, record what it produced, and verify the chain
|
|
5
5
|
Project-URL: Changelog, https://github.com/elliottower/reproducible-science/blob/main/CHANGELOG.md
|
|
6
6
|
Project-URL: Homepage, https://github.com/elliottower/reproducible-science
|
|
@@ -24,11 +24,11 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
[](https://pypi.org/project/results-cli/)
|
|
25
25
|
[](https://pypi.org/project/results-cli/)
|
|
26
26
|
[](https://github.com/elliottower/reproducible-science/blob/main/LICENSE)
|
|
27
|
-
[](https://
|
|
27
|
+
[](https://reproducible.science/)
|
|
28
28
|
|
|
29
29
|
Seal inputs, record outputs, and bind a paper's claims to the runs behind them.
|
|
30
30
|
|
|
31
|
-
Part of [reproducible-science](https://github.com/elliottower/reproducible-science) alongside `repro`, `citations` and `prereg` — see the [documentation](https://
|
|
31
|
+
Part of [reproducible-science](https://github.com/elliottower/reproducible-science) alongside `repro`, `citations` and `prereg` — see the [documentation](https://reproducible.science/tools/results/).
|
|
32
32
|
|
|
33
33
|
## Install
|
|
34
34
|
|
|
@@ -76,6 +76,8 @@ all checks passed.
|
|
|
76
76
|
| `results access <note>` | Record a data-access event |
|
|
77
77
|
| `results run <file>...` | Record outputs after a run |
|
|
78
78
|
| `results claim <text>` | Bind a manuscript claim to a run |
|
|
79
|
+
| `results review <file>...` | Record that someone examined these files, and what they found |
|
|
80
|
+
| `results annotate <file>...` | Link these files to something outside the ledger; linked, not checked |
|
|
79
81
|
| `results verify` | Check the ledger chain and every hash it names |
|
|
80
82
|
| `results timestamp` | Date the ledger's head outside the repository, and check earlier dates |
|
|
81
83
|
|
|
@@ -91,6 +93,74 @@ manuscript → claim → run → output file → sha256
|
|
|
91
93
|
|
|
92
94
|
`results verify --files` walks the whole thing and tells you what moved.
|
|
93
95
|
|
|
96
|
+
## Reviews
|
|
97
|
+
|
|
98
|
+
Some checks are done by reading: a person, or a model, compares a table with the file behind it.
|
|
99
|
+
`results review` records that the reading happened, and of which bytes.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
results review results/table3.csv paper/table3.pdf \
|
|
103
|
+
--scope "every cell of Table 3 against the regenerated file" \
|
|
104
|
+
--procedure "read the rendered page beside the csv" \
|
|
105
|
+
--verdict partial --reviewer "A. Reader" --model claude-opus-5-5 \
|
|
106
|
+
--note "30 cells agree at the certified revision; 4 differ"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Each file is recorded by its sha256, so the review is of those bytes and no others.
|
|
110
|
+
`results verify --files` lists every review and marks a subject `STALE` once the file has
|
|
111
|
+
changed, without failing: a reviewed file that then changes is the ordinary result of acting on
|
|
112
|
+
a review. `--scope`, `--procedure`, `--verdict` and `--reviewer` are required, and the verdict
|
|
113
|
+
is one of `pass`, `fail`, `partial` and `uncertain`.
|
|
114
|
+
|
|
115
|
+
The record is self-reported and unsigned. The ledger shows that the review was stated and when;
|
|
116
|
+
nothing checks who stated it. `--model` is the identifier the caller gives. Inside Claude Code
|
|
117
|
+
the command also records the harness, its version and the session identifier from the
|
|
118
|
+
environment, which does not say which model ran, so a model reviewer passes `--model`.
|
|
119
|
+
|
|
120
|
+
## Annotations
|
|
121
|
+
|
|
122
|
+
Some of what a paper rests on lives outside the repository: a Weights & Biases artifact, a
|
|
123
|
+
deposited dataset, an OSF node, a row in another group's table. `results annotate` records that
|
|
124
|
+
a file here corresponds to that outside thing.
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
results annotate checkpoints/model.pt \
|
|
128
|
+
--kind wandb:artifact --ref "lab/study/model:v3" \
|
|
129
|
+
--digest md5:0123456789abcdef0123456789abcdef \
|
|
130
|
+
--by "A. Reader" --note "the checkpoint behind Table 2"
|
|
131
|
+
|
|
132
|
+
results annotate results/table2.csv \
|
|
133
|
+
--kind doi:dataset --ref 10.5281/zenodo.1234567 --location "Table 2"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
An annotation is a statement of a link and not a check: the tool fetches nothing and compares
|
|
137
|
+
nothing, and the event carries the status `linked, not checked`. Each file is recorded by its
|
|
138
|
+
sha256, so the link is stated of those bytes.
|
|
139
|
+
|
|
140
|
+
`--kind` and `--ref` are required. `--kind` is `namespace:type` in lowercase, such as
|
|
141
|
+
`wandb:artifact`, `doi:dataset`, `osf:node`, `url:page`, `zenodo:record` or `hf:dataset`; any
|
|
142
|
+
kind of that shape is accepted, and a malformed kind writes nothing. `--ref` is the artifact
|
|
143
|
+
path, DOI, URL or identifier. `--digest` records the digest the outside system reports, as
|
|
144
|
+
`algorithm:hex`. `--by` names who states the link, `--note` says anything else, and `--location`
|
|
145
|
+
names where in the manuscript the linked number or sentence appears. No session information is
|
|
146
|
+
recorded.
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
annotations (linked, not checked):
|
|
150
|
+
2026-10-11T16:54:21 wandb:artifact lab/study/model:v3 [A. Reader]
|
|
151
|
+
digest as stated: md5:0123456789abcdef0123456789abcdef
|
|
152
|
+
STALE checkpoints/model.pt
|
|
153
|
+
annotated 1b465fa6b6bcbc06…, now 7e4fa2eb8c7ac089…
|
|
154
|
+
2026-10-11T16:54:21 doi:dataset 10.5281/zenodo.1234567
|
|
155
|
+
appears in: Table 2
|
|
156
|
+
current results/table2.csv
|
|
157
|
+
1 annotated file(s) are no longer the bytes the link was stated of.
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`results verify` lists every annotation. With `--files` each file is `current`, `STALE` once it
|
|
161
|
+
has changed, or `MISSING`; without `--files` it is `not checked`. None of the three changes the
|
|
162
|
+
exit code.
|
|
163
|
+
|
|
94
164
|
## Data-access levels
|
|
95
165
|
|
|
96
166
|
The access timeline is what makes the confirmatory/exploratory distinction verifiable.
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
[](https://pypi.org/project/results-cli/)
|
|
4
4
|
[](https://pypi.org/project/results-cli/)
|
|
5
5
|
[](https://github.com/elliottower/reproducible-science/blob/main/LICENSE)
|
|
6
|
-
[](https://
|
|
6
|
+
[](https://reproducible.science/)
|
|
7
7
|
|
|
8
8
|
Seal inputs, record outputs, and bind a paper's claims to the runs behind them.
|
|
9
9
|
|
|
10
|
-
Part of [reproducible-science](https://github.com/elliottower/reproducible-science) alongside `repro`, `citations` and `prereg` — see the [documentation](https://
|
|
10
|
+
Part of [reproducible-science](https://github.com/elliottower/reproducible-science) alongside `repro`, `citations` and `prereg` — see the [documentation](https://reproducible.science/tools/results/).
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -55,6 +55,8 @@ all checks passed.
|
|
|
55
55
|
| `results access <note>` | Record a data-access event |
|
|
56
56
|
| `results run <file>...` | Record outputs after a run |
|
|
57
57
|
| `results claim <text>` | Bind a manuscript claim to a run |
|
|
58
|
+
| `results review <file>...` | Record that someone examined these files, and what they found |
|
|
59
|
+
| `results annotate <file>...` | Link these files to something outside the ledger; linked, not checked |
|
|
58
60
|
| `results verify` | Check the ledger chain and every hash it names |
|
|
59
61
|
| `results timestamp` | Date the ledger's head outside the repository, and check earlier dates |
|
|
60
62
|
|
|
@@ -70,6 +72,74 @@ manuscript → claim → run → output file → sha256
|
|
|
70
72
|
|
|
71
73
|
`results verify --files` walks the whole thing and tells you what moved.
|
|
72
74
|
|
|
75
|
+
## Reviews
|
|
76
|
+
|
|
77
|
+
Some checks are done by reading: a person, or a model, compares a table with the file behind it.
|
|
78
|
+
`results review` records that the reading happened, and of which bytes.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
results review results/table3.csv paper/table3.pdf \
|
|
82
|
+
--scope "every cell of Table 3 against the regenerated file" \
|
|
83
|
+
--procedure "read the rendered page beside the csv" \
|
|
84
|
+
--verdict partial --reviewer "A. Reader" --model claude-opus-5-5 \
|
|
85
|
+
--note "30 cells agree at the certified revision; 4 differ"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Each file is recorded by its sha256, so the review is of those bytes and no others.
|
|
89
|
+
`results verify --files` lists every review and marks a subject `STALE` once the file has
|
|
90
|
+
changed, without failing: a reviewed file that then changes is the ordinary result of acting on
|
|
91
|
+
a review. `--scope`, `--procedure`, `--verdict` and `--reviewer` are required, and the verdict
|
|
92
|
+
is one of `pass`, `fail`, `partial` and `uncertain`.
|
|
93
|
+
|
|
94
|
+
The record is self-reported and unsigned. The ledger shows that the review was stated and when;
|
|
95
|
+
nothing checks who stated it. `--model` is the identifier the caller gives. Inside Claude Code
|
|
96
|
+
the command also records the harness, its version and the session identifier from the
|
|
97
|
+
environment, which does not say which model ran, so a model reviewer passes `--model`.
|
|
98
|
+
|
|
99
|
+
## Annotations
|
|
100
|
+
|
|
101
|
+
Some of what a paper rests on lives outside the repository: a Weights & Biases artifact, a
|
|
102
|
+
deposited dataset, an OSF node, a row in another group's table. `results annotate` records that
|
|
103
|
+
a file here corresponds to that outside thing.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
results annotate checkpoints/model.pt \
|
|
107
|
+
--kind wandb:artifact --ref "lab/study/model:v3" \
|
|
108
|
+
--digest md5:0123456789abcdef0123456789abcdef \
|
|
109
|
+
--by "A. Reader" --note "the checkpoint behind Table 2"
|
|
110
|
+
|
|
111
|
+
results annotate results/table2.csv \
|
|
112
|
+
--kind doi:dataset --ref 10.5281/zenodo.1234567 --location "Table 2"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
An annotation is a statement of a link and not a check: the tool fetches nothing and compares
|
|
116
|
+
nothing, and the event carries the status `linked, not checked`. Each file is recorded by its
|
|
117
|
+
sha256, so the link is stated of those bytes.
|
|
118
|
+
|
|
119
|
+
`--kind` and `--ref` are required. `--kind` is `namespace:type` in lowercase, such as
|
|
120
|
+
`wandb:artifact`, `doi:dataset`, `osf:node`, `url:page`, `zenodo:record` or `hf:dataset`; any
|
|
121
|
+
kind of that shape is accepted, and a malformed kind writes nothing. `--ref` is the artifact
|
|
122
|
+
path, DOI, URL or identifier. `--digest` records the digest the outside system reports, as
|
|
123
|
+
`algorithm:hex`. `--by` names who states the link, `--note` says anything else, and `--location`
|
|
124
|
+
names where in the manuscript the linked number or sentence appears. No session information is
|
|
125
|
+
recorded.
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
annotations (linked, not checked):
|
|
129
|
+
2026-10-11T16:54:21 wandb:artifact lab/study/model:v3 [A. Reader]
|
|
130
|
+
digest as stated: md5:0123456789abcdef0123456789abcdef
|
|
131
|
+
STALE checkpoints/model.pt
|
|
132
|
+
annotated 1b465fa6b6bcbc06…, now 7e4fa2eb8c7ac089…
|
|
133
|
+
2026-10-11T16:54:21 doi:dataset 10.5281/zenodo.1234567
|
|
134
|
+
appears in: Table 2
|
|
135
|
+
current results/table2.csv
|
|
136
|
+
1 annotated file(s) are no longer the bytes the link was stated of.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`results verify` lists every annotation. With `--files` each file is `current`, `STALE` once it
|
|
140
|
+
has changed, or `MISSING`; without `--files` it is `not checked`. None of the three changes the
|
|
141
|
+
exit code.
|
|
142
|
+
|
|
73
143
|
## Data-access levels
|
|
74
144
|
|
|
75
145
|
The access timeline is what makes the confirmatory/exploratory distinction verifiable.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "results",
|
|
3
3
|
"description": "Seal a run's inputs, record what it produced, bind every number in a manuscript to the run behind it as it is written, and verify the chain",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.4",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Elliot Tower",
|
|
7
7
|
"email": "elliot@elliottower.ai"
|
|
@@ -25,6 +25,8 @@ results seal <file>... [--role input] # hash inputs before a run
|
|
|
25
25
|
results access <note> [--level ...] # record a data-access event
|
|
26
26
|
results run <file>... --run-id <id> # record outputs after a run
|
|
27
27
|
results claim <text> --run-id <id> # bind a manuscript claim to a run
|
|
28
|
+
results review <file>... --scope ... --procedure ... --verdict ... --reviewer ... # record a reading
|
|
29
|
+
results annotate <file>... --kind <namespace:type> --ref <reference> # link to an outside artifact; not checked
|
|
28
30
|
results verify [--files] # check the chain and every hash it names
|
|
29
31
|
results coverage <manuscript> # which of a paper's numbers are bound
|
|
30
32
|
```
|
|
@@ -104,6 +106,28 @@ With `--files`, every sealed input and recorded output is re-hashed against its
|
|
|
104
106
|
**`CHAIN BROKEN` means the ledger was tampered with.** It does not mean the results are wrong —
|
|
105
107
|
it means the evidence that they are right was damaged. Restore from git history.
|
|
106
108
|
|
|
109
|
+
## Reviews
|
|
110
|
+
|
|
111
|
+
When you check something by reading it (a table against its file, a mismatch list against the
|
|
112
|
+
paper), record it: `results review <file>... --scope "<what was checked>" --procedure "<how>"
|
|
113
|
+
--verdict pass|fail|partial|uncertain --reviewer "<who answers for it>"`. Pass `--model` with
|
|
114
|
+
your own model identifier when you did the reading; the harness, its version and the session
|
|
115
|
+
are read from the environment. Each file is recorded by digest, and `results verify --files`
|
|
116
|
+
marks the review `STALE` once a file changes. The record is self-reported and unsigned, so
|
|
117
|
+
describe it as a recorded review and never as verified.
|
|
118
|
+
|
|
119
|
+
## Annotations
|
|
120
|
+
|
|
121
|
+
When a file corresponds to something outside the repository (a W&B artifact, a dataset DOI, an
|
|
122
|
+
OSF node, a URL), record the link: `results annotate <file>... --kind <namespace:type> --ref
|
|
123
|
+
"<artifact path, DOI, URL or id>"`, with `--kind` such as `wandb:artifact`, `doi:dataset`,
|
|
124
|
+
`osf:node` or `url:page`. Optional: `--digest <algorithm:hex>` for the digest the outside system
|
|
125
|
+
reports, `--by`, `--note`, and `--location "<where in the manuscript>"`. An annotation is a
|
|
126
|
+
statement of a link and not a check: the tool fetches nothing, and the event's status is
|
|
127
|
+
`linked, not checked`. `results verify --files` marks the file `STALE` once it changes and never
|
|
128
|
+
fails on it. Describe an annotated file as linked and never as verified, and do not use an
|
|
129
|
+
annotation in place of `results run` for an output the project produced.
|
|
130
|
+
|
|
107
131
|
## Claims
|
|
108
132
|
|
|
109
133
|
A claim names a run. A run names its outputs. The outputs were hashed when they were recorded.
|
|
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "results-cli"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.4"
|
|
8
8
|
description = "Seal a run, record what it produced, and verify the chain"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [ "open science", "provenance", "reproducibility", "results" ]
|
|
@@ -19,6 +19,7 @@ from provenance_core import anchor, sha256_of_tree, try_run
|
|
|
19
19
|
|
|
20
20
|
from results import ledger, manuscript, stamps
|
|
21
21
|
from results.paths import ledger_path, require_root
|
|
22
|
+
from results.record import LINKED
|
|
22
23
|
from results.timeline import first_outcomes_seen, first_run_timestamp, precedes
|
|
23
24
|
|
|
24
25
|
|
|
@@ -44,6 +45,75 @@ def in_history(ledger: pathlib.Path) -> str | None:
|
|
|
44
45
|
return None
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def reviews(events: list[dict], base: pathlib.Path, check_files: bool) -> list[str]:
|
|
49
|
+
"""One block per review, and with `check_files` whether each subject is still those bytes.
|
|
50
|
+
|
|
51
|
+
A review is of a digest. Where the file has changed since, the review says nothing about
|
|
52
|
+
what is there now, and printing the verdict without that would carry it forward. Reported
|
|
53
|
+
and never counted as drift: a reviewed file that then changes is the ordinary result of
|
|
54
|
+
acting on a review.
|
|
55
|
+
"""
|
|
56
|
+
lines: list[str] = []
|
|
57
|
+
for e in events:
|
|
58
|
+
if e.get("event") != "review":
|
|
59
|
+
continue
|
|
60
|
+
ran_in = e.get("session") or {}
|
|
61
|
+
harness = " ".join(v for k in ("harness", "harness_version") if (v := ran_in.get(k)))
|
|
62
|
+
named = ", ".join(v for v in (ran_in.get("model"), harness) if v)
|
|
63
|
+
lines.append(
|
|
64
|
+
f" {e['timestamp'][:19]} {e.get('verdict', '?'):<10} {e.get('reviewer', '?')}"
|
|
65
|
+
+ (f" [{named}]" if named else "")
|
|
66
|
+
)
|
|
67
|
+
lines.append(f" scope: {e.get('scope', '')}")
|
|
68
|
+
lines += subjects(e, base, check_files, "reviewed")
|
|
69
|
+
return lines
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def annotation_lines(events: list[dict], base: pathlib.Path, check_files: bool) -> list[str]:
|
|
73
|
+
"""One block per annotation: what it links to, and with `check_files` whether each subject
|
|
74
|
+
is still the bytes the link was stated of.
|
|
75
|
+
|
|
76
|
+
An annotation is a statement that these bytes correspond to something outside the ledger.
|
|
77
|
+
Nothing here reads the target, so the most this can say is whether the local side of the
|
|
78
|
+
link still exists as recorded. Reported and never counted as drift, as a review is.
|
|
79
|
+
"""
|
|
80
|
+
lines: list[str] = []
|
|
81
|
+
for e in events:
|
|
82
|
+
if e.get("event") != "annotation":
|
|
83
|
+
continue
|
|
84
|
+
target = e.get("target") or {}
|
|
85
|
+
lines.append(
|
|
86
|
+
f" {e['timestamp'][:19]} {target.get('kind', '?')} {target.get('ref', '?')}"
|
|
87
|
+
+ (f" [{by}]" if (by := e.get("by")) else "")
|
|
88
|
+
)
|
|
89
|
+
if stated := target.get("digest"):
|
|
90
|
+
lines.append(f" digest as stated: {stated}")
|
|
91
|
+
if location := e.get("location"):
|
|
92
|
+
lines.append(f" appears in: {location}")
|
|
93
|
+
lines += subjects(e, base, check_files, "annotated")
|
|
94
|
+
return lines
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def subjects(event: dict, base: pathlib.Path, check_files: bool, was: str) -> list[str]:
|
|
98
|
+
"""Each subject of a review or an annotation, and whether it is still the recorded bytes."""
|
|
99
|
+
lines: list[str] = []
|
|
100
|
+
for subject in event.get("subjects", []):
|
|
101
|
+
path, recorded = subject["path"], subject["sha256"]
|
|
102
|
+
p = base / path
|
|
103
|
+
if not check_files:
|
|
104
|
+
lines.append(f" not checked {path}")
|
|
105
|
+
elif not p.exists():
|
|
106
|
+
lines.append(f" MISSING {path}")
|
|
107
|
+
else:
|
|
108
|
+
now = sha256_of_tree(p)[0] if p.is_dir() else ledger.sha256_of_file(p)
|
|
109
|
+
if now == recorded:
|
|
110
|
+
lines.append(f" current {path}")
|
|
111
|
+
else:
|
|
112
|
+
lines.append(f" STALE {path}")
|
|
113
|
+
lines.append(f" {was} {recorded[:16]}…, now {now[:16]}…")
|
|
114
|
+
return lines
|
|
115
|
+
|
|
116
|
+
|
|
47
117
|
def coverage(manuscript_path: str, limit: int, strict: bool) -> int:
|
|
48
118
|
root = require_root()
|
|
49
119
|
events = ledger.read_ledger(ledger_path(root))
|
|
@@ -292,6 +362,22 @@ def verify(check_files: bool) -> int:
|
|
|
292
362
|
for e in access_events:
|
|
293
363
|
print(f" {e['timestamp'][:19]} {e['level']:<20} {e.get('note', '')}")
|
|
294
364
|
|
|
365
|
+
if review_lines := reviews(events, root.parent, check_files):
|
|
366
|
+
print("\nreviews (self-reported, unsigned):")
|
|
367
|
+
for line in review_lines:
|
|
368
|
+
print(line)
|
|
369
|
+
stale = sum(1 for line in review_lines if line.lstrip().startswith(("STALE", "MISSING")))
|
|
370
|
+
if stale:
|
|
371
|
+
print(f" {stale} reviewed file(s) are no longer the bytes that were reviewed.")
|
|
372
|
+
|
|
373
|
+
if linked := annotation_lines(events, root.parent, check_files):
|
|
374
|
+
print(f"\nannotations ({LINKED}):")
|
|
375
|
+
for line in linked:
|
|
376
|
+
print(line)
|
|
377
|
+
stale = sum(1 for line in linked if line.lstrip().startswith(("STALE", "MISSING")))
|
|
378
|
+
if stale:
|
|
379
|
+
print(f" {stale} annotated file(s) are no longer the bytes the link was stated of.")
|
|
380
|
+
|
|
295
381
|
claims = [e for e in events if e.get("event") == "claim"]
|
|
296
382
|
unlinked = []
|
|
297
383
|
contested = []
|
|
@@ -5,6 +5,8 @@ results seal <file>... hash inputs before a run (prereg, script, data)
|
|
|
5
5
|
results access <note> record a data-access event (what you looked at, when)
|
|
6
6
|
results run <file>... record outputs after a run completes
|
|
7
7
|
results claim <text> bind a manuscript claim to a run's output
|
|
8
|
+
results review <file>... record that someone examined these files, and what they found
|
|
9
|
+
results annotate <file>... link these files to something outside the ledger; linked, not checked
|
|
8
10
|
results coverage <paper> how many of a manuscript's numbers are bound to a run
|
|
9
11
|
results verify check the ledger chain and every hash it names
|
|
10
12
|
results reanchor record the ledger's current length as authoritative
|
|
@@ -31,7 +33,7 @@ from results.paths import find_root as find_root
|
|
|
31
33
|
from results.paths import ledger_path as ledger_path
|
|
32
34
|
from results.paths import record_path as record_path
|
|
33
35
|
from results.paths import require_root as require_root
|
|
34
|
-
from results.record import ACCESS_LEVELS
|
|
36
|
+
from results.record import ACCESS_LEVELS, VERDICTS
|
|
35
37
|
from results.timeline import first_outcomes_seen as first_outcomes_seen
|
|
36
38
|
from results.timeline import first_run_timestamp as first_run_timestamp
|
|
37
39
|
from results.timeline import freeze_timestamp as freeze_timestamp
|
|
@@ -65,6 +67,14 @@ def cmd_claim(a) -> int:
|
|
|
65
67
|
)
|
|
66
68
|
|
|
67
69
|
|
|
70
|
+
def cmd_review(a) -> int:
|
|
71
|
+
return record.review(a.files, a.scope, a.procedure, a.verdict, a.reviewer, a.model, a.note)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_annotate(a) -> int:
|
|
75
|
+
return record.annotate(a.files, a.kind, a.ref, a.digest, a.by, a.note, a.location)
|
|
76
|
+
|
|
77
|
+
|
|
68
78
|
def cmd_coverage(a) -> int:
|
|
69
79
|
return audit.coverage(a.manuscript, a.limit, a.strict)
|
|
70
80
|
|
|
@@ -147,6 +157,45 @@ def _main(argv: list[str] | None = None) -> int:
|
|
|
147
157
|
cl.add_argument("--location", help="where in the manuscript: Table 2, Section 4.1, etc.")
|
|
148
158
|
cl.set_defaults(fn=cmd_claim)
|
|
149
159
|
|
|
160
|
+
rv = sub.add_parser(
|
|
161
|
+
"review", help="record that someone examined these files, and what they found"
|
|
162
|
+
)
|
|
163
|
+
rv.add_argument("files", nargs="+", help="what was examined; each is recorded by digest")
|
|
164
|
+
rv.add_argument("--scope", required=True, help="what was checked about them")
|
|
165
|
+
rv.add_argument("--procedure", required=True, help="how it was checked")
|
|
166
|
+
rv.add_argument("--verdict", required=True, help=f"one of: {', '.join(VERDICTS)}")
|
|
167
|
+
rv.add_argument("--reviewer", required=True, help="who answers for the review")
|
|
168
|
+
rv.add_argument(
|
|
169
|
+
"--model",
|
|
170
|
+
help="the model that did the reading, where one did: its identifier, as reported",
|
|
171
|
+
)
|
|
172
|
+
rv.add_argument("--note", help="anything the verdict needs said beside it")
|
|
173
|
+
rv.set_defaults(fn=cmd_review)
|
|
174
|
+
|
|
175
|
+
an = sub.add_parser(
|
|
176
|
+
"annotate", help="link these files to something outside the ledger; linked, not checked"
|
|
177
|
+
)
|
|
178
|
+
an.add_argument(
|
|
179
|
+
"files", nargs="+", help="what the link is stated of; each is recorded by digest"
|
|
180
|
+
)
|
|
181
|
+
an.add_argument(
|
|
182
|
+
"--kind",
|
|
183
|
+
required=True,
|
|
184
|
+
help="what the outside thing is, as namespace:type: wandb:artifact, doi:dataset, "
|
|
185
|
+
"osf:node, url:page",
|
|
186
|
+
)
|
|
187
|
+
an.add_argument(
|
|
188
|
+
"--ref", required=True, help="where it is: an artifact path, a DOI, a URL, an id"
|
|
189
|
+
)
|
|
190
|
+
an.add_argument(
|
|
191
|
+
"--digest",
|
|
192
|
+
help="the digest the outside system states, as algorithm:hex; recorded, never compared",
|
|
193
|
+
)
|
|
194
|
+
an.add_argument("--by", help="who states the link")
|
|
195
|
+
an.add_argument("--note", help="anything the link needs said beside it")
|
|
196
|
+
an.add_argument("--location", help="where in the manuscript: Table 2, Section 4.1, etc.")
|
|
197
|
+
an.set_defaults(fn=cmd_annotate)
|
|
198
|
+
|
|
150
199
|
v = sub.add_parser("verify", help="check the ledger and every hash it names")
|
|
151
200
|
v.add_argument(
|
|
152
201
|
"--files",
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
"""The commands that put an event in the ledger.
|
|
2
2
|
|
|
3
|
-
`init`, `seal`, `access`, `run` and `
|
|
4
|
-
build one event, append it, and report what was written. What separates
|
|
5
|
-
each makes first -- a path that is not a file, an access level outside the
|
|
6
|
-
already in use, a confirmatory claim resting on a run recorded after the outcomes
|
|
3
|
+
`init`, `seal`, `access`, `run`, `claim`, `review` and `annotate` share a shape: find the
|
|
4
|
+
governing `.results/`, build one event, append it, and report what was written. What separates
|
|
5
|
+
them is the refusal each makes first -- a path that is not a file, an access level outside the
|
|
6
|
+
four, a run id already in use, a confirmatory claim resting on a run recorded after the outcomes
|
|
7
|
+
were seen, a review with a verdict outside the four, an annotation whose kind is not
|
|
8
|
+
`namespace:type`.
|
|
7
9
|
|
|
8
10
|
Each returns the process's exit code. `cli.py` unpacks the argparse namespace and calls in.
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
13
|
from __future__ import annotations
|
|
12
14
|
|
|
15
|
+
import os
|
|
13
16
|
import pathlib
|
|
17
|
+
import re
|
|
14
18
|
|
|
15
19
|
from provenance_core import sha256_of_tree
|
|
16
20
|
|
|
@@ -24,6 +28,22 @@ ACCESS_LEVELS = [
|
|
|
24
28
|
"outcomes seen",
|
|
25
29
|
]
|
|
26
30
|
|
|
31
|
+
VERDICTS = ["pass", "fail", "partial", "uncertain"]
|
|
32
|
+
|
|
33
|
+
#: What an annotation's target is, as `namespace:type`: `wandb:artifact`, `doi:dataset`,
|
|
34
|
+
#: `osf:node`, `url:page`. Open-ended, because the outside systems a project links to are not
|
|
35
|
+
#: this tool's to enumerate; only the shape is fixed, so that a reader can group by namespace.
|
|
36
|
+
KIND = re.compile(r"[a-z0-9._-]+:[a-z0-9._-]+")
|
|
37
|
+
|
|
38
|
+
#: A digest as the outside system states it: `algorithm:hex`.
|
|
39
|
+
DIGEST = re.compile(r"([a-z0-9][a-z0-9_-]*):([0-9a-fA-F]+)")
|
|
40
|
+
|
|
41
|
+
#: Hex digits in the digests whose length is fixed. Any other algorithm is taken at any length.
|
|
42
|
+
DIGEST_LENGTHS = {"md5": 32, "sha1": 40, "sha256": 64, "sha512": 128}
|
|
43
|
+
|
|
44
|
+
#: Written into every annotation and printed wherever one is shown.
|
|
45
|
+
LINKED = "linked, not checked"
|
|
46
|
+
|
|
27
47
|
|
|
28
48
|
#: Written into `.results/` so the ledger is tracked without anyone editing the project's own
|
|
29
49
|
#: ignore file. The ledger and its anchor are the record a claim appeals to, and one that exists
|
|
@@ -250,3 +270,144 @@ def claim(
|
|
|
250
270
|
elif retrospective:
|
|
251
271
|
print(f" recorded after outcomes were seen at {retrospective[:19]}; verify will report it")
|
|
252
272
|
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def session(model: str | None) -> dict[str, str]:
|
|
276
|
+
"""Where the command ran and which model the caller names, for a review's record.
|
|
277
|
+
|
|
278
|
+
Read off the environment and the `--model` flag, and checked against nothing. Claude Code
|
|
279
|
+
exports its own version and session but not the model, so the model is whatever the caller
|
|
280
|
+
passes. A person who runs the command from inside a session is recorded the same way as a
|
|
281
|
+
model that does, which is why the reviewer is a separate, required field.
|
|
282
|
+
"""
|
|
283
|
+
found: dict[str, str] = {}
|
|
284
|
+
if model:
|
|
285
|
+
found["model"] = model
|
|
286
|
+
if os.environ.get("CLAUDECODE") == "1":
|
|
287
|
+
found["harness"] = "Claude Code"
|
|
288
|
+
version = pathlib.PurePath(os.environ.get("CLAUDE_CODE_EXECPATH", "")).name
|
|
289
|
+
if re.fullmatch(r"\d+(\.\d+)+", version):
|
|
290
|
+
found["harness_version"] = version
|
|
291
|
+
if session_id := os.environ.get("CLAUDE_CODE_SESSION_ID"):
|
|
292
|
+
found["session_id"] = session_id
|
|
293
|
+
return found
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def digests_of(files: list[str], root: pathlib.Path) -> list[dict[str, str]] | None:
|
|
297
|
+
"""Each named file or directory by its digest, or None once one of them is neither.
|
|
298
|
+
|
|
299
|
+
What a review and an annotation both record under `subjects`. Named `subjects`, never
|
|
300
|
+
`files`: `verify --files` reads `files` as seals, and a subject that later changes would
|
|
301
|
+
then report the path as sealed twice.
|
|
302
|
+
"""
|
|
303
|
+
subjects = []
|
|
304
|
+
for name in files:
|
|
305
|
+
p = pathlib.Path(name).resolve()
|
|
306
|
+
if p.is_dir():
|
|
307
|
+
digest = sha256_of_tree(p)[0]
|
|
308
|
+
subjects.append({"path": record_path(p, root), "sha256": digest, "kind": "tree"})
|
|
309
|
+
continue
|
|
310
|
+
if not p.is_file():
|
|
311
|
+
print(f"not a file: {name}")
|
|
312
|
+
return None
|
|
313
|
+
digest = ledger.sha256_of_file(p)
|
|
314
|
+
subjects.append({"path": record_path(p, root), "sha256": digest, "kind": "file"})
|
|
315
|
+
return subjects
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def review(
|
|
319
|
+
files: list[str],
|
|
320
|
+
scope: str,
|
|
321
|
+
procedure: str,
|
|
322
|
+
verdict: str,
|
|
323
|
+
reviewer: str,
|
|
324
|
+
model: str | None,
|
|
325
|
+
note: str | None,
|
|
326
|
+
) -> int:
|
|
327
|
+
root = require_root()
|
|
328
|
+
lp = ledger_path(root)
|
|
329
|
+
if verdict not in VERDICTS:
|
|
330
|
+
print(f"verdict must be one of: {', '.join(VERDICTS)}")
|
|
331
|
+
return 1
|
|
332
|
+
subjects = digests_of(files, root)
|
|
333
|
+
if subjects is None:
|
|
334
|
+
return 1
|
|
335
|
+
ran_in = session(model)
|
|
336
|
+
ledger.append_event(
|
|
337
|
+
lp,
|
|
338
|
+
{
|
|
339
|
+
"event": "review",
|
|
340
|
+
"subjects": subjects,
|
|
341
|
+
"scope": scope,
|
|
342
|
+
"procedure": procedure,
|
|
343
|
+
"verdict": verdict,
|
|
344
|
+
"reviewer": reviewer,
|
|
345
|
+
"session": ran_in,
|
|
346
|
+
"note": note or "",
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
print(f"review ({verdict}) by {reviewer}: {scope[:72]}")
|
|
350
|
+
for subject in subjects:
|
|
351
|
+
print(f" {subject['sha256'][:16]}… {subject['path']}")
|
|
352
|
+
if ran_in:
|
|
353
|
+
print(f" session: {', '.join(f'{k} {v}' for k, v in ran_in.items())}")
|
|
354
|
+
if "harness" in ran_in and "model" not in ran_in:
|
|
355
|
+
print(" ran inside Claude Code with no --model; pass it if a model did the review.")
|
|
356
|
+
print("self-reported and unsigned: the ledger records that this was said, and of which bytes.")
|
|
357
|
+
return 0
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def annotate(
|
|
361
|
+
files: list[str],
|
|
362
|
+
kind: str,
|
|
363
|
+
ref: str,
|
|
364
|
+
digest: str | None,
|
|
365
|
+
by: str | None,
|
|
366
|
+
note: str | None,
|
|
367
|
+
location: str | None,
|
|
368
|
+
) -> int:
|
|
369
|
+
root = require_root()
|
|
370
|
+
lp = ledger_path(root)
|
|
371
|
+
if not KIND.fullmatch(kind):
|
|
372
|
+
print(f"kind must be namespace:type, such as wandb:artifact or doi:dataset; got '{kind}'")
|
|
373
|
+
print("lowercase letters, digits, '-', '_' and '.' on each side of one colon.")
|
|
374
|
+
return 1
|
|
375
|
+
if not ref.strip():
|
|
376
|
+
print("ref must name the outside thing: an artifact path, a DOI, a URL, an id.")
|
|
377
|
+
return 1
|
|
378
|
+
if digest is not None:
|
|
379
|
+
stated = DIGEST.fullmatch(digest)
|
|
380
|
+
length = DIGEST_LENGTHS.get(stated[1]) if stated else None
|
|
381
|
+
if not stated or (length is not None and len(stated[2]) != length):
|
|
382
|
+
print(f"digest must be algorithm:hex, such as sha256:<64 hex digits>; got '{digest}'")
|
|
383
|
+
return 1
|
|
384
|
+
subjects = digests_of(files, root)
|
|
385
|
+
if subjects is None:
|
|
386
|
+
return 1
|
|
387
|
+
# No session is read here. A review says who or what did the reading, so it records where
|
|
388
|
+
# the command ran; an annotation makes no such statement, and `--by` is whatever was typed.
|
|
389
|
+
target = {"kind": kind, "ref": ref}
|
|
390
|
+
if digest:
|
|
391
|
+
target["digest"] = digest
|
|
392
|
+
event: dict[str, object] = {
|
|
393
|
+
"event": "annotation",
|
|
394
|
+
"subjects": subjects,
|
|
395
|
+
"target": target,
|
|
396
|
+
"status": LINKED,
|
|
397
|
+
"note": note or "",
|
|
398
|
+
}
|
|
399
|
+
if by:
|
|
400
|
+
event["by"] = by
|
|
401
|
+
if location:
|
|
402
|
+
event["location"] = location
|
|
403
|
+
ledger.append_event(lp, event)
|
|
404
|
+
print(f"annotation: {kind} {ref[:72]}" + (f" by {by}" if by else ""))
|
|
405
|
+
for subject in subjects:
|
|
406
|
+
print(f" {subject['sha256'][:16]}… {subject['path']}")
|
|
407
|
+
if digest:
|
|
408
|
+
print(f" digest as stated: {digest}")
|
|
409
|
+
if location:
|
|
410
|
+
print(f" appears in: {location}")
|
|
411
|
+
print(f"{LINKED}: the ledger records that this link was stated, and of which bytes.")
|
|
412
|
+
print("Nothing was fetched or compared.")
|
|
413
|
+
return 0
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""`results annotate`: a stated link from named bytes to something outside the ledger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from results import ledger
|
|
11
|
+
|
|
12
|
+
LINK = ("--kind", "wandb:artifact", "--ref", "lab/study/model:v3")
|
|
13
|
+
SHA256 = "sha256:" + "0123456789abcdef" * 4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_cli(*args, cwd, env=None):
|
|
17
|
+
base = {k: v for k, v in os.environ.items() if not k.startswith(("CLAUDE", "ANTHROPIC"))}
|
|
18
|
+
return subprocess.run(
|
|
19
|
+
[sys.executable, "-m", "results.cli", *args],
|
|
20
|
+
cwd=cwd,
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
env={**base, **(env or {})},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def project(tmp_path):
|
|
28
|
+
run_cli("init", cwd=tmp_path)
|
|
29
|
+
(tmp_path / "model.pt").write_text("weights\n")
|
|
30
|
+
return tmp_path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ledger_bytes(tmp_path):
|
|
34
|
+
return (tmp_path / ".results" / "ledger.jsonl").read_bytes()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def last_event(tmp_path):
|
|
38
|
+
return ledger.read_ledger(tmp_path / ".results" / "ledger.jsonl")[-1]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_an_annotation_records_the_digest_of_the_subject_and_the_target(tmp_path):
|
|
42
|
+
project(tmp_path)
|
|
43
|
+
r = run_cli("annotate", "model.pt", *LINK, cwd=tmp_path)
|
|
44
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
45
|
+
event = last_event(tmp_path)
|
|
46
|
+
assert {k: v for k, v in event.items() if k not in ("seq", "timestamp", "prev_hash")} == {
|
|
47
|
+
"event": "annotation",
|
|
48
|
+
"subjects": [
|
|
49
|
+
{
|
|
50
|
+
"path": "model.pt",
|
|
51
|
+
"sha256": ledger.sha256_of_file(tmp_path / "model.pt"),
|
|
52
|
+
"kind": "file",
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"target": {"kind": "wandb:artifact", "ref": "lab/study/model:v3"},
|
|
56
|
+
"status": "linked, not checked",
|
|
57
|
+
"note": "",
|
|
58
|
+
}
|
|
59
|
+
assert "linked, not checked: the ledger records that this link was stated" in r.stdout
|
|
60
|
+
assert "Nothing was fetched or compared." in r.stdout
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_by_note_and_location_are_recorded_as_typed(tmp_path):
|
|
64
|
+
project(tmp_path)
|
|
65
|
+
run_cli(
|
|
66
|
+
"annotate",
|
|
67
|
+
"model.pt",
|
|
68
|
+
"--kind",
|
|
69
|
+
"doi:dataset",
|
|
70
|
+
"--ref",
|
|
71
|
+
"10.5281/zenodo.1234567",
|
|
72
|
+
"--by",
|
|
73
|
+
"A. Reader",
|
|
74
|
+
"--note",
|
|
75
|
+
"the checkpoint behind Table 2",
|
|
76
|
+
"--location",
|
|
77
|
+
"Table 2",
|
|
78
|
+
cwd=tmp_path,
|
|
79
|
+
)
|
|
80
|
+
event = last_event(tmp_path)
|
|
81
|
+
assert event["target"] == {"kind": "doi:dataset", "ref": "10.5281/zenodo.1234567"}
|
|
82
|
+
assert event["by"] == "A. Reader"
|
|
83
|
+
assert event["note"] == "the checkpoint behind Table 2"
|
|
84
|
+
assert event["location"] == "Table 2"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize(
|
|
88
|
+
"kind", ["wandb", "wandb:", ":artifact", "WandB:artifact", "wandb:art ifact", "a:b:c", "a/b:c"]
|
|
89
|
+
)
|
|
90
|
+
def test_a_malformed_kind_writes_nothing(tmp_path, kind):
|
|
91
|
+
project(tmp_path)
|
|
92
|
+
before = ledger_bytes(tmp_path)
|
|
93
|
+
r = run_cli("annotate", "model.pt", "--kind", kind, "--ref", "x", cwd=tmp_path)
|
|
94
|
+
assert r.returncode == 1
|
|
95
|
+
assert "namespace:type" in r.stdout
|
|
96
|
+
assert ledger_bytes(tmp_path) == before
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.parametrize(
|
|
100
|
+
"kind", ["osf:node", "url:page", "zenodo:record", "hf:dataset", "x-1.y:z_2"]
|
|
101
|
+
)
|
|
102
|
+
def test_any_well_formed_kind_is_accepted(tmp_path, kind):
|
|
103
|
+
project(tmp_path)
|
|
104
|
+
r = run_cli("annotate", "model.pt", "--kind", kind, "--ref", "x", cwd=tmp_path)
|
|
105
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
106
|
+
assert last_event(tmp_path)["target"]["kind"] == kind
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_kind_and_ref_are_required(tmp_path):
|
|
110
|
+
project(tmp_path)
|
|
111
|
+
before = ledger_bytes(tmp_path)
|
|
112
|
+
for dropped in ("--kind", "--ref"):
|
|
113
|
+
at = LINK.index(dropped)
|
|
114
|
+
args = LINK[:at] + LINK[at + 2 :]
|
|
115
|
+
r = run_cli("annotate", "model.pt", *args, cwd=tmp_path)
|
|
116
|
+
assert r.returncode == 2, dropped
|
|
117
|
+
assert dropped in r.stderr
|
|
118
|
+
blank = run_cli("annotate", "model.pt", "--kind", "url:page", "--ref", " ", cwd=tmp_path)
|
|
119
|
+
assert blank.returncode == 1
|
|
120
|
+
assert ledger_bytes(tmp_path) == before
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_a_path_that_is_not_a_file_writes_nothing(tmp_path):
|
|
124
|
+
project(tmp_path)
|
|
125
|
+
before = ledger_bytes(tmp_path)
|
|
126
|
+
r = run_cli("annotate", "absent.pt", *LINK, cwd=tmp_path)
|
|
127
|
+
assert r.returncode == 1
|
|
128
|
+
assert ledger_bytes(tmp_path) == before
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_no_session_is_recorded_even_inside_claude_code(tmp_path):
|
|
132
|
+
project(tmp_path)
|
|
133
|
+
env = {
|
|
134
|
+
"CLAUDECODE": "1",
|
|
135
|
+
"CLAUDE_CODE_EXECPATH": "/somewhere/claude/versions/2.1.295",
|
|
136
|
+
"CLAUDE_CODE_SESSION_ID": "0f0f0f0f-aaaa-bbbb-cccc-121212121212",
|
|
137
|
+
}
|
|
138
|
+
r = run_cli("annotate", "model.pt", *LINK, "--by", "A. Reader", cwd=tmp_path, env=env)
|
|
139
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
140
|
+
event = last_event(tmp_path)
|
|
141
|
+
assert not {"session", "model", "harness", "harness_version", "session_id"} & set(event)
|
|
142
|
+
line = ledger_bytes(tmp_path).splitlines()[-1].decode()
|
|
143
|
+
for leaked in ("Claude", "2.1.295", "0f0f0f0f", "session", "harness", 'model"'):
|
|
144
|
+
assert leaked not in line, leaked
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_a_well_formed_digest_is_stored_as_stated(tmp_path):
|
|
148
|
+
project(tmp_path)
|
|
149
|
+
for stated in (SHA256, "md5:" + "ab" * 16, "blake3:" + "CD" * 20):
|
|
150
|
+
r = run_cli("annotate", "model.pt", *LINK, "--digest", stated, cwd=tmp_path)
|
|
151
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
152
|
+
assert last_event(tmp_path)["target"]["digest"] == stated
|
|
153
|
+
assert last_event(tmp_path)["subjects"][0]["sha256"] != stated.split(":")[1]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.parametrize(
|
|
157
|
+
"digest",
|
|
158
|
+
["0123456789abcdef" * 4, "sha256:" + "0" * 63, "sha256:" + "g" * 64, "sha256:", "md5:abc"],
|
|
159
|
+
)
|
|
160
|
+
def test_a_malformed_digest_writes_nothing(tmp_path, digest):
|
|
161
|
+
project(tmp_path)
|
|
162
|
+
before = ledger_bytes(tmp_path)
|
|
163
|
+
r = run_cli("annotate", "model.pt", *LINK, "--digest", digest, cwd=tmp_path)
|
|
164
|
+
assert r.returncode == 1
|
|
165
|
+
assert "algorithm:hex" in r.stdout
|
|
166
|
+
assert ledger_bytes(tmp_path) == before
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_verify_reports_an_annotated_file_that_changed_as_stale_and_does_not_fail(tmp_path):
|
|
170
|
+
project(tmp_path)
|
|
171
|
+
run_cli("annotate", "model.pt", *LINK, "--by", "A. Reader", cwd=tmp_path)
|
|
172
|
+
recorded = ledger.sha256_of_file(tmp_path / "model.pt")
|
|
173
|
+
|
|
174
|
+
current = run_cli("verify", "--files", cwd=tmp_path)
|
|
175
|
+
assert current.returncode == 0, current.stdout
|
|
176
|
+
assert "annotations (linked, not checked):" in current.stdout
|
|
177
|
+
assert "wandb:artifact lab/study/model:v3 [A. Reader]" in current.stdout
|
|
178
|
+
assert "current model.pt" in current.stdout
|
|
179
|
+
assert "STALE" not in current.stdout
|
|
180
|
+
|
|
181
|
+
(tmp_path / "model.pt").write_text("other weights\n")
|
|
182
|
+
now = ledger.sha256_of_file(tmp_path / "model.pt")
|
|
183
|
+
stale = run_cli("verify", "--files", cwd=tmp_path)
|
|
184
|
+
assert stale.returncode == 0, stale.stdout
|
|
185
|
+
assert "STALE model.pt" in stale.stdout
|
|
186
|
+
assert f"annotated {recorded[:16]}…, now {now[:16]}…" in stale.stdout
|
|
187
|
+
assert "1 annotated file(s) are no longer the bytes the link was stated of" in stale.stdout
|
|
188
|
+
|
|
189
|
+
(tmp_path / "model.pt").unlink()
|
|
190
|
+
missing = run_cli("verify", "--files", cwd=tmp_path)
|
|
191
|
+
assert missing.returncode == 0, missing.stdout
|
|
192
|
+
assert "MISSING model.pt" in missing.stdout
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_an_annotation_is_not_a_seal(tmp_path):
|
|
196
|
+
project(tmp_path)
|
|
197
|
+
run_cli("annotate", "model.pt", *LINK, cwd=tmp_path)
|
|
198
|
+
(tmp_path / "model.pt").write_text("other weights\n")
|
|
199
|
+
run_cli("annotate", "model.pt", *LINK, cwd=tmp_path)
|
|
200
|
+
r = run_cli("verify", "--files", cwd=tmp_path)
|
|
201
|
+
assert r.returncode == 0, r.stdout
|
|
202
|
+
assert "RESEALED" not in r.stdout
|
|
203
|
+
assert "CHANGED" not in r.stdout
|
|
204
|
+
assert " ok " not in r.stdout
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_without_files_verify_lists_the_annotation_and_says_the_subject_was_not_checked(tmp_path):
|
|
208
|
+
project(tmp_path)
|
|
209
|
+
run_cli(
|
|
210
|
+
"annotate", "model.pt", *LINK, "--digest", SHA256, "--location", "Table 2", cwd=tmp_path
|
|
211
|
+
)
|
|
212
|
+
r = run_cli("verify", cwd=tmp_path)
|
|
213
|
+
assert r.returncode == 0, r.stdout
|
|
214
|
+
assert "annotations (linked, not checked):" in r.stdout
|
|
215
|
+
assert "wandb:artifact lab/study/model:v3" in r.stdout
|
|
216
|
+
assert f"digest as stated: {SHA256}" in r.stdout
|
|
217
|
+
assert "appears in: Table 2" in r.stdout
|
|
218
|
+
assert "not checked model.pt" in r.stdout
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_the_chain_stays_intact_with_annotations_among_the_other_events(tmp_path):
|
|
222
|
+
project(tmp_path)
|
|
223
|
+
lp = tmp_path / ".results" / "ledger.jsonl"
|
|
224
|
+
run_cli("seal", "model.pt", cwd=tmp_path)
|
|
225
|
+
run_cli("annotate", "model.pt", *LINK, cwd=tmp_path)
|
|
226
|
+
run_cli("run", "model.pt", "--run-id", "r1", cwd=tmp_path)
|
|
227
|
+
run_cli("annotate", "model.pt", "--kind", "osf:node", "--ref", "abc12", cwd=tmp_path)
|
|
228
|
+
run_cli("claim", "accuracy 0.91", "--run-id", "r1", cwd=tmp_path)
|
|
229
|
+
assert ledger.verify(lp) == (ledger.ChainStatus.INTACT, [])
|
|
230
|
+
assert [e["event"] for e in ledger.read_ledger(lp)] == [
|
|
231
|
+
"init",
|
|
232
|
+
"seal",
|
|
233
|
+
"annotation",
|
|
234
|
+
"run",
|
|
235
|
+
"annotation",
|
|
236
|
+
"claim",
|
|
237
|
+
]
|
|
238
|
+
r = run_cli("verify", "--files", cwd=tmp_path)
|
|
239
|
+
assert r.returncode == 0, r.stdout
|
|
240
|
+
assert "all checks passed." in r.stdout
|
|
241
|
+
|
|
242
|
+
tampered = lp.read_text().replace("lab/study/model:v3", "lab/study/model:v4")
|
|
243
|
+
lp.write_text(tampered)
|
|
244
|
+
assert ledger.verify(lp)[0] is ledger.ChainStatus.EDITED
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""`results review`: a record that someone examined named bytes, and what `verify` says of it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from results import ledger
|
|
10
|
+
|
|
11
|
+
REVIEW = (
|
|
12
|
+
"--scope",
|
|
13
|
+
"each mismatch against the paper's table",
|
|
14
|
+
"--procedure",
|
|
15
|
+
"read the rendered page beside the file",
|
|
16
|
+
"--reviewer",
|
|
17
|
+
"A. Reader",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_cli(*args, cwd, env=None):
|
|
22
|
+
base = {k: v for k, v in os.environ.items() if not k.startswith(("CLAUDE", "ANTHROPIC"))}
|
|
23
|
+
return subprocess.run(
|
|
24
|
+
[sys.executable, "-m", "results.cli", *args],
|
|
25
|
+
cwd=cwd,
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
env={**base, **(env or {})},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def project(tmp_path):
|
|
33
|
+
run_cli("init", cwd=tmp_path)
|
|
34
|
+
(tmp_path / "table.csv").write_text("a,b\n1,2\n")
|
|
35
|
+
return tmp_path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def last_event(tmp_path):
|
|
39
|
+
return ledger.read_ledger(tmp_path / ".results" / "ledger.jsonl")[-1]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_a_review_records_the_digest_of_what_was_examined(tmp_path):
|
|
43
|
+
project(tmp_path)
|
|
44
|
+
r = run_cli("review", "table.csv", *REVIEW, "--verdict", "partial", cwd=tmp_path)
|
|
45
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
46
|
+
event = last_event(tmp_path)
|
|
47
|
+
assert event["event"] == "review"
|
|
48
|
+
assert event["verdict"] == "partial"
|
|
49
|
+
assert event["reviewer"] == "A. Reader"
|
|
50
|
+
assert event["subjects"] == [
|
|
51
|
+
{
|
|
52
|
+
"path": "table.csv",
|
|
53
|
+
"sha256": ledger.sha256_of_file(tmp_path / "table.csv"),
|
|
54
|
+
"kind": "file",
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
assert "self-reported and unsigned" in r.stdout
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_a_verdict_outside_the_four_writes_nothing(tmp_path):
|
|
61
|
+
project(tmp_path)
|
|
62
|
+
before = (tmp_path / ".results" / "ledger.jsonl").read_bytes()
|
|
63
|
+
r = run_cli("review", "table.csv", *REVIEW, "--verdict", "verified", cwd=tmp_path)
|
|
64
|
+
assert r.returncode == 1
|
|
65
|
+
assert (tmp_path / ".results" / "ledger.jsonl").read_bytes() == before
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_scope_procedure_and_reviewer_are_required(tmp_path):
|
|
69
|
+
project(tmp_path)
|
|
70
|
+
for dropped in ("--scope", "--procedure", "--reviewer"):
|
|
71
|
+
at = REVIEW.index(dropped)
|
|
72
|
+
args = REVIEW[:at] + REVIEW[at + 2 :]
|
|
73
|
+
r = run_cli("review", "table.csv", *args, "--verdict", "pass", cwd=tmp_path)
|
|
74
|
+
assert r.returncode == 2, dropped
|
|
75
|
+
assert dropped in r.stderr
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_the_session_is_recorded_as_the_environment_and_the_flag_give_it(tmp_path):
|
|
79
|
+
project(tmp_path)
|
|
80
|
+
env = {
|
|
81
|
+
"CLAUDECODE": "1",
|
|
82
|
+
"CLAUDE_CODE_EXECPATH": "/somewhere/claude/versions/2.1.295",
|
|
83
|
+
"CLAUDE_CODE_SESSION_ID": "0f0f0f0f-aaaa-bbbb-cccc-121212121212",
|
|
84
|
+
}
|
|
85
|
+
r = run_cli(
|
|
86
|
+
"review",
|
|
87
|
+
"table.csv",
|
|
88
|
+
*REVIEW,
|
|
89
|
+
"--verdict",
|
|
90
|
+
"pass",
|
|
91
|
+
"--model",
|
|
92
|
+
"claude-opus-5-5",
|
|
93
|
+
cwd=tmp_path,
|
|
94
|
+
env=env,
|
|
95
|
+
)
|
|
96
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
97
|
+
assert last_event(tmp_path)["session"] == {
|
|
98
|
+
"model": "claude-opus-5-5",
|
|
99
|
+
"harness": "Claude Code",
|
|
100
|
+
"harness_version": "2.1.295",
|
|
101
|
+
"session_id": "0f0f0f0f-aaaa-bbbb-cccc-121212121212",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_outside_a_session_and_with_no_model_the_session_is_empty(tmp_path):
|
|
106
|
+
project(tmp_path)
|
|
107
|
+
run_cli("review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path)
|
|
108
|
+
assert last_event(tmp_path)["session"] == {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_a_session_with_no_model_named_says_so(tmp_path):
|
|
112
|
+
project(tmp_path)
|
|
113
|
+
r = run_cli(
|
|
114
|
+
"review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path, env={"CLAUDECODE": "1"}
|
|
115
|
+
)
|
|
116
|
+
assert "no --model" in r.stdout
|
|
117
|
+
assert "model" not in last_event(tmp_path)["session"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_verify_reports_a_reviewed_file_that_changed_as_stale_and_does_not_fail(tmp_path):
|
|
121
|
+
project(tmp_path)
|
|
122
|
+
run_cli("review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path)
|
|
123
|
+
|
|
124
|
+
current = run_cli("verify", "--files", cwd=tmp_path)
|
|
125
|
+
assert current.returncode == 0, current.stdout
|
|
126
|
+
assert "current table.csv" in current.stdout
|
|
127
|
+
assert "STALE" not in current.stdout
|
|
128
|
+
|
|
129
|
+
(tmp_path / "table.csv").write_text("a,b\n1,3\n")
|
|
130
|
+
stale = run_cli("verify", "--files", cwd=tmp_path)
|
|
131
|
+
assert stale.returncode == 0, stale.stdout
|
|
132
|
+
assert "STALE table.csv" in stale.stdout
|
|
133
|
+
assert "1 reviewed file(s) are no longer the bytes that were reviewed" in stale.stdout
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_a_review_is_not_a_seal(tmp_path):
|
|
137
|
+
project(tmp_path)
|
|
138
|
+
run_cli("review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path)
|
|
139
|
+
(tmp_path / "table.csv").write_text("a,b\n1,3\n")
|
|
140
|
+
run_cli("review", "table.csv", *REVIEW, "--verdict", "pass", cwd=tmp_path)
|
|
141
|
+
r = run_cli("verify", "--files", cwd=tmp_path)
|
|
142
|
+
assert "RESEALED" not in r.stdout
|
|
143
|
+
assert "CHANGED" not in r.stdout
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_without_files_verify_lists_the_review_and_says_the_subject_was_not_checked(tmp_path):
|
|
147
|
+
project(tmp_path)
|
|
148
|
+
run_cli("review", "table.csv", *REVIEW, "--verdict", "fail", cwd=tmp_path)
|
|
149
|
+
r = run_cli("verify", cwd=tmp_path)
|
|
150
|
+
assert "reviews (self-reported, unsigned):" in r.stdout
|
|
151
|
+
assert "fail" in r.stdout and "A. Reader" in r.stdout
|
|
152
|
+
assert "not checked table.csv" in r.stdout
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|