paperlint 10.1.1 → 10.3.0
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.
- package/README.md +2 -0
- package/dist/adapters/study-registry/index.d.ts +357 -0
- package/dist/adapters/study-registry/index.d.ts.map +1 -0
- package/dist/adapters/study-registry/index.js +221 -0
- package/dist/adapters/study-registry/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +42 -2
- package/dist/cli.js.map +1 -1
- package/dist/domain/repro-layout.d.ts +12 -4
- package/dist/domain/repro-layout.d.ts.map +1 -1
- package/dist/domain/repro-layout.js +49 -18
- package/dist/domain/repro-layout.js.map +1 -1
- package/dist/domain/scaffold-layout.d.ts +4 -3
- package/dist/domain/scaffold-layout.d.ts.map +1 -1
- package/dist/domain/scaffold-layout.js +7 -4
- package/dist/domain/scaffold-layout.js.map +1 -1
- package/dist/domain/study-registry.d.ts +372 -0
- package/dist/domain/study-registry.d.ts.map +1 -0
- package/dist/domain/study-registry.js +593 -0
- package/dist/domain/study-registry.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +15 -0
- package/dist/ports/study-registry-codec.d.ts.map +1 -0
- package/dist/ports/study-registry-codec.js +2 -0
- package/dist/ports/study-registry-codec.js.map +1 -0
- package/dist/repro-layout-rule.d.ts.map +1 -1
- package/dist/repro-layout-rule.js +4 -3
- package/dist/repro-layout-rule.js.map +1 -1
- package/dist/studies.d.ts +27 -0
- package/dist/studies.d.ts.map +1 -0
- package/dist/studies.js +163 -0
- package/dist/studies.js.map +1 -0
- package/dist/study-registry-rule.d.ts +24 -0
- package/dist/study-registry-rule.d.ts.map +1 -0
- package/dist/study-registry-rule.js +145 -0
- package/dist/study-registry-rule.js.map +1 -0
- package/docs/design/study-registry.md +246 -0
- package/docs/layout.md +51 -46
- package/docs/rules/paper/allowed-files-only.md +3 -2
- package/docs/rules/repro/allowed-files-only.md +40 -30
- package/docs/rules/repro/study-plan-name.md +70 -0
- package/docs/rules/repro/study-registered.md +320 -0
- package/docs/rules/study/declare-sample.md +1 -1
- package/docs/rules.md +53 -51
- package/package.json +1 -1
- package/skills/build-benchmark/SKILL.md +39 -8
- package/skills/build-benchmark/SKILL.md.spec.ts +38 -7
- package/skills/build-label-page/SKILL.md +14 -9
- package/skills/build-label-page/SKILL.md.spec.ts +13 -8
- package/skills/paper-pipeline/references/artifact-checklist.md +2 -1
- package/skills/paper-pipeline/references/pipeline-status-template.md +5 -3
- package/skills/run-agent-grid/SKILL.md +13 -1
- package/skills/run-agent-grid/SKILL.md.spec.ts +12 -0
- package/src/adapters/study-registry/index.test.ts +484 -0
- package/src/adapters/study-registry/index.ts +328 -0
- package/src/cli.ts +53 -2
- package/src/domain/paper-layout.test.ts +1 -1
- package/src/domain/repro-layout.test.ts +18 -9
- package/src/domain/repro-layout.ts +58 -18
- package/src/domain/scaffold-layout.ts +7 -4
- package/src/domain/study-measurements.test.ts +629 -0
- package/src/domain/study-registry.test.ts +666 -0
- package/src/domain/study-registry.ts +1044 -0
- package/src/folder-layout-hook.test.ts +10 -0
- package/src/new-paper.test.ts +29 -2
- package/src/ports/study-registry-codec.ts +15 -0
- package/src/repro-layout-rule.ts +4 -3
- package/src/rule-cases.test.ts +68 -0
- package/src/studies-cli.test.ts +113 -0
- package/src/studies.test.ts +359 -0
- package/src/studies.ts +268 -0
- package/src/study-registry-rule.test.ts +456 -0
- package/src/study-registry-rule.ts +239 -0
|
@@ -39,9 +39,11 @@ one file, so there is one place to read and nothing to keep in step:
|
|
|
39
39
|
- **No `PLAN.md` beside it.** A plan in a second file drifts from the status it plans against;
|
|
40
40
|
`paper/allowed-files-only` reports a `PLAN.md` at the paper's root. A decision's _history_ (why it was
|
|
41
41
|
taken, what was rejected) is a record, not live state: it goes to `research/` as a dated file.
|
|
42
|
-
- **Each study gets a
|
|
43
|
-
`repro/<slug
|
|
44
|
-
saying what it asks, how to run it and what it found, and a
|
|
42
|
+
- **Each study gets a registered folder in `repro/`.** The `study` stage's runs go in
|
|
43
|
+
`repro/<id>-<slug>/`, created by `npx paperlint studies new <paper-dir> "<title>"`, with a `README.md`
|
|
44
|
+
saying what it asks, how to run it and what it found, and a row in `repro/studies.json` that is
|
|
45
|
+
updated when the study ends (then `npx paperlint studies index <paper-dir>`); `repro/study-registered`
|
|
46
|
+
holds it. Scripts the
|
|
45
47
|
whole paper uses go in `repro/tools/`, shared data in `repro/data/`; `repro/allowed-files-only` reports
|
|
46
48
|
anything else at the top of `repro/`.
|
|
47
49
|
- **The scorecard's verdicts live only here.** The dated report `study-accepted-papers` writes under
|
|
@@ -4,7 +4,7 @@ description: "Run a paid or counted agent experiment grid end to end — units
|
|
|
4
4
|
allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Agent]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
<!-- vigiles:sha256:
|
|
7
|
+
<!-- vigiles:sha256:451354b8f781c409 compiled from skills/run-agent-grid/SKILL.md.spec.ts -->
|
|
8
8
|
|
|
9
9
|
# run-agent-grid — running a counted agent grid without spoiling it
|
|
10
10
|
|
|
@@ -23,6 +23,18 @@ and a driver loop that calls the guard: [`references/registry-and-ledger.md`](re
|
|
|
23
23
|
**Exit codes of every guard command:** 0 allowed or clean · 1 refused or a finding · 2 usage ·
|
|
24
24
|
3 (`gate` only) the cell is already counted, skip it.
|
|
25
25
|
|
|
26
|
+
**The grid's study folder.** A grid is one study of the paper. Its folder is made by
|
|
27
|
+
`npx paperlint studies new <paper-dir> "<title>" --kind measurement` — the next id in
|
|
28
|
+
`repro/studies.json`, the folder `repro/<id>-<slug>/` with the study's `plan.json` — and `grid.json`,
|
|
29
|
+
the runs and the ledger live in it. The plan says what the grid measures, the population and its
|
|
30
|
+
draw (`{ "kind": "seeded", "method", "seed" }`), the `arms` (each name once) and the `primary_outcome`, and is filled before the first counted
|
|
31
|
+
run; the author's approval of the drawn units goes into both `grid.json`'s `approval` (the guard reads
|
|
32
|
+
it) and the plan's `author_check` (the lint reads it). A grid that is one study of a larger
|
|
33
|
+
measurement names it in its row's `measurement`. When the grid ends, its row is updated (`status`,
|
|
34
|
+
`result`, `result_file`, `in_paper`) and `npx paperlint studies index <paper-dir>` regenerates the
|
|
35
|
+
index; `repro/study-registered` fails the lint until both are done, and on a running grid whose plan
|
|
36
|
+
is empty.
|
|
37
|
+
|
|
26
38
|
## The order — each gate is passed before the next phase starts
|
|
27
39
|
|
|
28
40
|
### 1. The registry: what may be measured, written as data (catalog 1, 3, 14)
|
|
@@ -24,6 +24,18 @@ and a driver loop that calls the guard: [\`references/registry-and-ledger.md\`](
|
|
|
24
24
|
**Exit codes of every guard command:** 0 allowed or clean · 1 refused or a finding · 2 usage ·
|
|
25
25
|
3 (\`${COMMANDS.gate}\` only) the cell is already counted, skip it.
|
|
26
26
|
|
|
27
|
+
**The grid's study folder.** A grid is one study of the paper. Its folder is made by
|
|
28
|
+
\`npx paperlint studies new <paper-dir> "<title>" --kind measurement\` — the next id in
|
|
29
|
+
\`repro/studies.json\`, the folder \`repro/<id>-<slug>/\` with the study's \`plan.json\` — and \`grid.json\`,
|
|
30
|
+
the runs and the ledger live in it. The plan says what the grid measures, the population and its
|
|
31
|
+
draw (\`{ "kind": "seeded", "method", "seed" }\`), the \`arms\` (each name once) and the \`primary_outcome\`, and is filled before the first counted
|
|
32
|
+
run; the author's approval of the drawn units goes into both \`grid.json\`'s \`approval\` (the guard reads
|
|
33
|
+
it) and the plan's \`author_check\` (the lint reads it). A grid that is one study of a larger
|
|
34
|
+
measurement names it in its row's \`measurement\`. When the grid ends, its row is updated (\`status\`,
|
|
35
|
+
\`result\`, \`result_file\`, \`in_paper\`) and \`npx paperlint studies index <paper-dir>\` regenerates the
|
|
36
|
+
index; \`repro/study-registered\` fails the lint until both are done, and on a running grid whose plan
|
|
37
|
+
is empty.
|
|
38
|
+
|
|
27
39
|
## The order — each gate is passed before the next phase starts
|
|
28
40
|
|
|
29
41
|
### 1. The registry: what may be measured, written as data (catalog 1, 3, 14)
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schema of `repro/studies.json`: the text a registry is parsed from, the extra fields a row keeps,
|
|
3
|
+
* and every way a file can fail to be a registry — refused with the reasons, never read as one.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
planTemplate,
|
|
8
|
+
planText,
|
|
9
|
+
registryText,
|
|
10
|
+
type StudyRegistry,
|
|
11
|
+
} from "../../domain/study-registry.ts";
|
|
12
|
+
import { studyRegistryCodec } from "./index.ts";
|
|
13
|
+
|
|
14
|
+
const parse = studyRegistryCodec.parse;
|
|
15
|
+
|
|
16
|
+
const ROW = {
|
|
17
|
+
id: "P1-S01",
|
|
18
|
+
folder: "P1-S01-baseline",
|
|
19
|
+
title: "Baseline pass rate",
|
|
20
|
+
question: "How often does the agent pass?",
|
|
21
|
+
kind: "measurement",
|
|
22
|
+
status: "done",
|
|
23
|
+
date: "2026-10-15",
|
|
24
|
+
result: "41 of 60 pass",
|
|
25
|
+
result_file: "results.tsv",
|
|
26
|
+
superseded_by: "P1-S02",
|
|
27
|
+
in_paper: "yes",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const text = (v: unknown): string => JSON.stringify(v);
|
|
31
|
+
const withRow = (row: Readonly<Record<string, unknown>>): string =>
|
|
32
|
+
text({ prefix: "P1", studies: [row] });
|
|
33
|
+
|
|
34
|
+
describe("parsing a studies.json", () => {
|
|
35
|
+
it("a registry parses to itself, and what registryText writes parses back", () => {
|
|
36
|
+
const registry: StudyRegistry = { prefix: "P1", studies: [ROW] };
|
|
37
|
+
expect(parse(text(registry))).toEqual({ ok: true, registry });
|
|
38
|
+
expect(parse(registryText(registry))).toEqual({ ok: true, registry });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("the optional fields may be absent", () => {
|
|
42
|
+
const { result, result_file, superseded_by, ...row } = ROW;
|
|
43
|
+
expect([result, result_file, superseded_by]).toHaveLength(3);
|
|
44
|
+
expect(parse(withRow(row))).toEqual({
|
|
45
|
+
ok: true,
|
|
46
|
+
registry: { prefix: "P1", studies: [row] },
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("🔴 a row's extra fields are kept, whatever they hold", () => {
|
|
51
|
+
const row = {
|
|
52
|
+
...ROW,
|
|
53
|
+
role: "counted",
|
|
54
|
+
frame: { population: 4210 },
|
|
55
|
+
rules_under_test: ["a", "b"],
|
|
56
|
+
author_check: null,
|
|
57
|
+
};
|
|
58
|
+
expect(parse(withRow(row))).toEqual({
|
|
59
|
+
ok: true,
|
|
60
|
+
registry: { prefix: "P1", studies: [row] },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("an empty registry parses", () => {
|
|
65
|
+
expect(parse(text({ prefix: "AB2", studies: [] }))).toEqual({
|
|
66
|
+
ok: true,
|
|
67
|
+
registry: { prefix: "AB2", studies: [] },
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("refusing what is not a registry", () => {
|
|
73
|
+
it("text that is not JSON", () => {
|
|
74
|
+
expect(parse("{ prefix: P1")).toEqual({ ok: false, why: "not JSON" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it.each([
|
|
78
|
+
[
|
|
79
|
+
"no prefix",
|
|
80
|
+
{ studies: [] },
|
|
81
|
+
"prefix: Invalid input: expected string, received undefined",
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
"a prefix of another shape",
|
|
85
|
+
{ prefix: "p1", studies: [] },
|
|
86
|
+
"prefix: a capital letter, then capitals or digits (P1)",
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
"an unknown top-level key",
|
|
90
|
+
{ prefix: "P1", studies: [], studys: [] },
|
|
91
|
+
'(top): Unrecognized key: "studys"',
|
|
92
|
+
],
|
|
93
|
+
[
|
|
94
|
+
"studies not a list",
|
|
95
|
+
{ prefix: "P1", studies: {} },
|
|
96
|
+
"studies: Invalid input: expected array, received object",
|
|
97
|
+
],
|
|
98
|
+
])("%s", (_why, v, why) => {
|
|
99
|
+
expect(parse(text(v))).toEqual({ ok: false, why });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("refusing a row that is not a study", () => {
|
|
104
|
+
it.each([
|
|
105
|
+
[
|
|
106
|
+
"kind",
|
|
107
|
+
"benchmark",
|
|
108
|
+
'studies.0.kind: Invalid option: expected one of "measurement"|"spike"|"tooling"|"data"|"artifact"|"salvage"',
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
"status",
|
|
112
|
+
"finished",
|
|
113
|
+
'studies.0.status: Invalid option: expected one of "designing"|"running"|"done"|"stopped"|"superseded"|"dropped"',
|
|
114
|
+
],
|
|
115
|
+
[
|
|
116
|
+
"in_paper",
|
|
117
|
+
"maybe",
|
|
118
|
+
'studies.0.in_paper: Invalid option: expected one of "yes"|"no"|"registry"',
|
|
119
|
+
],
|
|
120
|
+
["date", "15.10.2026", "studies.0.date: Invalid ISO date"],
|
|
121
|
+
[
|
|
122
|
+
"title",
|
|
123
|
+
"",
|
|
124
|
+
"studies.0.title: Too small: expected string to have >=1 characters",
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
"result",
|
|
128
|
+
41,
|
|
129
|
+
"studies.0.result: Invalid input: expected string, received number",
|
|
130
|
+
],
|
|
131
|
+
])("a row whose %s is %j", (field, value, why) => {
|
|
132
|
+
expect(parse(withRow({ ...ROW, [field]: value }))).toEqual({
|
|
133
|
+
ok: false,
|
|
134
|
+
why,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("every reason, joined", () => {
|
|
139
|
+
const { question, ...row } = ROW;
|
|
140
|
+
expect(question).toBeDefined();
|
|
141
|
+
expect(parse(text({ prefix: 1, studies: [row] }))).toEqual({
|
|
142
|
+
ok: false,
|
|
143
|
+
why: "prefix: Invalid input: expected string, received number; studies.0.question: Invalid input: expected string, received undefined",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** A registry with every key above the studies. */
|
|
149
|
+
const FULL = {
|
|
150
|
+
prefix: "P1",
|
|
151
|
+
author: "A. Author",
|
|
152
|
+
requirements: [
|
|
153
|
+
{
|
|
154
|
+
id: "R1",
|
|
155
|
+
text: "Every sample is a random draw from all rules found.",
|
|
156
|
+
date: "2026-10-10",
|
|
157
|
+
source: "chat",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
measurements: [
|
|
161
|
+
{
|
|
162
|
+
id: "M1",
|
|
163
|
+
title: "Rule census",
|
|
164
|
+
rq: "RQ1",
|
|
165
|
+
role: "headline",
|
|
166
|
+
approval: {
|
|
167
|
+
by: "A. Author",
|
|
168
|
+
date: "2026-10-12",
|
|
169
|
+
quote: "This is the one.",
|
|
170
|
+
source: "review comment",
|
|
171
|
+
},
|
|
172
|
+
requirements: ["R1"],
|
|
173
|
+
frame: "all rules found",
|
|
174
|
+
},
|
|
175
|
+
{ id: "M2", title: "Baseline", role: "baseline" },
|
|
176
|
+
],
|
|
177
|
+
studies: [{ ...ROW, measurement: "M1", plan: "plan.json" }],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
describe("the keys above the studies: author, requirements, measurements", () => {
|
|
181
|
+
it("a registry with all of them parses to itself, a measurement's extra fields kept", () => {
|
|
182
|
+
expect(parse(text(FULL))).toEqual({ ok: true, registry: FULL });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("refusing what is not a measurement, a requirement or a plan path", () => {
|
|
187
|
+
it.each([
|
|
188
|
+
[
|
|
189
|
+
"a role that is neither",
|
|
190
|
+
{ ...FULL, measurements: [{ id: "M1", title: "x", role: "main" }] },
|
|
191
|
+
'measurements.0.role: Invalid option: expected one of "headline"|"baseline"',
|
|
192
|
+
],
|
|
193
|
+
[
|
|
194
|
+
"an approval without the author's words",
|
|
195
|
+
{
|
|
196
|
+
...FULL,
|
|
197
|
+
measurements: [
|
|
198
|
+
{
|
|
199
|
+
id: "M1",
|
|
200
|
+
title: "x",
|
|
201
|
+
role: "headline",
|
|
202
|
+
approval: { by: "A", date: "2026-10-12" },
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
"measurements.0.approval.quote: Invalid input: expected string, received undefined",
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
"a requirement with a key of its own",
|
|
210
|
+
{ ...FULL, requirements: [{ ...FULL.requirements[0], note: "x" }] },
|
|
211
|
+
'requirements.0: Unrecognized key: "note"',
|
|
212
|
+
],
|
|
213
|
+
[
|
|
214
|
+
"a row's plan that is not a path",
|
|
215
|
+
{ ...FULL, studies: [{ ...ROW, plan: true }] },
|
|
216
|
+
"studies.0.plan: Invalid input: expected string, received boolean",
|
|
217
|
+
],
|
|
218
|
+
])("refuses %s", (_why, v, why) => {
|
|
219
|
+
expect(parse(text(v))).toEqual({ ok: false, why });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/** A filled plan of a seeded draw, with keys of the paper's own in `extra`. */
|
|
224
|
+
const parsePlan = studyRegistryCodec.parsePlan;
|
|
225
|
+
const PLAN = {
|
|
226
|
+
question: "How many rules can a linter check?",
|
|
227
|
+
population: {
|
|
228
|
+
source: "the crawl of 2026-10-01",
|
|
229
|
+
draw: { kind: "seeded", method: "random", seed: 7 },
|
|
230
|
+
},
|
|
231
|
+
arms: [{ name: "with the linter" }, { name: "without" }],
|
|
232
|
+
primary_outcome: "the share of rules checked",
|
|
233
|
+
author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
|
|
234
|
+
extra: { hypotheses: ["H1"], draw_script: "tools/draw.py" },
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** `PLAN` with `over` merged in, as the text of a file. */
|
|
238
|
+
const planWith = (over: Readonly<Record<string, unknown>>): string =>
|
|
239
|
+
JSON.stringify({ ...PLAN, ...over });
|
|
240
|
+
|
|
241
|
+
describe("parsing a study's plan: ready or draft", () => {
|
|
242
|
+
it("a filled plan is ready, its `extra` kept as it is", () => {
|
|
243
|
+
expect(parsePlan(JSON.stringify(PLAN))).toEqual({
|
|
244
|
+
kind: "ready",
|
|
245
|
+
plan: PLAN,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("a census draws all: no seed", () => {
|
|
250
|
+
const census = {
|
|
251
|
+
...PLAN,
|
|
252
|
+
population: {
|
|
253
|
+
source: "every rule found",
|
|
254
|
+
draw: { kind: "all", method: "census" },
|
|
255
|
+
},
|
|
256
|
+
arms: [],
|
|
257
|
+
};
|
|
258
|
+
expect(parsePlan(JSON.stringify(census))).toEqual({
|
|
259
|
+
kind: "ready",
|
|
260
|
+
plan: census,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("the template studies new writes is a draft of a census, naming every empty text", () => {
|
|
265
|
+
expect(planTemplate().population.draw).toEqual({ kind: "all", method: "" });
|
|
266
|
+
expect(parsePlan(planText(planTemplate()))).toEqual({
|
|
267
|
+
kind: "draft",
|
|
268
|
+
plan: planTemplate(),
|
|
269
|
+
empty: [
|
|
270
|
+
"question",
|
|
271
|
+
"population.source",
|
|
272
|
+
"population.draw.method",
|
|
273
|
+
"primary_outcome",
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("a text of white space only is empty", () => {
|
|
279
|
+
const plan = { ...PLAN, primary_outcome: " ", author_check: null };
|
|
280
|
+
expect(parsePlan(JSON.stringify(plan))).toEqual({
|
|
281
|
+
kind: "draft",
|
|
282
|
+
plan,
|
|
283
|
+
empty: ["primary_outcome"],
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("parsing a study's plan: no contradictory state", () => {
|
|
289
|
+
it.each([
|
|
290
|
+
[
|
|
291
|
+
"an author_check on a draft: the author approved a plan with empty texts",
|
|
292
|
+
planWith({ question: "", primary_outcome: " " }),
|
|
293
|
+
"author_check: is set, and the plan leaves `question`, `primary_outcome` empty — the author approves a complete plan: fill them, or set author_check to null",
|
|
294
|
+
],
|
|
295
|
+
[
|
|
296
|
+
"a seeded draw without its seed",
|
|
297
|
+
planWith({
|
|
298
|
+
population: {
|
|
299
|
+
source: "x",
|
|
300
|
+
draw: { kind: "seeded", method: "random" },
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
"population.draw.seed: Invalid input",
|
|
304
|
+
],
|
|
305
|
+
[
|
|
306
|
+
"a seeded draw whose seed is null",
|
|
307
|
+
planWith({
|
|
308
|
+
population: {
|
|
309
|
+
source: "x",
|
|
310
|
+
draw: { kind: "seeded", method: "random", seed: null },
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
"population.draw.seed: Invalid input",
|
|
314
|
+
],
|
|
315
|
+
[
|
|
316
|
+
"a draw of all that carries a seed",
|
|
317
|
+
planWith({
|
|
318
|
+
population: {
|
|
319
|
+
source: "x",
|
|
320
|
+
draw: { kind: "all", method: "census", seed: 7 },
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
'population.draw: Unrecognized key: "seed"',
|
|
324
|
+
],
|
|
325
|
+
[
|
|
326
|
+
"a draw of no known kind",
|
|
327
|
+
planWith({
|
|
328
|
+
population: { source: "x", draw: { method: "census" } },
|
|
329
|
+
}),
|
|
330
|
+
"population.draw.kind: Invalid discriminator value. Expected 'seeded' | 'all'",
|
|
331
|
+
],
|
|
332
|
+
])("refuses %s", (_why, t, why) => {
|
|
333
|
+
expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("parsing a study's plan: no name or fact said twice", () => {
|
|
338
|
+
it.each([
|
|
339
|
+
[
|
|
340
|
+
"two arms of one name",
|
|
341
|
+
planWith({ arms: [{ name: "a" }, { name: "b" }, { name: "a" }] }),
|
|
342
|
+
"arms: more than one arm is named «a» — an arm's name is unique",
|
|
343
|
+
],
|
|
344
|
+
[
|
|
345
|
+
"a key in `extra` that is a field of the plan",
|
|
346
|
+
planWith({ extra: { hypotheses: ["H1"], question: "x" } }),
|
|
347
|
+
"extra.question: is a field of the plan itself — set it there, not in extra",
|
|
348
|
+
],
|
|
349
|
+
[
|
|
350
|
+
"a population size: it is derived, never stored",
|
|
351
|
+
planWith({ population: { ...PLAN.population, size: 4210 } }),
|
|
352
|
+
'population: Unrecognized key: "size"',
|
|
353
|
+
],
|
|
354
|
+
])("refuses %s", (_why, t, why) => {
|
|
355
|
+
expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("parsing a study's plan: strict at every level", () => {
|
|
360
|
+
it.each([
|
|
361
|
+
["text that is not JSON", "{", "not JSON"],
|
|
362
|
+
[
|
|
363
|
+
"a misspelt key at the top",
|
|
364
|
+
planWith({ primary_outcom: "x" }),
|
|
365
|
+
'(top): Unrecognized key: "primary_outcom"',
|
|
366
|
+
],
|
|
367
|
+
[
|
|
368
|
+
"a misspelt key in the draw",
|
|
369
|
+
planWith({
|
|
370
|
+
population: {
|
|
371
|
+
...PLAN.population,
|
|
372
|
+
draw: { kind: "seeded", method: "random", seed: 7, sed: 7 },
|
|
373
|
+
},
|
|
374
|
+
}),
|
|
375
|
+
'population.draw: Unrecognized key: "sed"',
|
|
376
|
+
],
|
|
377
|
+
[
|
|
378
|
+
"a key of the paper's own outside `extra`",
|
|
379
|
+
planWith({ hypotheses: ["H1"] }),
|
|
380
|
+
'(top): Unrecognized key: "hypotheses"',
|
|
381
|
+
],
|
|
382
|
+
[
|
|
383
|
+
"no author_check: null is the one way to say none yet",
|
|
384
|
+
planWith({ author_check: undefined }),
|
|
385
|
+
"author_check: Invalid input: expected object, received undefined",
|
|
386
|
+
],
|
|
387
|
+
[
|
|
388
|
+
"an author_check with an empty quote",
|
|
389
|
+
planWith({ author_check: { ...PLAN.author_check, quote: "" } }),
|
|
390
|
+
"author_check.quote: Too small: expected string to have >=1 characters",
|
|
391
|
+
],
|
|
392
|
+
[
|
|
393
|
+
"an arm with a key besides its name",
|
|
394
|
+
planWith({ arms: [{ name: "a", model: "m" }] }),
|
|
395
|
+
'arms.0: Unrecognized key: "model"',
|
|
396
|
+
],
|
|
397
|
+
])("refuses %s", (_why, t, why) => {
|
|
398
|
+
expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/** A plan under its old name, written by hand: keys of its own, a size, no seed, no check. */
|
|
403
|
+
const parseLegacyPlan = studyRegistryCodec.parseLegacyPlan;
|
|
404
|
+
const OLD = {
|
|
405
|
+
question: "How many rules can a linter check?",
|
|
406
|
+
population: {
|
|
407
|
+
source: "the crawl of 2026-10-01",
|
|
408
|
+
about: "rules in instruction files",
|
|
409
|
+
size: 4210,
|
|
410
|
+
draw: { method: "random", script: "tools/draw.py" },
|
|
411
|
+
},
|
|
412
|
+
arms: [{ name: "with the linter", model: "m" }],
|
|
413
|
+
primary_outcome: "",
|
|
414
|
+
hypotheses: ["H1"],
|
|
415
|
+
strata: { by: "type" },
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
describe("parsing a plan under its old name, prereg.json: loose", () => {
|
|
419
|
+
it("keys of its own accepted, the top-level ones kept in `extra`; size dropped; no seed and no check: a draw of all, null", () => {
|
|
420
|
+
expect(parseLegacyPlan(JSON.stringify(OLD))).toEqual({
|
|
421
|
+
kind: "draft",
|
|
422
|
+
plan: {
|
|
423
|
+
question: "How many rules can a linter check?",
|
|
424
|
+
population: {
|
|
425
|
+
source: "the crawl of 2026-10-01",
|
|
426
|
+
draw: { kind: "all", method: "random" },
|
|
427
|
+
},
|
|
428
|
+
arms: [{ name: "with the linter" }],
|
|
429
|
+
primary_outcome: "",
|
|
430
|
+
author_check: null,
|
|
431
|
+
extra: { hypotheses: ["H1"], strata: { by: "type" } },
|
|
432
|
+
},
|
|
433
|
+
empty: ["primary_outcome"],
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe("parsing a plan under its old name, prereg.json: its draw", () => {
|
|
439
|
+
it("a seed, and a check: a seeded draw, the check kept", () => {
|
|
440
|
+
const old = {
|
|
441
|
+
...OLD,
|
|
442
|
+
population: {
|
|
443
|
+
source: "s",
|
|
444
|
+
draw: { method: "random", seed: "a1" },
|
|
445
|
+
},
|
|
446
|
+
primary_outcome: "share",
|
|
447
|
+
author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
|
|
448
|
+
};
|
|
449
|
+
expect(parseLegacyPlan(JSON.stringify(old))).toMatchObject({
|
|
450
|
+
kind: "ready",
|
|
451
|
+
plan: {
|
|
452
|
+
population: {
|
|
453
|
+
source: "s",
|
|
454
|
+
draw: { kind: "seeded", method: "random", seed: "a1" },
|
|
455
|
+
},
|
|
456
|
+
author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("a seed of null is a draw of all", () => {
|
|
462
|
+
const old = {
|
|
463
|
+
...OLD,
|
|
464
|
+
population: { source: "s", draw: { method: "census", seed: null } },
|
|
465
|
+
};
|
|
466
|
+
expect(parseLegacyPlan(JSON.stringify(old))).toMatchObject({
|
|
467
|
+
plan: { population: { draw: { kind: "all", method: "census" } } },
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("still needs the plan's own fields", () => {
|
|
472
|
+
expect(parseLegacyPlan(JSON.stringify({ hypotheses: ["H1"] }))).toEqual({
|
|
473
|
+
kind: "unreadable",
|
|
474
|
+
why: "question: Invalid input: expected string, received undefined; population: Invalid input: expected object, received undefined; arms: Invalid input: expected array, received undefined; primary_outcome: Invalid input: expected string, received undefined",
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("text that is not JSON", () => {
|
|
479
|
+
expect(parseLegacyPlan("{")).toEqual({
|
|
480
|
+
kind: "unreadable",
|
|
481
|
+
why: "not JSON",
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
});
|