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.
- package/CONTRIBUTING.md +16 -4
- package/dist/eslint-rules/js-needs-reason.d.ts +55 -0
- package/dist/eslint-rules/js-needs-reason.d.ts.map +1 -0
- package/dist/eslint-rules/js-needs-reason.js +35 -0
- package/dist/eslint-rules/js-needs-reason.js.map +1 -0
- package/dist/package-dirs.d.ts +6 -0
- package/dist/package-dirs.d.ts.map +1 -1
- package/dist/package-dirs.js +6 -0
- package/dist/package-dirs.js.map +1 -1
- package/dist/skills/label-agent-runs/scripts/labels-commands.d.ts +13 -0
- package/dist/skills/label-agent-runs/scripts/labels.d.ts +2 -0
- package/dist/skills/run-agent-grid/scripts/grid-guard-commands.d.ts +20 -0
- package/dist/skills/run-agent-grid/scripts/grid-guard.d.ts +2 -0
- package/docs/skills.md +2 -0
- package/docs/testing.md +13 -0
- package/eslint-rules/js-needs-reason.test.ts +141 -0
- package/eslint-rules/js-needs-reason.ts +77 -0
- package/eslint.config.mjs +22 -2
- package/lib/skill-trigger-cases.test.ts +211 -0
- package/package.json +8 -4
- package/scripts/coverage-config.test.ts +5 -3
- package/skills/build-benchmark/SKILL.md +4 -1
- package/skills/build-benchmark/SKILL.md.spec.ts +3 -0
- package/skills/build-label-page/SKILL.md +3 -1
- package/skills/build-label-page/SKILL.md.spec.ts +2 -0
- package/skills/label-agent-runs/SKILL.md +103 -0
- package/skills/label-agent-runs/SKILL.md.spec.ts +106 -0
- package/skills/label-agent-runs/label-agent-runs.eval.ts +55 -0
- package/skills/label-agent-runs/label-agent-runs.harness.ts +123 -0
- package/skills/label-agent-runs/references/formats.md +64 -0
- package/skills/label-agent-runs/scripts/labels-commands.js +13 -0
- package/skills/label-agent-runs/scripts/labels-commands.ts +13 -0
- package/skills/label-agent-runs/scripts/labels.js +396 -0
- package/skills/label-agent-runs/scripts/labels.test.ts +538 -0
- package/skills/label-agent-runs/scripts/labels.ts +501 -0
- package/skills/paper-pipeline/SKILL.md +8 -1
- package/skills/paper-pipeline/SKILL.md.spec.ts +7 -0
- package/skills/run-agent-grid/SKILL.md +181 -0
- package/skills/run-agent-grid/SKILL.md.spec.ts +184 -0
- package/skills/run-agent-grid/references/failure-catalog.md +36 -0
- package/skills/run-agent-grid/references/registry-and-ledger.md +142 -0
- package/skills/run-agent-grid/run-agent-grid.eval.ts +59 -0
- package/skills/run-agent-grid/run-agent-grid.harness.ts +115 -0
- package/skills/run-agent-grid/scripts/grid-guard-commands.js +18 -0
- package/skills/run-agent-grid/scripts/grid-guard-commands.ts +21 -0
- package/skills/run-agent-grid/scripts/grid-guard.js +700 -0
- package/skills/run-agent-grid/scripts/grid-guard.test.ts +1090 -0
- package/skills/run-agent-grid/scripts/grid-guard.ts +869 -0
- package/src/package-dirs.ts +8 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.json +6 -24
- package/tsconfig.pkg.json +3 -1
- package/tsconfig.skills.json +14 -0
- package/tsconfig.src.json +12 -0
|
@@ -0,0 +1,501 @@
|
|
|
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.ts";
|
|
31
|
+
|
|
32
|
+
class Refusal extends Error {
|
|
33
|
+
readonly exitCode: number;
|
|
34
|
+
constructor(exitCode: number, message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.exitCode = exitCode;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const refuse = (exitCode: number, message: string): never => {
|
|
40
|
+
throw new Refusal(exitCode, message);
|
|
41
|
+
};
|
|
42
|
+
const say = (line: string): void => {
|
|
43
|
+
process.stdout.write(`${line}\n`);
|
|
44
|
+
};
|
|
45
|
+
const prop = (e: unknown, key: string): unknown => Reflect.get(Object(e), key);
|
|
46
|
+
|
|
47
|
+
const HEADER = ["agent", "model", "cell", "label"];
|
|
48
|
+
interface Row {
|
|
49
|
+
readonly agent: string;
|
|
50
|
+
readonly model: string;
|
|
51
|
+
readonly cell: string;
|
|
52
|
+
readonly label: string;
|
|
53
|
+
readonly n: number;
|
|
54
|
+
}
|
|
55
|
+
type CellId = Pick<Row, "agent" | "model" | "cell">;
|
|
56
|
+
const keyOf = (r: CellId): string => `${r.agent}\t${r.model}\t${r.cell}`;
|
|
57
|
+
const show = (r: CellId): string => `${r.agent} ${r.model} ${r.cell}`;
|
|
58
|
+
|
|
59
|
+
interface Line {
|
|
60
|
+
readonly fields: readonly string[];
|
|
61
|
+
readonly n: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A line with exactly four fields as a row; anything else is not one. */
|
|
65
|
+
const asRow = ({ fields, n }: Line): Row[] => {
|
|
66
|
+
const [agent, model, cell, label] = fields;
|
|
67
|
+
return fields.length === 4
|
|
68
|
+
? [
|
|
69
|
+
{
|
|
70
|
+
agent: String(agent),
|
|
71
|
+
model: String(model),
|
|
72
|
+
cell: String(cell),
|
|
73
|
+
label: String(label),
|
|
74
|
+
n,
|
|
75
|
+
},
|
|
76
|
+
]
|
|
77
|
+
: [];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Rows of a label file, and the problems that stop a line being one. */
|
|
81
|
+
function readLabels(file: string): { rows: Row[]; problems: string[] } {
|
|
82
|
+
const [first, ...body] = readFileSync(file, "utf8").split("\n");
|
|
83
|
+
const lines: Line[] = body
|
|
84
|
+
.map((line, i) => ({ line, fields: line.split("\t"), n: i + 2 }))
|
|
85
|
+
.filter(({ line }) => line.trim() !== "");
|
|
86
|
+
const headerOk = String(first).replace(/\r$/, "") === HEADER.join("\t");
|
|
87
|
+
return {
|
|
88
|
+
rows: lines.flatMap(asRow),
|
|
89
|
+
problems: [
|
|
90
|
+
...(headerOk
|
|
91
|
+
? []
|
|
92
|
+
: [`${file}:1: the header must be "${HEADER.join("\\t")}"`]),
|
|
93
|
+
...lines
|
|
94
|
+
.filter(({ fields }) => fields.length !== 4)
|
|
95
|
+
.map(({ n }) => `${file}:${String(n)}: not four tab-separated fields`),
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
101
|
+
spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
102
|
+
/** Where `abs` sits in its git repository, or null outside one. */
|
|
103
|
+
const repoPath = (abs: string): { top: string; rel: string } | null => {
|
|
104
|
+
const top = git(dirname(abs), "rev-parse", "--show-toplevel");
|
|
105
|
+
return top.status === 0
|
|
106
|
+
? {
|
|
107
|
+
top: top.stdout.trim(),
|
|
108
|
+
rel: relative(top.stdout.trim(), abs).split(sep).join("/"),
|
|
109
|
+
}
|
|
110
|
+
: null;
|
|
111
|
+
};
|
|
112
|
+
/** Unix times of the commits that touched `abs` — only the ones that ADDED it, with `added`. */
|
|
113
|
+
const commitTimes = (abs: string, added: boolean): number[] => {
|
|
114
|
+
const at = repoPath(abs);
|
|
115
|
+
const filter = added ? ["--diff-filter=A"] : [];
|
|
116
|
+
return at
|
|
117
|
+
? git(at.top, "log", ...filter, "--format=%ct", "--", at.rel)
|
|
118
|
+
.stdout.trim()
|
|
119
|
+
.split("\n")
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map(Number)
|
|
122
|
+
: [];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const Codebook = z.looseObject({
|
|
126
|
+
labels: z
|
|
127
|
+
.array(
|
|
128
|
+
z.looseObject({
|
|
129
|
+
id: z.unknown().optional(),
|
|
130
|
+
definition: z.unknown().optional(),
|
|
131
|
+
borderline: z.unknown().optional(),
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
.default([]),
|
|
135
|
+
});
|
|
136
|
+
const filled = (v: unknown): boolean =>
|
|
137
|
+
typeof v === "string" && v.trim() !== "";
|
|
138
|
+
|
|
139
|
+
/** Is the committed copy of `abs` the one on disk? A message when it is not. */
|
|
140
|
+
function committedProblem(file: string, abs: string, text: string): string[] {
|
|
141
|
+
const at = repoPath(abs);
|
|
142
|
+
const head = at ? git(at.top, "show", `HEAD:${at.rel}`) : null;
|
|
143
|
+
if (!head || head.status !== 0)
|
|
144
|
+
return [
|
|
145
|
+
`${file} is not committed: commit the codebook before the first label`,
|
|
146
|
+
];
|
|
147
|
+
return head.stdout === text
|
|
148
|
+
? []
|
|
149
|
+
: [
|
|
150
|
+
`${file} differs from its committed version: the codebook is fixed before labelling`,
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type CodebookLabel = z.infer<typeof Codebook>["labels"][number];
|
|
155
|
+
|
|
156
|
+
/** Labels that lack an id, a definition or a borderline example. */
|
|
157
|
+
const incomplete = (file: string, labels: readonly CodebookLabel[]): string[] =>
|
|
158
|
+
labels
|
|
159
|
+
.filter(
|
|
160
|
+
(l) => !(filled(l.id) && filled(l.definition) && filled(l.borderline)),
|
|
161
|
+
)
|
|
162
|
+
.map(
|
|
163
|
+
(l) =>
|
|
164
|
+
`${file}: label "${String(l.id)}" needs an id, a definition and a borderline example`,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
function codebookProblems(
|
|
168
|
+
file: string,
|
|
169
|
+
labelsFile: string,
|
|
170
|
+
): { known: Set<unknown>; problems: string[] } {
|
|
171
|
+
const abs = resolve(file);
|
|
172
|
+
const text = readFileSync(abs, "utf8");
|
|
173
|
+
const { labels } = Codebook.parse(JSON.parse(text));
|
|
174
|
+
// EVERY commit of the codebook, not only the one that added it: an edit after labelling began
|
|
175
|
+
// changes what the labels already given mean.
|
|
176
|
+
const bookLast = Math.max(-Infinity, ...commitTimes(abs, false));
|
|
177
|
+
const labelsFirst = Math.min(
|
|
178
|
+
Infinity,
|
|
179
|
+
...commitTimes(resolve(labelsFile), true),
|
|
180
|
+
);
|
|
181
|
+
const late = bookLast > labelsFirst;
|
|
182
|
+
return {
|
|
183
|
+
known: new Set(labels.map((l) => l.id)),
|
|
184
|
+
problems: [
|
|
185
|
+
...committedProblem(file, abs, text),
|
|
186
|
+
...(labels.length ? [] : [`${file} defines no labels`]),
|
|
187
|
+
...incomplete(file, labels),
|
|
188
|
+
...(late
|
|
189
|
+
? [
|
|
190
|
+
`${file} was committed after ${labelsFile} was first committed: the codebook is fixed before labelling; a changed codebook needs a new label file`,
|
|
191
|
+
]
|
|
192
|
+
: []),
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const Attempt = z.looseObject({
|
|
198
|
+
attempt: z.string(),
|
|
199
|
+
agent: z.string().optional(),
|
|
200
|
+
model: z.string().optional(),
|
|
201
|
+
unit: z.string().optional(),
|
|
202
|
+
arm: z.string().optional(),
|
|
203
|
+
seed: z.union([z.string(), z.number()]).optional(),
|
|
204
|
+
status: z.string().optional(),
|
|
205
|
+
});
|
|
206
|
+
type Attempt = z.infer<typeof Attempt>;
|
|
207
|
+
|
|
208
|
+
/** The cells of the completed attempts in a run-agent-grid ledger. */
|
|
209
|
+
function ledgerCells(file: string): CellId[] {
|
|
210
|
+
const byId = new Map<string, Attempt>();
|
|
211
|
+
readFileSync(file, "utf8")
|
|
212
|
+
.split("\n")
|
|
213
|
+
.filter((l) => l.trim())
|
|
214
|
+
.map((l) => Attempt.parse(JSON.parse(l)))
|
|
215
|
+
.forEach((r) => {
|
|
216
|
+
byId.set(r.attempt, { ...byId.get(r.attempt), ...r });
|
|
217
|
+
});
|
|
218
|
+
return [...byId.values()]
|
|
219
|
+
.filter((r) => r.status === "complete")
|
|
220
|
+
.map((r) => ({
|
|
221
|
+
agent: String(r.agent),
|
|
222
|
+
model: String(r.model),
|
|
223
|
+
cell: `${String(r.unit)}/${String(r.arm)}/${String(r.seed)}`,
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The labels a revision of the label file held, by cell; null when it did not exist there. */
|
|
228
|
+
function labelsAt(labelsFile: string, rev: string): Map<string, string> | null {
|
|
229
|
+
const at = repoPath(resolve(labelsFile));
|
|
230
|
+
const then = at ? git(at.top, "show", `${rev}:${at.rel}`) : null;
|
|
231
|
+
if (!then || then.status !== 0) return null;
|
|
232
|
+
return new Map(
|
|
233
|
+
then.stdout
|
|
234
|
+
.split("\n")
|
|
235
|
+
.slice(1)
|
|
236
|
+
.filter((l) => l.trim())
|
|
237
|
+
.map((l) => l.split("\t"))
|
|
238
|
+
.map(([agent, model, cell, label]) => [
|
|
239
|
+
keyOf({
|
|
240
|
+
agent: String(agent),
|
|
241
|
+
model: String(model),
|
|
242
|
+
cell: String(cell),
|
|
243
|
+
}),
|
|
244
|
+
String(label),
|
|
245
|
+
]),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The sections of the notes under a "## YYYY-MM-DD" heading, each up to the next "## ". */
|
|
250
|
+
const datedSections = (notesFile: string): string[] =>
|
|
251
|
+
readFileSync(notesFile, "utf8")
|
|
252
|
+
.split(/^(?=## )/m)
|
|
253
|
+
.filter((s) => /^## \d{4}-\d{2}-\d{2}\b/.test(s));
|
|
254
|
+
|
|
255
|
+
/** A cell key back as the three fields `show` prints. */
|
|
256
|
+
const fromKey = (key: string): CellId => {
|
|
257
|
+
const [agent, model, cell] = key.split("\t");
|
|
258
|
+
return { agent: String(agent), model: String(model), cell: String(cell) };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const WHY = " with no dated note quoting the run";
|
|
262
|
+
|
|
263
|
+
/** Rows whose label is new or different since the analysis, and not explained. */
|
|
264
|
+
const touchedSince = (
|
|
265
|
+
rows: readonly Row[],
|
|
266
|
+
before: ReadonlyMap<string, string>,
|
|
267
|
+
noted: (cell: string) => boolean,
|
|
268
|
+
): string[] =>
|
|
269
|
+
rows
|
|
270
|
+
.filter((r) => before.get(keyOf(r)) !== r.label && !noted(r.cell))
|
|
271
|
+
.map((r) => {
|
|
272
|
+
const was = before.get(keyOf(r));
|
|
273
|
+
return was === undefined
|
|
274
|
+
? `${show(r)}: added after the analysis (${r.label}),${WHY}`
|
|
275
|
+
: `${show(r)}: relabelled ${was} → ${r.label} after the analysis,${WHY}`;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/** Cells the analysis read that are gone now, and not explained. */
|
|
279
|
+
const removedSince = (
|
|
280
|
+
rows: readonly Row[],
|
|
281
|
+
before: ReadonlyMap<string, string>,
|
|
282
|
+
noted: (cell: string) => boolean,
|
|
283
|
+
): string[] => {
|
|
284
|
+
const now = new Set(rows.map(keyOf));
|
|
285
|
+
return [...before]
|
|
286
|
+
.filter(([key]) => !now.has(key) && !noted(fromKey(key).cell))
|
|
287
|
+
.map(
|
|
288
|
+
([key, was]) =>
|
|
289
|
+
`${show(fromKey(key))}: removed after the analysis (was ${was}),${WHY}`,
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Every cell whose label differs from what the analysis read at `rev` — changed, ADDED after it,
|
|
295
|
+
* or REMOVED since — and is not explained in a dated, quoted note naming the cell.
|
|
296
|
+
*/
|
|
297
|
+
function relabelProblems(
|
|
298
|
+
labelsFile: string,
|
|
299
|
+
rev: string,
|
|
300
|
+
notesFile: string,
|
|
301
|
+
rows: readonly Row[],
|
|
302
|
+
): string[] {
|
|
303
|
+
const before = labelsAt(labelsFile, rev);
|
|
304
|
+
if (!before)
|
|
305
|
+
return [
|
|
306
|
+
`${labelsFile} does not exist at ${rev}: name the revision the analysis read`,
|
|
307
|
+
];
|
|
308
|
+
const sections = datedSections(notesFile);
|
|
309
|
+
const noted = (cell: string): boolean =>
|
|
310
|
+
sections.some((s) => s.includes(cell) && /^> \S/m.test(s));
|
|
311
|
+
return [
|
|
312
|
+
...touchedSince(rows, before, noted),
|
|
313
|
+
...removedSince(rows, before, noted),
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Cells a file labels more than once, with the lines. */
|
|
318
|
+
function duplicates(file: string, rows: readonly Row[]): string[] {
|
|
319
|
+
const seen = new Map<string, number[]>();
|
|
320
|
+
rows.forEach((r) => seen.set(keyOf(r), [...(seen.get(keyOf(r)) ?? []), r.n]));
|
|
321
|
+
return [...seen]
|
|
322
|
+
.filter(([, ns]) => ns.length > 1)
|
|
323
|
+
.map(
|
|
324
|
+
([k, ns]) =>
|
|
325
|
+
`${file}: ${show(fromKey(k))} is labelled ${String(ns.length)} times (lines ${ns.join(", ")})`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Rows naming a cell twice, and coverage against the ledger. */
|
|
330
|
+
function rowProblems(file: string, rows: readonly Row[], ledger?: string) {
|
|
331
|
+
const runs = ledger === undefined ? null : ledgerCells(ledger);
|
|
332
|
+
const ran = new Set((runs ?? []).map(keyOf));
|
|
333
|
+
const labelled = new Set(rows.map(keyOf));
|
|
334
|
+
return [
|
|
335
|
+
...duplicates(file, rows),
|
|
336
|
+
...(runs
|
|
337
|
+
? [
|
|
338
|
+
...rows
|
|
339
|
+
.filter((r) => !ran.has(keyOf(r)))
|
|
340
|
+
.map(
|
|
341
|
+
(r) =>
|
|
342
|
+
`${file}:${String(r.n)}: ${show(r)} has no completed run in the ledger`,
|
|
343
|
+
),
|
|
344
|
+
...runs
|
|
345
|
+
.filter((r) => !labelled.has(keyOf(r)))
|
|
346
|
+
.map((r) => `${show(r)}: a completed run with no label`),
|
|
347
|
+
]
|
|
348
|
+
: []),
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function validate(file: string, o: Values): void {
|
|
353
|
+
const { rows, problems } = readLabels(file);
|
|
354
|
+
const book = codebookProblems(String(o.codebook), file);
|
|
355
|
+
const all = [
|
|
356
|
+
...problems,
|
|
357
|
+
...book.problems,
|
|
358
|
+
...rows
|
|
359
|
+
.filter((r) => !book.known.has(r.label))
|
|
360
|
+
.map(
|
|
361
|
+
(r) => `${file}:${String(r.n)}: "${r.label}" is not a codebook label`,
|
|
362
|
+
),
|
|
363
|
+
...rowProblems(file, rows, o.ledger),
|
|
364
|
+
...(o["analysed-at"] === undefined
|
|
365
|
+
? []
|
|
366
|
+
: relabelProblems(file, o["analysed-at"], String(o.notes), rows)),
|
|
367
|
+
];
|
|
368
|
+
if (all.length) refuse(1, all.join("\n"));
|
|
369
|
+
say(`${file}: ${String(rows.length)} labels, valid`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── agreement ───────────────────────────────────────────────────────────────────────────────
|
|
373
|
+
const Z = 1.959963984540054;
|
|
374
|
+
/** 95% Wilson score interval for k successes in n. */
|
|
375
|
+
const wilson = (k: number, n: number): readonly [number, number] => {
|
|
376
|
+
const p = k / n;
|
|
377
|
+
const denom = 1 + (Z * Z) / n;
|
|
378
|
+
const centre = (p + (Z * Z) / (2 * n)) / denom;
|
|
379
|
+
const half =
|
|
380
|
+
(Z * Math.sqrt((p * (1 - p)) / n + (Z * Z) / (4 * n * n))) / denom;
|
|
381
|
+
return [centre - half, centre + half];
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** Cohen's kappa over paired labels; null when chance agreement is 1 (one label only). */
|
|
385
|
+
function cohenKappa(
|
|
386
|
+
pairs: readonly (readonly [string, string])[],
|
|
387
|
+
): number | null {
|
|
388
|
+
const n = pairs.length;
|
|
389
|
+
const po = pairs.filter(([a, b]) => a === b).length / n;
|
|
390
|
+
const labels = [...new Set(pairs.flat())];
|
|
391
|
+
const share = (i: 0 | 1, l: string): number =>
|
|
392
|
+
pairs.filter((p) => p[i] === l).length / n;
|
|
393
|
+
const pe = labels.reduce((s, l) => s + share(0, l) * share(1, l), 0);
|
|
394
|
+
return pe === 1 ? null : (po - pe) / (1 - pe);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Cells of either file that the other does not label. */
|
|
398
|
+
const onlyIn = (
|
|
399
|
+
fileA: string,
|
|
400
|
+
a: readonly Row[],
|
|
401
|
+
b: readonly Row[],
|
|
402
|
+
fileB: string,
|
|
403
|
+
): string[] => {
|
|
404
|
+
const inA = new Set(a.map(keyOf));
|
|
405
|
+
const inB = new Set(b.map(keyOf));
|
|
406
|
+
return [
|
|
407
|
+
...a
|
|
408
|
+
.filter((r) => !inB.has(keyOf(r)))
|
|
409
|
+
.map((r) => `${show(r)}: only in ${fileA}`),
|
|
410
|
+
...b
|
|
411
|
+
.filter((r) => !inA.has(keyOf(r)))
|
|
412
|
+
.map((r) => `${show(r)}: only in ${fileB}`),
|
|
413
|
+
];
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/** The second file's label for each cell of the first; a refusal when the cells differ. */
|
|
417
|
+
function matched(
|
|
418
|
+
fileA: string,
|
|
419
|
+
fileB: string,
|
|
420
|
+
): readonly (readonly [Row, string])[] {
|
|
421
|
+
const a = readLabels(fileA);
|
|
422
|
+
const b = readLabels(fileB);
|
|
423
|
+
// A cell labelled twice has no single label to compare: refused before any statistic.
|
|
424
|
+
const bad = [
|
|
425
|
+
...a.problems,
|
|
426
|
+
...b.problems,
|
|
427
|
+
...duplicates(fileA, a.rows),
|
|
428
|
+
...duplicates(fileB, b.rows),
|
|
429
|
+
];
|
|
430
|
+
if (bad.length) refuse(1, bad.join("\n"));
|
|
431
|
+
const inB = new Map(b.rows.map((r) => [keyOf(r), r.label]));
|
|
432
|
+
const unmatched = onlyIn(fileA, a.rows, b.rows, fileB);
|
|
433
|
+
if (unmatched.length)
|
|
434
|
+
refuse(
|
|
435
|
+
1,
|
|
436
|
+
`the two files label different cells — rate the same runs:\n${unmatched.join("\n")}`,
|
|
437
|
+
);
|
|
438
|
+
if (a.rows.length === 0) refuse(1, "no labels to compare");
|
|
439
|
+
return a.rows.map((r) => [r, String(inB.get(keyOf(r)))] as const);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function agree(fileA: string, fileB: string): void {
|
|
443
|
+
const rows = matched(fileA, fileB);
|
|
444
|
+
const pairs = rows.map(([r, other]) => [r.label, other] as const);
|
|
445
|
+
const n = pairs.length;
|
|
446
|
+
const k = pairs.filter(([x, y]) => x === y).length;
|
|
447
|
+
const [lo, hi] = wilson(k, n);
|
|
448
|
+
const kappa = cohenKappa(pairs);
|
|
449
|
+
const f = (x: number): string => x.toFixed(3);
|
|
450
|
+
say(
|
|
451
|
+
[
|
|
452
|
+
`n ${String(n)}`,
|
|
453
|
+
`agreement ${String(k)}/${String(n)} = ${f(k / n)} (95% Wilson ${f(lo)}–${f(hi)})`,
|
|
454
|
+
`cohen kappa ${kappa === null ? "undefined (one label only)" : f(kappa)}`,
|
|
455
|
+
...rows
|
|
456
|
+
.filter(([r, other]) => r.label !== other)
|
|
457
|
+
.map(([r, other]) => `disagree ${show(r)}: ${r.label} | ${other}`),
|
|
458
|
+
].join("\n"),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── command line ────────────────────────────────────────────────────────────────────────────
|
|
463
|
+
const OPTIONS = {
|
|
464
|
+
codebook: { type: "string" },
|
|
465
|
+
ledger: { type: "string" },
|
|
466
|
+
"analysed-at": { type: "string" },
|
|
467
|
+
notes: { type: "string" },
|
|
468
|
+
} as const;
|
|
469
|
+
type Values = ReturnType<
|
|
470
|
+
typeof parseArgs<{ options: typeof OPTIONS; allowPositionals: true }>
|
|
471
|
+
>["values"];
|
|
472
|
+
|
|
473
|
+
const USAGE =
|
|
474
|
+
"usage: labels.js validate <labels.tsv> --codebook <f> [--ledger <f>] [--analysed-at <rev> --notes <f>] | agree <a.tsv> <b.tsv>";
|
|
475
|
+
|
|
476
|
+
function main(): void {
|
|
477
|
+
const { values, positionals } = (() => {
|
|
478
|
+
try {
|
|
479
|
+
return parseArgs({ options: OPTIONS, allowPositionals: true });
|
|
480
|
+
} catch (e) {
|
|
481
|
+
return refuse(2, `${String(prop(e, "message"))}\n${USAGE}`);
|
|
482
|
+
}
|
|
483
|
+
})();
|
|
484
|
+
const [cmd, ...paths] = positionals;
|
|
485
|
+
const validateOk =
|
|
486
|
+
cmd === COMMANDS.validate &&
|
|
487
|
+
paths.length === 1 &&
|
|
488
|
+
values.codebook !== undefined &&
|
|
489
|
+
(values["analysed-at"] === undefined || values.notes !== undefined);
|
|
490
|
+
if (validateOk) validate(String(paths[0]), values);
|
|
491
|
+
else if (cmd === COMMANDS.agree && paths.length === 2)
|
|
492
|
+
agree(String(paths[0]), String(paths[1]));
|
|
493
|
+
else refuse(2, USAGE);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
main();
|
|
498
|
+
} catch (e) {
|
|
499
|
+
process.stderr.write(`labels: ${String(prop(e, "message"))}\n`);
|
|
500
|
+
process.exitCode = e instanceof Refusal ? e.exitCode : 1;
|
|
501
|
+
}
|
|
@@ -4,7 +4,7 @@ description: The orchestrator for writing a research paper end-to-end, from idea
|
|
|
4
4
|
allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Agent, Skill]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
<!-- vigiles:sha256:
|
|
7
|
+
<!-- vigiles:sha256:da7c692384fb79aa compiled from skills/paper-pipeline/SKILL.md.spec.ts -->
|
|
8
8
|
|
|
9
9
|
# paper-pipeline — the conductor for the whole organism
|
|
10
10
|
|
|
@@ -170,6 +170,13 @@ No numbers. Nothing here happens once, and nothing here is "next".
|
|
|
170
170
|
|
|
171
171
|
- **`build-benchmark`** — a design that inverts the pitfall; honest stats; a self-checking artifact
|
|
172
172
|
(`references/artifact-checklist.md`).
|
|
173
|
+
- **`run-agent-grid`** — when the study is a grid of agent runs (units × arms × seeds, one agent CLI
|
|
174
|
+
run per cell): the registry and the author's approval gate every counted run, a sandbox and secret
|
|
175
|
+
probe, a smoke per model, a locked and resumable driver, an overlap audit, finished runs committed.
|
|
176
|
+
Between the design and the analysis.
|
|
177
|
+
- **`label-agent-runs`** — when the runs are in and before any outcome number: a codebook fixed
|
|
178
|
+
first, the automatic classifier only a pre-sort, every uncertain run read and labelled by hand, a
|
|
179
|
+
blind second rater with its agreement, labels frozen after the analysis.
|
|
173
180
|
- **`build-label-page`** — when the study needs a person to label items (gold labels, a blind review):
|
|
174
181
|
a phone page with a shared answer store, the answers exported into the study's `data/`.
|
|
175
182
|
- **`draft-paper`** — section skeleton + the accept-and-get-cited properties
|
|
@@ -187,6 +187,13 @@ No numbers. Nothing here happens once, and nothing here is "next".
|
|
|
187
187
|
|
|
188
188
|
- **\`build-benchmark\`** — a design that inverts the pitfall; honest stats; a self-checking artifact
|
|
189
189
|
(\`references/artifact-checklist.md\`).
|
|
190
|
+
- **\`run-agent-grid\`** — when the study is a grid of agent runs (units × arms × seeds, one agent CLI
|
|
191
|
+
run per cell): the registry and the author's approval gate every counted run, a sandbox and secret
|
|
192
|
+
probe, a smoke per model, a locked and resumable driver, an overlap audit, finished runs committed.
|
|
193
|
+
Between the design and the analysis.
|
|
194
|
+
- **\`label-agent-runs\`** — when the runs are in and before any outcome number: a codebook fixed
|
|
195
|
+
first, the automatic classifier only a pre-sort, every uncertain run read and labelled by hand, a
|
|
196
|
+
blind second rater with its agreement, labels frozen after the analysis.
|
|
190
197
|
- **\`build-label-page\`** — when the study needs a person to label items (gold labels, a blind review):
|
|
191
198
|
a phone page with a shared answer store, the answers exported into the study's \`data/\`.
|
|
192
199
|
- **\`draft-paper\`** — section skeleton + the accept-and-get-cited properties
|