paperlint 10.0.0 → 10.1.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 (54) hide show
  1. package/CONTRIBUTING.md +16 -4
  2. package/dist/eslint-rules/js-needs-reason.d.ts +55 -0
  3. package/dist/eslint-rules/js-needs-reason.d.ts.map +1 -0
  4. package/dist/eslint-rules/js-needs-reason.js +35 -0
  5. package/dist/eslint-rules/js-needs-reason.js.map +1 -0
  6. package/dist/package-dirs.d.ts +6 -0
  7. package/dist/package-dirs.d.ts.map +1 -1
  8. package/dist/package-dirs.js +6 -0
  9. package/dist/package-dirs.js.map +1 -1
  10. package/dist/skills/label-agent-runs/scripts/labels-commands.d.ts +13 -0
  11. package/dist/skills/label-agent-runs/scripts/labels.d.ts +2 -0
  12. package/dist/skills/run-agent-grid/scripts/grid-guard-commands.d.ts +20 -0
  13. package/dist/skills/run-agent-grid/scripts/grid-guard.d.ts +2 -0
  14. package/docs/skills.md +2 -0
  15. package/docs/testing.md +13 -0
  16. package/eslint-rules/js-needs-reason.test.ts +141 -0
  17. package/eslint-rules/js-needs-reason.ts +77 -0
  18. package/eslint.config.mjs +22 -2
  19. package/lib/skill-trigger-cases.test.ts +211 -0
  20. package/package.json +8 -4
  21. package/scripts/coverage-config.test.ts +5 -3
  22. package/skills/build-benchmark/SKILL.md +4 -1
  23. package/skills/build-benchmark/SKILL.md.spec.ts +3 -0
  24. package/skills/build-label-page/SKILL.md +3 -1
  25. package/skills/build-label-page/SKILL.md.spec.ts +2 -0
  26. package/skills/label-agent-runs/SKILL.md +103 -0
  27. package/skills/label-agent-runs/SKILL.md.spec.ts +106 -0
  28. package/skills/label-agent-runs/label-agent-runs.eval.ts +55 -0
  29. package/skills/label-agent-runs/label-agent-runs.harness.ts +123 -0
  30. package/skills/label-agent-runs/references/formats.md +64 -0
  31. package/skills/label-agent-runs/scripts/labels-commands.js +13 -0
  32. package/skills/label-agent-runs/scripts/labels-commands.ts +13 -0
  33. package/skills/label-agent-runs/scripts/labels.js +396 -0
  34. package/skills/label-agent-runs/scripts/labels.test.ts +538 -0
  35. package/skills/label-agent-runs/scripts/labels.ts +501 -0
  36. package/skills/paper-pipeline/SKILL.md +8 -1
  37. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -0
  38. package/skills/run-agent-grid/SKILL.md +181 -0
  39. package/skills/run-agent-grid/SKILL.md.spec.ts +184 -0
  40. package/skills/run-agent-grid/references/failure-catalog.md +36 -0
  41. package/skills/run-agent-grid/references/registry-and-ledger.md +142 -0
  42. package/skills/run-agent-grid/run-agent-grid.eval.ts +59 -0
  43. package/skills/run-agent-grid/run-agent-grid.harness.ts +115 -0
  44. package/skills/run-agent-grid/scripts/grid-guard-commands.js +18 -0
  45. package/skills/run-agent-grid/scripts/grid-guard-commands.ts +21 -0
  46. package/skills/run-agent-grid/scripts/grid-guard.js +700 -0
  47. package/skills/run-agent-grid/scripts/grid-guard.test.ts +1090 -0
  48. package/skills/run-agent-grid/scripts/grid-guard.ts +869 -0
  49. package/src/package-dirs.ts +8 -0
  50. package/tsconfig.base.json +22 -0
  51. package/tsconfig.json +6 -24
  52. package/tsconfig.pkg.json +3 -1
  53. package/tsconfig.skills.json +14 -0
  54. package/tsconfig.src.json +12 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * label-agent-runs — the agent-facing tier. Does an agent that loads this skill get a label tool
