paperlint 13.1.0 → 13.2.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/CLAUDE.md +9 -1
- package/dist/adapters/git/index.d.ts.map +1 -1
- package/dist/adapters/git/index.js +13 -0
- package/dist/adapters/git/index.js.map +1 -1
- package/dist/adapters/study-registry/index.d.ts +142 -1
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +160 -1
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +43 -0
- package/dist/domain/decide-first.d.ts.map +1 -1
- package/dist/domain/decide-first.js +45 -2
- package/dist/domain/decide-first.js.map +1 -1
- package/dist/domain/study-gates.d.ts +96 -0
- package/dist/domain/study-gates.d.ts.map +1 -0
- package/dist/domain/study-gates.js +56 -0
- package/dist/domain/study-gates.js.map +1 -0
- package/dist/domain/study-mode.d.ts +6 -0
- package/dist/domain/study-mode.d.ts.map +1 -1
- package/dist/domain/study-mode.js.map +1 -1
- package/dist/domain/study-registry.d.ts +24 -4
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +39 -2
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/domain/study-result.d.ts +151 -0
- package/dist/domain/study-result.d.ts.map +1 -0
- package/dist/domain/study-result.js +175 -0
- package/dist/domain/study-result.js.map +1 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -1
- package/dist/outputs-before-decision-rule.js +37 -3
- package/dist/outputs-before-decision-rule.js.map +1 -1
- package/dist/ports/git-history.d.ts +12 -0
- package/dist/ports/git-history.d.ts.map +1 -1
- package/dist/ports/study-registry-codec.d.ts +3 -0
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
- package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
- package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
- package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
- package/dist/study-modes.d.ts +3 -0
- package/dist/study-modes.d.ts.map +1 -1
- package/dist/study-modes.js +8 -1
- package/dist/study-modes.js.map +1 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +1 -0
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-result-rule.d.ts +12 -0
- package/dist/study-result-rule.d.ts.map +1 -0
- package/dist/study-result-rule.js +87 -0
- package/dist/study-result-rule.js.map +1 -0
- package/docs/design/study-gates.md +208 -0
- package/docs/rules/repro/outputs-before-decision.md +8 -0
- package/docs/rules/repro/study-registered.md +38 -15
- package/docs/rules/repro/study-result.md +100 -0
- package/docs/rules.md +1 -0
- package/package.json +2 -1
- package/schemas/plan.v2.schema.json +341 -0
- package/skills/build-label-page/SKILL.md +85 -54
- package/skills/build-label-page/SKILL.md.spec.ts +85 -53
- package/skills/build-label-page/build-label-page.test.ts +10 -941
- package/skills/build-label-page/references/checklist.md +45 -3
- package/skills/build-label-page/references/closed-choice-generator.md +88 -0
- package/skills/build-label-page/scripts/label-page-cli.js +72 -0
- package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
- package/skills/build-label-page/scripts/label-page-commands.js +14 -0
- package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
- package/skills/build-label-page/scripts/label-page-core.js +850 -0
- package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
- package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
- package/skills/build-label-page/scripts/label-page-html.js +33 -0
- package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
- package/skills/build-label-page/scripts/label-page-html.ts +43 -0
- package/skills/build-label-page/scripts/label-page.js +70 -0
- package/skills/build-label-page/scripts/label-page.test.ts +71 -0
- package/skills/build-label-page/scripts/label-page.ts +71 -0
- package/skills/build-label-page/scripts/page-template.html +513 -0
- package/src/adapters/git/index.test.ts +33 -0
- package/src/adapters/git/index.ts +19 -0
- package/src/adapters/study-registry/gates.test.ts +181 -0
- package/src/adapters/study-registry/index.ts +219 -1
- package/src/adapters/study-registry/mode.test.ts +6 -0
- package/src/cli.ts +3 -0
- package/src/domain/decide-first.test.ts +131 -0
- package/src/domain/decide-first.ts +92 -2
- package/src/domain/study-gates.ts +125 -0
- package/src/domain/study-measurements.test.ts +71 -0
- package/src/domain/study-mode.ts +6 -0
- package/src/domain/study-registry.ts +64 -2
- package/src/domain/study-result.test.ts +300 -0
- package/src/domain/study-result.ts +358 -0
- package/src/outputs-before-decision-rule.test.ts +115 -2
- package/src/outputs-before-decision-rule.ts +50 -2
- package/src/ports/git-history.ts +12 -0
- package/src/ports/study-registry-codec.ts +3 -0
- package/src/rule-cases.test.ts +74 -0
- package/src/studies.test.ts +3 -0
- package/src/study-modes.ts +10 -1
- package/src/study-registry-rule.ts +1 -0
- package/src/study-result-rule.test.ts +281 -0
- package/src/study-result-rule.ts +123 -0
- package/skills/build-label-page/references/example-generator.py +0 -803
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
* - a history that cannot be read: no git, or a shallow clone, whose every file starts at the clone.
|
|
13
13
|
*
|
|
14
14
|
* Outputs that were committed and later deleted count: an output that existed before the decision is
|
|
15
|
-
* evidence after it is gone.
|
|
15
|
+
* evidence after it is gone.
|
|
16
|
+
*
|
|
17
|
+
* A plan v2's gates (src/domain/study-gates.ts) are frozen by the same history: the plan as it stood
|
|
18
|
+
* at the commit of the study's first output must declare them exactly as HEAD's does. A gate changed
|
|
19
|
+
* since is `gates-changed` (a threshold moved after the result was seen); a plan absent from that
|
|
20
|
+
* commit, or one without gates there (v1, or unreadable), is `gates-not-fixed`. Pure.
|
|
16
21
|
*/
|
|
22
|
+
import { isDeepEqual } from "remeda";
|
|
17
23
|
import { README } from "./repro-layout.ts";
|
|
24
|
+
import { GATE_NAMES, type PlanGates } from "./study-gates.ts";
|
|
18
25
|
import {
|
|
19
26
|
decisionAsOutput,
|
|
20
27
|
inStudy,
|
|
@@ -68,6 +75,23 @@ export type OrderFacts =
|
|
|
68
75
|
readonly files: readonly FirstCommit[];
|
|
69
76
|
};
|
|
70
77
|
|
|
78
|
+
/** The plan as it stood at the commit of the study's first output, as git gave it. */
|
|
79
|
+
export type PlanThen =
|
|
80
|
+
| { readonly kind: "gates"; readonly gates: PlanGates }
|
|
81
|
+
/** No gates were fixed there: no plan at that commit, a plan v1, or a text that is no plan. */
|
|
82
|
+
| {
|
|
83
|
+
readonly kind: "no-gates";
|
|
84
|
+
readonly why: "absent" | "v1" | "unreadable";
|
|
85
|
+
}
|
|
86
|
+
| { readonly kind: "unknown"; readonly why: string };
|
|
87
|
+
|
|
88
|
+
/** A plan v2's gates now, and the plan at the study's first output (the first that came after the decision). */
|
|
89
|
+
export interface GateHistory {
|
|
90
|
+
readonly now: PlanGates;
|
|
91
|
+
readonly output: FirstCommit;
|
|
92
|
+
readonly then: PlanThen;
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
/** What the judge reads of one decide-first study. */
|
|
72
96
|
export interface DecideFirstStudy {
|
|
73
97
|
readonly id: string;
|
|
@@ -78,6 +102,8 @@ export interface DecideFirstStudy {
|
|
|
78
102
|
/** Every file under the study's folder now, relative to it. */
|
|
79
103
|
readonly tree: readonly string[];
|
|
80
104
|
readonly facts: OrderFacts;
|
|
105
|
+
/** Only for a plan v2 with an output after its decision: nothing to freeze otherwise. */
|
|
106
|
+
readonly gates?: GateHistory | undefined;
|
|
81
107
|
}
|
|
82
108
|
|
|
83
109
|
/** One defect `repro/outputs-before-decision` reports. */
|
|
@@ -118,6 +144,24 @@ export type OrderFinding =
|
|
|
118
144
|
readonly path: string;
|
|
119
145
|
readonly decision: string;
|
|
120
146
|
}
|
|
147
|
+
| {
|
|
148
|
+
readonly problem: "gates-changed";
|
|
149
|
+
readonly id: string;
|
|
150
|
+
readonly folder: string;
|
|
151
|
+
readonly plan: string;
|
|
152
|
+
readonly gate: (typeof GATE_NAMES)[number];
|
|
153
|
+
readonly then: unknown;
|
|
154
|
+
readonly now: unknown;
|
|
155
|
+
readonly output: FirstCommit;
|
|
156
|
+
}
|
|
157
|
+
| {
|
|
158
|
+
readonly problem: "gates-not-fixed";
|
|
159
|
+
readonly id: string;
|
|
160
|
+
readonly folder: string;
|
|
161
|
+
readonly plan: string;
|
|
162
|
+
readonly why: "absent" | "v1" | "unreadable";
|
|
163
|
+
readonly output: FirstCommit;
|
|
164
|
+
}
|
|
121
165
|
| {
|
|
122
166
|
readonly problem: "output-before-decision";
|
|
123
167
|
readonly id: string;
|
|
@@ -141,6 +185,41 @@ export const outputCommits = (
|
|
|
141
185
|
files: readonly FirstCommit[],
|
|
142
186
|
): readonly FirstCommit[] => files.filter((f) => outputOf(s)(f.path));
|
|
143
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The first output of the study that entered history after its decision: the commit whose plan the
|
|
190
|
+
* gates are held to. None while no output followed the decision.
|
|
191
|
+
*/
|
|
192
|
+
export const firstOutputAfter = (
|
|
193
|
+
s: DecideFirstStudy,
|
|
194
|
+
files: readonly FirstCommit[],
|
|
195
|
+
): FirstCommit | null =>
|
|
196
|
+
outputCommits(s, files).find((f) => f.order === "after") ?? null;
|
|
197
|
+
|
|
198
|
+
/** The findings of a plan v2's gates against the plan at the study's first output. */
|
|
199
|
+
const gateFindings = (s: DecideFirstStudy): readonly OrderFinding[] => {
|
|
200
|
+
const g = s.gates;
|
|
201
|
+
if (g === undefined) return [];
|
|
202
|
+
const at = { id: s.id, folder: s.folder, plan: s.plan, output: g.output };
|
|
203
|
+
switch (g.then.kind) {
|
|
204
|
+
case "unknown":
|
|
205
|
+
return [{ problem: "no-history", id: s.id, why: g.then.why }];
|
|
206
|
+
case "no-gates":
|
|
207
|
+
return [{ problem: "gates-not-fixed", ...at, why: g.then.why }];
|
|
208
|
+
case "gates": {
|
|
209
|
+
const then = g.then.gates;
|
|
210
|
+
return GATE_NAMES.filter((n) => !isDeepEqual(then[n], g.now[n])).map(
|
|
211
|
+
(gate) => ({
|
|
212
|
+
problem: "gates-changed",
|
|
213
|
+
...at,
|
|
214
|
+
gate,
|
|
215
|
+
then: then[gate],
|
|
216
|
+
now: g.now[gate],
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
144
223
|
/** The findings of a history that was read. */
|
|
145
224
|
const ordered = (
|
|
146
225
|
s: DecideFirstStudy,
|
|
@@ -185,7 +264,7 @@ export const judgeOrder = (s: DecideFirstStudy): readonly OrderFinding[] => {
|
|
|
185
264
|
glob,
|
|
186
265
|
},
|
|
187
266
|
];
|
|
188
|
-
return [...clash, ...historyFindings(s)];
|
|
267
|
+
return [...clash, ...historyFindings(s), ...gateFindings(s)];
|
|
189
268
|
};
|
|
190
269
|
|
|
191
270
|
/** The findings of a decide-first study's history, whatever git could say of it. */
|
|
@@ -227,6 +306,13 @@ const NOT_A_FILE: Readonly<Record<NotAFile, string>> = {
|
|
|
227
306
|
"special file": "git tracks only regular files",
|
|
228
307
|
};
|
|
229
308
|
|
|
309
|
+
/** What the plan was at the first output, when it fixed no gates. */
|
|
310
|
+
const GATES_THEN: Readonly<Record<"absent" | "v1" | "unreadable", string>> = {
|
|
311
|
+
absent: "was not in history yet",
|
|
312
|
+
v1: "was a plan v1, which declares no gates,",
|
|
313
|
+
unreadable: "was no plan paperlint could read",
|
|
314
|
+
};
|
|
315
|
+
|
|
230
316
|
const short = (c: CommitAt): string => `${c.commit.slice(0, 12)} (${c.date})`;
|
|
231
317
|
|
|
232
318
|
const HEADS: {
|
|
@@ -246,6 +332,10 @@ const HEADS: {
|
|
|
246
332
|
`\`${f.id}\` is decided before its run, and this clone is shallow: every file's first commit is the clone's first, so the order of decision and outputs cannot be read. What to do: fetch the whole history (\`git fetch --unshallow\`; in GitHub Actions, \`actions/checkout\` with \`fetch-depth: 0\`).`,
|
|
247
333
|
"undecided-output": (f) =>
|
|
248
334
|
`\`repro/${f.folder}/${f.path}\` is an output of \`${f.id}\` — in the tree, or in its history — and the study is decided before its run, but its decision \`${f.decision}\` was never committed. What to do: write and commit the decision, check it with \`npx paperlint studies gate <paper-dir> ${f.id}\`, then run the study again; if the output was made before any decision, declare the study \`exploratory\` with the reason. Deleting the output does not undo it: history keeps it.`,
|
|
335
|
+
"gates-changed": (f) =>
|
|
336
|
+
`The gate \`${f.gate}\` in the plan of \`${f.id}\`, \`repro/${f.folder}/${f.plan}\`, was ${JSON.stringify(f.then)} when its first output, \`${f.output.path}\`, entered history in ${short(f.output)}, and is ${JSON.stringify(f.now)} now. A gate is fixed before the run: moved after a result exists, it can only follow the result. What to do: put the gate back as it was; if it was wrong, say so in the paper and run the study again under the corrected plan — never edit the history to make it pass.`,
|
|
337
|
+
"gates-not-fixed": (f) =>
|
|
338
|
+
`The plan of \`${f.id}\`, \`repro/${f.folder}/${f.plan}\`, ${GATES_THEN[f.why]} when its first output, \`${f.output.path}\`, entered history in ${short(f.output)}: its gates were not fixed before the run. What to do: run the study again now that the plan v2 is committed; if the outputs must stand, declare the study \`exploratory\` with the reason.`,
|
|
249
339
|
"output-before-decision": (f) =>
|
|
250
340
|
`\`repro/${f.folder}/${f.path}\`, an output of \`${f.id}\`, entered history in ${short(f.output)}, ${f.same ? "the very commit" : "not after the commit"} that first recorded its decision, ${short(f.decision)}. The study is declared decided before its run, and its history does not show that order. What to do: if the decision really came first, nothing in this history can prove it — declare the study \`exploratory\` with the reason, or run it again after the decision; never rewrite the history to make it pass.`,
|
|
251
341
|
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GATES OF A PLAN V2 — what a measurement study fixes before its run so that its number can be
|
|
3
|
+
* believed, and what its run must show after (docs/design/study-gates.md, issue #332).
|
|
4
|
+
*
|
|
5
|
+
* A plan says it is v2 by its `$schema`, `PLAN_V2`. A plan without it is v1 and is judged as before;
|
|
6
|
+
* a measurement study registered on or after `PLAN_V2_FROM` must be v2 (`repro/study-registered`).
|
|
7
|
+
*
|
|
8
|
+
* The five gates, each with ONE way to say "not this study" — the literal `"none"` — and one way to
|
|
9
|
+
* say "not decided yet" — null, which keeps the plan a draft. A key left out of a v2 plan is no plan:
|
|
10
|
+
*
|
|
11
|
+
* we_lose_if inside a decide-first mode only: the number in `result.json` and the threshold
|
|
12
|
+
* at which the study's claim loses. An exploratory study has no claim to lose, and
|
|
13
|
+
* cannot write one.
|
|
14
|
+
* second_check per labelling step, the least Cohen's kappa between two raters on it.
|
|
15
|
+
* self_test the check the study runs is first run on inputs whose answer is known: a script
|
|
16
|
+
* that makes and runs them, or one input it must catch and one it must pass.
|
|
17
|
+
* funnel the reasons a drawn unit may be dropped; the result counts each.
|
|
18
|
+
* recall_audit how complete the step is that extracts units from artefacts: a person reads
|
|
19
|
+
* `n_files` sampled files in full, and what the extractor found of the units the
|
|
20
|
+
* person found must be at least `min_recall`.
|
|
21
|
+
*
|
|
22
|
+
* After the run the study writes `result.json` (`RESULT_FILE`) beside its plan, and
|
|
23
|
+
* `repro/study-result` judges it against these (src/domain/study-result.ts). What no machine sees: a
|
|
24
|
+
* `"none"` that is false. The author's check of the plan is where that is caught. Pure.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The `$schema` of a plan v2: the JSON Schema the package ships for it. */
|
|
28
|
+
export const PLAN_V2 =
|
|
29
|
+
"https://unpkg.com/paperlint/schemas/plan.v2.schema.json";
|
|
30
|
+
|
|
31
|
+
/** A measurement study registered on this day or later must have a plan v2. */
|
|
32
|
+
export const PLAN_V2_FROM = "2026-10-31";
|
|
33
|
+
|
|
34
|
+
/** The one spelling of "this study has none of it". */
|
|
35
|
+
export const NONE = "none";
|
|
36
|
+
export type None = typeof NONE;
|
|
37
|
+
|
|
38
|
+
/** The claim loses when the number named `result` is below, or above, a threshold: exactly one. */
|
|
39
|
+
export type WeLoseIf =
|
|
40
|
+
| { readonly result: string; readonly below: number }
|
|
41
|
+
| { readonly result: string; readonly above: number };
|
|
42
|
+
|
|
43
|
+
/** One labelling step, judged by two raters: the least kappa between them it needs. */
|
|
44
|
+
export interface KappaStep {
|
|
45
|
+
readonly step: string;
|
|
46
|
+
readonly min_kappa: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SecondCheck = None | readonly KappaStep[];
|
|
50
|
+
|
|
51
|
+
/** The check is first run on known answers: made by a script, or a pair of inputs. Paths are in the study's folder. */
|
|
52
|
+
export type SelfTest =
|
|
53
|
+
| None
|
|
54
|
+
| { readonly made_by: string }
|
|
55
|
+
| { readonly must_catch: string; readonly must_pass: string };
|
|
56
|
+
|
|
57
|
+
/** The reasons a drawn unit may be dropped, each once. */
|
|
58
|
+
export type Funnel = None | readonly string[];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The audit of the extractor: how many files a person reads in full, and the least share of the units
|
|
62
|
+
* found there that the extractor must have found too.
|
|
63
|
+
*/
|
|
64
|
+
export interface RecallAuditPlan {
|
|
65
|
+
readonly n_files: number;
|
|
66
|
+
readonly min_recall: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type RecallAudit = None | RecallAuditPlan;
|
|
70
|
+
|
|
71
|
+
/** The file a study of a plan v2 writes its numbers to, beside its plan. */
|
|
72
|
+
export const RESULT_FILE = "result.json";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The five gates of a plan, as the plan declares them (null while undecided): what is frozen once the
|
|
76
|
+
* study's first output exists (`repro/outputs-before-decision`, src/domain/decide-first.ts).
|
|
77
|
+
*/
|
|
78
|
+
export interface PlanGates {
|
|
79
|
+
readonly we_lose_if: WeLoseIf | null;
|
|
80
|
+
readonly second_check: SecondCheck | null;
|
|
81
|
+
readonly self_test: SelfTest | null;
|
|
82
|
+
readonly funnel: Funnel | null;
|
|
83
|
+
readonly recall_audit: RecallAudit | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The gates' names, in the order they are reported. */
|
|
87
|
+
export const GATE_NAMES = [
|
|
88
|
+
"we_lose_if",
|
|
89
|
+
"second_check",
|
|
90
|
+
"self_test",
|
|
91
|
+
"funnel",
|
|
92
|
+
"recall_audit",
|
|
93
|
+
] as const satisfies readonly (keyof PlanGates)[];
|
|
94
|
+
|
|
95
|
+
/** A plan's gates, from the parts that hold them: none for a plan v1, which declares no gates. */
|
|
96
|
+
export const gatesOf = (plan: {
|
|
97
|
+
readonly $schema?: string | undefined;
|
|
98
|
+
readonly mode?:
|
|
99
|
+
| {
|
|
100
|
+
readonly kind: string;
|
|
101
|
+
readonly we_lose_if?: WeLoseIf | null | undefined;
|
|
102
|
+
}
|
|
103
|
+
| undefined;
|
|
104
|
+
readonly second_check?: SecondCheck | null | undefined;
|
|
105
|
+
readonly self_test?: SelfTest | null | undefined;
|
|
106
|
+
readonly funnel?: Funnel | null | undefined;
|
|
107
|
+
readonly recall_audit?: RecallAudit | null | undefined;
|
|
108
|
+
}): PlanGates | null =>
|
|
109
|
+
plan.$schema === PLAN_V2
|
|
110
|
+
? {
|
|
111
|
+
we_lose_if: plan.mode?.we_lose_if ?? null,
|
|
112
|
+
second_check: plan.second_check ?? null,
|
|
113
|
+
self_test: plan.self_test ?? null,
|
|
114
|
+
funnel: plan.funnel ?? null,
|
|
115
|
+
recall_audit: plan.recall_audit ?? null,
|
|
116
|
+
}
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
/** Whether the number `value` makes the claim lose by `w`. */
|
|
120
|
+
export const loses = (w: WeLoseIf, value: number): boolean =>
|
|
121
|
+
"below" in w ? value < w.below : value > w.above;
|
|
122
|
+
|
|
123
|
+
/** The threshold of `w`, and its side, in words: `below 0.2`. */
|
|
124
|
+
export const thresholdText = (w: WeLoseIf): string =>
|
|
125
|
+
"below" in w ? `below ${String(w.below)}` : `above ${String(w.above)}`;
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type IndexInput,
|
|
25
25
|
folderKey,
|
|
26
26
|
} from "./study-registry.ts";
|
|
27
|
+
import { PLAN_V2, PLAN_V2_FROM } from "./study-gates.ts";
|
|
27
28
|
|
|
28
29
|
/** No plan and no history: the index with every mode column `—`. */
|
|
29
30
|
const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
|
|
@@ -635,3 +636,73 @@ describe("studyMessage — the new findings stand alone", () => {
|
|
|
635
636
|
);
|
|
636
637
|
});
|
|
637
638
|
});
|
|
639
|
+
|
|
640
|
+
describe("PLAN_V2_FROM", () => {
|
|
641
|
+
it("is 2026-10-31: the first study the gates were written for was registered that day", () => {
|
|
642
|
+
expect(PLAN_V2_FROM).toBe("2026-10-31");
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
describe("plans — a measurement registered from PLAN_V2_FROM has a plan v2", () => {
|
|
647
|
+
const V2: StudyPlan = {
|
|
648
|
+
...PLAN,
|
|
649
|
+
$schema: PLAN_V2,
|
|
650
|
+
second_check: "none",
|
|
651
|
+
self_test: "none",
|
|
652
|
+
funnel: "none",
|
|
653
|
+
recall_audit: "none",
|
|
654
|
+
};
|
|
655
|
+
const onDay = (date: string, kind: Study["kind"] = "measurement") =>
|
|
656
|
+
reg({
|
|
657
|
+
studies: [study({ measurement: "M1", plan: PLAN_FILE, date, kind })],
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("a measurement registered on the day, its plan v1: reported, with what to add", () => {
|
|
661
|
+
const f = findings(onDay(PLAN_V2_FROM));
|
|
662
|
+
expect(f).toEqual([
|
|
663
|
+
{
|
|
664
|
+
problem: "plan-version",
|
|
665
|
+
id: "P1-S01",
|
|
666
|
+
path: PLAN_FILE,
|
|
667
|
+
date: PLAN_V2_FROM,
|
|
668
|
+
},
|
|
669
|
+
]);
|
|
670
|
+
expect(f.map(studyMessage).join("")).toContain(
|
|
671
|
+
`add \`"$schema": "${PLAN_V2}"\``,
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("silent on a plan v1 registered before the day, a spike's from it, and a plan v2", () => {
|
|
676
|
+
const v2 = onDay(PLAN_V2_FROM);
|
|
677
|
+
// Guards: old plans keep passing; only a measurement is asked; v2 answers it.
|
|
678
|
+
expect([
|
|
679
|
+
findings(onDay("2026-10-30")),
|
|
680
|
+
findings(onDay(PLAN_V2_FROM, "spike")),
|
|
681
|
+
findings(v2, {
|
|
682
|
+
plans: new Map([[folderKey("P1-S01-census", PLAN_FILE), read(V2)]]),
|
|
683
|
+
}),
|
|
684
|
+
]).toEqual([[], [], []]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("a draft plan v1 from the day is reported too; an unreadable one is bad-plan's alone", () => {
|
|
688
|
+
const key = folderKey("P1-S01-census", PLAN_FILE);
|
|
689
|
+
const asDraft = new Map<string, PlanState>([
|
|
690
|
+
[key, draft({ ...PLAN, author_check: null })],
|
|
691
|
+
]);
|
|
692
|
+
const unreadable = new Map<string, PlanState>([
|
|
693
|
+
[key, { kind: "unreadable", why: "x" }],
|
|
694
|
+
]);
|
|
695
|
+
const r = reg({
|
|
696
|
+
studies: [
|
|
697
|
+
study({ plan: PLAN_FILE, date: PLAN_V2_FROM, status: "designing" }),
|
|
698
|
+
],
|
|
699
|
+
measurements: [],
|
|
700
|
+
});
|
|
701
|
+
expect(findings(r, { plans: asDraft }).map((x) => x.problem)).toEqual([
|
|
702
|
+
"plan-version",
|
|
703
|
+
]);
|
|
704
|
+
expect(findings(r, { plans: unreadable }).map((x) => x.problem)).toEqual([
|
|
705
|
+
"bad-plan",
|
|
706
|
+
]);
|
|
707
|
+
});
|
|
708
|
+
});
|
package/src/domain/study-mode.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { posix } from "node:path";
|
|
19
19
|
import { REPRO_DIR } from "./repro-layout.ts";
|
|
20
20
|
import type { StudyKind } from "./study-registry.ts";
|
|
21
|
+
import type { WeLoseIf } from "./study-gates.ts";
|
|
21
22
|
|
|
22
23
|
/** A decision recorded in a file: where, and what it decides. */
|
|
23
24
|
export interface Decision {
|
|
@@ -32,6 +33,11 @@ export interface DecideFirst {
|
|
|
32
33
|
/** Globs relative to the study's folder: the files the run produces. */
|
|
33
34
|
readonly outputs: readonly string[];
|
|
34
35
|
readonly decision: Decision;
|
|
36
|
+
/**
|
|
37
|
+
* A plan v2's falsifier (src/domain/study-gates.ts): when the claim loses; null while undecided.
|
|
38
|
+
* Absent from a plan v1; an exploratory mode cannot carry one.
|
|
39
|
+
*/
|
|
40
|
+
readonly we_lose_if?: WeLoseIf | null | undefined;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/** Exploratory: no gate, and reported as exploratory. */
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
import { isAbsolute, posix, win32 } from "node:path";
|
|
20
20
|
import { STUDIES_FILE, STUDY_SLUG } from "./repro-layout.ts";
|
|
21
21
|
import { needsMode, type StudyMode } from "./study-mode.ts";
|
|
22
|
+
import {
|
|
23
|
+
PLAN_V2,
|
|
24
|
+
PLAN_V2_FROM,
|
|
25
|
+
type Funnel,
|
|
26
|
+
type RecallAudit,
|
|
27
|
+
type SecondCheck,
|
|
28
|
+
type SelfTest,
|
|
29
|
+
} from "./study-gates.ts";
|
|
22
30
|
import {
|
|
23
31
|
decisionCell,
|
|
24
32
|
firstOutputCell,
|
|
@@ -187,6 +195,16 @@ export interface StudyPlan {
|
|
|
187
195
|
* exploratory (src/domain/study-mode.ts). A measurement study's plan is not ready without it.
|
|
188
196
|
*/
|
|
189
197
|
readonly mode?: StudyMode | undefined;
|
|
198
|
+
/** A plan v2 says so here (src/domain/study-gates.ts); a plan without it is v1. */
|
|
199
|
+
readonly $schema?: typeof PLAN_V2 | undefined;
|
|
200
|
+
/** Plan v2: the least kappa of each labelling step; `"none"`; null while undecided. */
|
|
201
|
+
readonly second_check?: SecondCheck | null | undefined;
|
|
202
|
+
/** Plan v2: how the study's check is run on known answers first; `"none"`; null while undecided. */
|
|
203
|
+
readonly self_test?: SelfTest | null | undefined;
|
|
204
|
+
/** Plan v2: the reasons a drawn unit may be dropped; `"none"`; null while undecided. */
|
|
205
|
+
readonly funnel?: Funnel | null | undefined;
|
|
206
|
+
/** Plan v2: how complete the extractor is, audited by a person reading files; `"none"`; null while undecided. */
|
|
207
|
+
readonly recall_audit?: RecallAudit | null | undefined;
|
|
190
208
|
}
|
|
191
209
|
|
|
192
210
|
/**
|
|
@@ -216,12 +234,20 @@ export const PLAN_FIELDS = [
|
|
|
216
234
|
"author_check",
|
|
217
235
|
"extra",
|
|
218
236
|
"mode",
|
|
237
|
+
"$schema",
|
|
238
|
+
"second_check",
|
|
239
|
+
"self_test",
|
|
240
|
+
"funnel",
|
|
241
|
+
"recall_audit",
|
|
219
242
|
] as const;
|
|
220
243
|
|
|
221
244
|
/** A plan with empty required texts: the author has not approved it. */
|
|
222
245
|
export type DraftPlan = StudyPlan & { readonly author_check: null };
|
|
223
246
|
|
|
224
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* The fields a plan must fill before the study runs, in the order they are named: its texts, and a
|
|
249
|
+
* plan v2's gates, each null until decided.
|
|
250
|
+
*/
|
|
225
251
|
export const PLAN_TEXT_FIELDS = [
|
|
226
252
|
"question",
|
|
227
253
|
"population.source",
|
|
@@ -232,6 +258,11 @@ export const PLAN_TEXT_FIELDS = [
|
|
|
232
258
|
"mode.decision.file",
|
|
233
259
|
"mode.decision.note",
|
|
234
260
|
"mode.reason",
|
|
261
|
+
"mode.we_lose_if",
|
|
262
|
+
"second_check",
|
|
263
|
+
"self_test",
|
|
264
|
+
"funnel",
|
|
265
|
+
"recall_audit",
|
|
235
266
|
] as const;
|
|
236
267
|
export type PlanTextField = (typeof PLAN_TEXT_FIELDS)[number];
|
|
237
268
|
|
|
@@ -276,9 +307,11 @@ export const DECISION_FILE = "decision.md";
|
|
|
276
307
|
* The plan `paperlint studies new` writes for a study of `kind` in `folder`: every field present, its
|
|
277
308
|
* value to fill. Its draw is of all the population (a census); a sampled study changes it to
|
|
278
309
|
* `{ "kind": "seeded", "method", "seed" }`. A study that must declare its mode gets the default one,
|
|
279
|
-
* decided before the run, its decision file named and its outputs and note to fill
|
|
310
|
+
* decided before the run, its decision file named and its outputs and note to fill; its plan is v2,
|
|
311
|
+
* its gates null until decided (src/domain/study-gates.ts).
|
|
280
312
|
*/
|
|
281
313
|
export const planTemplate = (kind: StudyKind, folder: string): DraftPlan => ({
|
|
314
|
+
...(needsMode(kind) ? { $schema: PLAN_V2 } : {}),
|
|
282
315
|
question: "",
|
|
283
316
|
population: { source: "", draw: { kind: "all", method: "" } },
|
|
284
317
|
arms: [],
|
|
@@ -290,7 +323,12 @@ export const planTemplate = (kind: StudyKind, folder: string): DraftPlan => ({
|
|
|
290
323
|
kind: "decide_before_run",
|
|
291
324
|
outputs: [],
|
|
292
325
|
decision: { file: decisionPath(folder), note: "" },
|
|
326
|
+
we_lose_if: null,
|
|
293
327
|
},
|
|
328
|
+
second_check: null,
|
|
329
|
+
self_test: null,
|
|
330
|
+
funnel: null,
|
|
331
|
+
recall_audit: null,
|
|
294
332
|
}
|
|
295
333
|
: {}),
|
|
296
334
|
});
|
|
@@ -453,6 +491,12 @@ export type StudyFinding =
|
|
|
453
491
|
readonly problem: "legacy-plan";
|
|
454
492
|
readonly id: string;
|
|
455
493
|
readonly path: string;
|
|
494
|
+
}
|
|
495
|
+
| {
|
|
496
|
+
readonly problem: "plan-version";
|
|
497
|
+
readonly id: string;
|
|
498
|
+
readonly path: string;
|
|
499
|
+
readonly date: string;
|
|
456
500
|
};
|
|
457
501
|
|
|
458
502
|
/** The findings `repro/study-plan-name` reports, a warning; every other is `repro/study-registered`'s. */
|
|
@@ -765,6 +809,21 @@ const checkProblem = (
|
|
|
765
809
|
: { why: "not-author", by: check.by, author: r.author };
|
|
766
810
|
};
|
|
767
811
|
|
|
812
|
+
/**
|
|
813
|
+
* A measurement registered on or after `PLAN_V2_FROM` whose plan parsed and is v1: its gates
|
|
814
|
+
* (src/domain/study-gates.ts) are not declared. A plan that does not parse is `bad-plan`'s.
|
|
815
|
+
*/
|
|
816
|
+
const planVersion: RowCheck = ({ s, input }) => {
|
|
817
|
+
const path = s.plan;
|
|
818
|
+
if (path === undefined || !needsMode(s.kind) || s.date < PLAN_V2_FROM)
|
|
819
|
+
return null;
|
|
820
|
+
const state = planStateOf(input, s, path);
|
|
821
|
+
return (state.kind === "ready" || state.kind === "draft") &&
|
|
822
|
+
state.plan.$schema === undefined
|
|
823
|
+
? { problem: "plan-version", id: s.id, path, date: s.date }
|
|
824
|
+
: null;
|
|
825
|
+
};
|
|
826
|
+
|
|
768
827
|
/** The checks of a row, in the order their findings are reported. */
|
|
769
828
|
const ROW_CHECKS: readonly RowCheck[] = [
|
|
770
829
|
badId,
|
|
@@ -778,6 +837,7 @@ const ROW_CHECKS: readonly RowCheck[] = [
|
|
|
778
837
|
noSuccessor,
|
|
779
838
|
unknownMeasurement,
|
|
780
839
|
badPlan,
|
|
840
|
+
planVersion,
|
|
781
841
|
legacyPlan,
|
|
782
842
|
authorCheck,
|
|
783
843
|
];
|
|
@@ -1170,6 +1230,8 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
1170
1230
|
return `${head} the \`author_check\` in its plan is by ${f.by}, not by the registry's \`author\`, ${f.author}. What to do: only the author approves the drawn units of a headline measurement; record their own answer.`;
|
|
1171
1231
|
}
|
|
1172
1232
|
},
|
|
1233
|
+
"plan-version": (f) =>
|
|
1234
|
+
`The plan of ${tick(f.id)}, ${tick(f.path)} in its folder, is a plan v1, and ${tick(f.id)} is a measurement registered on ${f.date}: from ${PLAN_V2_FROM} a measurement's plan is v2 and declares the gates its number is believed by. What to do: add \`"$schema": "${PLAN_V2}"\`, then \`we_lose_if\` in a decide-first \`mode\`, \`second_check\`, \`self_test\`, \`funnel\` and \`recall_audit\` — each \`"none"\` where the study has none of it, null until decided (docs/design/study-gates.md).`,
|
|
1173
1235
|
"legacy-plan": (f) =>
|
|
1174
1236
|
`The plan of ${tick(f.id)} is ${tick(f.path)}, its old name; it is read as the plan. What to do: rename it to \`${PLAN_FILE}\` in the study's folder (\`git mv\`) and set the row's \`plan\` to \`${PLAN_FILE}\`.`,
|
|
1175
1237
|
};
|