paperlint 10.2.0 → 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/dist/adapters/study-registry/index.d.ts +352 -1
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +171 -8
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -4
- package/dist/cli.js.map +1 -1
- package/dist/domain/study-registry.d.ts +201 -5
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +224 -2
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/ports/study-registry-codec.d.ts +6 -2
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +7 -4
- package/dist/studies.js.map +1 -1
- package/dist/study-registry-rule.d.ts +12 -5
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +67 -37
- package/dist/study-registry-rule.js.map +1 -1
- package/docs/design/study-registry.md +131 -15
- package/docs/layout.md +23 -21
- package/docs/rules/repro/study-plan-name.md +70 -0
- package/docs/rules/repro/study-registered.md +164 -6
- package/docs/rules.md +53 -52
- package/package.json +1 -1
- package/skills/build-benchmark/SKILL.md +24 -12
- package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
- package/skills/build-label-page/SKILL.md +9 -7
- package/skills/build-label-page/SKILL.md.spec.ts +8 -6
- package/skills/run-agent-grid/SKILL.md +10 -5
- package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
- package/src/adapters/study-registry/index.test.ts +340 -0
- package/src/adapters/study-registry/index.ts +268 -10
- package/src/cli.ts +6 -4
- package/src/domain/study-measurements.test.ts +629 -0
- package/src/domain/study-registry.test.ts +10 -1
- package/src/domain/study-registry.ts +456 -10
- package/src/new-paper.test.ts +1 -0
- package/src/ports/study-registry-codec.ts +6 -2
- package/src/rule-cases.test.ts +27 -0
- package/src/studies-cli.test.ts +2 -2
- package/src/studies.test.ts +6 -0
- package/src/studies.ts +12 -3
- package/src/study-registry-rule.test.ts +140 -1
- package/src/study-registry-rule.ts +106 -39
|
@@ -5,7 +5,7 @@ context: fork
|
|
|
5
5
|
allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Agent]
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
<!-- vigiles:sha256:
|
|
8
|
+
<!-- vigiles:sha256:ed855c5f3aa34b10 compiled from skills/build-benchmark/SKILL.md.spec.ts -->
|
|
9
9
|
|
|
10
10
|
# build-benchmark — the study + the artifact reviewers can run
|
|
11
11
|
|
|
@@ -44,10 +44,19 @@ Every study gets its folder, its id and its row from one command, never from a f
|
|
|
44
44
|
npx paperlint studies new <paper-dir> "<plain title, at most 8 words>" --kind measurement
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
It takes the next id in `repro/studies.json`, creates `repro/<id>-<slug>/README.md
|
|
48
|
-
(`status: designing`) and regenerates the index
|
|
49
|
-
`spike`, `tooling`, `data`, `artifact`, `salvage`.
|
|
50
|
-
row at once.
|
|
47
|
+
It takes the next id in `repro/studies.json`, creates `repro/<id>-<slug>/README.md` and the study's
|
|
48
|
+
plan, `plan.json`, appends the row (`status: designing`, `plan: "plan.json"`) and regenerates the index
|
|
49
|
+
in `repro/README.md`. The kinds: `measurement`, `spike`, `tooling`, `data`, `artifact`, `salvage`.
|
|
50
|
+
Write the study's one-sentence `question` into its row at once. Never write a plan by hand under
|
|
51
|
+
another name: the template's fields are the ones `repro/study-registered` reads.
|
|
52
|
+
|
|
53
|
+
**A measurement that spans studies** (a pilot, the main run, a replication) is declared once, in
|
|
54
|
+
`measurements` at the top of `repro/studies.json` — `id`, `title`, `rq`, `role` (`headline` or
|
|
55
|
+
`baseline`) — and each of its studies' rows names it in `measurement`. The author's requirements on
|
|
56
|
+
the measurements go, verbatim, into `requirements` (`id`, `text`, `date`, `source`), and each
|
|
57
|
+
measurement lists the ones it must meet; never in a separate note. A `headline` measurement carries
|
|
58
|
+
the author's `approval` (`by` the registry's `author`, `date`, their words in `quote`), written
|
|
59
|
+
from the author's own message, never by the agent that designed the study.
|
|
51
60
|
|
|
52
61
|
As the study moves, edit its row in `repro/studies.json`: `running` when the first counted run starts;
|
|
53
62
|
when it ends, `done` with `result` (one line) and `result_file` (the file inside the folder the result
|
|
@@ -73,13 +82,16 @@ Write the design as the answer to "what would make the critiqued pitfall impossi
|
|
|
73
82
|
**Declare the population, the screens and the coverage before freezing the sample.** Each screen
|
|
74
83
|
between the population and the sample (fits one excerpt, one classifier lane, a model-picked item)
|
|
75
84
|
changes what the sample can say, not only its size; screens that are reasonable one by one can leave a
|
|
76
|
-
sample nobody meets in practice. So, before the draw is frozen, the study's `
|
|
77
|
-
the
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
sample nobody meets in practice. So, before the draw is frozen, fill the study's `plan.json`: the
|
|
86
|
+
`question`, the `population` (its `source`, and the `draw`: `{ "kind": "all", "method" }` for a census,
|
|
87
|
+
or `{ "kind": "seeded", "method", "seed" }` for a sample),
|
|
88
|
+
the `arms` (`[]` for a census) and the `primary_outcome`; what each screen removed per stratum and
|
|
89
|
+
what share the sample can represent go into the same file, under `extra` (the plan is closed: any
|
|
90
|
+
other unknown key is an error). Then show the
|
|
91
|
+
author the drawn **units** (the rules or cases, not the items made from them), ask *is this what you
|
|
92
|
+
meet in practice?*, and record the answer in the plan's `author_check` (`by`, `date`, their words in
|
|
93
|
+
`quote`). Once the study is `running`, `repro/study-registered` fails the lint on an empty plan field,
|
|
94
|
+
and, for a study of a `headline` measurement, on a plan without the author's check.
|
|
83
95
|
|
|
84
96
|
## 2. Honest statistics — the part reviewers attack first
|
|
85
97
|
|
|
@@ -50,10 +50,19 @@ Every study gets its folder, its id and its row from one command, never from a f
|
|
|
50
50
|
npx paperlint studies new <paper-dir> "<plain title, at most 8 words>" --kind measurement
|
|
51
51
|
\`\`\`
|
|
52
52
|
|
|
53
|
-
It takes the next id in \`repro/studies.json\`, creates \`repro/<id>-<slug>/README.md
|
|
54
|
-
(\`status: designing\`) and regenerates the index
|
|
55
|
-
\`spike\`, \`tooling\`, \`data\`, \`artifact\`, \`salvage\`.
|
|
56
|
-
row at once.
|
|
53
|
+
It takes the next id in \`repro/studies.json\`, creates \`repro/<id>-<slug>/README.md\` and the study's
|
|
54
|
+
plan, \`plan.json\`, appends the row (\`status: designing\`, \`plan: "plan.json"\`) and regenerates the index
|
|
55
|
+
in \`repro/README.md\`. The kinds: \`measurement\`, \`spike\`, \`tooling\`, \`data\`, \`artifact\`, \`salvage\`.
|
|
56
|
+
Write the study's one-sentence \`question\` into its row at once. Never write a plan by hand under
|
|
57
|
+
another name: the template's fields are the ones \`repro/study-registered\` reads.
|
|
58
|
+
|
|
59
|
+
**A measurement that spans studies** (a pilot, the main run, a replication) is declared once, in
|
|
60
|
+
\`measurements\` at the top of \`repro/studies.json\` — \`id\`, \`title\`, \`rq\`, \`role\` (\`headline\` or
|
|
61
|
+
\`baseline\`) — and each of its studies' rows names it in \`measurement\`. The author's requirements on
|
|
62
|
+
the measurements go, verbatim, into \`requirements\` (\`id\`, \`text\`, \`date\`, \`source\`), and each
|
|
63
|
+
measurement lists the ones it must meet; never in a separate note. A \`headline\` measurement carries
|
|
64
|
+
the author's \`approval\` (\`by\` the registry's \`author\`, \`date\`, their words in \`quote\`), written
|
|
65
|
+
from the author's own message, never by the agent that designed the study.
|
|
57
66
|
|
|
58
67
|
As the study moves, edit its row in \`repro/studies.json\`: \`running\` when the first counted run starts;
|
|
59
68
|
when it ends, \`done\` with \`result\` (one line) and \`result_file\` (the file inside the folder the result
|
|
@@ -79,13 +88,16 @@ Write the design as the answer to "what would make the critiqued pitfall impossi
|
|
|
79
88
|
**Declare the population, the screens and the coverage before freezing the sample.** Each screen
|
|
80
89
|
between the population and the sample (fits one excerpt, one classifier lane, a model-picked item)
|
|
81
90
|
changes what the sample can say, not only its size; screens that are reasonable one by one can leave a
|
|
82
|
-
sample nobody meets in practice. So, before the draw is frozen, the study's \`
|
|
83
|
-
the
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
sample nobody meets in practice. So, before the draw is frozen, fill the study's \`plan.json\`: the
|
|
92
|
+
\`question\`, the \`population\` (its \`source\`, and the \`draw\`: \`{ "kind": "all", "method" }\` for a census,
|
|
93
|
+
or \`{ "kind": "seeded", "method", "seed" }\` for a sample),
|
|
94
|
+
the \`arms\` (\`[]\` for a census) and the \`primary_outcome\`; what each screen removed per stratum and
|
|
95
|
+
what share the sample can represent go into the same file, under \`extra\` (the plan is closed: any
|
|
96
|
+
other unknown key is an error). Then show the
|
|
97
|
+
author the drawn **units** (the rules or cases, not the items made from them), ask *is this what you
|
|
98
|
+
meet in practice?*, and record the answer in the plan's \`author_check\` (\`by\`, \`date\`, their words in
|
|
99
|
+
\`quote\`). Once the study is \`running\`, \`repro/study-registered\` fails the lint on an empty plan field,
|
|
100
|
+
and, for a study of a \`headline\` measurement, on a plan without the author's check.
|
|
89
101
|
|
|
90
102
|
## 2. Honest statistics — the part reviewers attack first
|
|
91
103
|
|
|
@@ -4,7 +4,7 @@ description: Build a phone labelling page published as a claude.ai Artifact with
|
|
|
4
4
|
allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Artifact, ArtifactData]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
<!-- vigiles:sha256:
|
|
7
|
+
<!-- vigiles:sha256:dd742c1fcb34cfd6 compiled from skills/build-label-page/SKILL.md.spec.ts -->
|
|
8
8
|
|
|
9
9
|
# build-label-page — a page the person labels on a phone, and the labels back in the repo
|
|
10
10
|
|
|
@@ -49,15 +49,17 @@ The full text, each with its reason and its check, is
|
|
|
49
49
|
fit the time they have on a phone? Who is the labeller: a page with a store opens only for people
|
|
50
50
|
with access to the artifact, so the labeller is the owner or has been shared as a Contributor.
|
|
51
51
|
If the study design is not written yet, `build-benchmark` comes first.
|
|
52
|
-
If the items come from a drawn sample, the study's `
|
|
53
|
-
|
|
54
|
-
author has been shown the drawn **units** (the rules or cases, not
|
|
55
|
-
you meet in practice?". The labelling page is not that check: items
|
|
56
|
-
fine on their own.
|
|
52
|
+
If the items come from a drawn sample, the study's `plan.json` (written by
|
|
53
|
+
`paperlint studies new`) declares the population and its draw before the sample is frozen, and its
|
|
54
|
+
`author_check` records that the author has been shown the drawn **units** (the rules or cases, not
|
|
55
|
+
the items) with "is this what you meet in practice?". The labelling page is not that check: items
|
|
56
|
+
made from easy units each look fine on their own.
|
|
57
57
|
2. **Copy the generator into the study and write the items.** The study folder is
|
|
58
58
|
`<paper-dir>/repro/<id>-<slug>/`, made by `npx paperlint studies new <paper-dir> "Gold labels"
|
|
59
59
|
--kind data` when the labelling is a study of its own (`P1-S04-gold-labels`): the command writes
|
|
60
|
-
its `README.md` and its row in `repro/studies.json`, and `repro/study-registered`
|
|
60
|
+
its `README.md`, its `plan.json` and its row in `repro/studies.json`, and `repro/study-registered`
|
|
61
|
+
checks them; fill the plan (what is labelled, the items' source, `arms: []`, the primary outcome)
|
|
62
|
+
before the first label. When
|
|
61
63
|
the labels are in, update the row (`status`, `result`, `result_file`) and run
|
|
62
64
|
`npx paperlint studies index <paper-dir>`. Where this sits in stage 3, and what checks each place:
|
|
63
65
|
`docs/layout.md` in the paperlint package.
|
|
@@ -59,15 +59,17 @@ The full text, each with its reason and its check, is
|
|
|
59
59
|
fit the time they have on a phone? Who is the labeller: a page with a store opens only for people
|
|
60
60
|
with access to the artifact, so the labeller is the owner or has been shared as a Contributor.
|
|
61
61
|
If the study design is not written yet, \`build-benchmark\` comes first.
|
|
62
|
-
If the items come from a drawn sample, the study's \`
|
|
63
|
-
|
|
64
|
-
author has been shown the drawn **units** (the rules or cases, not
|
|
65
|
-
you meet in practice?". The labelling page is not that check: items
|
|
66
|
-
fine on their own.
|
|
62
|
+
If the items come from a drawn sample, the study's \`plan.json\` (written by
|
|
63
|
+
\`paperlint studies new\`) declares the population and its draw before the sample is frozen, and its
|
|
64
|
+
\`author_check\` records that the author has been shown the drawn **units** (the rules or cases, not
|
|
65
|
+
the items) with "is this what you meet in practice?". The labelling page is not that check: items
|
|
66
|
+
made from easy units each look fine on their own.
|
|
67
67
|
2. **Copy the generator into the study and write the items.** The study folder is
|
|
68
68
|
\`<paper-dir>/repro/<id>-<slug>/\`, made by \`npx paperlint studies new <paper-dir> "Gold labels"
|
|
69
69
|
--kind data\` when the labelling is a study of its own (\`P1-S04-gold-labels\`): the command writes
|
|
70
|
-
its \`README.md\` and its row in \`repro/studies.json\`, and \`repro/study-registered\`
|
|
70
|
+
its \`README.md\`, its \`plan.json\` and its row in \`repro/studies.json\`, and \`repro/study-registered\`
|
|
71
|
+
checks them; fill the plan (what is labelled, the items' source, \`arms: []\`, the primary outcome)
|
|
72
|
+
before the first label. When
|
|
71
73
|
the labels are in, update the row (\`status\`, \`result\`, \`result_file\`) and run
|
|
72
74
|
\`npx paperlint studies index <paper-dir>\`. Where this sits in stage 3, and what checks each place:
|
|
73
75
|
\`docs/layout.md\` in the paperlint package.
|
|
@@ -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
|
|
|
@@ -25,10 +25,15 @@ and a driver loop that calls the guard: [`references/registry-and-ledger.md`](re
|
|
|
25
25
|
|
|
26
26
|
**The grid's study folder.** A grid is one study of the paper. Its folder is made by
|
|
27
27
|
`npx paperlint studies new <paper-dir> "<title>" --kind measurement` — the next id in
|
|
28
|
-
`repro/studies.json`, the folder `repro/<id>-<slug>/` — and `grid.json`,
|
|
29
|
-
|
|
30
|
-
`
|
|
31
|
-
|
|
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.
|
|
32
37
|
|
|
33
38
|
## The order — each gate is passed before the next phase starts
|
|
34
39
|
|
|
@@ -26,10 +26,15 @@ and a driver loop that calls the guard: [\`references/registry-and-ledger.md\`](
|
|
|
26
26
|
|
|
27
27
|
**The grid's study folder.** A grid is one study of the paper. Its folder is made by
|
|
28
28
|
\`npx paperlint studies new <paper-dir> "<title>" --kind measurement\` — the next id in
|
|
29
|
-
\`repro/studies.json\`, the folder \`repro/<id>-<slug>/\` — and \`grid.json\`,
|
|
30
|
-
|
|
31
|
-
\`
|
|
32
|
-
|
|
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.
|
|
33
38
|
|
|
34
39
|
## The order — each gate is passed before the next phase starts
|
|
35
40
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
6
6
|
import {
|
|
7
|
+
planTemplate,
|
|
8
|
+
planText,
|
|
7
9
|
registryText,
|
|
8
10
|
type StudyRegistry,
|
|
9
11
|
} from "../../domain/study-registry.ts";
|
|
@@ -142,3 +144,341 @@ describe("refusing a row that is not a study", () => {
|
|
|
142
144
|
});
|
|
143
145
|
});
|
|
144
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
|
+
});
|