prereg 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prereg/__init__.py +2 -0
- prereg/cli.py +325 -0
- prereg/template.py +82 -0
- prereg-0.1.0.dist-info/METADATA +111 -0
- prereg-0.1.0.dist-info/RECORD +8 -0
- prereg-0.1.0.dist-info/WHEEL +4 -0
- prereg-0.1.0.dist-info/entry_points.txt +2 -0
- prereg-0.1.0.dist-info/licenses/LICENSE +18 -0
prereg/__init__.py
ADDED
prereg/cli.py
ADDED
|
@@ -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())
|
prereg/template.py
ADDED
|
@@ -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,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.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
prereg/__init__.py,sha256=mBQq0zoaUgKXOxFCAbKnf6fDc6-54-hg-WIwOBnD2Ps,92
|
|
2
|
+
prereg/cli.py,sha256=lKnSqeVDUlRQAQHbZYpW1kkHKsc0jZIA10Yhtqls9Gc,12489
|
|
3
|
+
prereg/template.py,sha256=FRBeVubycDUJ5agMEvIpQtWbb3xDh_ICC1OfP8F4824,3128
|
|
4
|
+
prereg-0.1.0.dist-info/METADATA,sha256=jF0MgV_Kw2p9IVUhg73hNK9wfo7ajPyuUdNhvNS1Jm4,3280
|
|
5
|
+
prereg-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
prereg-0.1.0.dist-info/entry_points.txt,sha256=ipPK_DL-7qDJZD2MYJBm_rIziZ20BtIMHdfbXAT1dCw,43
|
|
7
|
+
prereg-0.1.0.dist-info/licenses/LICENSE,sha256=5Cxc8x04RnJhPINqLjdKNIGXE1Fp6Dle0jaj9x1xQW8,1069
|
|
8
|
+
prereg-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|