paperlint 10.0.0 → 10.1.1
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/render-paper/check-render.sh +9 -0
- package/skills/render-paper/render-paper.harness.mjs +102 -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
|
|
@@ -184,10 +184,19 @@ if [ -f "$BLG" ]; then
|
|
|
184
184
|
grep -E "^(You're|I'm skipping|Repeated entry|I couldn't open)" "$BLG" | sed 's/^/ /' | head -12
|
|
185
185
|
fi
|
|
186
186
|
# A cited key that never made it into the .bbl is the same failure seen from the other side.
|
|
187
|
+
# An IEEEtran BST control entry (`@IEEEtranBSTCTL`, activated by `\bstctlcite{key}`) is cited
|
|
188
|
+
# in the .aux like any key, but IEEEtran reads it as configuration and never prints it as a
|
|
189
|
+
# \bibitem — so it is not a missing reference. A key is exempt only when IEEEtran itself reports
|
|
190
|
+
# reading it as a control entry: the .blg line `** IEEEtran BST control entry "key" detected.`,
|
|
191
|
+
# which every IEEEtran*.bst prints. BibTeX wrote that line after parsing the build's own databases,
|
|
192
|
+
# so a record in a .bib the build does not read, inside `@comment{…}`, or misspelled exempts
|
|
193
|
+
# nothing, and every layout BibTeX accepts is recognised without parsing .bib files here.
|
|
187
194
|
if [ -f "$BASE.aux" ] && [ -f "$BASE.bbl" ]; then
|
|
188
195
|
missing=0
|
|
196
|
+
ctl=$(sed -n 's/^\*\* IEEEtran BST control entry "\(.*\)" detected\.$/\1/p' "$BASE.blg" 2>/dev/null | sort -u)
|
|
189
197
|
while read -r key; do
|
|
190
198
|
[ -z "$key" ] && continue
|
|
199
|
+
printf '%s\n' "$ctl" | grep -qxF -- "$key" && continue
|
|
191
200
|
grep -qF "{$key}" "$BASE.bbl" || { echo " cited but NOT in the bibliography: $key"; missing=$((missing+1)); }
|
|
192
201
|
done < <(grep -oE '\\citation\{[^}]*\}' "$BASE.aux" | sed 's/.*{//;s/}//' | tr ',' '\n' | sort -u)
|
|
193
202
|
[ "$missing" -gt 0 ] && { echo " CITED-BUT-ABSENT references: $missing"; blgerr=$((blgerr+missing)); }
|
|
@@ -295,3 +295,105 @@ await checkSkill("render-paper");
|
|
|
295
295
|
: ` under ${root}/`),
|
|
296
296
|
);
|
|
297
297
|
}
|
|
298
|
+
|
|
299
|
+
// ── AN IEEEtran BST CONTROL ENTRY IS NOT A MISSING REFERENCE ─────────────────
|
|
300
|
+
// `\\bstctlcite{key}` cites an `@IEEEtranBSTCTL{key, …}` entry, so the key lands in the .aux like any
|
|
301
|
+
// citation, but IEEEtran reads it as configuration and never prints a \\bibitem for it. The
|
|
302
|
+
// "cited but NOT in the bibliography" check failed a real IEEE paper on exactly that key (an
|
|
303
|
+
// "et al. after six authors" setting). The exemption comes only from IEEEtran's own .blg line
|
|
304
|
+
// `** IEEEtran BST control entry "key" detected.`: BibTeX wrote it after parsing the build's
|
|
305
|
+
// databases, so a record BibTeX never read (a .bib the build does not use, a record inside
|
|
306
|
+
// `@comment{…}`) or a misspelled key exempts nothing, and a genuinely missing key is still reported.
|
|
307
|
+
{
|
|
308
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
309
|
+
const CHECK = join(HERE, "check-render.sh");
|
|
310
|
+
// `detected` = the keys IEEEtran reported in the .blg as control entries it read.
|
|
311
|
+
const paper = (citations, detected = ["BSTcontrol"]) => {
|
|
312
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "bstctl-")));
|
|
313
|
+
writeFileSync(
|
|
314
|
+
join(dir, "paper.tex"),
|
|
315
|
+
"\\documentclass{IEEEtran}\n\\begin{document}\n\\bstctlcite{BSTcontrol}\nText \\cite{real}.\n\\bibliographystyle{IEEEtran}\n\\bibliography{refs}\n\\end{document}\n",
|
|
316
|
+
);
|
|
317
|
+
writeFileSync(
|
|
318
|
+
join(dir, "paper.aux"),
|
|
319
|
+
citations.map((k) => `\\citation{${k}}`).join("\n") +
|
|
320
|
+
"\n\\bibstyle{IEEEtran}\n\\bibdata{refs}\n",
|
|
321
|
+
);
|
|
322
|
+
writeFileSync(
|
|
323
|
+
join(dir, "paper.bbl"),
|
|
324
|
+
"\\begin{thebibliography}{1}\n\\bibitem{real}\nA real reference, 2026.\n\\end{thebibliography}\n",
|
|
325
|
+
);
|
|
326
|
+
writeFileSync(
|
|
327
|
+
join(dir, "paper.blg"),
|
|
328
|
+
"This is BibTeX, Version 0.99e\nDatabase file #1: refs.bib\n-- IEEEtran.bst version 1.14 (2015/08/26) by Michael Shell.\n" +
|
|
329
|
+
detected
|
|
330
|
+
.map((k) => `** IEEEtran BST control entry "${k}" detected.\n`)
|
|
331
|
+
.join("") +
|
|
332
|
+
"\nDone.\n",
|
|
333
|
+
);
|
|
334
|
+
writeFileSync(
|
|
335
|
+
join(dir, "paper.log"),
|
|
336
|
+
"This is pdfTeX\nOutput written on paper.pdf (1 page, 1000 bytes).\n",
|
|
337
|
+
);
|
|
338
|
+
const r = spawnSync("bash", [CHECK, dir, "paper"], {
|
|
339
|
+
encoding: "utf8",
|
|
340
|
+
env: { ...process.env, NO_COMPILE: "1" },
|
|
341
|
+
});
|
|
342
|
+
// The script must run to its verdict: a run that stopped early prints no complaint either, and
|
|
343
|
+
// "no complaint" is what the exemption case asserts. stderr and the bash version name the cause.
|
|
344
|
+
assert.match(
|
|
345
|
+
r.stdout,
|
|
346
|
+
/^(PASS|FAIL)\b/m,
|
|
347
|
+
`check-render.sh stopped before its verdict (exit ${r.status}, ${BASH_VERSION}):\n` +
|
|
348
|
+
`--- stdout\n${r.stdout}--- stderr\n${r.stderr}`,
|
|
349
|
+
);
|
|
350
|
+
return r;
|
|
351
|
+
};
|
|
352
|
+
const BASH_VERSION = spawnSync("bash", ["--version"], {
|
|
353
|
+
encoding: "utf8",
|
|
354
|
+
}).stdout.split("\n")[0];
|
|
355
|
+
|
|
356
|
+
const ctlOnly = paper(["BSTcontrol", "real"]);
|
|
357
|
+
assert.doesNotMatch(
|
|
358
|
+
ctlOnly.stdout,
|
|
359
|
+
/CITED-BUT-ABSENT/,
|
|
360
|
+
`a control key IEEEtran reported reading was flagged as a missing reference:\n${ctlOnly.stdout}`,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// A key that starts with "-" is still a key, not a grep option.
|
|
364
|
+
const dashKey = paper(["-ctl", "real"], ["-ctl"]);
|
|
365
|
+
assert.doesNotMatch(
|
|
366
|
+
dashKey.stdout,
|
|
367
|
+
/CITED-BUT-ABSENT/,
|
|
368
|
+
`a control key starting with "-" was flagged:\n${dashKey.stdout}`,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
// A misspelled \\bstctlcite key: IEEEtran detected a different key, so this one is not exempt.
|
|
372
|
+
const typo = paper(["BSTcontrl", "real"]);
|
|
373
|
+
assert.match(
|
|
374
|
+
typo.stdout,
|
|
375
|
+
/cited but NOT in the bibliography: BSTcontrl/,
|
|
376
|
+
`a \\bstctlcite key IEEEtran did not report must be flagged:\n${typo.stdout}`,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// A record BibTeX never read — in a .bib the build does not use, or inside @comment{…} — leaves
|
|
380
|
+
// no "detected" line, so the key is not exempt.
|
|
381
|
+
const unread = paper(["BSTcontrol", "real"], []);
|
|
382
|
+
assert.match(
|
|
383
|
+
unread.stdout,
|
|
384
|
+
/cited but NOT in the bibliography: BSTcontrol/,
|
|
385
|
+
`a control key IEEEtran never reported reading must be flagged:\n${unread.stdout}`,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const withGap = paper(["BSTcontrol", "real", "gone"]);
|
|
389
|
+
assert.match(
|
|
390
|
+
withGap.stdout,
|
|
391
|
+
/cited but NOT in the bibliography: gone/,
|
|
392
|
+
`a key with no \\bibitem must still be reported:\n${withGap.stdout}`,
|
|
393
|
+
);
|
|
394
|
+
assert.doesNotMatch(
|
|
395
|
+
withGap.stdout,
|
|
396
|
+
/bibliography: BSTcontrol/,
|
|
397
|
+
withGap.stdout,
|
|
398
|
+
);
|
|
399
|
+
}
|