paperlint 13.0.3 → 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.
Files changed (114) hide show
  1. package/CLAUDE.md +9 -1
  2. package/dist/adapters/git/index.d.ts.map +1 -1
  3. package/dist/adapters/git/index.js +13 -0
  4. package/dist/adapters/git/index.js.map +1 -1
  5. package/dist/adapters/study-registry/index.d.ts +142 -1
  6. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  7. package/dist/adapters/study-registry/index.js +160 -1
  8. package/dist/adapters/study-registry/index.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +3 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/domain/decide-first.d.ts +43 -0
  13. package/dist/domain/decide-first.d.ts.map +1 -1
  14. package/dist/domain/decide-first.js +45 -2
  15. package/dist/domain/decide-first.js.map +1 -1
  16. package/dist/domain/study-gates.d.ts +96 -0
  17. package/dist/domain/study-gates.d.ts.map +1 -0
  18. package/dist/domain/study-gates.js +56 -0
  19. package/dist/domain/study-gates.js.map +1 -0
  20. package/dist/domain/study-mode.d.ts +6 -0
  21. package/dist/domain/study-mode.d.ts.map +1 -1
  22. package/dist/domain/study-mode.js.map +1 -1
  23. package/dist/domain/study-registry.d.ts +24 -4
  24. package/dist/domain/study-registry.d.ts.map +1 -1
  25. package/dist/domain/study-registry.js +39 -2
  26. package/dist/domain/study-registry.js.map +1 -1
  27. package/dist/domain/study-result.d.ts +151 -0
  28. package/dist/domain/study-result.d.ts.map +1 -0
  29. package/dist/domain/study-result.js +175 -0
  30. package/dist/domain/study-result.js.map +1 -0
  31. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  32. package/dist/outputs-before-decision-rule.js +37 -3
  33. package/dist/outputs-before-decision-rule.js.map +1 -1
  34. package/dist/ports/git-history.d.ts +12 -0
  35. package/dist/ports/git-history.d.ts.map +1 -1
  36. package/dist/ports/study-registry-codec.d.ts +3 -0
  37. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  38. package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
  39. package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
  40. package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
  41. package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
  42. package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
  43. package/dist/study-modes.d.ts +3 -0
  44. package/dist/study-modes.d.ts.map +1 -1
  45. package/dist/study-modes.js +8 -1
  46. package/dist/study-modes.js.map +1 -1
  47. package/dist/study-registry-rule.d.ts.map +1 -1
  48. package/dist/study-registry-rule.js +1 -0
  49. package/dist/study-registry-rule.js.map +1 -1
  50. package/dist/study-result-rule.d.ts +12 -0
  51. package/dist/study-result-rule.d.ts.map +1 -0
  52. package/dist/study-result-rule.js +87 -0
  53. package/dist/study-result-rule.js.map +1 -0
  54. package/docs/design/study-gates.md +208 -0
  55. package/docs/rules/repro/outputs-before-decision.md +8 -0
  56. package/docs/rules/repro/study-registered.md +38 -15
  57. package/docs/rules/repro/study-result.md +100 -0
  58. package/docs/rules.md +1 -0
  59. package/eslint.config.mjs +1 -1
  60. package/hooks/hooks.harness.ts +51 -13
  61. package/hooks/paper-skills-nudge.hook.d.mts +22 -0
  62. package/hooks/paper-skills-nudge.hook.mjs +85 -23
  63. package/hooks/paper-skills-nudge.test.ts +82 -4
  64. package/package.json +2 -1
  65. package/plugin/hooks/hooks.json +7 -2
  66. package/schemas/plan.v2.schema.json +341 -0
  67. package/skills/argument-arc/SKILL.md.spec.ts +1 -1
  68. package/skills/build-label-page/SKILL.md +85 -54
  69. package/skills/build-label-page/SKILL.md.spec.ts +85 -53
  70. package/skills/build-label-page/build-label-page.test.ts +10 -941
  71. package/skills/build-label-page/references/checklist.md +45 -3
  72. package/skills/build-label-page/references/closed-choice-generator.md +88 -0
  73. package/skills/build-label-page/scripts/label-page-cli.js +72 -0
  74. package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
  75. package/skills/build-label-page/scripts/label-page-commands.js +14 -0
  76. package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
  77. package/skills/build-label-page/scripts/label-page-core.js +850 -0
  78. package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
  79. package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
  80. package/skills/build-label-page/scripts/label-page-html.js +33 -0
  81. package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
  82. package/skills/build-label-page/scripts/label-page-html.ts +43 -0
  83. package/skills/build-label-page/scripts/label-page.js +70 -0
  84. package/skills/build-label-page/scripts/label-page.test.ts +71 -0
  85. package/skills/build-label-page/scripts/label-page.ts +71 -0
  86. package/skills/build-label-page/scripts/page-template.html +513 -0
  87. package/skills/find-venue/SKILL.md.spec.ts +1 -1
  88. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  89. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  90. package/src/adapters/git/index.test.ts +33 -0
  91. package/src/adapters/git/index.ts +19 -0
  92. package/src/adapters/study-registry/gates.test.ts +181 -0
  93. package/src/adapters/study-registry/index.ts +219 -1
  94. package/src/adapters/study-registry/mode.test.ts +6 -0
  95. package/src/cli.ts +3 -0
  96. package/src/domain/decide-first.test.ts +131 -0
  97. package/src/domain/decide-first.ts +92 -2
  98. package/src/domain/study-gates.ts +125 -0
  99. package/src/domain/study-measurements.test.ts +71 -0
  100. package/src/domain/study-mode.ts +6 -0
  101. package/src/domain/study-registry.ts +64 -2
  102. package/src/domain/study-result.test.ts +300 -0
  103. package/src/domain/study-result.ts +358 -0
  104. package/src/outputs-before-decision-rule.test.ts +115 -2
  105. package/src/outputs-before-decision-rule.ts +50 -2
  106. package/src/ports/git-history.ts +12 -0
  107. package/src/ports/study-registry-codec.ts +3 -0
  108. package/src/rule-cases.test.ts +74 -0
  109. package/src/studies.test.ts +3 -0
  110. package/src/study-modes.ts +10 -1
  111. package/src/study-registry-rule.ts +1 -0
  112. package/src/study-result-rule.test.ts +281 -0
  113. package/src/study-result-rule.ts +123 -0
  114. package/skills/build-label-page/references/example-generator.py +0 -803
