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,1210 @@
1
+ /**
2
+ * The CORE of build-label-page's generator, with no page layout: the items.json schema and its
3
+ * checks, the leak guard, neutral ids, the key with its `allowed` and `needs_why` lists, the check of
4
+ * the store's dump against the key, and the export (labels and practice). The page is built from its
5
+ * payload by `label-page-html.ts`; the command line is `label-page.ts`. No side effect on import.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { z } from "zod";
11
+
12
+ // ── refusals and output ───────────────────────────────────────────────────────────────────────
13
+ export class Refusal extends Error {
14
+ readonly exitCode: number;
15
+ constructor(exitCode: number, message: string) {
16
+ super(message);
17
+ this.exitCode = exitCode;
18
+ }
19
+ }
20
+ export const fail = (message: string): never => {
21
+ throw new Refusal(1, `REFUSED: ${message}`);
22
+ };
23
+ /** What a run printed, line by line; `cli` takes it out after each run. */
24
+ const printed: string[] = [];
25
+ /** The lines printed since the last call, each ending in a newline. */
26
+ export const takePrinted = (): string =>
27
+ printed
28
+ .splice(0)
29
+ .map((l) => `${l}\n`)
30
+ .join("");
31
+ export const say = (line: string): void => {
32
+ printed.push(line);
33
+ };
34
+
35
+ type Json =
36
+ | null
37
+ | boolean
38
+ | number
39
+ | string
40
+ | readonly Json[]
41
+ | { readonly [key: string]: Json };
42
+ type JsonObject = { readonly [key: string]: Json };
43
+ const isList = (v: Json): v is readonly Json[] => Array.isArray(v);
44
+ const isObject = (v: Json | undefined): v is JsonObject =>
45
+ typeof v === "object" && v !== null && !Array.isArray(v);
46
+ const has = (o: JsonObject, key: string): boolean => Object.hasOwn(o, key);
47
+
48
+ // ── how a value is quoted in a message: as Python wrote it, so messages read as before ───────
49
+ const quoted = (s: string): string => {
50
+ const q = s.includes("'") && !s.includes('"') ? '"' : "'";
51
+ const body = s
52
+ .replaceAll("\\", "\\\\")
53
+ .replaceAll("\n", "\\n")
54
+ .replaceAll("\t", "\\t")
55
+ .replaceAll("\r", "\\r");
56
+ return `${q}${q === "'" ? body.replaceAll("'", "\\'") : body}${q}`;
57
+ };
58
+ const repr = (v: Json | undefined): string => {
59
+ if (v === null || v === undefined) return "None";
60
+ if (typeof v === "boolean") return v ? "True" : "False";
61
+ if (typeof v === "string") return quoted(v);
62
+ if (typeof v === "number") return String(v);
63
+ if (Array.isArray(v)) return `[${v.map(repr).join(", ")}]`;
64
+ return `{${Object.entries(v)
65
+ .map(([k, x]) => `${quoted(k)}: ${repr(x)}`)
66
+ .join(", ")}}`;
67
+ };
68
+ /** The name Python gave the type of a value that is not an object, as the dump refusals print it. */
69
+ const typeName = (v: Json): string => {
70
+ if (v === null) return "NoneType";
71
+ if (Array.isArray(v)) return "list";
72
+ if (typeof v === "number") return Number.isInteger(v) ? "int" : "float";
73
+ return typeof v === "boolean" ? "bool" : "str";
74
+ };
75
+ /** Code-point order of two strings, as Python's `sorted` has it. */
76
+ const byText = (a: string, b: string): number => Number(a > b) - Number(a < b);
77
+ const writeJson = (path: string, value: unknown): void => {
78
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
79
+ };
80
+ const JSON_VALUE = z.json();
81
+ const readJson = (path: string): Json => {
82
+ try {
83
+ return JSON_VALUE.parse(JSON.parse(readFileSync(path, "utf8")));
84
+ } catch (e) {
85
+ return fail(`cannot read ${path} as JSON: ${String(e)}`);
86
+ }
87
+ };
88
+
89
+ // ── THE LEAK GUARD ───────────────────────────────────────────────────────────────────────────
90
+ // What an item may carry. `PAGE_FIELDS` reach the labeller; `PRIVATE_FIELDS` go to key.json only.
91
+ // Anything else on an item — a model answer, a checker verdict, a source label, a difficulty tag —
92
+ // stops the build: a blind page fails by what leaked in, and a field nobody listed is exactly the
93
+ // one nobody thought of. A NEW FIELD IS ADDED HERE ON PURPOSE, after asking "does this tell the
94
+ // labeller what the answer is, or where the item came from?" — never to make a build pass.
95
+ const PAGE_FIELDS = ["group", "title", "body", "code", "context"] as const;
96
+ const PRIVATE_FIELDS = ["source_id"] as const;
97
+ const ALLOWED_FIELDS: readonly string[] = [...PAGE_FIELDS, ...PRIVATE_FIELDS];
98
+
99
+ const FAMILIARITY = [
100
+ { value: "well", label: "Well" },
101
+ { value: "somewhat", label: "Somewhat" },
102
+ { value: "not_really", label: "Not really" },
103
+ ];
104
+ // An id is a key of a plain JavaScript object on the page, so a name Object.prototype already has
105
+ // is refused: `answers["__proto__"] = v` sets the prototype and saves nothing, and an unanswered
106
+ // `answers["constructor"]` reads back a function instead of nothing.
107
+ const RESERVED_NAMES = [
108
+ "__proto__",
109
+ "constructor",
110
+ "prototype",
111
+ "hasOwnProperty",
112
+ "isPrototypeOf",
113
+ "propertyIsEnumerable",
114
+ "toLocaleString",
115
+ "toString",
116
+ "valueOf",
117
+ "__defineGetter__",
118
+ "__defineSetter__",
119
+ "__lookupGetter__",
120
+ "__lookupSetter__",
121
+ ];
122
+ const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
123
+ const SOURCES = ["page", "chat"];
124
+ const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
125
+
126
+ // ── THE CONFIG SCHEMA ────────────────────────────────────────────────────────────────────────
127
+ // items.json is checked ONCE, against this declaration, before anything is built. A refusal names
128
+ // the field's path (`questions[0].options[1].label`). A NEW FIELD IS A NEW LINE HERE; the
129
+ // build-label-page test walks this very table (`schema` prints it) with a wrong type, null and an
130
+ // empty value for each path, so a field without a line is a test failure, not a review finding.
131
+ // The leak guard on items (ALLOWED_FIELDS) stays separate and stays closed.
132
+ type SchemaNode =
133
+ | { kind: "string"; required: boolean; pattern?: string }
134
+ | { kind: "integer"; required: boolean }
135
+ | { kind: "array"; required: boolean; min: number; items: SchemaNode }
136
+ | {
137
+ kind: "map";
138
+ required: boolean;
139
+ min: number;
140
+ key_pattern: string;
141
+ values: SchemaNode;
142
+ }
143
+ | {
144
+ kind: "object";
145
+ required: boolean;
146
+ closed: boolean;
147
+ fields: Readonly<Record<string, SchemaNode>>;
148
+ };
149
+
150
+ const string = (required = true, pattern?: string): SchemaNode =>
151
+ pattern === undefined
152
+ ? { kind: "string", required }
153
+ : { kind: "string", required, pattern };
154
+ const integer = (required = true): SchemaNode => ({
155
+ kind: "integer",
156
+ required,
157
+ });
158
+ const array = (items: SchemaNode, required = true, min = 0): SchemaNode => ({
159
+ kind: "array",
160
+ required,
161
+ min,
162
+ items,
163
+ });
164
+ const mapping = (
165
+ values: SchemaNode,
166
+ keyPattern: string,
167
+ min = 0,
168
+ ): SchemaNode => ({
169
+ kind: "map",
170
+ required: true,
171
+ min,
172
+ key_pattern: keyPattern,
173
+ values,
174
+ });
175
+ const obj = (
176
+ fields: Readonly<Record<string, SchemaNode>>,
177
+ closed = true,
178
+ ): SchemaNode => ({ kind: "object", required: true, closed, fields });
179
+
180
+ const ITEM_SCHEMA = obj(
181
+ {
182
+ source_id: string(),
183
+ ...Object.fromEntries(
184
+ PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
185
+ ),
186
+ },
187
+ false, // an unlisted item field is the leak guard's refusal, with its own message
188
+ );
189
+ // `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
190
+ // reads as decoration, not meaning (the build-label-page look test refuses one).
191
+ // `ask_why` (a prompt) makes the answer incomplete until the labeller says why: an approval page's "drop".
192
+ const OPTION_SCHEMA = obj({
193
+ value: string(),
194
+ label: string(),
195
+ example: string(),
196
+ icon: string(false),
197
+ ask_why: string(false),
198
+ });
199
+ const PRACTICE_SCHEMA = obj({
200
+ ...Object.fromEntries(
201
+ PAGE_FIELDS.filter((f) => f !== "group").map((f) => [
202
+ f,
203
+ string(f === "body"),
204
+ ]),
205
+ ),
206
+ expected: mapping(string(), NAME_PATTERN, 1),
207
+ why: string(),
208
+ });
209
+ const MIN_PRACTICE = 3;
210
+ const QUESTION_SCHEMA = obj({
211
+ id: string(true, NAME_PATTERN),
212
+ text: string(),
213
+ short: string(false),
214
+ options: array(OPTION_SCHEMA, true, 2),
215
+ });
216
+ export const CONFIG_SCHEMA = obj({
217
+ study: string(true, NAME_PATTERN), // becomes a file name
218
+ title: string(),
219
+ intro: string(false),
220
+ seed: integer(),
221
+ groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
222
+ labeller_knows: array(string(true, NAME_PATTERN), false),
223
+ familiarity_question: string(false),
224
+ questions: array(QUESTION_SCHEMA, true, 1),
225
+ practice: array(PRACTICE_SCHEMA, true, MIN_PRACTICE),
226
+ practice_round: integer(false),
227
+ items: array(ITEM_SCHEMA, true, 1),
228
+ });
229
+
230
+ // ── checking a value against the schema ──────────────────────────────────────────────────────
231
+ const at = (path: string, name: string): string =>
232
+ path ? `${path}.${name}` : name;
233
+ const fullMatch = (pattern: string, s: string): boolean =>
234
+ new RegExp(`^(?:${pattern})$`, "u").test(s);
235
+ const ID_RULE = "letters, digits, - and _ only";
236
+
237
+ function checkString(
238
+ value: Json,
239
+ node: { pattern?: string },
240
+ where: string,
241
+ ): void {
242
+ if (typeof value !== "string" || !value.trim()) {
243
+ fail(`${where}: must be a non-empty string, got ${repr(value)}`);
244
+ } else if (node.pattern !== undefined && !fullMatch(node.pattern, value)) {
245
+ fail(`${where}: ${repr(value)} must be ${ID_RULE}`);
246
+ }
247
+ }
248
+ const checkCount = (n: number, min: number, where: string): void => {
249
+ if (n < min)
250
+ fail(`${where}: needs ${String(min)} or more entries, has ${String(n)}`);
251
+ };
252
+ function checkArray(
253
+ value: Json,
254
+ node: { min: number; items: SchemaNode },
255
+ path: string,
256
+ where: string,
257
+ ): void {
258
+ if (!isList(value))
259
+ return fail(`${where}: must be a list, got ${repr(value)}`);
260
+ checkCount(value.length, node.min, where);
261
+ value.forEach((entry, n) => {
262
+ checkValue(entry, node.items, `${path}[${String(n)}]`);
263
+ });
264
+ }
265
+ function checkMap(
266
+ value: Json,
267
+ node: { min: number; key_pattern: string; values: SchemaNode },
268
+ path: string,
269
+ where: string,
270
+ ): void {
271
+ if (!isObject(value))
272
+ return fail(`${where}: must be an object, got ${repr(value)}`);
273
+ checkCount(Object.keys(value).length, node.min, where);
274
+ for (const [key, entry] of Object.entries(value)) {
275
+ if (!fullMatch(node.key_pattern, key))
276
+ fail(`${at(path, key)}: key must be ${ID_RULE}`);
277
+ checkValue(entry, node.values, at(path, key));
278
+ }
279
+ }
280
+ function checkObject(
281
+ value: Json,
282
+ node: { closed: boolean; fields: Readonly<Record<string, SchemaNode>> },
283
+ path: string,
284
+ where: string,
285
+ ): void {
286
+ if (!isObject(value))
287
+ return fail(`${where}: must be an object, got ${repr(value)}`);
288
+ for (const [name, sub] of Object.entries(node.fields)) {
289
+ if (has(value, name)) checkValue(value[name] ?? null, sub, at(path, name));
290
+ else if (sub.required) fail(`${at(path, name)}: is required and missing`);
291
+ }
292
+ const unknown = Object.keys(value).find((name) => !has(node.fields, name));
293
+ if (node.closed && unknown !== undefined) {
294
+ fail(
295
+ `${at(path, unknown)}: not a known field (known: ${Object.keys(node.fields).join(", ")})`,
296
+ );
297
+ }
298
+ }
299
+ /** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
300
+ function checkValue(value: Json, node: SchemaNode, path: string): void {
301
+ const where = path || "items.json";
302
+ switch (node.kind) {
303
+ case "string":
304
+ checkString(value, node, where);
305
+ break;
306
+ case "integer":
307
+ if (typeof value !== "number" || !Number.isInteger(value)) {
308
+ fail(`${where}: must be an integer, got ${repr(value)}`);
309
+ }
310
+ break;
311
+ case "array":
312
+ checkArray(value, node, path, where);
313
+ break;
314
+ case "map":
315
+ checkMap(value, node, path, where);
316
+ break;
317
+ case "object":
318
+ checkObject(value, node, path, where);
319
+ }
320
+ }
321
+
322
+ // ── the config, once it passed the schema ─────────────────────────────────────────────────────
323
+ interface Option {
324
+ readonly value: string;
325
+ readonly label: string;
326
+ readonly example: string;
327
+ readonly icon?: string;
328
+ readonly ask_why?: string;
329
+ }
330
+ interface Question {
331
+ readonly id: string;
332
+ readonly text: string;
333
+ readonly short?: string;
334
+ readonly options: readonly Option[];
335
+ }
336
+ type Item = Readonly<Record<string, string>> & {
337
+ readonly source_id: string;
338
+ readonly group: string;
339
+ };
340
+ interface Practice {
341
+ readonly title?: string;
342
+ readonly body: string;
343
+ readonly code?: string;
344
+ readonly context?: string;
345
+ readonly expected: Readonly<Record<string, string>>;
346
+ readonly why: string;
347
+ }
348
+ interface Config {
349
+ readonly study: string;
350
+ readonly title: string;
351
+ readonly intro?: string;
352
+ readonly seed: number;
353
+ readonly groups: Readonly<Record<string, string>>;
354
+ readonly labeller_knows?: readonly string[];
355
+ readonly familiarity_question?: string;
356
+ readonly questions: readonly Question[];
357
+ readonly practice: readonly Practice[];
358
+ readonly practice_round?: number;
359
+ readonly items: readonly Item[];
360
+ }
361
+ /** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
362
+ function assertConfig(value: Json): asserts value is Json & Config {
363
+ checkValue(value, CONFIG_SCHEMA, "");
364
+ }
365
+
366
+ /**
367
+ * THE definition of a valid answer: the values the build offers, per question and for familiarity.
368
+ * The page script (`DATA.allowed`) and the exporter (`key.json`) both read this one result, so a
369
+ * stored value counts as an answer on the page exactly when it counts in the export.
370
+ */
371
+ const allowedValues = (questions: readonly Question[]): JsonObject => ({
372
+ questions: Object.fromEntries(
373
+ questions.map((q) => [q.id, q.options.map((o) => o.value)]),
374
+ ),
375
+ // The answers that need a reason, per question: the page and the export read the same list.
376
+ needs_why: Object.fromEntries(
377
+ questions.map((q) => [
378
+ q.id,
379
+ q.options.filter((o) => o.ask_why !== undefined).map((o) => o.value),
380
+ ]),
381
+ ),
382
+ familiarity: FAMILIARITY.map((o) => o.value),
383
+ });
384
+
385
+ /** Two values that read the same once case and surrounding space are gone. */
386
+ const readsAs = (s: string): string => s.trim().toLowerCase();
387
+
388
+ /** The first entry whose reading repeats an earlier one, with the index of that earlier one. */
389
+ const firstRepeat = (
390
+ labels: readonly string[],
391
+ ): { readonly at: number; readonly earlier: number } | undefined => {
392
+ const seen = labels.map(readsAs);
393
+ const at = seen.findIndex((x, i) => seen.indexOf(x) !== i);
394
+ return at === -1
395
+ ? undefined
396
+ : { at, earlier: seen.findIndex((x) => x === seen[at]) };
397
+ };
398
+
399
+ // What a schema cannot say: references between fields, and values that must differ.
400
+ function checkGroups(cfg: Config): void {
401
+ // The page heads each group with its label: two that read the same are two familiarity questions
402
+ // the labeller cannot tell apart, exported under different group ids.
403
+ const ids = Object.keys(cfg.groups);
404
+ const labels = Object.values(cfg.groups);
405
+ const twice = firstRepeat(labels);
406
+ if (twice !== undefined) {
407
+ fail(
408
+ `groups.${String(ids[twice.at])}: ${repr(labels[twice.at])} reads the same as groups.${String(ids[twice.earlier])} — each group needs its own label`,
409
+ );
410
+ }
411
+ (cfg.labeller_knows ?? []).forEach((gid, n) => {
412
+ if (!has(cfg.groups, gid))
413
+ fail(`labeller_knows[${String(n)}]: ${repr(gid)} is not in \`groups\``);
414
+ });
415
+ }
416
+ function checkQuestion(
417
+ q: Question,
418
+ qn: number,
419
+ earlierIds: readonly string[],
420
+ ): void {
421
+ if (earlierIds.includes(q.id))
422
+ fail(`questions[${String(qn)}].id: ${repr(q.id)} is repeated`);
423
+ const values = q.options.map((o) => o.value);
424
+ if (new Set(values).size !== values.length) {
425
+ fail(
426
+ `questions[${String(qn)}].options: values must be distinct, got ${repr(values)}`,
427
+ );
428
+ }
429
+ // Two buttons that read the same store different answers no one can tell apart afterwards.
430
+ const twice = firstRepeat(q.options.map((o) => o.label));
431
+ if (twice !== undefined) {
432
+ fail(
433
+ `questions[${String(qn)}].options[${String(twice.at)}].label: ${repr(q.options[twice.at]?.label)} reads the same as options[${String(twice.earlier)}].label — each button needs its own label`,
434
+ );
435
+ }
436
+ }
437
+ /**
438
+ * Each practice item knows the answer to every question, and only offered answers: a practice the
439
+ * page cannot score would let a misread question through unseen.
440
+ */
441
+ function checkPractice(cfg: Config): void {
442
+ const offered: Readonly<Record<string, readonly string[]>> =
443
+ Object.fromEntries(
444
+ cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]),
445
+ );
446
+ if ((cfg.practice_round ?? 1) < 1) {
447
+ fail(`practice_round: ${repr(cfg.practice_round)} — must be 1 or more`);
448
+ }
449
+ cfg.practice.forEach((item, n) => {
450
+ const where = `practice[${String(n)}].expected`;
451
+ for (const [q, v] of Object.entries(item.expected)) {
452
+ const values = offered[q];
453
+ if (values === undefined)
454
+ fail(
455
+ `${where}.${q}: not a question of this page (${Object.keys(offered).join(", ")})`,
456
+ );
457
+ else if (!values.includes(v))
458
+ fail(`${where}.${q}: ${repr(v)} is not one of ${values.join(", ")}`);
459
+ }
460
+ const missing = Object.keys(offered).find((q) => !has(item.expected, q));
461
+ if (missing !== undefined)
462
+ fail(`${where}.${missing}: every question needs its expected answer`);
463
+ });
464
+ }
465
+ function checkConfig(cfg: Config): void {
466
+ checkGroups(cfg);
467
+ cfg.questions.forEach((q, qn) => {
468
+ checkQuestion(
469
+ q,
470
+ qn,
471
+ cfg.questions.slice(0, qn).map((x) => x.id),
472
+ );
473
+ });
474
+ checkPractice(cfg);
475
+ }
476
+
477
+ /** The practice as the page shows it and the key keeps it: neutral ids P1, P2 … in the given order. */
478
+ const practiceOf = (
479
+ cfg: Config,
480
+ ): readonly (Practice & { readonly id: string })[] =>
481
+ cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
482
+
483
+ // ── the items: the leak guard, and the references to the config ───────────────────────────────
484
+ function checkItemFields(cfg: Config): void {
485
+ const sids: string[] = [];
486
+ cfg.items.forEach((item, n) => {
487
+ const extra = Object.keys(item)
488
+ .filter((f) => !ALLOWED_FIELDS.includes(f))
489
+ .sort();
490
+ if (extra.length) {
491
+ fail(
492
+ `item #${String(n)} (${item.source_id}) carries ${extra.join(", ")}, which is not in the allow-list ${repr([...ALLOWED_FIELDS].sort())}. A blind page shows the labeller the item and nothing about the answer or the source. Remove the field from items.json; add it to PAGE_FIELDS only if it is safe for the labeller to see.`,
493
+ );
494
+ }
495
+ if (sids.includes(item.source_id))
496
+ fail(
497
+ `items[${String(n)}].source_id: ${repr(item.source_id)} is repeated`,
498
+ );
499
+ sids.push(item.source_id);
500
+ if (!has(cfg.groups, item.group))
501
+ fail(
502
+ `items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``,
503
+ );
504
+ });
505
+ }
506
+ const escapeRegExp = (s: string): string =>
507
+ s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
508
+ /**
509
+ * A source id typed into a visible field gives the source away as well as a field would. Short ids
510
+ * ("7") would match ordinary text, so only ids of four characters or more are looked for.
511
+ */
512
+ function checkNoSourceShown(cfg: Config): void {
513
+ const long = cfg.items
514
+ .map((i) => i.source_id)
515
+ .filter((sid) => sid.length >= 4);
516
+ for (const item of cfg.items) {
517
+ for (const sid of long) {
518
+ const word = new RegExp(
519
+ `(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`,
520
+ "u",
521
+ );
522
+ const field = PAGE_FIELDS.find((f) => word.test(item[f] ?? ""));
523
+ if (field !== undefined) {
524
+ fail(
525
+ `the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`,
526
+ );
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ // ── ids: neutral, in an order fixed by the seed, kept across rebuilds ─────────────────────────
533
+ interface KeyEntry {
534
+ readonly source_id: string;
535
+ readonly group: string;
536
+ readonly on_page: boolean;
537
+ }
538
+ interface OldKey {
539
+ readonly seed: number;
540
+ readonly ids: Readonly<Record<string, KeyEntry>>;
541
+ }
542
+ /** key.json is this script's own output, written by `build`; its shape is trusted, not re-checked. */
543
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- an assertion signature, no body to run
544
+ function assertOwnKey(_value: Json): asserts _value is Json & OldKey {}
545
+ const readOldKey = (keyPath: string, seed: number): OldKey | undefined => {
546
+ if (!existsSync(keyPath)) return undefined;
547
+ const old = readJson(keyPath);
548
+ assertOwnKey(old);
549
+ if (old.seed !== seed) {
550
+ fail(
551
+ `${keyPath} was made with seed ${String(old.seed)}; this config says ${String(seed)}. Answers are keyed by the old ids: restore the seed, or start a new study folder.`,
552
+ );
553
+ }
554
+ return old;
555
+ };
556
+ /** Neutral ids G001… in an order fixed by the seed, and kept when a key file already exists. */
557
+ function assignIds(
558
+ items: readonly Item[],
559
+ seed: number,
560
+ keyPath: string,
561
+ ): {
562
+ readonly ids: ReadonlyMap<string, string>;
563
+ readonly entries: Readonly<Record<string, KeyEntry>>;
564
+ } {
565
+ const old = readOldKey(keyPath, seed);
566
+ const entries = old?.ids ?? {};
567
+ const known = new Map(
568
+ Object.entries(entries).map(([nid, v]) => [v.source_id, nid]),
569
+ );
570
+ const rank = (s: string): string =>
571
+ createHash("sha256")
572
+ .update(`${String(seed)}:${s}`)
573
+ .digest("hex");
574
+ const fresh = items
575
+ .map((i) => i.source_id)
576
+ .filter((sid) => !known.has(sid))
577
+ .sort((a, b) => byText(rank(a), rank(b)));
578
+ const ids = new Map([
579
+ ...known,
580
+ ...fresh.map((sid, k): [string, string] => [
581
+ sid,
582
+ `G${String(known.size + k + 1).padStart(3, "0")}`,
583
+ ]),
584
+ ]);
585
+ return { ids, entries };
586
+ }
587
+
588
+ // ── a build, short of the page ──────────────────────────────────────────────────────────────────
589
+ const shownFields = (item: Item): Readonly<Record<string, string>> =>
590
+ Object.fromEntries(
591
+ PAGE_FIELDS.flatMap((f) => {
592
+ const v = item[f];
593
+ return v === undefined ? [] : [[f, v]];
594
+ }),
595
+ );
596
+
597
+ /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
598
+ function itemsOnPage(cfg: Config, keyPath: string) {
599
+ const knows = cfg.labeller_knows;
600
+ const items = cfg.items.filter(
601
+ (i) => knows === undefined || knows.includes(i.group),
602
+ );
603
+ if (!items.length) fail("no item is left after `labeller_knows`");
604
+ const { ids, entries } = assignIds(items, cfg.seed, keyPath);
605
+ // Every item on the page was given an id by `assignIds` above.
606
+ const idOf = (i: Item): string => String(ids.get(i.source_id));
607
+ const sorted = [...items].sort((a, b) => byText(idOf(a), idOf(b)));
608
+ return { items: sorted, ids, entries, idOf };
609
+ }
610
+ const practiceRound = (cfg: Config): number => cfg.practice_round ?? 1;
611
+
612
+ /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
613
+ function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>) {
614
+ const used = Object.keys(cfg.groups).filter((g) =>
615
+ page.items.some((i) => i.group === g),
616
+ );
617
+ return {
618
+ title: cfg.title,
619
+ intro: cfg.intro ?? "",
620
+ groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
621
+ familiarity_question:
622
+ cfg.familiarity_question ?? "How well do you know {group}?",
623
+ familiarity: FAMILIARITY,
624
+ allowed: allowedValues(cfg.questions),
625
+ questions: cfg.questions,
626
+ practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
627
+ items: page.items.map((i) => ({ id: page.idOf(i), ...shownFields(i) })),
628
+ };
629
+ }
630
+ export type Payload = ReturnType<typeof payloadOf>;
631
+ /** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
632
+ function idsOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): JsonObject {
633
+ const shown = new Set(page.items.map(page.idOf));
634
+ const groupOf = new Map([
635
+ ...Object.values(page.entries).map((v): [string, string] => [
636
+ v.source_id,
637
+ v.group,
638
+ ]),
639
+ ...cfg.items.map((i): [string, string] => [i.source_id, i.group]),
640
+ ]);
641
+ const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
642
+ return Object.fromEntries(
643
+ byId.map(([sid, nid]) => [
644
+ nid,
645
+ {
646
+ source_id: sid,
647
+ group: String(groupOf.get(sid)),
648
+ on_page: shown.has(nid),
649
+ },
650
+ ]),
651
+ );
652
+ }
653
+ /** Round → its practice items' expected answers. */
654
+ type Rounds = Readonly<Record<string, Expected>>;
655
+ type Expected = Readonly<Record<string, Readonly<Record<string, string>>>>;
656
+ /** Every round of a key's practice; a key from before `rounds` has only its own round. */
657
+ const roundsIn = (p: NonNullable<Key["practice"]>): Rounds =>
658
+ p.rounds ?? { [String(p.round)]: p.expected };
659
+ /** The rounds an existing key holds, kept by the next build. */
660
+ const roundsOf = (keyPath: string): Rounds => {
661
+ if (!existsSync(keyPath)) return {};
662
+ const old = readJson(keyPath);
663
+ assertKey(old);
664
+ return old.practice === undefined ? {} : roundsIn(old.practice);
665
+ };
666
+ function keyOf(
667
+ cfg: Config,
668
+ page: ReturnType<typeof itemsOnPage>,
669
+ earlier: Rounds,
670
+ ): JsonObject {
671
+ const practice = practiceOf(cfg);
672
+ return {
673
+ study: cfg.study,
674
+ seed: cfg.seed,
675
+ questions: cfg.questions.map((q) => q.id),
676
+ // What counts as an answer: the same lists the page was given (`allowedValues`). `export`
677
+ // checks every stored answer and familiarity against them, so the build's options decide.
678
+ allowed: allowedValues(cfg.questions),
679
+ // The practice is scored against this at export; its answers never go into the labels.
680
+ practice: {
681
+ round: practiceRound(cfg),
682
+ expected: Object.fromEntries(practice.map((p) => [p.id, p.expected])),
683
+ // Every round's items and expected answers, kept across rebuilds: an earlier round's stored
684
+ // answers stay known to the export (a later round may have fewer items) and are scored too.
685
+ rounds: {
686
+ ...earlier,
687
+ [String(practiceRound(cfg))]: Object.fromEntries(
688
+ practice.map((p) => [p.id, p.expected]),
689
+ ),
690
+ },
691
+ },
692
+ ids: idsOf(cfg, page),
693
+ };
694
+ }
695
+ /**
696
+ * Everything a build does but lay out the page: reads and checks items.json (the schema, the
697
+ * cross-field checks, the leak guard), gives the items their ids, writes key.json beside the page and
698
+ * returns what the page shows. A page written by hand can take its blindness and its key from here.
699
+ */
700
+ export function prepareBuild(configPath: string, outDir: string) {
701
+ const cfg = readJson(configPath);
702
+ assertConfig(cfg);
703
+ checkConfig(cfg);
704
+ checkItemFields(cfg);
705
+ checkNoSourceShown(cfg);
706
+ mkdirSync(outDir, { recursive: true });
707
+ const keyPath = join(outDir, "key.json");
708
+ const earlier = roundsOf(keyPath);
709
+ const page = itemsOnPage(cfg, keyPath);
710
+ writeJson(keyPath, keyOf(cfg, page, earlier));
711
+ return {
712
+ payload: payloadOf(cfg, page),
713
+ dropped: cfg.items.length - page.items.length,
714
+ };
715
+ }
716
+
717
+ // ── the store's dump, checked against the key ─────────────────────────────────────────────────
718
+ interface Key {
719
+ readonly study: string;
720
+ readonly questions: readonly string[];
721
+ readonly allowed?: Json;
722
+ readonly practice?: {
723
+ readonly round: number;
724
+ readonly expected: Readonly<
725
+ Record<string, Readonly<Record<string, string>>>
726
+ >;
727
+ /** Round → its items' expected answers; a key from before this field has only `round`. */
728
+ readonly rounds?: Rounds;
729
+ };
730
+ readonly ids: Readonly<Record<string, KeyEntry>>;
731
+ }
732
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- an assertion signature, no body to run
733
+ function assertKey(_value: Json): asserts _value is Json & Key {
734
+ // key.json is this script's own output; what an older build left out is checked where it is read.
735
+ }
736
+
737
+ const ANSWER_DOC_FIELDS = ["answers", "reasons", "savedAt", "source"];
738
+ const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
739
+ const DOC_FIELD_TYPES: readonly (readonly [
740
+ string,
741
+ (v: Json) => boolean,
742
+ string,
743
+ ])[] = [
744
+ ["answers", isObject, "an object {question: value}"],
745
+ ["reasons", isObject, "an object {question: text}"],
746
+ [
747
+ "familiarity",
748
+ (v) => v === null || typeof v === "string",
749
+ "a familiarity value (a string) or null",
750
+ ],
751
+ [
752
+ "savedAt",
753
+ (v) => v === null || typeof v === "string",
754
+ "a timestamp string or null",
755
+ ],
756
+ ];
757
+ const COLLECTIONS = ["answers", "groups", "practice"] as const;
758
+ type Collection = (typeof COLLECTIONS)[number];
759
+ const COLLECTION_NAMES: readonly string[] = COLLECTIONS;
760
+ const NOUN: Readonly<Record<Collection, string>> = {
761
+ answers: "answer",
762
+ groups: "group",
763
+ practice: "practice item",
764
+ };
765
+
766
+ /**
767
+ * The store documents a practice of this key may have: every round up to the key's, so the answers
768
+ * of an earlier round are known (and kept), not refused.
769
+ */
770
+ const practiceDocs = (
771
+ key: Key,
772
+ ): readonly (readonly [string, readonly string[]])[] => {
773
+ const p = key.practice;
774
+ if (p === undefined) return [];
775
+ // Each round's own questions: a later round may have dropped or renamed one.
776
+ return Object.entries(roundsIn(p)).flatMap(([r, expected]) =>
777
+ Object.entries(expected).map(
778
+ ([pid, answers]) => [`r${r}-${pid}`, Object.keys(answers)] as const,
779
+ ),
780
+ );
781
+ };
782
+ /** For each collection: the names the key knows, the names still on the page, a document's fields. */
783
+ interface Names {
784
+ /** Each name the key knows, with the questions a document of it may answer. */
785
+ readonly known: readonly (readonly [string, readonly string[]])[];
786
+ readonly onPage: readonly string[];
787
+ readonly fields: readonly string[];
788
+ }
789
+ function knownNames(key: Key): Readonly<Record<Collection, Names>> {
790
+ const entries = Object.entries(key.ids);
791
+ const shown = entries.filter(([, m]) => m.on_page);
792
+ const practice = practiceDocs(key);
793
+ return {
794
+ answers: {
795
+ known: entries.map(([nid]) => [nid, key.questions] as const),
796
+ onPage: shown.map(([nid]) => nid),
797
+ fields: ANSWER_DOC_FIELDS,
798
+ },
799
+ groups: {
800
+ known: entries.map(([, m]) => [m.group, key.questions] as const),
801
+ onPage: shown.map(([, m]) => m.group),
802
+ fields: GROUP_DOC_FIELDS,
803
+ },
804
+ practice: {
805
+ known: practice,
806
+ onPage: practice.map(([n]) => n),
807
+ fields: ANSWER_DOC_FIELDS,
808
+ },
809
+ };
810
+ }
811
+ const sortedEntries = (o: JsonObject): readonly (readonly [string, Json])[] =>
812
+ Object.entries(o).sort(([a], [b]) => byText(a, b));
813
+
814
+ function checkDumpShape(dump: Json): asserts dump is JsonObject {
815
+ if (!isObject(dump))
816
+ return fail(
817
+ `store: ${typeName(dump)} — must be an object {answers, groups, practice}`,
818
+ );
819
+ for (const kind of COLLECTIONS) {
820
+ const c = dump[kind];
821
+ if (c !== undefined && !isObject(c))
822
+ fail(`${kind}: ${typeName(c)} — must be an object {id: stored document}`);
823
+ }
824
+ const stray = Object.keys(dump)
825
+ .sort()
826
+ .find((top) => !COLLECTION_NAMES.includes(top));
827
+ if (stray !== undefined)
828
+ fail(
829
+ `${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`,
830
+ );
831
+ }
832
+ function checkDocName(
833
+ where: string,
834
+ name: string,
835
+ kind: Collection,
836
+ names: Names,
837
+ ): readonly string[] {
838
+ const hit = names.known.find(([n]) => n === name);
839
+ if (hit === undefined) {
840
+ return fail(
841
+ `${where}: the key does not know this ${NOUN[kind]} (${[...new Set(names.known.map(([n]) => n))].sort().join(", ")})`,
842
+ );
843
+ }
844
+ if (!names.onPage.includes(name)) {
845
+ fail(
846
+ `${where}: stored, but no longer on the page (a rebuild left it out), so the export would drop it; rebuild with it on the page, or take it out of the dump on purpose`,
847
+ );
848
+ }
849
+ return hit[1];
850
+ }
851
+ function checkDocFields(
852
+ where: string,
853
+ doc: Json,
854
+ kind: Collection,
855
+ fields: readonly string[],
856
+ ): asserts doc is JsonObject {
857
+ if (!isObject(doc))
858
+ return fail(`${where}: must be an object with ${fields.join(", ")}`);
859
+ const stray = Object.keys(doc)
860
+ .sort()
861
+ .find((f) => !fields.includes(f));
862
+ if (stray !== undefined)
863
+ fail(
864
+ `${where}/${stray}: not a field of a stored ${NOUN[kind]} (${fields.join(", ")})`,
865
+ );
866
+ if (!has(doc, "source"))
867
+ fail(
868
+ `${where}: no source; add "source": "chat" or "page" (one of ${SOURCES_SHOWN})`,
869
+ );
870
+ const source = doc["source"];
871
+ if (typeof source !== "string" || !SOURCES.includes(source)) {
872
+ fail(`${where}/source: ${repr(source)}; expected one of ${SOURCES_SHOWN}`);
873
+ }
874
+ // The types the export reads, so a hand merge is refused by path rather than crashing it.
875
+ for (const [field, ok, what] of DOC_FIELD_TYPES) {
876
+ const v = doc[field];
877
+ if (v !== undefined && !ok(v))
878
+ fail(`${where}/${field}: ${repr(v)} — must be ${what}`);
879
+ }
880
+ }
881
+ function checkDocAnswers(
882
+ where: string,
883
+ doc: JsonObject,
884
+ questions: readonly string[],
885
+ ): void {
886
+ const known = (part: string, q: string): void => {
887
+ if (!questions.includes(q))
888
+ fail(
889
+ `${where}/${part}/${q}: not a question of the key (${questions.join(", ")})`,
890
+ );
891
+ };
892
+ for (const [q, v] of sortedEntries(
893
+ isObject(doc["answers"]) ? doc["answers"] : {},
894
+ )) {
895
+ known("answers", q);
896
+ if (v !== null && typeof v !== "string")
897
+ fail(
898
+ `${where}/answers/${q}: ${repr(v)} — must be an option value (a string) or null`,
899
+ );
900
+ }
901
+ for (const [q, v] of sortedEntries(
902
+ isObject(doc["reasons"]) ? doc["reasons"] : {},
903
+ )) {
904
+ known("reasons", q);
905
+ if (typeof v !== "string")
906
+ fail(`${where}/reasons/${q}: ${repr(v)} — must be text`);
907
+ }
908
+ }
909
+ /**
910
+ * The whole store against the key, ONCE, before anything is read from it. Every name in it must be
911
+ * one the key knows: an item id, a group, a question id, a field of the document; an item or group
912
+ * must also still be on the page, or a rebuild that left it out would drop its stored answer. A
913
+ * name the export does not read would otherwise be dropped without a word — a misspelled question
914
+ * key in a chat merge (`boundry`), a misspelled group (`pyhton`) — and its answer lost from the
915
+ * labels. Refuses, naming the path; the fix is one edit in the dump. Every document also says where
916
+ * it came from: the page always writes `source: "page"`, so a document without one was merged by hand.
917
+ */
918
+ function checkDump(dump: Json, key: Key): asserts dump is JsonObject {
919
+ checkDumpShape(dump);
920
+ const names = knownNames(key);
921
+ for (const kind of COLLECTIONS) {
922
+ const collection = dump[kind];
923
+ for (const [name, doc] of sortedEntries(
924
+ isObject(collection) ? collection : {},
925
+ )) {
926
+ const where = `${kind}/${name}`;
927
+ const questions = checkDocName(where, name, kind, names[kind]);
928
+ checkDocFields(where, doc, kind, names[kind].fields);
929
+ checkDocAnswers(where, doc, questions);
930
+ }
931
+ }
932
+ }
933
+
934
+ // ── export ────────────────────────────────────────────────────────────────────────────────────
935
+ interface Allowed {
936
+ readonly questions: Readonly<Record<string, readonly string[]>>;
937
+ readonly needs_why?: Readonly<Record<string, readonly string[]>>;
938
+ readonly familiarity: readonly string[];
939
+ }
940
+ /** The key's `allowed` lists, or a refusal naming a key made by an older build. */
941
+ function assertAllowed(
942
+ a: Json | undefined,
943
+ key: Key,
944
+ keyPath: string,
945
+ ): asserts a is Json & Allowed {
946
+ const questions = isObject(a) ? a["questions"] : undefined;
947
+ const complete =
948
+ isObject(a) &&
949
+ has(a, "familiarity") &&
950
+ isObject(questions) &&
951
+ key.questions.every((q) => has(questions, q));
952
+ if (!complete) {
953
+ fail(
954
+ `${keyPath} has no \`allowed\` values for every question and for familiarity (made by an older build). Run \`build\` again with the same items.json and seed: ids are kept, the key gains them.`,
955
+ );
956
+ }
957
+ }
958
+ const docOf = (collection: Json | undefined, name: string): JsonObject => {
959
+ const doc = isObject(collection) ? collection[name] : undefined;
960
+ return isObject(doc) ? doc : {};
961
+ };
962
+ type Lists = Readonly<Record<string, readonly string[]>>;
963
+ /** The values a question offers (or needs a reason for); the key has a list for every question. */
964
+ const valuesOf = (lists: Lists, q: string): readonly string[] =>
965
+ Object.entries(lists)
966
+ .filter(([k]) => k === q)
967
+ .flatMap(([, vs]) => vs);
968
+ const listed = (lists: Lists, q: string, v: string): boolean =>
969
+ valuesOf(lists, q).includes(v);
970
+ const textOf = (v: Json | undefined): string | null =>
971
+ typeof v === "string" ? v : null;
972
+ /** The text values of a stored object (answers, reasons): a null answer is no text. */
973
+ const textsOf = (o: Json | undefined): Readonly<Record<string, string>> =>
974
+ Object.fromEntries(
975
+ Object.entries(isObject(o) ? o : {}).filter(
976
+ (e): e is [string, string] => typeof e[1] === "string",
977
+ ),
978
+ );
979
+ interface Ctx {
980
+ readonly key: Key;
981
+ readonly allowed: Json & Allowed;
982
+ }
983
+
984
+ /**
985
+ * An answer outside the question's allowed values (a typo in a chat answer, an option that was
986
+ * removed or renamed between builds) is MARKED, not refused and not dropped: the item is
987
+ * incomplete, the raw value is kept under `invalid_answers`, and the report names the item.
988
+ * Refusing would hold every good label hostage to one typo at the moment the export must happen
989
+ * (checklist rule 6); dropping it would lose what the labeller said.
990
+ */
991
+ function answersOf(doc: JsonObject, ctx: Ctx) {
992
+ const stored = textsOf(doc["answers"]);
993
+ const raw = ctx.key.questions.map((q): [string, string | null] => [
994
+ q,
995
+ stored[q] ?? null,
996
+ ]);
997
+ const invalid = Object.fromEntries(
998
+ raw.filter(([q, v]) => v !== null && !listed(ctx.allowed.questions, q, v)),
999
+ );
1000
+ const given = Object.fromEntries(
1001
+ raw.map(([q, v]) => [q, has(invalid, q) ? null : v]),
1002
+ );
1003
+ return { given, invalid };
1004
+ }
1005
+ /** One item of the labels file: its answers, what is invalid, the reasons, and whether it is done. */
1006
+ function labelOf(nid: string, meta: KeyEntry, doc: JsonObject, ctx: Ctx) {
1007
+ const { given, invalid } = answersOf(doc, ctx);
1008
+ const reasons = Object.fromEntries(
1009
+ Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()),
1010
+ );
1011
+ const needsWhy = ctx.allowed.needs_why ?? {};
1012
+ const unexplained = Object.entries(given)
1013
+ .filter(
1014
+ ([q, v]) => v !== null && listed(needsWhy, q, v) && !has(reasons, q),
1015
+ )
1016
+ .map(([q]) => q);
1017
+ return {
1018
+ id: nid,
1019
+ source_id: meta.source_id,
1020
+ group: meta.group,
1021
+ answers: given,
1022
+ invalid_answers: invalid,
1023
+ reasons,
1024
+ unexplained,
1025
+ complete:
1026
+ Object.values(given).every((v) => v !== null) && !unexplained.length,
1027
+ source: textOf(doc["source"]),
1028
+ saved_at: doc["savedAt"] ?? null,
1029
+ };
1030
+ }
1031
+ type Label = ReturnType<typeof labelOf>;
1032
+
1033
+ /** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
1034
+ function groupsOf(
1035
+ groupIds: readonly string[],
1036
+ groups: Json | undefined,
1037
+ allowed: Allowed,
1038
+ ) {
1039
+ return Object.fromEntries(
1040
+ groupIds.map((g) => {
1041
+ const doc = docOf(groups, g);
1042
+ const fam = textOf(doc["familiarity"]);
1043
+ const bad = fam !== null && !allowed.familiarity.includes(fam);
1044
+ return [
1045
+ g,
1046
+ {
1047
+ familiarity: bad ? null : fam,
1048
+ invalid_familiarity: bad ? fam : null,
1049
+ source: textOf(doc["source"]),
1050
+ saved_at: doc["savedAt"] ?? null,
1051
+ },
1052
+ ];
1053
+ }),
1054
+ );
1055
+ }
1056
+ function reportLabels(items: readonly Label[], allowed: Allowed): void {
1057
+ for (const i of items) {
1058
+ for (const [q, v] of Object.entries(i.invalid_answers)) {
1059
+ say(
1060
+ ` invalid ${i.id}: ${q}=${repr(v)} is not one of ${valuesOf(allowed.questions, q).join(", ")} (not counted)`,
1061
+ );
1062
+ }
1063
+ const missing = Object.entries(i.answers)
1064
+ .filter(([q, v]) => v === null && !has(i.invalid_answers, q))
1065
+ .map(([q]) => q);
1066
+ if (missing.length)
1067
+ say(` incomplete ${i.id}: missing ${missing.join(", ")}`);
1068
+ for (const q of i.unexplained)
1069
+ say(` incomplete ${i.id}: ${q}=${String(i.answers[q])} needs a reason`);
1070
+ }
1071
+ }
1072
+ function reportGroups(
1073
+ groups: ReturnType<typeof groupsOf>,
1074
+ allowed: Allowed,
1075
+ ): void {
1076
+ for (const [g, v] of Object.entries(groups)) {
1077
+ if (v.invalid_familiarity !== null) {
1078
+ say(
1079
+ ` invalid group ${g}: familiarity=${repr(v.invalid_familiarity)} is not one of ${allowed.familiarity.join(", ")} (not counted as rated)`,
1080
+ );
1081
+ } else if (v.familiarity === null)
1082
+ say(` group ${g}: no familiarity answer`);
1083
+ }
1084
+ }
1085
+ /** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
1086
+ function scorePractice(dump: JsonObject, round: string, all: Expected) {
1087
+ return Object.entries(all).map(([pid, expected]) => {
1088
+ const doc = docOf(dump["practice"], `r${round}-${pid}`);
1089
+ const answers = isObject(doc["answers"]) ? doc["answers"] : {};
1090
+ const matched = Object.entries(expected).every(
1091
+ ([q, v]) => answers[q] === v,
1092
+ );
1093
+ return {
1094
+ id: pid,
1095
+ answers,
1096
+ expected,
1097
+ matched,
1098
+ savedAt: doc["savedAt"] ?? null,
1099
+ };
1100
+ });
1101
+ }
1102
+ /** One file per round: a second round exported the same day keeps the first. */
1103
+ const practiceFile = (study: string, round: number, day: string): string =>
1104
+ `${study}-practice-r${String(round)}-${day}.json`;
1105
+ /**
1106
+ * Every practice round the store holds answers for, each scored against its own expected answers
1107
+ * into its own file; never into the labels. The key's own round is reported even unanswered.
1108
+ */
1109
+ function exportPractice(
1110
+ dump: JsonObject,
1111
+ key: Key,
1112
+ target: string,
1113
+ day: string,
1114
+ ): void {
1115
+ const p = key.practice;
1116
+ if (p === undefined) return;
1117
+ Object.entries(roundsIn(p))
1118
+ .sort(([a], [b]) => Number(a) - Number(b))
1119
+ .forEach(([round, expected]) => {
1120
+ exportRound(
1121
+ key.study,
1122
+ Number(round),
1123
+ scorePractice(dump, round, expected),
1124
+ {
1125
+ target,
1126
+ day,
1127
+ current: Number(round) === p.round,
1128
+ },
1129
+ );
1130
+ });
1131
+ }
1132
+ /** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
1133
+ function exportRound(
1134
+ study: string,
1135
+ round: number,
1136
+ items: ReturnType<typeof scorePractice>,
1137
+ at: {
1138
+ readonly target: string;
1139
+ readonly day: string;
1140
+ readonly current: boolean;
1141
+ },
1142
+ ): void {
1143
+ if (!items.some((i) => Object.keys(i.answers).length)) {
1144
+ if (at.current) say(`practice round ${String(round)}: not answered`);
1145
+ return;
1146
+ }
1147
+ const missed = items.filter((i) => !i.matched).map((i) => i.id);
1148
+ const matched = items.length - missed.length;
1149
+ const path = join(at.target, practiceFile(study, round, at.day));
1150
+ writeJson(path, {
1151
+ study,
1152
+ round,
1153
+ exported: at.day,
1154
+ matched,
1155
+ total: items.length,
1156
+ items,
1157
+ });
1158
+ say(
1159
+ `practice round ${String(round)}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`,
1160
+ );
1161
+ }
1162
+ /** The labels file: every item on the page with its answers, the groups, and the counts. */
1163
+ function labelsOf(dump: JsonObject, ctx: Ctx, day: string) {
1164
+ const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
1165
+ const items = onPage.map(([nid, meta]) =>
1166
+ labelOf(nid, meta, docOf(dump["answers"], nid), ctx),
1167
+ );
1168
+ const groups = groupsOf(
1169
+ [...new Set(onPage.map(([, m]) => m.group))],
1170
+ dump["groups"],
1171
+ ctx.allowed,
1172
+ );
1173
+ return {
1174
+ study: ctx.key.study,
1175
+ exported: day,
1176
+ questions: ctx.key.questions,
1177
+ items,
1178
+ groups,
1179
+ complete_items: items.filter((i) => i.complete).length,
1180
+ invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
1181
+ .length,
1182
+ invalid_groups: Object.values(groups).filter(
1183
+ (g) => g.invalid_familiarity !== null,
1184
+ ).length,
1185
+ total_items: items.length,
1186
+ };
1187
+ }
1188
+ export function exportLabels(
1189
+ dumpPath: string,
1190
+ keyPath: string,
1191
+ outDir: string,
1192
+ day: string,
1193
+ ): void {
1194
+ const dump = readJson(dumpPath);
1195
+ const key = readJson(keyPath);
1196
+ assertKey(key);
1197
+ const allowed = key.allowed;
1198
+ assertAllowed(allowed, key, keyPath);
1199
+ checkDump(dump, key);
1200
+ const out = labelsOf(dump, { key, allowed }, day);
1201
+ mkdirSync(outDir, { recursive: true });
1202
+ const path = join(outDir, `${key.study}-labels-${day}.json`);
1203
+ writeJson(path, out);
1204
+ exportPractice(dump, key, outDir, day);
1205
+ say(
1206
+ `wrote ${path}: ${String(out.complete_items)} of ${String(out.total_items)} items complete`,
1207
+ );
1208
+ reportLabels(out.items, allowed);
1209
+ reportGroups(out.groups, allowed);
1210
+ }