paperlint 13.2.0 → 13.3.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 (38) hide show
  1. package/.github/workflows/ci.yml +10 -0
  2. package/dist/doctor.d.ts.map +1 -1
  3. package/dist/doctor.js +2 -2
  4. package/dist/doctor.js.map +1 -1
  5. package/dist/skills/assess-novelty/scripts/candidate_page.d.ts +1 -0
  6. package/dist/skills/assess-novelty/scripts/novelty_reader_brief.d.ts +25 -0
  7. package/docs/toolchain.md +1 -1
  8. package/eslint.config.mjs +52 -0
  9. package/package.json +2 -1
  10. package/presets/agentverify.md +15 -4
  11. package/skills/assess-novelty/scripts/candidate_page.css +31 -0
  12. package/skills/assess-novelty/scripts/candidate_page.html +934 -0
  13. package/skills/assess-novelty/scripts/candidate_page.js +165 -0
  14. package/skills/assess-novelty/scripts/candidate_page.test.ts +401 -0
  15. package/skills/assess-novelty/scripts/candidate_page.ts +194 -0
  16. package/skills/assess-novelty/scripts/check_novelty.py +230 -0
  17. package/skills/assess-novelty/scripts/check_novelty.test.ts +490 -0
  18. package/skills/assess-novelty/scripts/check_novelty_coverage.test.ts +176 -0
  19. package/skills/assess-novelty/scripts/crop_quote.py +104 -0
  20. package/skills/assess-novelty/scripts/crop_quote.test.ts +130 -0
  21. package/skills/assess-novelty/scripts/fixtures/crop-quote/make_sample.py +42 -0
  22. package/skills/assess-novelty/scripts/fixtures/crop-quote/sample.pdf +106 -0
  23. package/skills/assess-novelty/scripts/fixtures/novelty/CLAIMS.md +11 -0
  24. package/skills/assess-novelty/scripts/fixtures/novelty/paper.tex +10 -0
  25. package/skills/assess-novelty/scripts/fixtures/novelty/siblings/2026-10-27-novelty-refutation.md +8 -0
  26. package/skills/assess-novelty/scripts/fixtures/novelty/siblings/data/doe-preprint.txt +63 -0
  27. package/skills/assess-novelty/scripts/fixtures/novelty/siblings/novelty.json +54 -0
  28. package/skills/assess-novelty/scripts/novelty_candidates.py +136 -0
  29. package/skills/assess-novelty/scripts/novelty_candidates.test.ts +222 -0
  30. package/skills/assess-novelty/scripts/novelty_cells.py +88 -0
  31. package/skills/assess-novelty/scripts/novelty_cells.test.ts +159 -0
  32. package/skills/assess-novelty/scripts/novelty_coverage.py +55 -0
  33. package/skills/assess-novelty/scripts/novelty_reader_brief.js +165 -0
  34. package/skills/assess-novelty/scripts/novelty_reader_brief.test.ts +251 -0
  35. package/skills/assess-novelty/scripts/novelty_reader_brief.ts +215 -0
  36. package/skills/assess-novelty/scripts/quote_match.py +34 -0
  37. package/skills/assess-novelty/scripts/script_edges.test.ts +174 -0
  38. package/src/doctor.ts +4 -2