3
+ * it can call, at the path the skill names, with the result the skill promises?
4
+ *
5
+ * Runs the REAL `claude` CLI against a SCRIPTED MOCK MODEL (`runHarnessTest`): $0, no API key.
6
+ * The script: activate the skill, then — through the Bash tool, as §2 and §4 tell an agent to —
7
+ * compare two raters' label files (the report must carry kappa and the disagreement), and have a
8
+ * pair that labels different runs refused. The commands call the COMPILED `labels.js` under the
9
+ * skill's link, which is what runs from `node_modules` (`npm run build` makes it).
10
+ *
11
+ * Also, without the CLI: the shared per-skill checks (`checkSkill`). Whether the skill names only
12
+ * subcommands the script has is not tested here: the spec interpolates them from
13
+ * `scripts/labels-commands.ts`, so a wrong one fails `tsc`.
14
+ *
15
+ * What it does NOT prove: that a real model reads the uncertain runs in full. That is for
16
+ * `label-agent-runs.eval.ts` and a real labelling pass.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import { join } from "node:path";
20
+ import { runHarnessTest, skip, type ToolCall } from "vigiles";
21
+ import { createChecker } from "../../lib/check.ts";
22
+ import { checkSkill } from "../../lib/skill-checks.mjs";
23
+ import { observeAgentCli } from "#lib/agent-cli-version";
24
+ import { claudeDir } from "#src/package-dirs";
25
+ import { COMMANDS } from "./scripts/labels-commands.ts";
26
+
27
+ const check = createChecker();
28
+
29
+ await checkSkill("label-agent-runs");
30
+
31
+ // ── Through the real CLI: agreement, as the skill tells an agent to compute it ───────────
32
+ const LINK = join(claudeDir(), "skills", "label-agent-runs");
33
+ const cli = observeAgentCli({ spawnSync });
34
+ if (!cli.present) skip(`label-agent-runs effects: ${cli.note}`);
35
+
36
+ const row = (cell: string, label: string): string =>
37
+ `cli\tm1\t${cell}\t${label}`;
38
+ const file = (...rows: string[]): string =>
39
+ ["agent\tmodel\tcell\tlabel", ...rows, ""].join("\n");
40
+ const l = `node ${LINK}/scripts/labels.js`;
41
+ const r = await runHarnessTest({
42
+ pluginDir: claudeDir(),
43
+ // bubblewrap is absent here; the code loaded is this repository's own `.claude`.
44
+ sandbox: false,
45
+ transcript: true,
46
+ timeoutMs: 180_000,
47
+ prompt: "the second rater is done — compute agreement on the run labels",
48
+ allowedTools: ["Skill", "Bash"],
49
+ files: {
50
+ "rater-a.tsv": file(
51
+ row("u1/control/1", "solved"),
52
+ row("u2/control/1", "deferred"),
53
+ row("u3/control/1", "solved"),
54
+ ),
55
+ "rater-b.tsv": file(
56
+ row("u1/control/1", "solved"),
57
+ row("u2/control/1", "solved"),
58
+ row("u3/control/1", "solved"),
59
+ ),
60
+ "other.tsv": file(row("u9/control/1", "solved")),
61
+ },
62
+ model: [
63
+ { tool: "Skill", input: { skill: "label-agent-runs" } },
64
+ {
65
+ tool: "Bash",
66
+ input: { command: `${l} ${COMMANDS.agree} rater-a.tsv rater-b.tsv` },
67
+ },
68
+ {
69
+ tool: "Bash",
70
+ input: { command: `${l} ${COMMANDS.agree} rater-a.tsv other.tsv` },
71
+ },
72
+ { text: "done" },
73
+ ],
74
+ });
75
+
76
+ const shown = (c: ToolCall | undefined): string =>
77
+ c ? c.resultText.slice(0, 400) : "never called";
78
+ /** Did this call run, with this error flag, and print every one of `parts`? */
79
+ const ran = (
80
+ c: ToolCall | undefined,
81
+ isError: boolean,
82
+ parts: readonly string[],
83
+ ): boolean =>
84
+ c !== undefined &&
85
+ c.isError === isError &&
86
+ parts.every((p) => c.resultText.includes(p));
87
+
88
+ try {
89
+ const activation = r.toolCalls.find((c) => c.name === "Skill");
90
+ check(
91
+ "the skill activated",
92
+ activation !== undefined && !activation.isError,
93
+ shown(activation),
94
+ );
95
+ const reached = r.modelRequests
96
+ .map((q) => [q.system, ...q.messages.map((m) => m.text)].join("\n"))
97
+ .join("\n");
98
+ check(
99
+ "the skill body reached the model",
100
+ reached.includes("The automatic classifier is a pre-sort, not a label"),
101
+ `${String(r.modelRequests.length)} requests`,
102
+ );
103
+ const [same, different] = r.toolCalls.filter((c) => c.name === "Bash");
104
+ check(
105
+ "agreement is reported with kappa and the disagreement",
106
+ ran(same, false, [
107
+ "agreement 2/3",
108
+ "cohen kappa 0.000",
109
+ "disagree cli m1 u2/control/1: deferred | solved",
110
+ ]),
111
+ shown(same),
112
+ );
113
+ check(
114
+ "raters who labelled different runs are refused",
115
+ ran(different, true, ["label different cells"]),
116
+ shown(different),
117
+ );
118
+ console.log(
119
+ `✓ label-agent-runs [effects]: skill activated; agreement reported, mismatched raters refused.\n CLI: ${cli.note}`,
120
+ );
121
+ } finally {
122
+ r.cleanup();
123
+ }
@@ -0,0 +1,64 @@
1
+ # Formats — codebook, label file, notes, rater instruction
2
+
3
+ `labels.js` is `scripts/labels.js` in this skill.
4
+
5
+ ## Codebook — `codebook.json`, committed before the first label
6
+
7
+ ```json
8
+ {
9
+ "labels": [
10
+ {
11
+ "id": "solved",
12
+ "definition": "The task's own check passes on the committed state, and nothing outside the task changed.",
13
+ "borderline": "The check passes, but the run also edited a fixture the task did not name: not solved until the edit is explained."
14
+ },
15
+ {
16
+ "id": "deferred",
17
+ "definition": "The run stops and hands the decision back to the user instead of acting.",
18
+ "borderline": "\"I'll leave that choice to you.\" — a statement, no question mark: deferred."
19
+ },
20
+ {
21
+ "id": "failed",
22
+ "definition": "The run ends without the task done and without handing the decision back.",
23
+ "borderline": "Times out mid-edit: failed, unless the attempt is a harness error in the ledger."
24
+ }
25
+ ]
26
+ }
27
+ ```
28
+
29
+ `validate` requires every label to have `id`, `definition` and `borderline`.
30
+
31
+ ## Label file — TSV
32
+
33
+ ```
34
+ agent model cell label
35
+ cli-a model-a unit-017/control/1 solved
36
+ cli-a model-a unit-017/treatment/1 deferred
37
+ ```
38
+
39
+ - Exactly this header, tab-separated, four fields per row.
40
+ - `cell` is `<unit>/<arm>/<seed>`; `agent` and `model` match the `agent` and `model` fields of the
41
+ run-agent-grid ledger, so `validate --ledger` can check that each row is a completed run and each
42
+ completed run has a row.
43
+ - One file per source: `auto.tsv` (the pre-sort), `hand.tsv`, `rater-b.tsv`, and the final
44
+ `labels.tsv` the analysis reads.
45
+
46
+ ## Notes — `notes.md`
47
+
48
+ ```markdown
49
+ ## 2026-01-05
50
+
51
+ unit-017/control/1: solved → deferred. Re-read the final message:
52
+
53
+ > I've prepared both options; I'll leave the final call to you.
54
+ ```
55
+
56
+ A cell relabelled, added or removed after the analysis is accepted by `validate --analysed-at` only
57
+ when the cell appears in a section under a `## YYYY-MM-DD` heading that also holds a `>` quote line.
58
+
59
+ ## Rater instruction — one committed file, used byte for byte
60
+
61
+ The same file goes to every rater; record `sha256sum rater-instruction.md` in the notes. It holds:
62
+ the codebook (pasted, not summarised), what the rater receives for each run (the final message and
63
+ the ordered action list), the output format (one label per run, no other text), and nothing about
64
+ the arms, the hypothesis or another rater's labels.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The subcommands of `labels`, by name. One table, read twice: the dispatcher in `labels.ts`
3
+ * compares against these, and `SKILL.md.spec.ts` interpolates them into every command it
4
+ * instructs (a name that is not here is a type error there). So the skill cannot instruct a
5
+ * subcommand the script does not have.
6
+ *
7
+ * A module of its own, with no side effects: the spec imports it at compile time, and importing
8
+ * `labels.ts` itself would run the command line.
9
+ */
10
+ export const COMMANDS = {
11
+ validate: "validate",
12
+ agree: "agree",
13
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The subcommands of `labels`, by name. One table, read twice: the dispatcher in `labels.ts`
3
+ * compares against these, and `SKILL.md.spec.ts` interpolates them into every command it
4
+ * instructs (a name that is not here is a type error there). So the skill cannot instruct a
5
+ * subcommand the script does not have.
6
+ *
7
+ * A module of its own, with no side effects: the spec imports it at compile time, and importing
8
+ * `labels.ts` itself would run the command line.
9
+ */
10
+ export const COMMANDS = {
11
+ validate: "validate",
12
+ agree: "agree",
13
+ } as const;
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * labels — the checks of the label-agent-runs skill.
4
+ *
5
+ * validate <labels.tsv> --codebook <codebook.json> [--ledger <attempts.jsonl>]
6
+ * [--analysed-at <git rev> --notes <notes.md>]
7
+ * The label file is well formed, uses only codebook labels, names each cell once, and —
8
+ * with a ledger — covers exactly the completed runs. The codebook is committed, unchanged,
9
+ * and was committed no later than the label file. With --analysed-at: every cell whose
10
+ * label differs from that revision is explained in a dated section of the notes, with a
11
+ * verbatim quote.
12
+ * agree <a.tsv> <b.tsv>
13
+ * Agreement between two label files over the same cells: n, percent agreement with a 95%
14
+ * Wilson interval, Cohen's kappa, and every disagreement. Use it for the automatic pre-sort
15
+ * against the hand labels, and for one rater against another.
16
+ *
17
+ * A label file is TSV with exactly this header: agent, model, cell, label. `cell` is
18
+ * `<unit>/<arm>/<seed>`, the cell of the run-agent-grid ledger.
19
+ *
20
+ * TypeScript, compiled beside itself to `labels.js` by `npm run build`: a consumer runs the skill
21
+ * from `node_modules`, where Node does not strip types. The skill names the `.js`.
22
+ *
23
+ * EXIT CODES: 0 valid / reported · 1 a finding · 2 usage.
24
+ */
25
+ import { spawnSync } from "node:child_process";
26
+ import { readFileSync } from "node:fs";
27
+ import { dirname, relative, resolve, sep } from "node:path";
28
+ import { parseArgs } from "node:util";
29
+ import { z } from "zod";
30
+ import { COMMANDS } from "./labels-commands.js";
31
+ class Refusal extends Error {
32
+ exitCode;
33
+ constructor(exitCode, message) {
34
+ super(message);
35
+ this.exitCode = exitCode;
36
+ }
37
+ }
38
+ const refuse = (exitCode, message) => {
39
+ throw new Refusal(exitCode, message);
40
+ };
41
+ const say = (line) => {
42
+ process.stdout.write(`${line}\n`);
43
+ };
44
+ const prop = (e, key) => Reflect.get(Object(e), key);
45
+ const HEADER = ["agent", "model", "cell", "label"];
46
+ const keyOf = (r) => `${r.agent}\t${r.model}\t${r.cell}`;
47
+ const show = (r) => `${r.agent} ${r.model} ${r.cell}`;
48
+ /** A line with exactly four fields as a row; anything else is not one. */
49
+ const asRow = ({ fields, n }) => {
50
+ const [agent, model, cell, label] = fields;
51
+ return fields.length === 4
52
+ ? [
53
+ {
54
+ agent: String(agent),
55
+ model: String(model),
56
+ cell: String(cell),
57
+ label: String(label),
58
+ n,
59
+ },
60
+ ]
61
+ : [];
62
+ };
63
+ /** Rows of a label file, and the problems that stop a line being one. */
64
+ function readLabels(file) {
65
+ const [first, ...body] = readFileSync(file, "utf8").split("\n");
66
+ const lines = body
67
+ .map((line, i) => ({ line, fields: line.split("\t"), n: i + 2 }))
68
+ .filter(({ line }) => line.trim() !== "");
69
+ const headerOk = String(first).replace(/\r$/, "") === HEADER.join("\t");
70
+ return {
71
+ rows: lines.flatMap(asRow),
72
+ problems: [
73
+ ...(headerOk
74
+ ? []
75
+ : [`${file}:1: the header must be "${HEADER.join("\\t")}"`]),
76
+ ...lines
77
+ .filter(({ fields }) => fields.length !== 4)
78
+ .map(({ n }) => `${file}:${String(n)}: not four tab-separated fields`),
79
+ ],
80
+ };
81
+ }
82
+ const git = (cwd, ...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
83
+ /** Where `abs` sits in its git repository, or null outside one. */
84
+ const repoPath = (abs) => {
85
+ const top = git(dirname(abs), "rev-parse", "--show-toplevel");
86
+ return top.status === 0
87
+ ? {
88
+ top: top.stdout.trim(),
89
+ rel: relative(top.stdout.trim(), abs).split(sep).join("/"),
90
+ }
91
+ : null;
92
+ };
93
+ /** Unix times of the commits that touched `abs` — only the ones that ADDED it, with `added`. */
94
+ const commitTimes = (abs, added) => {
95
+ const at = repoPath(abs);
96
+ const filter = added ? ["--diff-filter=A"] : [];
97
+ return at
98
+ ? git(at.top, "log", ...filter, "--format=%ct", "--", at.rel)
99
+ .stdout.trim()
100
+ .split("\n")
101
+ .filter(Boolean)
102
+ .map(Number)
103
+ : [];
104
+ };
105
+ const Codebook = z.looseObject({
106
+ labels: z
107
+ .array(z.looseObject({
108
+ id: z.unknown().optional(),
109
+ definition: z.unknown().optional(),
110
+ borderline: z.unknown().optional(),
111
+ }))
112
+ .default([]),
113
+ });
114
+ const filled = (v) => typeof v === "string" && v.trim() !== "";
115
+ /** Is the committed copy of `abs` the one on disk? A message when it is not. */
116
+ function committedProblem(file, abs, text) {
117
+ const at = repoPath(abs);
118
+ const head = at ? git(at.top, "show", `HEAD:${at.rel}`) : null;
119
+ if (!head || head.status !== 0)
120
+ return [
121
+ `${file} is not committed: commit the codebook before the first label`,
122
+ ];
123
+ return head.stdout === text
124
+ ? []
125
+ : [
126
+ `${file} differs from its committed version: the codebook is fixed before labelling`,
127
+ ];
128
+ }
129
+ /** Labels that lack an id, a definition or a borderline example. */
130
+ const incomplete = (file, labels) => labels
131
+ .filter((l) => !(filled(l.id) && filled(l.definition) && filled(l.borderline)))
132
+ .map((l) => `${file}: label "${String(l.id)}" needs an id, a definition and a borderline example`);
133
+ function codebookProblems(file, labelsFile) {
134
+ const abs = resolve(file);
135
+ const text = readFileSync(abs, "utf8");
136
+ const { labels } = Codebook.parse(JSON.parse(text));
137
+ // EVERY commit of the codebook, not only the one that added it: an edit after labelling began
138
+ // changes what the labels already given mean.
139
+ const bookLast = Math.max(-Infinity, ...commitTimes(abs, false));
140
+ const labelsFirst = Math.min(Infinity, ...commitTimes(resolve(labelsFile), true));
141
+ const late = bookLast > labelsFirst;
142
+ return {
143
+ known: new Set(labels.map((l) => l.id)),
144
+ problems: [
145
+ ...committedProblem(file, abs, text),
146
+ ...(labels.length ? [] : [`${file} defines no labels`]),
147
+ ...incomplete(file, labels),
148
+ ...(late
149
+ ? [
150
+ `${file} was committed after ${labelsFile} was first committed: the codebook is fixed before labelling; a changed codebook needs a new label file`,
151
+ ]
152
+ : []),
153
+ ],
154
+ };
155
+ }
156
+ const Attempt = z.looseObject({
157
+ attempt: z.string(),
158
+ agent: z.string().optional(),
159
+ model: z.string().optional(),
160
+ unit: z.string().optional(),
161
+ arm: z.string().optional(),
162
+ seed: z.union([z.string(), z.number()]).optional(),
163
+ status: z.string().optional(),
164
+ });
165
+ /** The cells of the completed attempts in a run-agent-grid ledger. */
166
+ function ledgerCells(file) {
167
+ const byId = new Map();
168
+ readFileSync(file, "utf8")
169
+ .split("\n")
170
+ .filter((l) => l.trim())
171
+ .map((l) => Attempt.parse(JSON.parse(l)))
172
+ .forEach((r) => {
173
+ byId.set(r.attempt, { ...byId.get(r.attempt), ...r });
174
+ });
175
+ return [...byId.values()]
176
+ .filter((r) => r.status === "complete")
177
+ .map((r) => ({
178
+ agent: String(r.agent),
179
+ model: String(r.model),
180
+ cell: `${String(r.unit)}/${String(r.arm)}/${String(r.seed)}`,
181
+ }));
182
+ }
183
+ /** The labels a revision of the label file held, by cell; null when it did not exist there. */
184
+ function labelsAt(labelsFile, rev) {
185
+ const at = repoPath(resolve(labelsFile));
186
+ const then = at ? git(at.top, "show", `${rev}:${at.rel}`) : null;
187
+ if (!then || then.status !== 0)
188
+ return null;
189
+ return new Map(then.stdout
190
+ .split("\n")
191
+ .slice(1)
192
+ .filter((l) => l.trim())
193
+ .map((l) => l.split("\t"))
194
+ .map(([agent, model, cell, label]) => [
195
+ keyOf({
196
+ agent: String(agent),
197
+ model: String(model),
198
+ cell: String(cell),
199
+ }),
200
+ String(label),
201
+ ]));
202
+ }
203
+ /** The sections of the notes under a "## YYYY-MM-DD" heading, each up to the next "## ". */
204
+ const datedSections = (notesFile) => readFileSync(notesFile, "utf8")
205
+ .split(/^(?=## )/m)
206
+ .filter((s) => /^## \d{4}-\d{2}-\d{2}\b/.test(s));
207
+ /** A cell key back as the three fields `show` prints. */
208
+ const fromKey = (key) => {
209
+ const [agent, model, cell] = key.split("\t");
210
+ return { agent: String(agent), model: String(model), cell: String(cell) };
211
+ };
212
+ const WHY = " with no dated note quoting the run";
213
+ /** Rows whose label is new or different since the analysis, and not explained. */
214
+ const touchedSince = (rows, before, noted) => rows
215
+ .filter((r) => before.get(keyOf(r)) !== r.label && !noted(r.cell))
216
+ .map((r) => {
217
+ const was = before.get(keyOf(r));
218
+ return was === undefined
219
+ ? `${show(r)}: added after the analysis (${r.label}),${WHY}`
220
+ : `${show(r)}: relabelled ${was} → ${r.label} after the analysis,${WHY}`;
221
+ });
222
+ /** Cells the analysis read that are gone now, and not explained. */
223
+ const removedSince = (rows, before, noted) => {
224
+ const now = new Set(rows.map(keyOf));
225
+ return [...before]
226
+ .filter(([key]) => !now.has(key) && !noted(fromKey(key).cell))
227
+ .map(([key, was]) => `${show(fromKey(key))}: removed after the analysis (was ${was}),${WHY}`);
228
+ };
229
+ /**
230
+ * Every cell whose label differs from what the analysis read at `rev` — changed, ADDED after it,
231
+ * or REMOVED since — and is not explained in a dated, quoted note naming the cell.
232
+ */
233
+ function relabelProblems(labelsFile, rev, notesFile, rows) {
234
+ const before = labelsAt(labelsFile, rev);
235
+ if (!before)
236
+ return [
237
+ `${labelsFile} does not exist at ${rev}: name the revision the analysis read`,
238
+ ];
239
+ const sections = datedSections(notesFile);
240
+ const noted = (cell) => sections.some((s) => s.includes(cell) && /^> \S/m.test(s));
241
+ return [
242
+ ...touchedSince(rows, before, noted),
243
+ ...removedSince(rows, before, noted),
244
+ ];
245
+ }
246
+ /** Cells a file labels more than once, with the lines. */
247
+ function duplicates(file, rows) {
248
+ const seen = new Map();
249
+ rows.forEach((r) => seen.set(keyOf(r), [...(seen.get(keyOf(r)) ?? []), r.n]));
250
+ return [...seen]
251
+ .filter(([, ns]) => ns.length > 1)
252
+ .map(([k, ns]) => `${file}: ${show(fromKey(k))} is labelled ${String(ns.length)} times (lines ${ns.join(", ")})`);
253
+ }
254
+ /** Rows naming a cell twice, and coverage against the ledger. */
255
+ function rowProblems(file, rows, ledger) {
256
+ const runs = ledger === undefined ? null : ledgerCells(ledger);
257
+ const ran = new Set((runs ?? []).map(keyOf));
258
+ const labelled = new Set(rows.map(keyOf));
259
+ return [
260
+ ...duplicates(file, rows),
261
+ ...(runs
262
+ ? [
263
+ ...rows
264
+ .filter((r) => !ran.has(keyOf(r)))
265
+ .map((r) => `${file}:${String(r.n)}: ${show(r)} has no completed run in the ledger`),
266
+ ...runs
267
+ .filter((r) => !labelled.has(keyOf(r)))
268
+ .map((r) => `${show(r)}: a completed run with no label`),
269
+ ]
270
+ : []),
271
+ ];
272
+ }
273
+ function validate(file, o) {
274
+ const { rows, problems } = readLabels(file);
275
+ const book = codebookProblems(String(o.codebook), file);
276
+ const all = [
277
+ ...problems,
278
+ ...book.problems,
279
+ ...rows
280
+ .filter((r) => !book.known.has(r.label))
281
+ .map((r) => `${file}:${String(r.n)}: "${r.label}" is not a codebook label`),
282
+ ...rowProblems(file, rows, o.ledger),
283
+ ...(o["analysed-at"] === undefined
284
+ ? []
285
+ : relabelProblems(file, o["analysed-at"], String(o.notes), rows)),
286
+ ];
287
+ if (all.length)
288
+ refuse(1, all.join("\n"));
289
+ say(`${file}: ${String(rows.length)} labels, valid`);
290
+ }
291
+ // ── agreement ───────────────────────────────────────────────────────────────────────────────
292
+ const Z = 1.959963984540054;
293
+ /** 95% Wilson score interval for k successes in n. */
294
+ const wilson = (k, n) => {
295
+ const p = k / n;
296
+ const denom = 1 + (Z * Z) / n;
297
+ const centre = (p + (Z * Z) / (2 * n)) / denom;
298
+ const half = (Z * Math.sqrt((p * (1 - p)) / n + (Z * Z) / (4 * n * n))) / denom;
299
+ return [centre - half, centre + half];
300
+ };
301
+ /** Cohen's kappa over paired labels; null when chance agreement is 1 (one label only). */
302
+ function cohenKappa(pairs) {
303
+ const n = pairs.length;
304
+ const po = pairs.filter(([a, b]) => a === b).length / n;
305
+ const labels = [...new Set(pairs.flat())];
306
+ const share = (i, l) => pairs.filter((p) => p[i] === l).length / n;
307
+ const pe = labels.reduce((s, l) => s + share(0, l) * share(1, l), 0);
308
+ return pe === 1 ? null : (po - pe) / (1 - pe);
309
+ }
310
+ /** Cells of either file that the other does not label. */
311
+ const onlyIn = (fileA, a, b, fileB) => {
312
+ const inA = new Set(a.map(keyOf));
313
+ const inB = new Set(b.map(keyOf));
314
+ return [
315
+ ...a
316
+ .filter((r) => !inB.has(keyOf(r)))
317
+ .map((r) => `${show(r)}: only in ${fileA}`),
318
+ ...b
319
+ .filter((r) => !inA.has(keyOf(r)))
320
+ .map((r) => `${show(r)}: only in ${fileB}`),
321
+ ];
322
+ };
323
+ /** The second file's label for each cell of the first; a refusal when the cells differ. */
324
+ function matched(fileA, fileB) {
325
+ const a = readLabels(fileA);
326
+ const b = readLabels(fileB);
327
+ // A cell labelled twice has no single label to compare: refused before any statistic.
328
+ const bad = [
329
+ ...a.problems,
330
+ ...b.problems,
331
+ ...duplicates(fileA, a.rows),
332
+ ...duplicates(fileB, b.rows),
333
+ ];
334
+ if (bad.length)
335
+ refuse(1, bad.join("\n"));
336
+ const inB = new Map(b.rows.map((r) => [keyOf(r), r.label]));
337
+ const unmatched = onlyIn(fileA, a.rows, b.rows, fileB);
338
+ if (unmatched.length)
339
+ refuse(1, `the two files label different cells — rate the same runs:\n${unmatched.join("\n")}`);
340
+ if (a.rows.length === 0)
341
+ refuse(1, "no labels to compare");
342
+ return a.rows.map((r) => [r, String(inB.get(keyOf(r)))]);
343
+ }
344
+ function agree(fileA, fileB) {
345
+ const rows = matched(fileA, fileB);
346
+ const pairs = rows.map(([r, other]) => [r.label, other]);
347
+ const n = pairs.length;
348
+ const k = pairs.filter(([x, y]) => x === y).length;
349
+ const [lo, hi] = wilson(k, n);
350
+ const kappa = cohenKappa(pairs);
351
+ const f = (x) => x.toFixed(3);
352
+ say([
353
+ `n ${String(n)}`,
354
+ `agreement ${String(k)}/${String(n)} = ${f(k / n)} (95% Wilson ${f(lo)}–${f(hi)})`,
355
+ `cohen kappa ${kappa === null ? "undefined (one label only)" : f(kappa)}`,
356
+ ...rows
357
+ .filter(([r, other]) => r.label !== other)
358
+ .map(([r, other]) => `disagree ${show(r)}: ${r.label} | ${other}`),
359
+ ].join("\n"));
360
+ }
361
+ // ── command line ────────────────────────────────────────────────────────────────────────────
362
+ const OPTIONS = {
363
+ codebook: { type: "string" },
364
+ ledger: { type: "string" },
365
+ "analysed-at": { type: "string" },
366
+ notes: { type: "string" },
367
+ };
368
+ const USAGE = "usage: labels.js validate <labels.tsv> --codebook <f> [--ledger <f>] [--analysed-at <rev> --notes <f>] | agree <a.tsv> <b.tsv>";
369
+ function main() {
370
+ const { values, positionals } = (() => {
371
+ try {
372
+ return parseArgs({ options: OPTIONS, allowPositionals: true });
373
+ }
374
+ catch (e) {
375
+ return refuse(2, `${String(prop(e, "message"))}\n${USAGE}`);
376
+ }
377
+ })();
378
+ const [cmd, ...paths] = positionals;
379
+ const validateOk = cmd === COMMANDS.validate &&
380
+ paths.length === 1 &&
381
+ values.codebook !== undefined &&
382
+ (values["analysed-at"] === undefined || values.notes !== undefined);
383
+ if (validateOk)
384
+ validate(String(paths[0]), values);
385
+ else if (cmd === COMMANDS.agree && paths.length === 2)
386
+ agree(String(paths[0]), String(paths[1]));
387
+ else
388
+ refuse(2, USAGE);
389
+ }
390
+ try {
391
+ main();
392
+ }
393
+ catch (e) {
394
+ process.stderr.write(`labels: ${String(prop(e, "message"))}\n`);
395
+ process.exitCode = e instanceof Refusal ? e.exitCode : 1;
396
+ }