@@ -0,0 +1,300 @@
1
+ /**
2
+ * What `repro/study-result` decides from a done study's plan v2 and its `result.json`
3
+ * (`src/domain/study-result.ts`). Both halves: a result that shows every gate is silent, and so is a
4
+ * lost claim said in so many words; each missing or failed gate is reported once, with a text that
5
+ * stands alone.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { PLAN_V2 } from "./study-gates.ts";
9
+ import type { StudyPlan } from "./study-registry.ts";
10
+ import {
11
+ gatesOf,
12
+ judgeResult,
13
+ resultMessage,
14
+ RESULT_PROBLEMS,
15
+ type ResultFinding,
16
+ type ResultState,
17
+ type ResultStudy,
18
+ type StudyResult,
19
+ } from "./study-result.ts";
20
+
21
+ const GOOD: StudyResult = {
22
+ pass_rate: 0.7,
23
+ kappa: { pass: 0.71, fits: 0.6 },
24
+ self_test: { passed: 12, failed: 0 },
25
+ funnel: { "timed out": 2, "clone failed": 0 },
26
+ recall_audit: { files: 50, units_in_files: 95, extracted: 63 },
27
+ };
28
+
29
+ const study = (
30
+ state: ResultState,
31
+ over: Partial<ResultStudy> = {},
32
+ ): ResultStudy => ({
33
+ id: "P1-S01",
34
+ folder: "P1-S01-x",
35
+ we_lose_if: { result: "pass_rate", below: 0.5 },
36
+ second_check: [
37
+ { step: "pass", min_kappa: 0.6 },
38
+ { step: "fits", min_kappa: 0.6 },
39
+ ],
40
+ self_test: { made_by: "selftest.mjs" },
41
+ funnel: ["timed out", "clone failed"],
42
+ recall_audit: { n_files: 50, min_recall: 0.6 },
43
+ state,
44
+ ...over,
45
+ });
46
+
47
+ const read = (result: StudyResult): ResultState => ({ kind: "read", result });
48
+ const judge = (result: StudyResult, over: Partial<ResultStudy> = {}) =>
49
+ judgeResult(study(read(result), over));
50
+
51
+ describe("silent", () => {
52
+ it("on a result that shows every gate, kappa exactly at its bar", () => {
53
+ expect(judge(GOOD)).toEqual([]);
54
+ });
55
+
56
+ it("on a lost claim the result says is lost, and on every gate none", () => {
57
+ expect(judge({ ...GOOD, pass_rate: 0.4, claim: "lost" })).toEqual([]);
58
+ expect(
59
+ judge(
60
+ {},
61
+ {
62
+ we_lose_if: null,
63
+ second_check: "none",
64
+ self_test: "none",
65
+ funnel: "none",
66
+ recall_audit: "none",
67
+ },
68
+ ),
69
+ ).toEqual([]);
70
+ });
71
+
72
+ it("above: a claim loses only past its threshold", () => {
73
+ const over = { we_lose_if: { result: "fp_rate", above: 0.1 } };
74
+ expect(judge({ ...GOOD, fp_rate: 0.1 }, over)).toEqual([]);
75
+ expect(
76
+ judge({ ...GOOD, fp_rate: 0.2 }, over).map((f) => f.problem),
77
+ ).toEqual(["claim-lost"]);
78
+ });
79
+ });
80
+
81
+ describe("reported", () => {
82
+ it("the file absent, reached through a link, or unreadable", () => {
83
+ expect(
84
+ [
85
+ study({ kind: "absent" }),
86
+ study({ kind: "linked", link: "data" }),
87
+ study({ kind: "unreadable", why: "not JSON" }),
88
+ ].flatMap(judgeResult),
89
+ ).toEqual([
90
+ { problem: "no-result-file", id: "P1-S01" },
91
+ { problem: "result-linked", id: "P1-S01", link: "data" },
92
+ { problem: "result-unreadable", id: "P1-S01", why: "not JSON" },
93
+ ]);
94
+ });
95
+
96
+ it("the claim's number missing, lost unsaid, or said lost while it holds", () => {
97
+ expect(judge({ ...GOOD, pass_rate: undefined })).toEqual([
98
+ { problem: "no-number", id: "P1-S01", key: "pass_rate" },
99
+ ]);
100
+ expect(judge({ ...GOOD, pass_rate: 0.4 })).toEqual([
101
+ {
102
+ problem: "claim-lost",
103
+ id: "P1-S01",
104
+ key: "pass_rate",
105
+ value: 0.4,
106
+ threshold: "below 0.5",
107
+ },
108
+ ]);
109
+ expect(judge({ ...GOOD, claim: "lost" })).toEqual([
110
+ {
111
+ problem: "claim-holds",
112
+ id: "P1-S01",
113
+ key: "pass_rate",
114
+ value: 0.7,
115
+ threshold: "below 0.5",
116
+ },
117
+ ]);
118
+ });
119
+
120
+ it("a kappa missing or below its bar; a self-test missing or failed; a funnel reason uncounted", () => {
121
+ expect(
122
+ judge({
123
+ pass_rate: 0.7,
124
+ kappa: { pass: 0.59 },
125
+ funnel: { "timed out": 1 },
126
+ recall_audit: GOOD.recall_audit,
127
+ }),
128
+ ).toEqual([
129
+ { problem: "kappa", id: "P1-S01", step: "pass", min: 0.6, got: 0.59 },
130
+ { problem: "kappa", id: "P1-S01", step: "fits", min: 0.6, got: null },
131
+ { problem: "self-test", id: "P1-S01", failed: null },
132
+ { problem: "funnel", id: "P1-S01", missing: ["clone failed"] },
133
+ ]);
134
+ expect(judge({ ...GOOD, self_test: { passed: 3, failed: 2 } })).toEqual([
135
+ { problem: "self-test", id: "P1-S01", failed: 2 },
136
+ ]);
137
+ // A self-test that ran no case proves nothing about the check.
138
+ expect(judge({ ...GOOD, self_test: { passed: 0, failed: 0 } })).toEqual([
139
+ { problem: "self-test-empty", id: "P1-S01" },
140
+ ]);
141
+ });
142
+ });
143
+
144
+ describe("resultMessage", () => {
145
+ const LOST: ResultFinding = {
146
+ problem: "claim-lost",
147
+ id: "P1-S01",
148
+ key: "pass_rate",
149
+ value: 0.4,
150
+ threshold: "below 0.5",
151
+ };
152
+ const ALL: readonly ResultFinding[] = [
153
+ { problem: "no-result-file", id: "P1-S01" },
154
+ { problem: "result-linked", id: "P1-S01", link: "data" },
155
+ { problem: "result-unreadable", id: "P1-S01", why: "not JSON" },
156
+ { problem: "no-number", id: "P1-S01", key: "pass_rate" },
157
+ {
158
+ problem: "claim-lost",
159
+ id: "P1-S01",
160
+ key: "pass_rate",
161
+ value: 0.4,
162
+ threshold: "below 0.5",
163
+ },
164
+ {
165
+ problem: "claim-holds",
166
+ id: "P1-S01",
167
+ key: "pass_rate",
168
+ value: 0.7,
169
+ threshold: "below 0.5",
170
+ },
171
+ { problem: "kappa", id: "P1-S01", step: "pass", min: 0.6, got: 0.4 },
172
+ { problem: "kappa", id: "P1-S01", step: "pass", min: 0.6, got: null },
173
+ { problem: "self-test", id: "P1-S01", failed: 2 },
174
+ { problem: "self-test", id: "P1-S01", failed: null },
175
+ { problem: "self-test-empty", id: "P1-S01" },
176
+ { problem: "funnel", id: "P1-S01", missing: ["a", "b"] },
177
+ { problem: "no-recall-audit", id: "P1-S01" },
178
+ { problem: "recall-sample", id: "P1-S01", files: 30, min_files: 50 },
179
+ { problem: "recall", id: "P1-S01", extracted: 63, units: 95, min: 0.7 },
180
+ ];
181
+
182
+ it("every problem has a text that names the study, says what to do and links the page", () => {
183
+ // Guards: each message stands alone — no reader needs the rule's source to act on it.
184
+ expect(
185
+ ALL.map((f) => {
186
+ const m = resultMessage(f);
187
+ return (
188
+ m.includes("`P1-S01`") &&
189
+ m.includes("What to do:") &&
190
+ m.endsWith("docs/rules/repro/study-result.md")
191
+ );
192
+ }),
193
+ ).toEqual(ALL.map(() => true));
194
+ expect([...new Set(ALL.map((f) => f.problem))]).toEqual(RESULT_PROBLEMS);
195
+ });
196
+
197
+ it("a lost claim says LOST, the number and the threshold fixed before the run", () => {
198
+ expect(resultMessage(LOST).split("\n")[0]).toBe(
199
+ '`P1-S01`\'s claim LOST: its `pass_rate` is 0.4, below 0.5, the threshold its plan fixed before the run. What to do: report the loss in the paper, and say so in `result.json` with `"claim": "lost"`; the threshold is not moved after the run.',
200
+ );
201
+ });
202
+ });
203
+
204
+ describe("recall_audit", () => {
205
+ it("a recall audit missing, on fewer files than the plan fixed, or below its least recall", () => {
206
+ expect(judge({ ...GOOD, recall_audit: undefined })).toEqual([
207
+ { problem: "no-recall-audit", id: "P1-S01" },
208
+ ]);
209
+ expect(
210
+ judge({
211
+ ...GOOD,
212
+ recall_audit: { files: 30, units_in_files: 60, extracted: 30 },
213
+ }),
214
+ ).toEqual([
215
+ { problem: "recall-sample", id: "P1-S01", files: 30, min_files: 50 },
216
+ {
217
+ problem: "recall",
218
+ id: "P1-S01",
219
+ extracted: 30,
220
+ units: 60,
221
+ min: 0.6,
222
+ },
223
+ ]);
224
+ // Guards: exactly at the bar is enough; a recall of 60 of 100 is 0.6.
225
+ expect(
226
+ judge({
227
+ ...GOOD,
228
+ recall_audit: { files: 50, units_in_files: 100, extracted: 60 },
229
+ }),
230
+ ).toEqual([]);
231
+ });
232
+ it("a recall below its bar gives the counts, the recall and the bar", () => {
233
+ expect(
234
+ resultMessage({
235
+ problem: "recall",
236
+ id: "P1-S01",
237
+ extracted: 63,
238
+ units: 95,
239
+ min: 0.7,
240
+ }).split("\n")[0],
241
+ ).toBe(
242
+ "`P1-S01`: the extractor found 63 of the 95 units a person found reading the audited files in full, a recall of 0.663, below the 0.7 its plan fixed before the run — the extractor misses too much. What to do: report the recall as a limit of the number, or fix the extractor and audit again; the bar is not lowered after the fact.",
243
+ );
244
+ });
245
+ });
246
+
247
+ describe("gatesOf — the gates of a plan, only when every one is decided", () => {
248
+ const PLAN: StudyPlan = {
249
+ $schema: PLAN_V2,
250
+ question: "q",
251
+ population: { source: "s", draw: { kind: "all", method: "census" } },
252
+ arms: [],
253
+ primary_outcome: "o",
254
+ author_check: null,
255
+ mode: {
256
+ kind: "decide_before_run",
257
+ outputs: ["result.json"],
258
+ decision: { file: "d.md", note: "n" },
259
+ we_lose_if: { result: "x", above: 1 },
260
+ },
261
+ second_check: "none",
262
+ self_test: "none",
263
+ funnel: "none",
264
+ recall_audit: "none",
265
+ };
266
+
267
+ it("reads a plan v2's gates, and none of a plan v1 or one with a gate undecided", () => {
268
+ const undecided: readonly StudyPlan[] = [
269
+ { ...PLAN, second_check: undefined },
270
+ { ...PLAN, second_check: null },
271
+ { ...PLAN, self_test: null },
272
+ { ...PLAN, funnel: null },
273
+ { ...PLAN, recall_audit: null },
274
+ ];
275
+ expect(gatesOf(PLAN)).toEqual({
276
+ we_lose_if: { result: "x", above: 1 },
277
+ second_check: "none",
278
+ self_test: "none",
279
+ funnel: "none",
280
+ recall_audit: "none",
281
+ });
282
+ // Guards: a gate absent or null is never read as decided.
283
+ expect(undecided.map(gatesOf)).toEqual(undecided.map(() => null));
284
+ });
285
+
286
+ it("has no claim for an exploratory plan, or a decide-first mode without we_lose_if", () => {
287
+ const noClaim = {
288
+ kind: "decide_before_run" as const,
289
+ outputs: [],
290
+ decision: { file: "d.md", note: "n" },
291
+ };
292
+ expect(
293
+ [
294
+ { ...PLAN, mode: { kind: "exploratory" as const, reason: "r" } },
295
+ { ...PLAN, mode: noClaim },
296
+ { ...PLAN, mode: undefined },
297
+ ].map((p) => gatesOf(p)?.we_lose_if),
298
+ ).toEqual([null, null, null]);
299
+ });
300
+ });
@@ -0,0 +1,358 @@
1
+ /**
2
+ * WHAT A STUDY'S RUN SHOWED, AGAINST WHAT ITS PLAN V2 FIXED — `repro/study-result`
3
+ * (docs/rules/repro/study-result.md, src/domain/study-gates.ts).
4
+ *
5
+ * A study whose plan is v2 writes its numbers to `result.json` beside the plan; the study's own
6
+ * script produces the facts, this module judges them. For a study that is `done`:
7
+ *
8
+ * we_lose_if the number it names is in the file; when it crosses the threshold the file says
9
+ * `"claim": "lost"` — a loss is written where the paper reads its numbers, never
10
+ * left for a reader to work out — and it never says so of a claim that holds.
11
+ * second_check each step's kappa is in `kappa`, and at least its `min_kappa`.
12
+ * self_test `self_test` holds `passed` and `failed`, and `failed` is 0.
13
+ * funnel `funnel` counts every declared reason.
14
+ * recall_audit `recall_audit` gives the files a person read in full, the units found there, and
15
+ * how many of them the extractor found; at least `n_files` files, and a recall
16
+ * (extracted / units) of at least `min_recall`. The counts, not a recall the study
17
+ * computed: the files are checked against the plan, and the share is computed here.
18
+ *
19
+ * A gate that is `"none"` asks nothing of the file. Pure.
20
+ */
21
+ import {
22
+ loses,
23
+ thresholdText,
24
+ RESULT_FILE,
25
+ type Funnel,
26
+ type RecallAudit,
27
+ type RecallAuditPlan,
28
+ type SecondCheck,
29
+ type SelfTest,
30
+ type WeLoseIf,
31
+ } from "./study-gates.ts";
32
+ import type { StudyPlan } from "./study-registry.ts";
33
+
34
+ /** The value a study's result says its claim has, when its number crossed `we_lose_if`. */
35
+ export const CLAIM_LOST = "lost";
36
+
37
+ /** What a person found reading the audited files in full, and how many of those units the extractor found. */
38
+ export interface RecallCounts {
39
+ readonly files: number;
40
+ readonly units_in_files: number;
41
+ readonly extracted: number;
42
+ }
43
+
44
+ /** A study's `result.json`: its own numbers by name, and the five parts the gates read. */
45
+ export interface StudyResult {
46
+ readonly claim?: typeof CLAIM_LOST | undefined;
47
+ readonly kappa?: Readonly<Record<string, number>> | undefined;
48
+ readonly self_test?:
49
+ { readonly passed: number; readonly failed: number } | undefined;
50
+ readonly funnel?: Readonly<Record<string, number>> | undefined;
51
+ readonly recall_audit?: RecallCounts | undefined;
52
+ readonly [key: string]: unknown;
53
+ }
54
+
55
+ /** A `result.json`'s text, parsed: the result, or why it is none. */
56
+ export type ParsedResult =
57
+ | { readonly kind: "read"; readonly result: StudyResult }
58
+ | { readonly kind: "unreadable"; readonly why: string };
59
+
60
+ /** What the study's folder holds of `result.json`. */
61
+ export type ResultState =
62
+ | { readonly kind: "absent" }
63
+ | { readonly kind: "linked"; readonly link: string }
64
+ | ParsedResult;
65
+
66
+ /** A done study of a ready plan v2: its gates, and what its folder holds of its result. */
67
+ export interface ResultStudy {
68
+ readonly id: string;
69
+ readonly folder: string;
70
+ /** null: an exploratory study, which has no claim to lose. */
71
+ readonly we_lose_if: WeLoseIf | null;
72
+ readonly second_check: SecondCheck;
73
+ readonly self_test: SelfTest;
74
+ readonly funnel: Funnel;
75
+ readonly recall_audit: RecallAudit;
76
+ readonly state: ResultState;
77
+ }
78
+
79
+ /** The gates a ready plan v2 declares, as the judge reads them. */
80
+ export type Gates = Pick<
81
+ ResultStudy,
82
+ "we_lose_if" | "second_check" | "self_test" | "funnel" | "recall_audit"
83
+ >;
84
+
85
+ /** Whether a gate is decided: neither absent (a plan v1) nor null (not decided yet). */
86
+ const decided = <T>(g: T | null | undefined): g is T =>
87
+ g !== null && g !== undefined;
88
+
89
+ /**
90
+ * The gates of plan `p`, when every one is decided; null for a plan v1 or a draft. An exploratory
91
+ * plan, or one whose mode carries no `we_lose_if`, has no claim to lose.
92
+ */
93
+ export const gatesOf = (p: StudyPlan): Gates | null => {
94
+ const { second_check, self_test, funnel, recall_audit, mode } = p;
95
+ if (
96
+ !decided(second_check) ||
97
+ !decided(self_test) ||
98
+ !decided(funnel) ||
99
+ !decided(recall_audit)
100
+ )
101
+ return null;
102
+ const w = mode?.kind === "decide_before_run" ? mode.we_lose_if : null;
103
+ return {
104
+ we_lose_if: decided(w) ? w : null,
105
+ second_check,
106
+ self_test,
107
+ funnel,
108
+ recall_audit,
109
+ };
110
+ };
111
+
112
+ /** One defect of a study's result. */
113
+ export type ResultFinding =
114
+ | { readonly problem: "no-result-file"; readonly id: string }
115
+ | {
116
+ readonly problem: "result-linked";
117
+ readonly id: string;
118
+ readonly link: string;
119
+ }
120
+ | {
121
+ readonly problem: "result-unreadable";
122
+ readonly id: string;
123
+ readonly why: string;
124
+ }
125
+ | { readonly problem: "no-number"; readonly id: string; readonly key: string }
126
+ | {
127
+ readonly problem: "claim-lost";
128
+ readonly id: string;
129
+ readonly key: string;
130
+ readonly value: number;
131
+ readonly threshold: string;
132
+ }
133
+ | {
134
+ readonly problem: "claim-holds";
135
+ readonly id: string;
136
+ readonly key: string;
137
+ readonly value: number;
138
+ readonly threshold: string;
139
+ }
140
+ | {
141
+ readonly problem: "kappa";
142
+ readonly id: string;
143
+ readonly step: string;
144
+ readonly min: number;
145
+ readonly got: number | null;
146
+ }
147
+ | {
148
+ readonly problem: "self-test";
149
+ readonly id: string;
150
+ readonly failed: number | null;
151
+ }
152
+ /** The self-test ran no case: it says nothing about the check. */
153
+ | { readonly problem: "self-test-empty"; readonly id: string }
154
+ | {
155
+ readonly problem: "funnel";
156
+ readonly id: string;
157
+ readonly missing: readonly string[];
158
+ }
159
+ | { readonly problem: "no-recall-audit"; readonly id: string }
160
+ | {
161
+ readonly problem: "recall-sample";
162
+ readonly id: string;
163
+ readonly files: number;
164
+ readonly min_files: number;
165
+ }
166
+ | {
167
+ readonly problem: "recall";
168
+ readonly id: string;
169
+ readonly extracted: number;
170
+ readonly units: number;
171
+ readonly min: number;
172
+ };
173
+
174
+ /** What `we_lose_if` asks of `r`: the number, and a loss said in so many words. */
175
+ const claimFindings = (
176
+ id: string,
177
+ w: WeLoseIf,
178
+ r: StudyResult,
179
+ ): readonly ResultFinding[] => {
180
+ const value = r[w.result];
181
+ if (typeof value !== "number")
182
+ return [{ problem: "no-number", id, key: w.result }];
183
+ const lost = loses(w, value);
184
+ const said = r.claim === CLAIM_LOST;
185
+ if (lost === said) return [];
186
+ const at = { id, key: w.result, value, threshold: thresholdText(w) };
187
+ return [
188
+ lost ? { problem: "claim-lost", ...at } : { problem: "claim-holds", ...at },
189
+ ];
190
+ };
191
+
192
+ /** What `second_check` asks of `r`: each step's kappa, at least its least. */
193
+ const kappaFindings = (
194
+ id: string,
195
+ c: SecondCheck,
196
+ r: StudyResult,
197
+ ): readonly ResultFinding[] =>
198
+ c === "none"
199
+ ? []
200
+ : c.flatMap((s): readonly ResultFinding[] => {
201
+ const got = r.kappa?.[s.step] ?? null;
202
+ return got !== null && got >= s.min_kappa
203
+ ? []
204
+ : [{ problem: "kappa", id, step: s.step, min: s.min_kappa, got }];
205
+ });
206
+
207
+ /** What `self_test` asks of `r`: its counts, none failed. */
208
+ const selfTestFindings = (
209
+ id: string,
210
+ t: SelfTest,
211
+ r: StudyResult,
212
+ ): readonly ResultFinding[] => {
213
+ if (t === "none") return [];
214
+ const run = r.self_test;
215
+ if (run === undefined) return [{ problem: "self-test", id, failed: null }];
216
+ if (run.failed !== 0)
217
+ return [{ problem: "self-test", id, failed: run.failed }];
218
+ return run.passed > 0 ? [] : [{ problem: "self-test-empty", id }];
219
+ };
220
+
221
+ /** What `funnel` asks of `r`: a count for every reason. */
222
+ const funnelFindings = (
223
+ id: string,
224
+ f: Funnel,
225
+ r: StudyResult,
226
+ ): readonly ResultFinding[] => {
227
+ if (f === "none") return [];
228
+ const missing = f.filter((reason) => r.funnel?.[reason] === undefined);
229
+ return missing.length === 0 ? [] : [{ problem: "funnel", id, missing }];
230
+ };
231
+
232
+ /** The audit's file count against the plan's: too few files is a finding. */
233
+ const sampleFindings = (
234
+ id: string,
235
+ a: RecallAuditPlan,
236
+ c: RecallCounts,
237
+ ): readonly ResultFinding[] =>
238
+ c.files < a.n_files
239
+ ? [{ problem: "recall-sample", id, files: c.files, min_files: a.n_files }]
240
+ : [];
241
+
242
+ /** The recall the counts give, against the plan's least. */
243
+ const shareFindings = (
244
+ id: string,
245
+ a: RecallAuditPlan,
246
+ c: RecallCounts,
247
+ ): readonly ResultFinding[] =>
248
+ c.extracted / c.units_in_files < a.min_recall
249
+ ? [
250
+ {
251
+ problem: "recall",
252
+ id,
253
+ extracted: c.extracted,
254
+ units: c.units_in_files,
255
+ min: a.min_recall,
256
+ },
257
+ ]
258
+ : [];
259
+
260
+ /** What `recall_audit` asks of `r`: the counts, on enough files, with a recall at least its least. */
261
+ const recallFindings = (
262
+ id: string,
263
+ a: RecallAudit,
264
+ r: StudyResult,
265
+ ): readonly ResultFinding[] => {
266
+ if (a === "none") return [];
267
+ const c = r.recall_audit;
268
+ if (c === undefined) return [{ problem: "no-recall-audit", id }];
269
+ return [...sampleFindings(id, a, c), ...shareFindings(id, a, c)];
270
+ };
271
+
272
+ /** Every defect of one study's result, in the order of its gates. */
273
+ export const judgeResult = (s: ResultStudy): readonly ResultFinding[] => {
274
+ const st = s.state;
275
+ switch (st.kind) {
276
+ case "absent":
277
+ return [{ problem: "no-result-file", id: s.id }];
278
+ case "linked":
279
+ return [{ problem: "result-linked", id: s.id, link: st.link }];
280
+ case "unreadable":
281
+ return [{ problem: "result-unreadable", id: s.id, why: st.why }];
282
+ case "read":
283
+ return [
284
+ ...(s.we_lose_if === null
285
+ ? []
286
+ : claimFindings(s.id, s.we_lose_if, st.result)),
287
+ ...kappaFindings(s.id, s.second_check, st.result),
288
+ ...selfTestFindings(s.id, s.self_test, st.result),
289
+ ...funnelFindings(s.id, s.funnel, st.result),
290
+ ...recallFindings(s.id, s.recall_audit, st.result),
291
+ ];
292
+ }
293
+ };
294
+
295
+ const RULE = "repro/study-result";
296
+ const tick = (s: string): string => `\`${s}\``;
297
+
298
+ /** Each finding by its problem. */
299
+ type FindingOf = { readonly [F in ResultFinding as F["problem"]]: F };
300
+ type Problem = keyof FindingOf;
301
+
302
+ /** The text of each finding: what is wrong, and what to do. */
303
+ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
304
+ "no-result-file": (f) =>
305
+ `${tick(f.id)} is \`done\` and its plan is v2, but its folder has no ${tick(RESULT_FILE)}. What to do: have the study's script write its numbers there — the one its \`we_lose_if\` names, \`kappa\`, \`self_test\` and \`funnel\` as its plan declares them.`,
306
+ "result-linked": (f) =>
307
+ `The ${tick(RESULT_FILE)} of ${tick(f.id)} passes through the symbolic link ${tick(f.link)}; nothing is read through a link. What to do: write the file itself in the study's folder.`,
308
+ "result-unreadable": (f) =>
309
+ `The ${tick(RESULT_FILE)} of ${tick(f.id)} is not a study result: ${f.why}. What to do: give it the shape on the rule's page.`,
310
+ "no-number": (f) =>
311
+ `The ${tick(RESULT_FILE)} of ${tick(f.id)} has no number ${tick(f.key)}, the one its plan's \`we_lose_if\` names. What to do: write it there, or correct \`we_lose_if.result\` — before the run, never after.`,
312
+ "claim-lost": (f) =>
313
+ `${tick(f.id)}'s claim LOST: its ${tick(f.key)} is ${String(f.value)}, ${f.threshold}, the threshold its plan fixed before the run. What to do: report the loss in the paper, and say so in ${tick(RESULT_FILE)} with \`"claim": "${CLAIM_LOST}"\`; the threshold is not moved after the run.`,
314
+ "claim-holds": (f) =>
315
+ `The ${tick(RESULT_FILE)} of ${tick(f.id)} says \`"claim": "${CLAIM_LOST}"\`, but its ${tick(f.key)} is ${String(f.value)}, not ${f.threshold}: by its plan the claim holds. What to do: remove \`claim\`, or correct the number.`,
316
+ kappa: (f) =>
317
+ f.got === null
318
+ ? `The ${tick(RESULT_FILE)} of ${tick(f.id)} has no kappa for the step ${tick(f.step)} its plan's \`second_check\` declares. What to do: write it in \`kappa.${f.step}\`.`
319
+ : `${tick(f.id)}: the two raters of the step ${tick(f.step)} agree with kappa ${String(f.got)}, below the ${String(f.min)} its plan fixed before labelling. What to do: the labels of that step do not carry a number — revise the codebook and label again, or report the step as unreliable; the threshold is not lowered after the fact.`,
320
+ "self-test": (f) =>
321
+ f.failed === null
322
+ ? `The ${tick(RESULT_FILE)} of ${tick(f.id)} has no \`self_test\` counts, and its plan declares a self-test. What to do: write \`"self_test": { "passed": n, "failed": n }\` from the run on the known answers.`
323
+ : `${tick(f.id)}: ${String(f.failed)} self-test case(s) failed — a check that misses a known answer counts nothing. What to do: fix the check or leave it out of the count, and run the self-test again.`,
324
+ "self-test-empty": (f) =>
325
+ `${tick(f.id)}: its self-test ran no case (0 passed, 0 failed) — a check never tried on a known answer is not tested. What to do: make the self-test find and run its known inputs, and write the counts of that run.`,
326
+ funnel: (f) =>
327
+ `The ${tick(RESULT_FILE)} of ${tick(f.id)} counts no drop for ${f.missing.map(tick).join(", ")}, declared in its plan's \`funnel\`. What to do: write each in \`funnel\`, 0 when none was dropped for it.`,
328
+ "no-recall-audit": (f) =>
329
+ `The ${tick(RESULT_FILE)} of ${tick(f.id)} has no \`recall_audit\` counts, and its plan declares a recall audit. What to do: have a person read the sampled files in full and write \`"recall_audit": { "files": n, "units_in_files": n, "extracted": n }\`.`,
330
+ "recall-sample": (f) =>
331
+ `${tick(f.id)}: its recall audit read ${String(f.files)} file(s) in full, fewer than the ${String(f.min_files)} its plan fixed. What to do: audit the remaining files of the sample before the recall is reported.`,
332
+ recall: (f) =>
333
+ `${tick(f.id)}: the extractor found ${String(f.extracted)} of the ${String(f.units)} units a person found reading the audited files in full, a recall of ${(f.extracted / f.units).toFixed(3)}, below the ${String(f.min)} its plan fixed before the run — the extractor misses too much. What to do: report the recall as a limit of the number, or fix the extractor and audit again; the bar is not lowered after the fact.`,
334
+ };
335
+
336
+ const headOf = <P extends Problem>(p: P, f: FindingOf[P]): string =>
337
+ HEADS[p](f);
338
+
339
+ /** The one text a finding is reported with. */
340
+ export const resultMessage = (f: ResultFinding): string =>
341
+ `${headOf(f.problem, f)}\nRule ${RULE}: https://github.com/zernie/paperlint/blob/main/docs/rules/${RULE}.md`;
342
+
343
+ /** Every problem the rule reports, each its own message id. */
344
+ export const RESULT_PROBLEMS: readonly Problem[] = [
345
+ "no-result-file",
346
+ "result-linked",
347
+ "result-unreadable",
348
+ "no-number",
349
+ "claim-lost",
350
+ "claim-holds",
351
+ "kappa",
352
+ "self-test",
353
+ "self-test-empty",
354
+ "funnel",
355
+ "no-recall-audit",
356
+ "recall-sample",
357
+ "recall",
358
+ ];