@@ -0,0 +1,490 @@
1
+ /**
2
+ * check_novelty.py — execution tier: a real fixture on disk, no model, no network.
3
+ * Run: npx vitest run skills/assess-novelty/scripts/check_novelty.test.ts
4
+ *
5
+ * The fixture (fixtures/novelty/) is shaped after a real review finding: a neighbour's paper (a synthetic
6
+ * excerpt saved as text) took the "first rule finder recall" claim. Each
7
+ * case below copies it and makes ONE change; every failing class has a clean twin that must pass.
8
+ */
9
+ import {
10
+ cpSync,
11
+ mkdtempSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { spawnSync } from "node:child_process";
18
+ import { tmpdir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import assert from "node:assert/strict";
22
+ import { test } from "vitest";
23
+
24
+ test("check_novelty", () => {
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const FIXTURE = join(here, "fixtures", "novelty");
27
+ const CHECKER = join(here, "check_novelty.py");
28
+
29
+ // Copy the fixture, apply one change, run the checker; returns {code, out}.
30
+ type Files = { json: any; tex: string; claims: string };
31
+ type Change = (f: Files) => void;
32
+ const run = (change: Change = () => undefined) => {
33
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "novelty-")));
34
+ try {
35
+ cpSync(FIXTURE, dir, { recursive: true });
36
+ const jsonPath = join(dir, "siblings", "novelty.json");
37
+ const files = {
38
+ json: JSON.parse(readFileSync(jsonPath, "utf8")),
39
+ tex: readFileSync(join(dir, "paper.tex"), "utf8"),
40
+ claims: readFileSync(join(dir, "CLAIMS.md"), "utf8"),
41
+ };
42
+ change(files);
43
+ writeFileSync(jsonPath, JSON.stringify(files.json, null, 2));
44
+ writeFileSync(join(dir, "paper.tex"), files.tex);
45
+ writeFileSync(join(dir, "CLAIMS.md"), files.claims);
46
+ const r = spawnSync("python3", ["-I", CHECKER, dir], {
47
+ encoding: "utf8",
48
+ });
49
+ return { code: r.status, out: `${r.stdout}\n${r.stderr}` };
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ };
54
+ const passes = (change: Change, what: string) => {
55
+ const r = run(change);
56
+ assert.equal(r.code, 0, `must pass: ${what}\n${r.out}`);
57
+ return r.out;
58
+ };
59
+ const fails = (change: Change, re: RegExp, what: string) => {
60
+ const r = run(change);
61
+ assert.equal(r.code, 1, `must fail: ${what}\n${r.out}`);
62
+ assert.match(r.out, re, `wrong reason for: ${what}\n${r.out}`);
63
+ };
64
+ const claim = (j: any, id: string) => j.claims.find((c: any) => c.id === id);
65
+ const FIRST =
66
+ "This is the first rule finder recall reported in this literature.";
67
+
68
+ // ── clean fixture: passes, verdicts computed from the cells ────────────────────
69
+ {
70
+ const out = passes(() => undefined, "the fixture as committed");
71
+ assert.match(out, /R1\s+🔴/, "Doe has every piece of R1 → taken");
72
+ assert.match(out, /doe26/, "the verdict names who took it");
73
+ assert.match(out, /P5-1\s+🟢/, "Doe misses two pieces of P5-1 → free");
74
+ }
75
+
76
+ // ── 1. a priority sentence in the paper (a finding a review panel made) ─────────
77
+ fails(
78
+ (f) => {
79
+ f.tex = f.tex.replace("\\end{document}", `${FIRST}\n\\end{document}`);
80
+ },
81
+ /first rule finder recall/,
82
+ "R3's false 'first' sentence, not mapped",
83
+ );
84
+ fails(
85
+ (f) => {
86
+ f.tex = f.tex.replace("\\end{document}", `${FIRST}\n\\end{document}`);
87
+ f.json.paper_sentences.push({ quote: FIRST, claim: "R1" });
88
+ },
89
+ /R1.*🔴|not 🟢/,
90
+ "the same sentence mapped to a claim that is taken",
91
+ );
92
+ fails(
93
+ (f) => {
94
+ f.tex = f.tex.replace(
95
+ "\\end{document}",
96
+ "None of the eight studies counting these files reports it.\n\\end{document}",
97
+ );
98
+ },
99
+ /None of the eight studies/,
100
+ "the panel's second never-claim, not mapped",
101
+ );
102
+ {
103
+ const TOK =
104
+ "To our knowledge, no prior study checks rules-file prose against the repository's committed lint configuration.";
105
+ fails(
106
+ (f) => {
107
+ f.tex = f.tex.replace("\\end{document}", `${TOK}\n\\end{document}`);
108
+ },
109
+ /To our knowledge/,
110
+ "a to-our-knowledge sentence, not mapped",
111
+ );
112
+ passes((f) => {
113
+ f.tex = f.tex.replace("\\end{document}", `${TOK}\n\\end{document}`);
114
+ f.json.paper_sentences.push({ quote: TOK, claim: "P5-1" });
115
+ }, "the same sentence mapped to a 🟢 claim");
116
+ }
117
+ // Run 1: the paper's main priority sentence had this shape and slipped past the detector,
118
+ // while "it cannot supply a rule nobody wrote" (not about anyone's work) was flagged.
119
+ fails(
120
+ (f) => {
121
+ f.tex = f.tex.replace(
122
+ "\\end{document}",
123
+ "What none of them measures is the guards developers actually install.\n\\end{document}",
124
+ );
125
+ },
126
+ /none of them measures/,
127
+ "'none of them measures', not mapped",
128
+ );
129
+ passes((f) => {
130
+ f.tex = f.tex.replace(
131
+ "\\end{document}",
132
+ "Normalization cannot supply a rule nobody wrote.\n\\end{document}",
133
+ );
134
+ }, "'a rule nobody wrote' is not a claim about prior work");
135
+ {
136
+ const FP = "No prior study of this corpus was needed to draw the sample.";
137
+ passes((f) => {
138
+ f.tex = f.tex.replace("\\end{document}", `${FP}\n\\end{document}`);
139
+ f.json.paper_sentences.push({
140
+ quote: FP,
141
+ claim: null,
142
+ not_priority: "about our sampling, not about prior work",
143
+ });
144
+ }, "a flagged sentence recorded as not a priority claim, with the reason");
145
+ fails(
146
+ (f) => {
147
+ f.tex = f.tex.replace("\\end{document}", `${FP}\n\\end{document}`);
148
+ f.json.paper_sentences.push({ quote: FP, claim: null });
149
+ },
150
+ /not_priority/,
151
+ "claim null without saying why",
152
+ );
153
+ }
154
+ // Run 2: the detector caught 0 of a paper's 5 real priority sentences, and flagged one
155
+ // that is about our own procedure. The sentences below keep the shapes of the real ones.
156
+ for (const s of [
157
+ "No study we know of has measured how often a project's CI enforces the checks that its contributing guide states; that is our first research question.",
158
+ "These studies count whether a guide is present or what it says; none asks whether anything in the project enforces what the guide says.",
159
+ "Each builds its own tests and evaluates them on data it produced; none reports whether the project's existing setup already enforces the requirement.",
160
+ "None of this work checks whether a requirement a guide states is switched on in the same project's CI.",
161
+ "None tests a requirement from the guide against the committed setup of the same project.",
162
+ ]) {
163
+ fails(
164
+ (f) => {
165
+ f.tex = f.tex.replace("\\end{document}", `${s}\n\\end{document}`);
166
+ },
167
+ /priority sentence not in paper_sentences/,
168
+ `a real-shaped priority sentence: ${s.slice(0, 40)}`,
169
+ );
170
+ }
171
+ for (const s of [
172
+ "We report RQ2 because its design was committed before the first count; its value is the bound it sets, not a difference it finds.",
173
+ "Before the first count we committed the design: this threshold, the quantities reported below, the result that would refute our hypothesis, and a table fixing where each outcome would be reported.",
174
+ ]) {
175
+ passes(
176
+ (f) => {
177
+ f.tex = f.tex.replace("\\end{document}", `${s}\n\\end{document}`);
178
+ },
179
+ `about our own procedure, not prior work: ${s.slice(0, 40)}`,
180
+ );
181
+ }
182
+ // Guards: the fixture already holds "The first row of Table 1" and "None of the 900 prose blocks …"
183
+ // (sentences from review findings) and passes above; a detector that flags them fails the clean run.
184
+
185
+ // ── 2. absence read from an abstract (F2, F6) ──────────────────────────────────
186
+ // The table prints "doe26 (abstract)" too, so match the error line itself, not the word.
187
+ fails(
188
+ (f) => {
189
+ f.json.neighbours[0].read = "abstract";
190
+ },
191
+ /ERROR P5-1\/doe26\/object: 'no' from a neighbour read only as an abstract/,
192
+ "a 'no' cell on a neighbour read only as an abstract",
193
+ );
194
+
195
+ // ── 3. a quote that is not in the neighbour's text ────────────────────────────
196
+ fails(
197
+ (f) => {
198
+ claim(f.json, "R1").cells.doe26.object.quote =
199
+ "our extraction method captured 76.3% of eligible safety rules";
200
+ },
201
+ /not found/,
202
+ "a quote edited away from the source",
203
+ );
204
+ fails(
205
+ (f) => {
206
+ claim(f.json, "R1").cells.doe26.object.quote = null;
207
+ },
208
+ /quote/,
209
+ "a 'yes' cell without a quote",
210
+ );
211
+
212
+ // ── 4. the ledger disagrees with the computed verdict ─────────────────────────
213
+ fails(
214
+ (f) => {
215
+ f.claims = f.claims.replace(
216
+ "| R1 | The first measurement of a rule extractor's recall in this literature | 🔴",
217
+ "| R1 | The first measurement of a rule extractor's recall in this literature | 🟢",
218
+ );
219
+ },
220
+ /R1.*CLAIMS\.md/,
221
+ "CLAIMS.md says 🟢 where the cells say 🔴",
222
+ );
223
+ fails(
224
+ (f) => {
225
+ claim(f.json, "R1").ledger = "R9";
226
+ },
227
+ /R9/,
228
+ "a ledger row that does not exist",
229
+ );
230
+ // A design-stage claim (a planned measurement, not in paper.tex yet) has no ledger row until it enters the text:
231
+ // "planned": true is the one way to say so. Without it, a missing row is still an error.
232
+ fails(
233
+ (f) => {
234
+ delete claim(f.json, "R1").ledger;
235
+ },
236
+ /R1: ledger row None/,
237
+ "a claim with no ledger row and no planned flag",
238
+ );
239
+ passes((f) => {
240
+ const c = claim(f.json, "R1");
241
+ delete c.ledger;
242
+ c.planned = true;
243
+ }, "a planned claim with no ledger row");
244
+
245
+ // ── 5. one-piece delta and unknown cells ──────────────────────────────────────
246
+ {
247
+ const oneNo = (f: Files) => {
248
+ claim(f.json, "R1").cells.doe26.measure = { has: "no", quote: null };
249
+ };
250
+ fails(
251
+ oneNo,
252
+ /🟡/,
253
+ "nearest neighbour misses one piece, ledger still says 🔴",
254
+ );
255
+ const out = passes((f) => {
256
+ oneNo(f);
257
+ f.claims = f.claims.replace(
258
+ "| 🔴 taken (Doe 2026, recall 66.3 %) |",
259
+ "| 🟡 narrowed |",
260
+ );
261
+ }, "one-piece delta with the ledger at 🟡");
262
+ assert.match(out, /R1\s+🟡/);
263
+ assert.match(
264
+ out,
265
+ /measure/,
266
+ "the 🟡 verdict names the one piece that differs",
267
+ );
268
+ }
269
+ // Run 1: "nearest" must be the neighbour with fewest 'no' and then most 'yes'; it used to be whichever came
270
+ // first, which named a neighbour with an unread cell over one matching every other piece.
271
+ {
272
+ const out = passes((f) => {
273
+ f.json.neighbours.unshift({ ...f.json.neighbours[0], id: "zz26" });
274
+ const r1 = claim(f.json, "R1");
275
+ r1.cells.doe26.measure = { has: "no", quote: null };
276
+ r1.cells.zz26 = {
277
+ population: r1.cells.doe26.population,
278
+ object: { has: "no", quote: null },
279
+ };
280
+ claim(f.json, "P5-1").cells.zz26 = claim(f.json, "P5-1").cells.doe26;
281
+ f.claims = f.claims.replace(
282
+ "| 🔴 taken (Doe 2026, recall 66.3 %) |",
283
+ "| 🟡 narrowed |",
284
+ );
285
+ }, "two neighbours each missing one piece");
286
+ assert.match(
287
+ out,
288
+ /R1\s+🟡\s+nearest doe26 /,
289
+ "the nearer is doe26 (two yes) over zz26 (one yes, one unread)",
290
+ );
291
+ }
292
+ {
293
+ const out = run((f) => {
294
+ delete claim(f.json, "R1").cells.doe26.measure;
295
+ });
296
+ assert.match(
297
+ out.out,
298
+ /R1\s+⚪/,
299
+ "two yes and one unread piece could still be taken → unknown",
300
+ );
301
+ }
302
+
303
+ // ── 5b. a neighbour with the same object and measure has the result: never 🟢 ───
304
+ // Eval runs 3 and 3b (research/repro/assess-novelty-eval-2026-10-27/): the agent split the recall claim into
305
+ // object + measure (Doe: yes, quoted) and setting pieces such as "comparator: public human-segmented gold set"
306
+ // and "population: directive-like rules (not only safety rules)" (run-3b/trial-2). Each setting piece marked
307
+ // 'no' is one step toward 🟢: two of them made the claim "free" with the checker green, although Doe measured
308
+ // the same thing (extractor recall) first. A setting delta over the same result is 🟡 at best.
309
+ {
310
+ const split = (f: Files, comparator: any, population: any) => {
311
+ const r1 = claim(f.json, "R1");
312
+ r1.pieces = {
313
+ object: "a rule finder over agent rules files",
314
+ measure: "recall",
315
+ comparator: "public human-segmented gold set",
316
+ population: "directive-like rules (not only safety rules)",
317
+ };
318
+ r1.cells.doe26 = {
319
+ object: {
320
+ has: "yes",
321
+ quote:
322
+ "Rule-word retrieval catches about twothirds of the safety rules in a file.",
323
+ },
324
+ measure: {
325
+ has: "yes",
326
+ quote:
327
+ "it finds 63 of 95 in-scope reference segments, a recall of 66.3%",
328
+ },
329
+ ...(comparator ? { comparator: { has: comparator, quote: null } } : {}),
330
+ ...(population ? { population: { has: population, quote: null } } : {}),
331
+ };
332
+ r1.refutation = {
333
+ by: "fable",
334
+ date: "2026-10-27",
335
+ file: "siblings/2026-10-27-novelty-refutation.md",
336
+ verdict: "holds",
337
+ };
338
+ };
339
+ const ledger = (f: Files, mark: string) => {
340
+ f.claims = f.claims.replace(
341
+ "| 🔴 taken (Doe 2026, recall 66.3 %) |",
342
+ `| ${mark} |`,
343
+ );
344
+ };
345
+ // fires: run-3b/trial-2's own split, with its two unread setting cells marked 'no' and the ledger at 🟢
346
+ fails(
347
+ (f) => {
348
+ split(f, "no", "no");
349
+ ledger(f, "🟢 ours");
350
+ },
351
+ /R1: CLAIMS\.md row R1 says 🟢, the cells give 🟡/,
352
+ "same object and measure as Doe, two setting pieces 'no'",
353
+ );
354
+ {
355
+ const out = passes((f) => {
356
+ split(f, "no", "no");
357
+ ledger(f, "🟡 narrowed");
358
+ }, "the same table with the ledger at 🟡");
359
+ assert.match(
360
+ out,
361
+ /R1\s+🟡\s+doe26 has the object and the measure/,
362
+ "the 🟡 names who has the result",
363
+ );
364
+ assert.match(
365
+ out,
366
+ /comparator, population/,
367
+ "and the setting pieces that differ",
368
+ );
369
+ }
370
+ // unread setting pieces stay ⚪ (could be 🔴), and the line says it cannot be free
371
+ assert.match(
372
+ run((f) => {
373
+ split(f, null, null);
374
+ ledger(f, "⚪ not checked");
375
+ }).out,
376
+ /R1\s+⚪\s+could still be taken by doe26; not free: doe26 has the object and the measure/,
377
+ "run-3b/trial-2 as written: object and measure yes, the rest unread",
378
+ );
379
+ // silent: a neighbour with the measure but not the object (run-3/trial-3's P5-1) stays 🟢
380
+ {
381
+ const out = passes((f) => {
382
+ const p = claim(f.json, "P5-1");
383
+ p.pieces.comparator = "the repository's own linter configuration";
384
+ p.cells.doe26.measure = {
385
+ has: "yes",
386
+ quote:
387
+ "only about 4–16% of the retrieved safety rules had a matching built-in guard",
388
+ };
389
+ p.cells.doe26.comparator = { has: "no", quote: null };
390
+ }, "measure yes, object no: a different result");
391
+ assert.match(out, /P5-1\s+🟢/);
392
+ }
393
+ }
394
+
395
+ // ── 6. the three search sources ───────────────────────────────────────────────
396
+ fails(
397
+ (f) => {
398
+ f.json.searched = f.json.searched.filter(
399
+ (s: any) => s.source !== "venue-pc",
400
+ );
401
+ },
402
+ /venue-pc/,
403
+ "no search of the venue's PC",
404
+ );
405
+ fails(
406
+ (f) => {
407
+ f.json.searched = f.json.searched.filter(
408
+ (s: any) => s.source !== "prev-edition",
409
+ );
410
+ },
411
+ /prev-edition/,
412
+ "no search of the previous edition",
413
+ );
414
+
415
+ // ── 7. a 🟢 claim needs a refutation pass; every claim needs a type and its answer ─
416
+ fails(
417
+ (f) => {
418
+ delete claim(f.json, "P5-1").refutation;
419
+ },
420
+ /refutation/,
421
+ "🟢 without a refutation pass",
422
+ );
423
+ fails(
424
+ (f) => {
425
+ claim(f.json, "P5-1").refutation.file = "siblings/missing.md";
426
+ },
427
+ /missing\.md/,
428
+ "a refutation file that does not exist",
429
+ );
430
+ fails(
431
+ (f) => {
432
+ delete claim(f.json, "R1").objection_answer;
433
+ },
434
+ /objection_answer/,
435
+ "no answer to the type's objection",
436
+ );
437
+ fails(
438
+ (f) => {
439
+ claim(f.json, "R1").type = "novel";
440
+ },
441
+ /type/,
442
+ "a type outside the list",
443
+ );
444
+
445
+ // ── 8. a sentence mapped by hand is judged even when the detector misses it ────
446
+ // The skill tells the author to add phrasings the detector misses to paper_sentences; the check of such an
447
+ // entry used to run only for sentences the detector found, so the hand mapping did nothing.
448
+ {
449
+ const UNLIKE =
450
+ "Unlike prior work, we report the recall of a rule finder over agent rules files.";
451
+ fails(
452
+ (f) => {
453
+ f.tex = f.tex.replace("\\end{document}", `${UNLIKE}\n\\end{document}`);
454
+ f.json.paper_sentences.push({ quote: UNLIKE, claim: "R1" });
455
+ },
456
+ /rests on R1, which is 🔴/,
457
+ "a hand-mapped sentence resting on a taken claim",
458
+ );
459
+ passes((f) => {
460
+ f.tex = f.tex.replace("\\end{document}", `${UNLIKE}\n\\end{document}`);
461
+ f.json.paper_sentences.push({ quote: UNLIKE, claim: "P5-1" });
462
+ }, "the same sentence mapped to a 🟢 claim");
463
+ }
464
+
465
+ // ── 9. a 'yes' cell whose quote cannot be checked does not count ───────────────
466
+ // An abstract-only neighbour with no saved text skipped the quote check, so an invented quote still made a
467
+ // claim 🔴 with exit 0. The fixture is cut to R1 so the abstract's 'no' cells do not fire first.
468
+ {
469
+ const abstractOnly = (f: Files, text: string) => {
470
+ f.json.claims = f.json.claims.filter((c: any) => c.id === "R1");
471
+ f.json.one_new_result = { claim: "R1", diluted_by: [] };
472
+ Object.assign(f.json.neighbours[0], { read: "abstract", text });
473
+ };
474
+ fails(
475
+ (f) => {
476
+ abstractOnly(f, "siblings/data/missing.txt");
477
+ for (const cell of Object.values<any>(claim(f.json, "R1").cells.doe26))
478
+ cell.quote = "an invented sentence";
479
+ },
480
+ /R1\/doe26\/\w+: 'yes' but no text to check the quote against/,
481
+ "abstract-only neighbour, text missing",
482
+ );
483
+ passes(
484
+ (f) => abstractOnly(f, "siblings/data/doe-preprint.txt"),
485
+ "abstract-only neighbour whose text is saved",
486
+ );
487
+ }
488
+
489
+ console.log("check_novelty.harness: all cases pass");
490
+ }, 300_000);
@@ -0,0 +1,176 @@
1
+ /**
2
+ * check_novelty.py, coverage: a neighbour marked read "full" proves it with one verbatim sentence per chunk of
3
+ * its saved text. Execution tier: the novelty fixture on disk, no model, no network.
4
+ * Run: npx vitest run skills/assess-novelty/scripts/check_novelty_coverage.test.ts
5
+ *
6
+ * Why: one Read returns at most 25,000 tokens — a 1,790-line text came back as lines 1-627 — yet
7
+ * the neighbour was marked read "full", and a 'no' cell (the one that grants novelty) rests on that mark.
8
+ * Each case copies the fixture, makes ONE change; every failing class has a clean twin that passes.
9
+ */
10
+ import {
11
+ cpSync,
12
+ mkdtempSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { spawnSync } from "node:child_process";
19
+ import { tmpdir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import assert from "node:assert/strict";
23
+ import { test } from "vitest";
24
+
25
+ test("check_novelty_coverage", () => {
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const FIXTURE = join(here, "fixtures", "novelty");
28
+ const CHECKER = join(here, "check_novelty.py");
29
+ const TEXT = join("siblings", "data", "doe-preprint.txt");
30
+
31
+ // Copy the fixture, let `change` edit the novelty table and the saved text, run the checker.
32
+ const run = (change: (f: { json: any; text: string }) => void) => {
33
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "novelty-cov-")));
34
+ try {
35
+ cpSync(FIXTURE, dir, { recursive: true });
36
+ const jsonPath = join(dir, "siblings", "novelty.json");
37
+ const files = {
38
+ json: JSON.parse(readFileSync(jsonPath, "utf8")),
39
+ text: readFileSync(join(dir, TEXT), "utf8"),
40
+ };
41
+ change(files);
42
+ writeFileSync(jsonPath, JSON.stringify(files.json, null, 2));
43
+ writeFileSync(join(dir, TEXT), files.text);
44
+ const r = spawnSync("python3", ["-I", CHECKER, dir], {
45
+ encoding: "utf8",
46
+ });
47
+ return { code: r.status, out: `${r.stdout}\n${r.stderr}` };
48
+ } finally {
49
+ rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ };
52
+ const passes = (change: Parameters<typeof run>[0], what: string) => {
53
+ const r = run(change);
54
+ assert.equal(r.code, 0, `must pass: ${what}\n${r.out}`);
55
+ };
56
+ const fails = (
57
+ change: Parameters<typeof run>[0],
58
+ re: RegExp,
59
+ what: string,
60
+ ) => {
61
+ const r = run(change);
62
+ assert.equal(r.code, 1, `must fail: ${what}\n${r.out}`);
63
+ assert.match(r.out, re, `wrong reason for: ${what}\n${r.out}`);
64
+ };
65
+ const doe = (j: any) => j.neighbours.find((n: any) => n.id === "doe26");
66
+ const lines = (t: string) =>
67
+ t.split("\n").length - (t.endsWith("\n") ? 1 : 0);
68
+ // A sentence that sits on line 9 of the fixture text, and one from its last lines.
69
+ const EARLY = "instruction that the model interprets. The agent’s block is";
70
+ const LATE = "the denominator of the main estimate; it means the estimate";
71
+
72
+ // ── clean: the committed fixture covers its one chunk ───────────────────────────
73
+ passes(
74
+ () => {},
75
+ "fixture as committed: one chunk, lines 1-63, quote inside it",
76
+ );
77
+
78
+ // ── 1. read "full" with no coverage at all ─────────────────────────────────────
79
+ fails(
80
+ (f) => {
81
+ delete doe(f.json).coverage;
82
+ },
83
+ /doe26: read 'full' not proven: no coverage/,
84
+ "full without coverage",
85
+ );
86
+ {
87
+ // An abstract read turns P5-1 ⚪ against its 🟢 ledger row, so this twin cannot exit 0; it must only be silent
88
+ // about coverage.
89
+ const r = run((f) => {
90
+ delete doe(f.json).coverage;
91
+ doe(f.json).read = "abstract";
92
+ for (const c of f.json.claims)
93
+ for (const cell of Object.values<any>(c.cells?.doe26 ?? {}))
94
+ if (cell.has === "no") cell.has = "unknown";
95
+ });
96
+ assert.doesNotMatch(
97
+ r.out,
98
+ /coverage|not proven/,
99
+ `an abstract-only neighbour needs no coverage\n${r.out}`,
100
+ );
101
+ }
102
+
103
+ // ── 2. the chunks must tile the text: start at 1, no gap, end at the last line ──
104
+ fails(
105
+ (f) => {
106
+ doe(f.json).coverage = [
107
+ { lines: [1, 30], quote: EARLY },
108
+ { lines: [40, lines(f.text)], quote: LATE },
109
+ ];
110
+ },
111
+ /doe26: read 'full' not proven: lines 31-39 have no coverage quote/,
112
+ "a gap between chunks",
113
+ );
114
+ fails(
115
+ (f) => {
116
+ doe(f.json).coverage = [{ lines: [1, 50], quote: EARLY }];
117
+ },
118
+ /doe26: read 'full' not proven: lines 51-63 have no coverage quote/,
119
+ "stops before the last line",
120
+ );
121
+ passes((f) => {
122
+ doe(f.json).coverage = [
123
+ { lines: [1, 30], quote: EARLY },
124
+ { lines: [31, lines(f.text)], quote: LATE },
125
+ ];
126
+ }, "two chunks that tile the text");
127
+
128
+ // ── 3. a chunk longer than one careful read ────────────────────────────────────
129
+ fails(
130
+ (f) => {
131
+ f.text =
132
+ f.text +
133
+ Array.from(
134
+ { length: 400 },
135
+ (_, i) => `padding line ${i} so the text runs past one chunk`,
136
+ ).join("\n") +
137
+ "\n";
138
+ doe(f.json).coverage = [{ lines: [1, lines(f.text)], quote: EARLY }];
139
+ },
140
+ /doe26: coverage chunk 1-463 is 463 lines; at most 400/,
141
+ "one chunk over 400 lines",
142
+ );
143
+
144
+ // ── 4. the quote must be inside ITS chunk, not anywhere in the text ────────────
145
+ fails(
146
+ (f) => {
147
+ doe(f.json).coverage = [
148
+ { lines: [1, 30], quote: LATE },
149
+ { lines: [31, lines(f.text)], quote: EARLY },
150
+ ];
151
+ },
152
+ /doe26: coverage quote for lines 1-30 not found in those lines/,
153
+ "quotes swapped between chunks",
154
+ );
155
+
156
+ // ── 5. a coverage quote is a sentence, not a word ──────────────────────────────
157
+ fails(
158
+ (f) => {
159
+ doe(f.json).coverage = [
160
+ { lines: [1, lines(f.text)], quote: "the model" },
161
+ ];
162
+ },
163
+ /doe26: coverage quote for lines 1-63 has 2 words; at least 8/,
164
+ "a two-word quote",
165
+ );
166
+
167
+ // ── 6. lines are counted the way Read and wc -l count them: by \n only ─────────
168
+ // pdftotext writes a form feed at every page break; Python's splitlines() splits on it, so a 1,825-line text
169
+ // counted 1,850 and readers (sibling-read) had to stretch their last range past the file's real end.
170
+ passes((f) => {
171
+ f.text = f.text
172
+ .replace("\n\nAbstract", "\n\f\nAbstract")
173
+ .replace("Pat Doe", "Pat\fDoe");
174
+ doe(f.json).coverage = [{ lines: [1, lines(f.text)], quote: EARLY }];
175
+ }, "a text with form feeds is covered by its \\n line count");
176
+ }, 300_000);