prereg 0.1.0__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.
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "prereg",
3
+ "owner": {
4
+ "name": "Elliot Tower"
5
+ },
6
+ "metadata": {
7
+ "description": "Freeze a plan before you run it, and record what changed after",
8
+ "version": "0.1.0"
9
+ },
10
+ "plugins": [
11
+ {
12
+ "name": "prereg",
13
+ "source": "./plugin",
14
+ "strict": false
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,21 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ contents: read
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.11"
19
+ - run: pip install hatchling
20
+ - run: python -m hatchling build
21
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .DS_Store
6
+ *.egg-info/
prereg-0.1.0/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elliot Tower
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software
6
+ and associated documentation files (the "Software"), to deal in the Software without
7
+ restriction, including without limitation the rights to use, copy, modify, merge, publish,
8
+ distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or
12
+ substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
15
+ BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
prereg-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: prereg
3
+ Version: 0.1.0
4
+ Summary: Freeze a plan before you run it, and record what changed after
5
+ Author-email: Elliot Tower <elliot@elliottower.ai>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: open science,osf,preregistration,reproducibility
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: pyyaml>=6.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # prereg
14
+
15
+ Freeze a plan before you run it, and record what changed after.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install prereg
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ prereg new V16_reliability_ceilings
27
+ # fill in the plan, commit it
28
+ prereg freeze
29
+ # run the experiment, then log what happened
30
+ prereg log "tolerance now derived from fixtures" --access "no results seen"
31
+ prereg check
32
+ ```
33
+
34
+ ```
35
+ unchanged V16_reliability_ceilings/PREREG.md
36
+ ```
37
+
38
+ ## Commands
39
+
40
+ | Command | What it does |
41
+ |---------|-------------|
42
+ | `prereg new <name>` | Scaffold a plan in OSF's headings |
43
+ | `prereg freeze` | Record the commit and hash |
44
+ | `prereg log <note>` | Append to the log without freezing |
45
+ | `prereg check` | Has the plan changed since the freeze? |
46
+
47
+ ## One file, one rule
48
+
49
+ ```
50
+ V16_reliability_ceilings/
51
+ PREREG.md the plan, then a line, then an append-only log
52
+ tests/ results/
53
+ ```
54
+
55
+ **Never edit above the line. Only append below it.**
56
+
57
+ `prereg check` enforces it — the freeze records a hash of the plan, and any later edit to it
58
+ fails the check. Appending to the log does not.
59
+
60
+ ## The log
61
+
62
+ ```
63
+ 2026-08-11 frozen at 9894e148e429 nothing run
64
+ 2026-08-13 tolerance now from fixtures no results seen
65
+ 2026-08-14 ran results not opened
66
+ 2026-08-15 C5 failed at k=15: 6.6% vs 5% results seen
67
+ ```
68
+
69
+ The last column is what distinguishes an amendment from a deviation, so you never have to
70
+ decide which word to use. `nothing run`, `no results seen`, `results not opened`, `results
71
+ seen`. An entry logged before results is an amendment; one logged after is a deviation.
72
+
73
+ ## Check output
74
+
75
+ | Exit | Result | Meaning |
76
+ |------|--------|---------|
77
+ | 0 | `unchanged` | The plan says what it said |
78
+ | 1 | `CHANGED` | The plan was edited above the line after freezing |
79
+ | 2 | `not frozen` | No hash recorded — nothing was measured |
80
+
81
+ `not frozen` is not a pass. It is the absence of a check.
82
+
83
+ ## The plan uses OSF's headings
84
+
85
+ Verbatim, so the document maps onto an [OSF registration](https://osf.io/prereg/) without being
86
+ rewritten. Two of the twenty-seven do the real work:
87
+
88
+ - **Foreknowledge of data or evidence** — what you have already seen.
89
+ - **Inference criteria** — the decision rule as a commitment, before the number exists.
90
+
91
+ A heading that does not apply is answered `N/A` with a reason, never deleted.
92
+
93
+ ## What a freeze is
94
+
95
+ A commit and a hash. The commit is the evidence — it is in history, dated, and not yours to
96
+ revise quietly. The hash is the convenience that lets `prereg check` tell you in a second
97
+ whether the plan still says what it said.
98
+
99
+ Neither proves you did not run the experiment first. Nothing can: a timestamp bounds when
100
+ something existed, never when work began.
101
+
102
+ ## Claude Code
103
+
104
+ `plugin/` is a Claude Code plugin that tells Claude when to reach for the CLI.
105
+
106
+ ```bash
107
+ /plugin marketplace add elliottower/prereg
108
+ /plugin install prereg@prereg
109
+ ```
110
+
111
+ MIT licensed.
prereg-0.1.0/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # prereg
2
+
3
+ Freeze a plan before you run it, and record what changed after.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install prereg
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ prereg new V16_reliability_ceilings
15
+ # fill in the plan, commit it
16
+ prereg freeze
17
+ # run the experiment, then log what happened
18
+ prereg log "tolerance now derived from fixtures" --access "no results seen"
19
+ prereg check
20
+ ```
21
+
22
+ ```
23
+ unchanged V16_reliability_ceilings/PREREG.md
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ | Command | What it does |
29
+ |---------|-------------|
30
+ | `prereg new <name>` | Scaffold a plan in OSF's headings |
31
+ | `prereg freeze` | Record the commit and hash |
32
+ | `prereg log <note>` | Append to the log without freezing |
33
+ | `prereg check` | Has the plan changed since the freeze? |
34
+
35
+ ## One file, one rule
36
+
37
+ ```
38
+ V16_reliability_ceilings/
39
+ PREREG.md the plan, then a line, then an append-only log
40
+ tests/ results/
41
+ ```
42
+
43
+ **Never edit above the line. Only append below it.**
44
+
45
+ `prereg check` enforces it — the freeze records a hash of the plan, and any later edit to it
46
+ fails the check. Appending to the log does not.
47
+
48
+ ## The log
49
+
50
+ ```
51
+ 2026-08-11 frozen at 9894e148e429 nothing run
52
+ 2026-08-13 tolerance now from fixtures no results seen
53
+ 2026-08-14 ran results not opened
54
+ 2026-08-15 C5 failed at k=15: 6.6% vs 5% results seen
55
+ ```
56
+
57
+ The last column is what distinguishes an amendment from a deviation, so you never have to
58
+ decide which word to use. `nothing run`, `no results seen`, `results not opened`, `results
59
+ seen`. An entry logged before results is an amendment; one logged after is a deviation.
60
+
61
+ ## Check output
62
+
63
+ | Exit | Result | Meaning |
64
+ |------|--------|---------|
65
+ | 0 | `unchanged` | The plan says what it said |
66
+ | 1 | `CHANGED` | The plan was edited above the line after freezing |
67
+ | 2 | `not frozen` | No hash recorded — nothing was measured |
68
+
69
+ `not frozen` is not a pass. It is the absence of a check.
70
+
71
+ ## The plan uses OSF's headings
72
+
73
+ Verbatim, so the document maps onto an [OSF registration](https://osf.io/prereg/) without being
74
+ rewritten. Two of the twenty-seven do the real work:
75
+
76
+ - **Foreknowledge of data or evidence** — what you have already seen.
77
+ - **Inference criteria** — the decision rule as a commitment, before the number exists.
78
+
79
+ A heading that does not apply is answered `N/A` with a reason, never deleted.
80
+
81
+ ## What a freeze is
82
+
83
+ A commit and a hash. The commit is the evidence — it is in history, dated, and not yours to
84
+ revise quietly. The hash is the convenience that lets `prereg check` tell you in a second
85
+ whether the plan still says what it said.
86
+
87
+ Neither proves you did not run the experiment first. Nothing can: a timestamp bounds when
88
+ something existed, never when work began.
89
+
90
+ ## Claude Code
91
+
92
+ `plugin/` is a Claude Code plugin that tells Claude when to reach for the CLI.
93
+
94
+ ```bash
95
+ /plugin marketplace add elliottower/prereg
96
+ /plugin install prereg@prereg
97
+ ```
98
+
99
+ MIT licensed.
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "prereg",
3
+ "description": "Freeze a plan before you run it, and record what changed after",
4
+ "version": "0.1.0",
5
+ "author": { "name": "Elliot Tower", "email": "elliot@elliottower.ai" },
6
+ "repository": "https://github.com/elliottower/prereg",
7
+ "license": "MIT",
8
+ "keywords": ["preregistration", "reproducibility", "provenance", "experiments"]
9
+ }
@@ -0,0 +1,31 @@
1
+ # The Claude Code plugin
2
+
3
+ Tells Claude the `prereg` CLI exists and when to reach for it. The CLI does the work; this adds
4
+ nothing the CLI cannot do.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ /plugin marketplace add elliottower/prereg
10
+ /plugin install prereg@prereg
11
+ ```
12
+
13
+ The plugin ships instructions, not binaries. Install the tool too:
14
+
15
+ ```bash
16
+ uv tool install prereg
17
+ ```
18
+
19
+ For development against a checkout:
20
+
21
+ ```bash
22
+ /plugin marketplace add ~/Documents/GitHub/prereg
23
+ /plugin install prereg@prereg
24
+ ```
25
+
26
+ ## What it changes
27
+
28
+ Claude will freeze a plan before running it rather than after, commit the PREREG.md on its own so
29
+ the freeze is not contaminated by a code change, and log a change to a frozen plan instead of
30
+ editing it quietly. It will also stop writing `**Status:** FROZEN` headers by hand, which produce a
31
+ document that reads as registered and cannot be verified.
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: prereg
3
+ description: Freeze an experiment's plan against a commit and a content hash before running it, then log amendments and deviations. Use when writing or freezing a PREREG.md, before launching a run a plan governs, when a frozen plan needs to change, or when asked whether a plan still says what it said. Requires the `prereg` CLI (`uv tool install prereg`).
4
+ ---
5
+
6
+ # prereg
7
+
8
+ A plan, frozen against a commit and a hash, plus an append-only log of what changed after.
9
+
10
+ ## The rule that matters
11
+
12
+ **Freeze before you look. Never write the header by hand.**
13
+
14
+ A pre-registration is worth exactly one thing: evidence that the predictions existed before the
15
+ data did. Every way it fails is a version of the plan moving after the numbers were seen — a
16
+ threshold nudged, a hypothesis dropped, a subgroup added. The freeze exists so that movement leaves
17
+ a trace, and a hand-written `**Status:** FROZEN` header leaves none. It produces a document that
18
+ reads as registered and cannot be verified, which is worse than one that never claimed to be.
19
+
20
+ `**Status:**`, `**Plan sha256:**` and `**Frozen:**` are the tool's output. Writing them yourself is
21
+ the one thing this skill exists to prevent.
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ prereg new <name> # scaffold PREREG.md in OSF's headings, plus tests/ and results/
27
+ prereg freeze # write the header, hash the plan, append to the log
28
+ prereg log <note> --access <level>
29
+ prereg check # has anything above the log line changed since the freeze?
30
+ ```
31
+
32
+ ## Freezing, in order
33
+
34
+ 1. **Write the plan, then get it reviewed.** A plan frozen without review registers the author's
35
+ guesses, not an agreed design.
36
+ 2. **Commit the PREREG.md alone.** No code in that commit. A freeze whose commit also carries a
37
+ code change cannot distinguish the registered design from the change made while registering it.
38
+ 3. **`prereg freeze`** in the experiment directory. It refuses on a dirty tree, because the freeze
39
+ names a commit.
40
+ 4. **Commit the freeze header.** The freeze is only evidence once it is in history.
41
+ 5. **Then run.** Not before step 4.
42
+
43
+ ## Reading `prereg check`
44
+
45
+ Three results, exhaustive:
46
+
47
+ | exit | | |
48
+ |---|---|---|
49
+ | 0 | `unchanged` | the plan says what it said |
50
+ | 1 | `CHANGED` | the plan was edited above the log line after freezing |
51
+ | 2 | `not frozen` | no hash recorded — **nothing was measured** |
52
+
53
+ **`not frozen` is not a pass.** It is the absence of a check, and it reads identically to success
54
+ if you only look at whether the command complained.
55
+
56
+ **`CHANGED` is not fixed by re-freezing.** Re-freezing overwrites the evidence that the plan moved.
57
+ Restore the plan and `prereg log` the change with an honest `--access`.
58
+
59
+ At a repository root with no governing plan, `check` checks every plan below it.
60
+
61
+ ## Amendments and deviations
62
+
63
+ `--access` is one of `nothing run`, `no results seen`, `results not opened`, `results seen`. It
64
+ records what was known when the change was made, so the distinction is never a judgment call: an
65
+ entry logged before results is an amendment, one logged after is a deviation. Log the honest level
66
+ even when it is the damaging one — that is the entire function of the field.
67
+
68
+ ## When to reach for this
69
+
70
+ - Before launching any run whose result will be reported as confirmatory
71
+ - When a plan is ready to freeze, after review
72
+ - When a frozen plan has to change — log first, never edit silently
73
+ - Before reporting a result a plan governs, to confirm the plan still says what it said
74
+ - In CI, as `prereg check`
75
+
76
+ ## Non-obvious behavior
77
+
78
+ - `freeze` is idempotent: the commit, digest and date sit on lines the hash skips, so re-freezing
79
+ an unedited plan reproduces its hash.
80
+ - `--force` re-freezes an already-frozen plan. Use it when the plan legitimately changed and was
81
+ re-committed, never to clear a `CHANGED` warning.
82
+ - Appending below the log line is the allowed edit and does not fail `check`.
83
+
84
+ ## What it will not do
85
+
86
+ It cannot prove you did not run the experiment first. Nothing can: a timestamp bounds when
87
+ something existed, never when the work began. It also cannot tell you the plan was any good — a
88
+ frozen bad design is still a bad design, registered.
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "prereg"
3
+ version = "0.1.0"
4
+ description = "Freeze a plan before you run it, and record what changed after"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Elliot Tower", email = "elliot@elliottower.ai" }]
9
+ keywords = ["preregistration", "reproducibility", "open science", "osf"]
10
+ dependencies = ["PyYAML>=6.0"]
11
+
12
+ [project.scripts]
13
+ prereg = "prereg.cli:main"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/prereg"]
@@ -0,0 +1,2 @@
1
+ """Freeze a plan before you run it, and record what changed after."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,325 @@
1
+ """Freeze a plan before you run it, and record what changed after.
2
+
3
+ prereg new <name> scaffold the plan, in OSF's headings
4
+ prereg freeze record the commit and hash, append to the log
5
+ prereg log <note> append a line without freezing
6
+ prereg check has anything above the line changed since the freeze?
7
+
8
+ One file per experiment, one rule: never edit above the line, only append below it.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import datetime
14
+ import hashlib
15
+ import pathlib
16
+ import re
17
+ import subprocess
18
+ import sys
19
+
20
+ from prereg import template
21
+
22
+ PREREG = "PREREG.md"
23
+ MARK = "\n---\n\n## Log\n"
24
+ ACCESS = ["nothing run", "no results seen", "results not opened", "results seen"]
25
+
26
+
27
+ def today() -> str:
28
+ return datetime.date.today().isoformat()
29
+
30
+
31
+ def git(*args, cwd=None) -> str:
32
+ r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
33
+ return r.stdout.strip() if r.returncode == 0 else ""
34
+
35
+
36
+ def find(start: pathlib.Path | None = None) -> pathlib.Path | None:
37
+ """The PREREG.md governing this directory: here, or the nearest one above."""
38
+ here = (start or pathlib.Path.cwd()).resolve()
39
+ for d in [here, *here.parents]:
40
+ if (d / PREREG).is_file():
41
+ return d / PREREG
42
+ return None
43
+
44
+
45
+ def plan_of(text: str) -> str:
46
+ """What the freeze hashes: the plan, minus its own status block.
47
+
48
+ The status lines carry the commit, the hash and the freeze date, and they are written into
49
+ the file by `freeze` itself. Including them would mean the hash covered a value derived
50
+ from the hash, so the check could never pass.
51
+ """
52
+ i = text.find(MARK)
53
+ plan = text if i < 0 else text[:i]
54
+ keep = [ln for ln in plan.splitlines()
55
+ if not ln.startswith(("**Status:**", "**Plan sha256:**", "**Frozen:**"))]
56
+ return "\n".join(keep).strip() + "\n"
57
+
58
+
59
+ def sha256_of(s: str) -> str:
60
+ return hashlib.sha256(s.encode()).hexdigest()
61
+
62
+
63
+ def unhashed_content(text: str) -> list[str]:
64
+ """Parts of the plan the hash would not cover, which `freeze` refuses to register.
65
+
66
+ Two things are skipped when hashing, both for good reasons, and both exploitable if they
67
+ appear where they are not meant to. The log marker ends the hashed region, so a second one
68
+ in the body leaves everything after it editable without `check` noticing. Marker-prefixed
69
+ lines are skipped because `freeze` writes them, so one in the body is editable the same way.
70
+
71
+ Refusing is better than hashing them anyway: changing what the hash covers would invalidate
72
+ every plan already frozen, while refusing only affects plans not yet registered.
73
+ """
74
+ problems = []
75
+ if text.count(MARK) > 1:
76
+ problems.append(
77
+ "the log marker (`---` then `## Log`) appears more than once. Hashing stops at the "
78
+ "first, so the plan after it would not be covered.")
79
+ i = text.find(MARK)
80
+ plan = text if i < 0 else text[:i]
81
+ m = STATUS_BLOCK.search(plan)
82
+ body = (plan[:m.start()] + plan[m.end():]) if m else plan
83
+ stray = [ln for ln in body.splitlines()
84
+ if ln.startswith(("**Status:**", "**Plan sha256:**", "**Frozen:**"))]
85
+ if stray:
86
+ problems.append(
87
+ "these lines sit outside the status block and are skipped when hashing, so they "
88
+ "could be edited after freezing without `check` noticing:\n "
89
+ + "\n ".join(stray[:5]))
90
+ return problems
91
+
92
+
93
+ STATUS_BLOCK = re.compile(r"^\*\*Status:\*\*.*?(?=\n[ \t]*\n|\Z)", re.S | re.M)
94
+
95
+ # A status line may carry a note after its sentence — "third version; see Log".
96
+ # The note is plan content and has to survive a freeze; the marker and its own
97
+ # value do not.
98
+ STATUS_VALUES = [
99
+ re.compile(r"^\*\*Status:\*\*[ \t]*DRAFT[ \t]*—[ \t]*not frozen\.?"),
100
+ re.compile(r"^\*\*Status:\*\*[ \t]*FROZEN at `[^`]*`\.?"),
101
+ re.compile(r"^\*\*Plan sha256:\*\*[ \t]*`[0-9a-f]*`\.?"),
102
+ re.compile(r"^\*\*Frozen:\*\*[ \t]*\d{4}-\d{2}-\d{2}\.?"),
103
+ ]
104
+
105
+
106
+ def status_note(block: str) -> str:
107
+ """Whatever the status block says beyond the markers' own values."""
108
+ out = []
109
+ for line in block.splitlines():
110
+ for rx in STATUS_VALUES:
111
+ stripped = rx.sub("", line, count=1)
112
+ if stripped != line:
113
+ line = stripped
114
+ break
115
+ if line.strip():
116
+ out.append(line.strip())
117
+ return " ".join(out)
118
+
119
+
120
+ def rewrite_status(text: str, commit: str, digest: str, date: str) -> str:
121
+ """Replace the whole status block, draft or already frozen.
122
+
123
+ Matching only the literal draft sentence did nothing on a plan that was
124
+ already frozen, so `--force` printed a new hash, wrote none of it, and left
125
+ the plan failing its own check — silently, with a zero exit code. Matching
126
+ the block also stops a note written after `**Status:**` from being glued onto
127
+ the freeze date, which is what a prefix-only replacement did to it.
128
+ """
129
+ m = STATUS_BLOCK.search(text)
130
+ if m is None:
131
+ return text
132
+ note = status_note(m.group(0))
133
+ block = (f"**Status:** FROZEN at `{commit[:12]}`\n"
134
+ f"**Plan sha256:** `{digest}`\n"
135
+ f"**Frozen:** {date}")
136
+ if note:
137
+ block += f"\n{note}"
138
+ return text[:m.start()] + block + text[m.end():]
139
+
140
+
141
+ def append(path: pathlib.Path, date: str, event: str, access: str) -> None:
142
+ text = path.read_text()
143
+ if MARK not in text:
144
+ text += MARK.rstrip("\n") + "\n\n```\n```\n"
145
+ head, _, tail = text.partition(MARK)
146
+ # Two spaces, not just padding. `{event:<36}` emits nothing extra once the note passes 36
147
+ # characters, and the access level then runs into the note — losing the boundary of the one
148
+ # field that separates an amendment from a deviation.
149
+ line = f"{date} {event:<36} {access}"
150
+ if "```" in tail:
151
+ before, fence, after = tail.rpartition("```")
152
+ tail = before.rstrip("\n") + f"\n{line}\n" + fence + after
153
+ else:
154
+ tail = tail.rstrip("\n") + f"\n{line}\n"
155
+ path.write_text(head + MARK + tail)
156
+
157
+
158
+ def cmd_new(a) -> int:
159
+ d = pathlib.Path(a.name)
160
+ if (d / PREREG).exists():
161
+ print(f"{d / PREREG} already exists")
162
+ return 1
163
+ (d / "tests").mkdir(parents=True, exist_ok=True)
164
+ (d / "results").mkdir(exist_ok=True)
165
+ title = a.title or d.name.replace("_", " ").replace("-", " ")
166
+ (d / PREREG).write_text(template.render(title, today()))
167
+ print(f"created {d}/")
168
+ print(f" {PREREG} the plan, in OSF's headings")
169
+ print(f" tests/ results/")
170
+ print("\nfill it in, then `prereg freeze`. Never edit above the log line afterwards.")
171
+ return 0
172
+
173
+
174
+ def cmd_freeze(a) -> int:
175
+ path = find()
176
+ if path is None:
177
+ print(f"no {PREREG} here or above. `prereg new <name>` makes one.")
178
+ return 2
179
+ text = path.read_text()
180
+ if STATUS_BLOCK.search(text) is None:
181
+ print(f"{path} has no `**Status:**` line, so there is nowhere to record the freeze.")
182
+ print("Add one — `**Status:** DRAFT — not frozen.` — or scaffold with `prereg new`.")
183
+ return 1
184
+ if "**Status:** DRAFT" not in text and not a.force:
185
+ print(f"{path} is already frozen. Use `prereg log` to append, or --force.")
186
+ return 1
187
+
188
+ problems = unhashed_content(text)
189
+ if problems:
190
+ print(f"{path} has content the freeze would not cover:\n")
191
+ for p in problems:
192
+ print(f" - {p}")
193
+ print("\nA freeze that leaves part of the plan editable is worse than none, because it"
194
+ "\nreads as registered. Fix these and freeze again.")
195
+ return 1
196
+
197
+ repo = path.parent
198
+ dirty = git("status", "--porcelain", str(path), cwd=repo)
199
+ if dirty and not a.force:
200
+ print(f"{path} has uncommitted changes. Commit first — the freeze names a commit.")
201
+ return 1
202
+
203
+ commit = git("rev-parse", "HEAD", cwd=repo) or "(not in a git repository)"
204
+ # Normalize the layout first, then hash. Freezing moves any status note onto
205
+ # its own line, and `plan_of` skips marker lines but not that one, so hashing
206
+ # the pre-freeze text would store a digest of a layout the file no longer has
207
+ # and `check` would fail on the freeze itself. Hashing after also makes the
208
+ # freeze idempotent: commit and date sit on skipped lines, so re-freezing an
209
+ # unedited plan reproduces the same digest.
210
+ placeholder = "0" * 64
211
+ text = rewrite_status(text, commit, placeholder, today())
212
+ digest = sha256_of(plan_of(text))
213
+ text = text.replace(f"`{placeholder}`", f"`{digest}`", 1)
214
+ path.write_text(text)
215
+ append(path, today(), f"frozen at {commit[:12]}", "nothing run")
216
+ print(f"frozen {path}")
217
+ print(f" commit {commit[:12]}")
218
+ print(f" sha256 {digest[:16]}… (of everything above the log)")
219
+ print("\nCommit this. The freeze is only evidence once it is in history.")
220
+ return 0
221
+
222
+
223
+ def cmd_log(a) -> int:
224
+ path = find()
225
+ if path is None:
226
+ print(f"no {PREREG} here or above.")
227
+ return 2
228
+ if a.access not in ACCESS:
229
+ print(f"access must be one of: {', '.join(ACCESS)}")
230
+ return 1
231
+ # The log is the tamper record, so a note is not free text. One line of it is one entry, and
232
+ # a note carrying a newline writes a second line that reads exactly like an entry somebody
233
+ # made — including a `frozen at ...` one. A fence closes the block early and puts everything
234
+ # after it outside the log.
235
+ if "\n" in a.note or "\r" in a.note:
236
+ print("a note is one line — a newline in it would read as a second log entry.")
237
+ return 1
238
+ if "```" in a.note:
239
+ print("a note cannot contain ``` — it would close the log block early.")
240
+ return 1
241
+ append(path, today(), a.note, a.access)
242
+ print(f"logged: {a.note} ({a.access})")
243
+ if a.access == "results seen":
244
+ print("\nRecorded as a deviation: the results were already known.")
245
+ return 0
246
+
247
+
248
+ def check_one(path: pathlib.Path) -> int:
249
+ """0 unchanged, 1 changed, 2 not frozen."""
250
+ text = path.read_text()
251
+ m = re.search(r"\*\*Plan sha256:\*\* `([0-9a-f]{64})`", text)
252
+ if not m:
253
+ print(f"not frozen {path}")
254
+ return 2
255
+ now = sha256_of(plan_of(text))
256
+ if now == m.group(1):
257
+ print(f"unchanged {path}")
258
+ return 0
259
+ print(f"CHANGED {path}")
260
+ print(f" frozen {m.group(1)[:16]}…")
261
+ print(f" now {now[:16]}…")
262
+ return 1
263
+
264
+
265
+ def cmd_check(a) -> int:
266
+ """Check the governing plan, or every plan below when there is none.
267
+
268
+ A repository usually holds one plan per experiment, side by side, so running this at the
269
+ root has to mean "check them all" — otherwise the command is unusable from the one place
270
+ someone would naturally run it.
271
+ """
272
+ path = find()
273
+ if path is not None:
274
+ rc = check_one(path)
275
+ if rc == 1:
276
+ print("\nThe plan was edited after freezing. Restore it and record the change in the log.")
277
+ elif rc == 2:
278
+ print("\nNothing to check against yet. `prereg freeze` records the hash.")
279
+ return rc
280
+
281
+ found = sorted(pathlib.Path.cwd().rglob(PREREG))
282
+ if not found:
283
+ print(f"no {PREREG} here, above, or below.")
284
+ return 2
285
+
286
+ codes = [check_one(f) for f in found]
287
+ changed = codes.count(1)
288
+ print(f"\n{len(found)} plans: {codes.count(0)} unchanged, {changed} changed, "
289
+ f"{codes.count(2)} not frozen")
290
+ if changed:
291
+ print("A changed plan was edited after freezing. Restore it and record the change.")
292
+ return 1 if changed else 0
293
+
294
+
295
+ def main() -> int:
296
+ ap = argparse.ArgumentParser(prog="prereg", description=__doc__.split("\n")[0])
297
+ sub = ap.add_subparsers(dest="cmd")
298
+
299
+ n = sub.add_parser("new", help="scaffold a plan")
300
+ n.add_argument("name")
301
+ n.add_argument("--title")
302
+ n.set_defaults(fn=cmd_new)
303
+
304
+ f = sub.add_parser("freeze", help="record the commit and hash")
305
+ f.add_argument("--force", action="store_true")
306
+ f.set_defaults(fn=cmd_freeze)
307
+
308
+ lg = sub.add_parser("log", help="append a line")
309
+ lg.add_argument("note")
310
+ lg.add_argument("--access", default="no results seen",
311
+ help=f"one of: {', '.join(ACCESS)}")
312
+ lg.set_defaults(fn=cmd_log)
313
+
314
+ c = sub.add_parser("check", help="has the plan changed since the freeze?")
315
+ c.set_defaults(fn=cmd_check)
316
+
317
+ a = ap.parse_args()
318
+ if not a.cmd:
319
+ ap.print_help()
320
+ return 0
321
+ return a.fn(a)
322
+
323
+
324
+ if __name__ == "__main__":
325
+ sys.exit(main())
@@ -0,0 +1,82 @@
1
+ """The plan, under OSF's question titles.
2
+
3
+ Using OSF's headings verbatim costs nothing and means the document maps onto a registration
4
+ without being rewritten. Two of them do the real work:
5
+
6
+ Foreknowledge of data or evidence forces you to say what you have already seen
7
+ Inference criteria forces the decision rule to be a commitment
8
+
9
+ A heading that does not apply is answered N/A with a reason, never deleted. A deleted heading
10
+ and an inapplicable one look identical in a file and very different to a reader.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ # Verbatim from the OSF Preregistration schema, in order.
15
+ QUESTIONS = [
16
+ ("Research questions or hypotheses", "What is being asked, and what would count as an answer."),
17
+ ("Foreknowledge of data or evidence",
18
+ "What have you already seen? Pilot runs, exploratory results, anything from a related "
19
+ "study. If nothing, say so."),
20
+ ("Explanation of foreknowledge and managing unintended influences",
21
+ "How the answer above does or does not constrain the predictions below."),
22
+ ("Study type", "Experimental, observational, meta-analytic."),
23
+ ("Intention for causal interpretation", "Or N/A if no causal claim is intended."),
24
+ ("Blinding of experimental treatments", ""),
25
+ ("Additional blinding during research or analysis", ""),
26
+ ("Study design", ""),
27
+ ("Randomization", ""),
28
+ ("Data collection procedures", ""),
29
+ ("Data collection procedures - File upload", ""),
30
+ ("Sample size", "And what it can and cannot detect."),
31
+ ("Sample size rationale", ""),
32
+ ("Starting and stopping rules", ""),
33
+ ("Manipulated variables", ""),
34
+ ("Measured variables", ""),
35
+ ("Measured variables - File upload", ""),
36
+ ("Indices", ""),
37
+ ("Indices - File upload", ""),
38
+ ("Statistical models", ""),
39
+ ("Statistical models - File upload", ""),
40
+ ("Transformations", ""),
41
+ ("Inference criteria",
42
+ "The decision rule, as a commitment, before the number exists. A threshold, not a hope."),
43
+ ("Data inclusion and exclusion", ""),
44
+ ("Missing data", ""),
45
+ ("Other planned analysis", "Anything beyond the above is exploratory and labelled so."),
46
+ ("Context and additional information", ""),
47
+ ]
48
+
49
+ HEADER = """\
50
+ # {title}
51
+
52
+ **Status:** DRAFT — not frozen.
53
+
54
+ Sections use the [OSF Preregistration](https://osf.io/prereg/) question titles verbatim, so
55
+ this maps onto a registration without being rewritten. A question that does not apply is
56
+ answered **N/A** with the reason, never deleted.
57
+ """
58
+
59
+ LOG = """\
60
+
61
+ ---
62
+
63
+ ## Log
64
+
65
+ Append only. Never edit above the line.
66
+
67
+ The last column is what distinguishes an amendment from a deviation, so you do not have to
68
+ decide which word to use: `nothing run`, `no results seen`, `results not opened`, `results seen`.
69
+
70
+ ```
71
+ {date} created nothing run
72
+ ```
73
+ """
74
+
75
+
76
+ def render(title: str, date: str) -> str:
77
+ parts = [HEADER.format(title=title)]
78
+ for q, hint in QUESTIONS:
79
+ parts.append(f"\n## {q}\n")
80
+ parts.append(f"_{hint}_\n" if hint else "N/A — \n")
81
+ parts.append(LOG.format(date=date))
82
+ return "".join(parts)
@@ -0,0 +1,247 @@
1
+ """A freeze is only worth anything if editing the plan afterwards is detectable."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ from prereg import cli
10
+
11
+
12
+ def run(args, cwd):
13
+ return subprocess.run([sys.executable, "-m", "prereg.cli", *args],
14
+ cwd=cwd, capture_output=True, text=True)
15
+
16
+
17
+ @pytest.fixture
18
+ def repo(tmp_path):
19
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path)
20
+ run(["new", "study"], tmp_path)
21
+ subprocess.run(["git", "add", "-A"], cwd=tmp_path)
22
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
23
+ "commit", "-q", "-m", "init"], cwd=tmp_path)
24
+ return tmp_path / "study"
25
+
26
+
27
+ def test_new_uses_the_osf_headings(tmp_path):
28
+ run(["new", "study"], tmp_path)
29
+ text = (tmp_path / "study" / "PREREG.md").read_text()
30
+ for q, _ in cli.template.QUESTIONS:
31
+ assert f"## {q}" in text
32
+
33
+
34
+ def test_the_two_headings_that_do_the_work_are_present(tmp_path):
35
+ run(["new", "study"], tmp_path)
36
+ text = (tmp_path / "study" / "PREREG.md").read_text()
37
+ assert "## Foreknowledge of data or evidence" in text
38
+ assert "## Inference criteria" in text
39
+
40
+
41
+ def test_check_passes_immediately_after_freeze(repo):
42
+ run(["freeze"], repo)
43
+ assert run(["check"], repo).returncode == 0
44
+
45
+
46
+ def test_editing_the_plan_is_detected(repo):
47
+ run(["freeze"], repo)
48
+ p = repo / "PREREG.md"
49
+ p.write_text(p.read_text().replace("## Randomization", "## Randomisation"))
50
+ r = run(["check"], repo)
51
+ assert r.returncode == 1
52
+ assert "CHANGED" in r.stdout
53
+
54
+
55
+ def test_appending_to_the_log_is_not_a_change(repo):
56
+ run(["freeze"], repo)
57
+ run(["log", "tolerance from fixtures", "--access", "no results seen"], repo)
58
+ assert run(["check"], repo).returncode == 0, "appending below the line is the allowed edit"
59
+
60
+
61
+ def test_freezing_twice_is_refused(repo):
62
+ run(["freeze"], repo)
63
+ assert run(["freeze"], repo).returncode == 1
64
+
65
+
66
+ def test_freeze_refuses_uncommitted_changes(tmp_path):
67
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path)
68
+ run(["new", "study"], tmp_path)
69
+ r = run(["freeze"], tmp_path / "study")
70
+ assert r.returncode == 1
71
+ assert "Commit first" in r.stdout
72
+
73
+
74
+ def test_an_unknown_access_value_is_refused(repo):
75
+ run(["freeze"], repo)
76
+ r = run(["log", "something", "--access", "probably fine"], repo)
77
+ assert r.returncode == 1
78
+
79
+
80
+ def test_results_seen_is_called_a_deviation(repo):
81
+ run(["freeze"], repo)
82
+ r = run(["log", "criterion failed", "--access", "results seen"], repo)
83
+ assert "deviation" in r.stdout
84
+
85
+
86
+ def test_check_on_an_unfrozen_plan_does_not_report_success(tmp_path):
87
+ run(["new", "study"], tmp_path)
88
+ assert run(["check"], tmp_path / "study").returncode != 0
89
+
90
+
91
+ def test_commands_find_the_plan_from_a_subdirectory(repo):
92
+ run(["freeze"], repo)
93
+ (repo / "results").mkdir(exist_ok=True)
94
+ assert run(["check"], repo / "results").returncode == 0
95
+
96
+
97
+ def test_force_refreeze_rewrites_the_header_it_prints(repo):
98
+ run(["freeze"], repo)
99
+ p = repo / "PREREG.md"
100
+ p.write_text(p.read_text().replace("## Randomization", "## Randomization\n\nBy seed."))
101
+ subprocess.run(["git", "add", "-A"], cwd=repo)
102
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
103
+ "commit", "-q", "-m", "edit"], cwd=repo)
104
+ r = run(["freeze", "--force"], repo)
105
+ printed = [w for w in r.stdout.split() if len(w.rstrip("…")) == 16][-1].rstrip("…")
106
+ assert printed in p.read_text(), "freeze printed a digest it did not write"
107
+ assert run(["check"], repo).returncode == 0
108
+
109
+
110
+ def test_status_note_survives_the_freeze_intact(repo):
111
+ p = repo / "PREREG.md"
112
+ p.write_text(p.read_text().replace(
113
+ "**Status:** DRAFT — not frozen.",
114
+ "**Status:** DRAFT — not frozen. Third version; see Log."))
115
+ subprocess.run(["git", "add", "-A"], cwd=repo)
116
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
117
+ "commit", "-q", "-m", "note"], cwd=repo)
118
+ run(["freeze"], repo)
119
+ text = p.read_text()
120
+ frozen_line = next(ln for ln in text.splitlines() if ln.startswith("**Frozen:**"))
121
+ assert frozen_line.strip().endswith(cli.today()), \
122
+ f"note was glued onto the freeze date: {frozen_line!r}"
123
+ assert "Third version; see Log." in text
124
+ assert run(["check"], repo).returncode == 0
125
+
126
+
127
+ def test_refreezing_an_unedited_plan_is_idempotent(repo):
128
+ run(["freeze"], repo)
129
+ first = (repo / "PREREG.md").read_text()
130
+ digest = cli.re.search(r"`([0-9a-f]{64})`", first).group(1)
131
+ run(["freeze", "--force"], repo)
132
+ second = (repo / "PREREG.md").read_text()
133
+ assert cli.re.search(r"`([0-9a-f]{64})`", second).group(1) == digest
134
+ assert run(["check"], repo).returncode == 0
135
+
136
+
137
+ # --- what the hash does not cover ------------------------------------------------------------
138
+
139
+ def test_freeze_refuses_a_plan_with_no_status_line(repo):
140
+ p = repo / "PREREG.md"
141
+ p.write_text(p.read_text().replace("**Status:** DRAFT — not frozen.", ""))
142
+ subprocess.run(["git", "add", "-A"], cwd=repo)
143
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
144
+ "commit", "-q", "-m", "no status"], cwd=repo)
145
+ r = run(["freeze"], repo)
146
+ assert r.returncode != 0, "reported a freeze it did not perform"
147
+ assert "not frozen" not in run(["check"], repo).stdout or r.returncode != 0
148
+
149
+
150
+ def test_a_status_marker_in_the_body_cannot_hide_from_the_hash(repo):
151
+ """Marker-prefixed lines are skipped when hashing, so one in the body would be editable
152
+ after freezing without `check` noticing."""
153
+ p = repo / "PREREG.md"
154
+ p.write_text(p.read_text().replace(
155
+ "## Randomization",
156
+ "## Randomization\n\n**Frozen:** whatever the author likes\n"))
157
+ subprocess.run(["git", "add", "-A"], cwd=repo)
158
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
159
+ "commit", "-q", "-m", "marker in body"], cwd=repo)
160
+ r = run(["freeze"], repo)
161
+ assert r.returncode == 1, "froze a plan whose body line the hash does not cover"
162
+ assert "**Frozen:** whatever the author likes" in r.stdout, \
163
+ "the refusal must name the offending line"
164
+
165
+
166
+ def test_a_log_marker_in_the_body_cannot_truncate_the_hash(repo):
167
+ """Hashing stops at the log marker. One in the body would leave the real plan after it
168
+ unhashed and freely editable."""
169
+ p = repo / "PREREG.md"
170
+ p.write_text(p.read_text().replace(
171
+ "## Randomization",
172
+ "## Randomization\n\nSeeds 0-4.\n\n---\n\n## Log\n\n## Sample size\n\nn=300 per arm.\n"))
173
+ subprocess.run(["git", "add", "-A"], cwd=repo)
174
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
175
+ "commit", "-q", "-m", "marker in body"], cwd=repo)
176
+ r = run(["freeze"], repo)
177
+ assert r.returncode == 1, "froze a plan whose tail the hash does not cover"
178
+ assert "more than once" in r.stdout
179
+
180
+
181
+ def test_line_endings_do_not_change_the_hash(repo):
182
+ run(["freeze"], repo)
183
+ p = repo / "PREREG.md"
184
+ p.write_bytes(p.read_text().replace("\n", "\r\n").encode())
185
+ assert run(["check"], repo).returncode == 0, \
186
+ "a checkout with CRLF endings must not read as a tampered plan"
187
+
188
+
189
+ def test_trailing_whitespace_alone_is_not_a_change(repo):
190
+ run(["freeze"], repo)
191
+ p = repo / "PREREG.md"
192
+ p.write_text(p.read_text() + "\n\n")
193
+ assert run(["check"], repo).returncode == 0
194
+
195
+
196
+ # --- the log is the tamper record, so it is worth attacking -----------------------------------
197
+
198
+ def test_a_log_note_cannot_forge_another_entry(repo):
199
+ run(["freeze"], repo)
200
+ run(["log", "harmless\n2020-01-01 frozen at 000000000000 nothing run",
201
+ "--access", "no results seen"], repo)
202
+ text = (repo / "PREREG.md").read_text()
203
+ assert "2020-01-01 frozen at" not in text, \
204
+ "a newline in a note forged a second log entry"
205
+
206
+
207
+ def test_a_log_note_cannot_break_out_of_the_fence(repo):
208
+ run(["freeze"], repo)
209
+ run(["log", "see ``` and then some", "--access", "no results seen"], repo)
210
+ text = (repo / "PREREG.md").read_text()
211
+ _, _, tail = text.partition(cli.MARK)
212
+ assert tail.count("```") == 2, f"fence count is {tail.count('```')}, log structure broken"
213
+
214
+
215
+ def test_freezing_without_a_commit_says_so(tmp_path):
216
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path)
217
+ run(["new", "study"], tmp_path)
218
+ r = run(["freeze"], tmp_path / "study")
219
+ assert "(not in a git repository)" not in (tmp_path / "study" / "PREREG.md").read_text(), \
220
+ "wrote a placeholder where a commit should be"
221
+
222
+
223
+ def test_check_at_a_root_fails_if_any_plan_below_it_changed(tmp_path):
224
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path)
225
+ for name in ("good", "bad"):
226
+ run(["new", name], tmp_path)
227
+ subprocess.run(["git", "add", "-A"], cwd=tmp_path)
228
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
229
+ "commit", "-q", "-m", "init"], cwd=tmp_path)
230
+ run(["freeze"], tmp_path / "good")
231
+ run(["freeze"], tmp_path / "bad")
232
+ p = tmp_path / "bad" / "PREREG.md"
233
+ p.write_text(p.read_text().replace("## Randomization", "## Randomisation"))
234
+ assert run(["check"], tmp_path).returncode != 0, \
235
+ "a root check passed while a plan below it had been edited"
236
+
237
+
238
+ def test_a_long_note_still_separates_from_its_access_level(repo):
239
+ """The access level is what distinguishes an amendment from a deviation, so it must never
240
+ run into the note."""
241
+ run(["freeze"], repo)
242
+ note = "restricting the target to {0,1} and {0,1,2} because its value set is integers"
243
+ run(["log", note, "--access", "no results seen"], repo)
244
+ line = next(ln for ln in (repo / "PREREG.md").read_text().splitlines() if note in ln)
245
+ assert line.endswith("no results seen")
246
+ assert not line.endswith(note + "no results seen"), "access level glued to the note"
247
+ assert " no results seen" in line