paperlint 13.2.0 → 13.3.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/.github/workflows/ci.yml +43 -1
- package/CLAUDE.md +3 -3
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +2 -2
- package/dist/doctor.js.map +1 -1
- package/dist/skills/assess-novelty/scripts/candidate_page.d.ts +1 -0
- package/dist/skills/assess-novelty/scripts/novelty_reader_brief.d.ts +25 -0
- package/docs/testing.md +5 -2
- package/docs/toolchain.md +1 -1
- package/docs/website.md +10 -0
- package/eslint.config.mjs +52 -0
- package/package.json +2 -1
- package/presets/agentverify.md +15 -4
- package/scripts/check.ts +4 -1
- package/skills/assess-novelty/scripts/candidate_page.css +31 -0
- package/skills/assess-novelty/scripts/candidate_page.html +934 -0
- package/skills/assess-novelty/scripts/candidate_page.js +165 -0
- package/skills/assess-novelty/scripts/candidate_page.test.ts +401 -0
- package/skills/assess-novelty/scripts/candidate_page.ts +194 -0
- package/skills/assess-novelty/scripts/check_novelty.py +275 -0
- package/skills/assess-novelty/scripts/check_novelty.test.ts +624 -0
- package/skills/assess-novelty/scripts/check_novelty_coverage.test.ts +176 -0
- package/skills/assess-novelty/scripts/crop_quote.py +104 -0
- package/skills/assess-novelty/scripts/crop_quote.test.ts +130 -0
- package/skills/assess-novelty/scripts/fixtures/crop-quote/make_sample.py +42 -0
- package/skills/assess-novelty/scripts/fixtures/crop-quote/sample.pdf +106 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/CLAIMS.md +11 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/paper.tex +10 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/siblings/2026-10-27-novelty-refutation.md +8 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/siblings/data/doe-preprint.txt +63 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/siblings/novelty.json +54 -0
- package/skills/assess-novelty/scripts/novelty_candidates.py +136 -0
- package/skills/assess-novelty/scripts/novelty_candidates.test.ts +222 -0
- package/skills/assess-novelty/scripts/novelty_cells.py +88 -0
- package/skills/assess-novelty/scripts/novelty_cells.test.ts +159 -0
- package/skills/assess-novelty/scripts/novelty_coverage.py +55 -0
- package/skills/assess-novelty/scripts/novelty_reader_brief.js +165 -0
- package/skills/assess-novelty/scripts/novelty_reader_brief.test.ts +251 -0
- package/skills/assess-novelty/scripts/novelty_reader_brief.ts +215 -0
- package/skills/assess-novelty/scripts/quote_match.py +34 -0
- package/skills/assess-novelty/scripts/script_edges.test.ts +174 -0
- package/src/doctor.ts +4 -2
|
@@ -0,0 +1,624 @@
|
|
|
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
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const FIXTURE = join(here, "fixtures", "novelty");
|
|
26
|
+
const CHECKER = join(here, "check_novelty.py");
|
|
27
|
+
|
|
28
|
+
// Copy the fixture, apply one change, run the checker; returns {code, out}.
|
|
29
|
+
type Files = { json: any; tex: string; claims: string; dir: string };
|
|
30
|
+
type Change = (f: Files) => void;
|
|
31
|
+
const run = (change: Change = () => undefined) => {
|
|
32
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "novelty-")));
|
|
33
|
+
try {
|
|
34
|
+
cpSync(FIXTURE, dir, { recursive: true });
|
|
35
|
+
const jsonPath = join(dir, "siblings", "novelty.json");
|
|
36
|
+
const files = {
|
|
37
|
+
json: JSON.parse(readFileSync(jsonPath, "utf8")),
|
|
38
|
+
tex: readFileSync(join(dir, "paper.tex"), "utf8"),
|
|
39
|
+
claims: readFileSync(join(dir, "CLAIMS.md"), "utf8"),
|
|
40
|
+
dir,
|
|
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
|
+
test("check_novelty", () => {
|
|
69
|
+
// ── clean fixture: passes, verdicts computed from the cells ────────────────────
|
|
70
|
+
{
|
|
71
|
+
const out = passes(() => undefined, "the fixture as committed");
|
|
72
|
+
assert.match(out, /R1\s+🔴/, "Doe has every piece of R1 → taken");
|
|
73
|
+
assert.match(out, /doe26/, "the verdict names who took it");
|
|
74
|
+
assert.match(out, /P5-1\s+🟢/, "Doe misses two pieces of P5-1 → free");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 1. a priority sentence in the paper (a finding a review panel made) ─────────
|
|
78
|
+
fails(
|
|
79
|
+
(f) => {
|
|
80
|
+
f.tex = f.tex.replace("\\end{document}", `${FIRST}\n\\end{document}`);
|
|
81
|
+
},
|
|
82
|
+
/first rule finder recall/,
|
|
83
|
+
"R3's false 'first' sentence, not mapped",
|
|
84
|
+
);
|
|
85
|
+
fails(
|
|
86
|
+
(f) => {
|
|
87
|
+
f.tex = f.tex.replace("\\end{document}", `${FIRST}\n\\end{document}`);
|
|
88
|
+
f.json.paper_sentences.push({ quote: FIRST, claim: "R1" });
|
|
89
|
+
},
|
|
90
|
+
/R1.*🔴|not 🟢/,
|
|
91
|
+
"the same sentence mapped to a claim that is taken",
|
|
92
|
+
);
|
|
93
|
+
fails(
|
|
94
|
+
(f) => {
|
|
95
|
+
f.tex = f.tex.replace(
|
|
96
|
+
"\\end{document}",
|
|
97
|
+
"None of the eight studies counting these files reports it.\n\\end{document}",
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
/None of the eight studies/,
|
|
101
|
+
"the panel's second never-claim, not mapped",
|
|
102
|
+
);
|
|
103
|
+
{
|
|
104
|
+
const TOK =
|
|
105
|
+
"To our knowledge, no prior study checks rules-file prose against the repository's committed lint configuration.";
|
|
106
|
+
fails(
|
|
107
|
+
(f) => {
|
|
108
|
+
f.tex = f.tex.replace("\\end{document}", `${TOK}\n\\end{document}`);
|
|
109
|
+
},
|
|
110
|
+
/To our knowledge/,
|
|
111
|
+
"a to-our-knowledge sentence, not mapped",
|
|
112
|
+
);
|
|
113
|
+
passes((f) => {
|
|
114
|
+
f.tex = f.tex.replace("\\end{document}", `${TOK}\n\\end{document}`);
|
|
115
|
+
f.json.paper_sentences.push({ quote: TOK, claim: "P5-1" });
|
|
116
|
+
}, "the same sentence mapped to a 🟢 claim");
|
|
117
|
+
}
|
|
118
|
+
// Run 1: the paper's main priority sentence had this shape and slipped past the detector,
|
|
119
|
+
// while "it cannot supply a rule nobody wrote" (not about anyone's work) was flagged.
|
|
120
|
+
fails(
|
|
121
|
+
(f) => {
|
|
122
|
+
f.tex = f.tex.replace(
|
|
123
|
+
"\\end{document}",
|
|
124
|
+
"What none of them measures is the guards developers actually install.\n\\end{document}",
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
/none of them measures/,
|
|
128
|
+
"'none of them measures', not mapped",
|
|
129
|
+
);
|
|
130
|
+
passes((f) => {
|
|
131
|
+
f.tex = f.tex.replace(
|
|
132
|
+
"\\end{document}",
|
|
133
|
+
"Normalization cannot supply a rule nobody wrote.\n\\end{document}",
|
|
134
|
+
);
|
|
135
|
+
}, "'a rule nobody wrote' is not a claim about prior work");
|
|
136
|
+
{
|
|
137
|
+
const FP = "No prior study of this corpus was needed to draw the sample.";
|
|
138
|
+
passes((f) => {
|
|
139
|
+
f.tex = f.tex.replace("\\end{document}", `${FP}\n\\end{document}`);
|
|
140
|
+
f.json.paper_sentences.push({
|
|
141
|
+
quote: FP,
|
|
142
|
+
claim: null,
|
|
143
|
+
not_priority: "about our sampling, not about prior work",
|
|
144
|
+
});
|
|
145
|
+
}, "a flagged sentence recorded as not a priority claim, with the reason");
|
|
146
|
+
fails(
|
|
147
|
+
(f) => {
|
|
148
|
+
f.tex = f.tex.replace("\\end{document}", `${FP}\n\\end{document}`);
|
|
149
|
+
f.json.paper_sentences.push({ quote: FP, claim: null });
|
|
150
|
+
},
|
|
151
|
+
/not_priority/,
|
|
152
|
+
"claim null without saying why",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
// Run 2: the detector caught 0 of a paper's 5 real priority sentences, and flagged one
|
|
156
|
+
// that is about our own procedure. The sentences below keep the shapes of the real ones.
|
|
157
|
+
for (const s of [
|
|
158
|
+
"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.",
|
|
159
|
+
"These studies count whether a guide is present or what it says; none asks whether anything in the project enforces what the guide says.",
|
|
160
|
+
"Each builds its own tests and evaluates them on data it produced; none reports whether the project's existing setup already enforces the requirement.",
|
|
161
|
+
"None of this work checks whether a requirement a guide states is switched on in the same project's CI.",
|
|
162
|
+
"None tests a requirement from the guide against the committed setup of the same project.",
|
|
163
|
+
]) {
|
|
164
|
+
fails(
|
|
165
|
+
(f) => {
|
|
166
|
+
f.tex = f.tex.replace("\\end{document}", `${s}\n\\end{document}`);
|
|
167
|
+
},
|
|
168
|
+
/priority sentence not in paper_sentences/,
|
|
169
|
+
`a real-shaped priority sentence: ${s.slice(0, 40)}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
for (const s of [
|
|
173
|
+
"We report RQ2 because its design was committed before the first count; its value is the bound it sets, not a difference it finds.",
|
|
174
|
+
"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.",
|
|
175
|
+
]) {
|
|
176
|
+
passes(
|
|
177
|
+
(f) => {
|
|
178
|
+
f.tex = f.tex.replace("\\end{document}", `${s}\n\\end{document}`);
|
|
179
|
+
},
|
|
180
|
+
`about our own procedure, not prior work: ${s.slice(0, 40)}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
// Guards: the fixture already holds "The first row of Table 1" and "None of the 900 prose blocks …"
|
|
184
|
+
// (sentences from review findings) and passes above; a detector that flags them fails the clean run.
|
|
185
|
+
|
|
186
|
+
// ── 2. absence read from an abstract (F2, F6) ──────────────────────────────────
|
|
187
|
+
// The table prints "doe26 (abstract)" too, so match the error line itself, not the word.
|
|
188
|
+
fails(
|
|
189
|
+
(f) => {
|
|
190
|
+
f.json.neighbours[0].read = "abstract";
|
|
191
|
+
},
|
|
192
|
+
/ERROR P5-1\/doe26\/object: 'no' from a neighbour read only as an abstract/,
|
|
193
|
+
"a 'no' cell on a neighbour read only as an abstract",
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// ── 3. a quote that is not in the neighbour's text ────────────────────────────
|
|
197
|
+
fails(
|
|
198
|
+
(f) => {
|
|
199
|
+
claim(f.json, "R1").cells.doe26.object.quote =
|
|
200
|
+
"our extraction method captured 76.3% of eligible safety rules";
|
|
201
|
+
},
|
|
202
|
+
/not found/,
|
|
203
|
+
"a quote edited away from the source",
|
|
204
|
+
);
|
|
205
|
+
fails(
|
|
206
|
+
(f) => {
|
|
207
|
+
claim(f.json, "R1").cells.doe26.object.quote = null;
|
|
208
|
+
},
|
|
209
|
+
/quote/,
|
|
210
|
+
"a 'yes' cell without a quote",
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// ── 4. the ledger disagrees with the computed verdict ─────────────────────────
|
|
214
|
+
fails(
|
|
215
|
+
(f) => {
|
|
216
|
+
f.claims = f.claims.replace(
|
|
217
|
+
"| R1 | The first measurement of a rule extractor's recall in this literature | 🔴",
|
|
218
|
+
"| R1 | The first measurement of a rule extractor's recall in this literature | 🟢",
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
/R1.*CLAIMS\.md/,
|
|
222
|
+
"CLAIMS.md says 🟢 where the cells say 🔴",
|
|
223
|
+
);
|
|
224
|
+
fails(
|
|
225
|
+
(f) => {
|
|
226
|
+
claim(f.json, "R1").ledger = "R9";
|
|
227
|
+
},
|
|
228
|
+
/R9/,
|
|
229
|
+
"a ledger row that does not exist",
|
|
230
|
+
);
|
|
231
|
+
// A design-stage claim (a planned measurement, not in paper.tex yet) has no ledger row until it enters the text:
|
|
232
|
+
// "planned": true is the one way to say so. Without it, a missing row is still an error.
|
|
233
|
+
fails(
|
|
234
|
+
(f) => {
|
|
235
|
+
delete claim(f.json, "R1").ledger;
|
|
236
|
+
},
|
|
237
|
+
/R1: ledger row None/,
|
|
238
|
+
"a claim with no ledger row and no planned flag",
|
|
239
|
+
);
|
|
240
|
+
passes((f) => {
|
|
241
|
+
const c = claim(f.json, "R1");
|
|
242
|
+
delete c.ledger;
|
|
243
|
+
c.planned = true;
|
|
244
|
+
}, "a planned claim with no ledger row");
|
|
245
|
+
|
|
246
|
+
// ── 5. one-piece delta and unknown cells ──────────────────────────────────────
|
|
247
|
+
{
|
|
248
|
+
const oneNo = (f: Files) => {
|
|
249
|
+
claim(f.json, "R1").cells.doe26.measure = { has: "no", quote: null };
|
|
250
|
+
};
|
|
251
|
+
fails(
|
|
252
|
+
oneNo,
|
|
253
|
+
/🟡/,
|
|
254
|
+
"nearest neighbour misses one piece, ledger still says 🔴",
|
|
255
|
+
);
|
|
256
|
+
const out = passes((f) => {
|
|
257
|
+
oneNo(f);
|
|
258
|
+
f.claims = f.claims.replace(
|
|
259
|
+
"| 🔴 taken (Doe 2026, recall 66.3 %) |",
|
|
260
|
+
"| 🟡 narrowed |",
|
|
261
|
+
);
|
|
262
|
+
}, "one-piece delta with the ledger at 🟡");
|
|
263
|
+
assert.match(out, /R1\s+🟡/);
|
|
264
|
+
assert.match(
|
|
265
|
+
out,
|
|
266
|
+
/measure/,
|
|
267
|
+
"the 🟡 verdict names the one piece that differs",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
// Run 1: "nearest" must be the neighbour with fewest 'no' and then most 'yes'; it used to be whichever came
|
|
271
|
+
// first, which named a neighbour with an unread cell over one matching every other piece.
|
|
272
|
+
{
|
|
273
|
+
const out = passes((f) => {
|
|
274
|
+
f.json.neighbours.unshift({ ...f.json.neighbours[0], id: "zz26" });
|
|
275
|
+
const r1 = claim(f.json, "R1");
|
|
276
|
+
r1.cells.doe26.measure = { has: "no", quote: null };
|
|
277
|
+
r1.cells.zz26 = {
|
|
278
|
+
population: r1.cells.doe26.population,
|
|
279
|
+
object: { has: "no", quote: null },
|
|
280
|
+
};
|
|
281
|
+
claim(f.json, "P5-1").cells.zz26 = claim(f.json, "P5-1").cells.doe26;
|
|
282
|
+
f.claims = f.claims.replace(
|
|
283
|
+
"| 🔴 taken (Doe 2026, recall 66.3 %) |",
|
|
284
|
+
"| 🟡 narrowed |",
|
|
285
|
+
);
|
|
286
|
+
}, "two neighbours each missing one piece");
|
|
287
|
+
assert.match(
|
|
288
|
+
out,
|
|
289
|
+
/R1\s+🟡\s+nearest doe26 /,
|
|
290
|
+
"the nearer is doe26 (two yes) over zz26 (one yes, one unread)",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
{
|
|
294
|
+
const out = run((f) => {
|
|
295
|
+
delete claim(f.json, "R1").cells.doe26.measure;
|
|
296
|
+
});
|
|
297
|
+
assert.match(
|
|
298
|
+
out.out,
|
|
299
|
+
/R1\s+⚪/,
|
|
300
|
+
"two yes and one unread piece could still be taken → unknown",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── 5b. a neighbour with the same object and measure has the result: never 🟢 ───
|
|
305
|
+
// Eval runs 3 and 3b (research/repro/assess-novelty-eval-2026-10-27/): the agent split the recall claim into
|
|
306
|
+
// object + measure (Doe: yes, quoted) and setting pieces such as "comparator: public human-segmented gold set"
|
|
307
|
+
// and "population: directive-like rules (not only safety rules)" (run-3b/trial-2). Each setting piece marked
|
|
308
|
+
// 'no' is one step toward 🟢: two of them made the claim "free" with the checker green, although Doe measured
|
|
309
|
+
// the same thing (extractor recall) first. A setting delta over the same result is 🟡 at best.
|
|
310
|
+
{
|
|
311
|
+
const split = (f: Files, comparator: any, population: any) => {
|
|
312
|
+
const r1 = claim(f.json, "R1");
|
|
313
|
+
r1.pieces = {
|
|
314
|
+
object: "a rule finder over agent rules files",
|
|
315
|
+
measure: "recall",
|
|
316
|
+
comparator: "public human-segmented gold set",
|
|
317
|
+
population: "directive-like rules (not only safety rules)",
|
|
318
|
+
};
|
|
319
|
+
r1.cells.doe26 = {
|
|
320
|
+
object: {
|
|
321
|
+
has: "yes",
|
|
322
|
+
quote:
|
|
323
|
+
"Rule-word retrieval catches about twothirds of the safety rules in a file.",
|
|
324
|
+
},
|
|
325
|
+
measure: {
|
|
326
|
+
has: "yes",
|
|
327
|
+
quote:
|
|
328
|
+
"it finds 63 of 95 in-scope reference segments, a recall of 66.3%",
|
|
329
|
+
},
|
|
330
|
+
...(comparator ? { comparator: { has: comparator, quote: null } } : {}),
|
|
331
|
+
...(population ? { population: { has: population, quote: null } } : {}),
|
|
332
|
+
};
|
|
333
|
+
r1.refutation = {
|
|
334
|
+
by: "fable",
|
|
335
|
+
date: "2026-10-27",
|
|
336
|
+
file: "siblings/2026-10-27-novelty-refutation.md",
|
|
337
|
+
verdict: "holds",
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
const ledger = (f: Files, mark: string) => {
|
|
341
|
+
f.claims = f.claims.replace(
|
|
342
|
+
"| 🔴 taken (Doe 2026, recall 66.3 %) |",
|
|
343
|
+
`| ${mark} |`,
|
|
344
|
+
);
|
|
345
|
+
};
|
|
346
|
+
// fires: run-3b/trial-2's own split, with its two unread setting cells marked 'no' and the ledger at 🟢
|
|
347
|
+
fails(
|
|
348
|
+
(f) => {
|
|
349
|
+
split(f, "no", "no");
|
|
350
|
+
ledger(f, "🟢 ours");
|
|
351
|
+
},
|
|
352
|
+
/R1: CLAIMS\.md row R1 says 🟢, the cells give 🟡/,
|
|
353
|
+
"same object and measure as Doe, two setting pieces 'no'",
|
|
354
|
+
);
|
|
355
|
+
{
|
|
356
|
+
const out = passes((f) => {
|
|
357
|
+
split(f, "no", "no");
|
|
358
|
+
ledger(f, "🟡 narrowed");
|
|
359
|
+
}, "the same table with the ledger at 🟡");
|
|
360
|
+
assert.match(
|
|
361
|
+
out,
|
|
362
|
+
/R1\s+🟡\s+doe26 has the object and the measure/,
|
|
363
|
+
"the 🟡 names who has the result",
|
|
364
|
+
);
|
|
365
|
+
assert.match(
|
|
366
|
+
out,
|
|
367
|
+
/comparator, population/,
|
|
368
|
+
"and the setting pieces that differ",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
// unread setting pieces stay ⚪ (could be 🔴), and the line says it cannot be free
|
|
372
|
+
assert.match(
|
|
373
|
+
run((f) => {
|
|
374
|
+
split(f, null, null);
|
|
375
|
+
ledger(f, "⚪ not checked");
|
|
376
|
+
}).out,
|
|
377
|
+
/R1\s+⚪\s+could still be taken by doe26; not free: doe26 has the object and the measure/,
|
|
378
|
+
"run-3b/trial-2 as written: object and measure yes, the rest unread",
|
|
379
|
+
);
|
|
380
|
+
// silent: a neighbour with the measure but not the object (run-3/trial-3's P5-1) stays 🟢
|
|
381
|
+
{
|
|
382
|
+
const out = passes((f) => {
|
|
383
|
+
const p = claim(f.json, "P5-1");
|
|
384
|
+
p.pieces.comparator = "the repository's own linter configuration";
|
|
385
|
+
p.cells.doe26.measure = {
|
|
386
|
+
has: "yes",
|
|
387
|
+
quote:
|
|
388
|
+
"only about 4–16% of the retrieved safety rules had a matching built-in guard",
|
|
389
|
+
};
|
|
390
|
+
p.cells.doe26.comparator = { has: "no", quote: null };
|
|
391
|
+
}, "measure yes, object no: a different result");
|
|
392
|
+
assert.match(out, /P5-1\s+🟢/);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── 6. the three search sources ───────────────────────────────────────────────
|
|
397
|
+
fails(
|
|
398
|
+
(f) => {
|
|
399
|
+
f.json.searched = f.json.searched.filter(
|
|
400
|
+
(s: any) => s.source !== "venue-pc",
|
|
401
|
+
);
|
|
402
|
+
},
|
|
403
|
+
/venue-pc/,
|
|
404
|
+
"no search of the venue's PC",
|
|
405
|
+
);
|
|
406
|
+
fails(
|
|
407
|
+
(f) => {
|
|
408
|
+
f.json.searched = f.json.searched.filter(
|
|
409
|
+
(s: any) => s.source !== "prev-edition",
|
|
410
|
+
);
|
|
411
|
+
},
|
|
412
|
+
/prev-edition/,
|
|
413
|
+
"no search of the previous edition",
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// ── 7. a 🟢 claim needs a refutation pass; every claim needs a type and its answer ─
|
|
417
|
+
fails(
|
|
418
|
+
(f) => {
|
|
419
|
+
delete claim(f.json, "P5-1").refutation;
|
|
420
|
+
},
|
|
421
|
+
/refutation/,
|
|
422
|
+
"🟢 without a refutation pass",
|
|
423
|
+
);
|
|
424
|
+
fails(
|
|
425
|
+
(f) => {
|
|
426
|
+
claim(f.json, "P5-1").refutation.file = "siblings/missing.md";
|
|
427
|
+
},
|
|
428
|
+
/missing\.md/,
|
|
429
|
+
"a refutation file that does not exist",
|
|
430
|
+
);
|
|
431
|
+
fails(
|
|
432
|
+
(f) => {
|
|
433
|
+
delete claim(f.json, "R1").objection_answer;
|
|
434
|
+
},
|
|
435
|
+
/objection_answer/,
|
|
436
|
+
"no answer to the type's objection",
|
|
437
|
+
);
|
|
438
|
+
fails(
|
|
439
|
+
(f) => {
|
|
440
|
+
claim(f.json, "R1").type = "novel";
|
|
441
|
+
},
|
|
442
|
+
/type/,
|
|
443
|
+
"a type outside the list",
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
// ── 8. a sentence mapped by hand is judged even when the detector misses it ────
|
|
447
|
+
// The skill tells the author to add phrasings the detector misses to paper_sentences; the check of such an
|
|
448
|
+
// entry used to run only for sentences the detector found, so the hand mapping did nothing.
|
|
449
|
+
{
|
|
450
|
+
const UNLIKE =
|
|
451
|
+
"Unlike prior work, we report the recall of a rule finder over agent rules files.";
|
|
452
|
+
fails(
|
|
453
|
+
(f) => {
|
|
454
|
+
f.tex = f.tex.replace("\\end{document}", `${UNLIKE}\n\\end{document}`);
|
|
455
|
+
f.json.paper_sentences.push({ quote: UNLIKE, claim: "R1" });
|
|
456
|
+
},
|
|
457
|
+
/rests on R1, which is 🔴/,
|
|
458
|
+
"a hand-mapped sentence resting on a taken claim",
|
|
459
|
+
);
|
|
460
|
+
passes((f) => {
|
|
461
|
+
f.tex = f.tex.replace("\\end{document}", `${UNLIKE}\n\\end{document}`);
|
|
462
|
+
f.json.paper_sentences.push({ quote: UNLIKE, claim: "P5-1" });
|
|
463
|
+
}, "the same sentence mapped to a 🟢 claim");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── 9. a 'yes' cell whose quote cannot be checked does not count ───────────────
|
|
467
|
+
// An abstract-only neighbour with no saved text skipped the quote check, so an invented quote still made a
|
|
468
|
+
// claim 🔴 with exit 0. The fixture is cut to R1 so the abstract's 'no' cells do not fire first.
|
|
469
|
+
{
|
|
470
|
+
const abstractOnly = (f: Files, text: string) => {
|
|
471
|
+
f.json.claims = f.json.claims.filter((c: any) => c.id === "R1");
|
|
472
|
+
f.json.one_new_result = { claim: "R1", diluted_by: [] };
|
|
473
|
+
Object.assign(f.json.neighbours[0], { read: "abstract", text });
|
|
474
|
+
};
|
|
475
|
+
fails(
|
|
476
|
+
(f) => {
|
|
477
|
+
abstractOnly(f, "siblings/data/missing.txt");
|
|
478
|
+
for (const cell of Object.values<any>(claim(f.json, "R1").cells.doe26))
|
|
479
|
+
cell.quote = "an invented sentence";
|
|
480
|
+
},
|
|
481
|
+
/R1\/doe26\/\w+: 'yes' but no text to check the quote against/,
|
|
482
|
+
"abstract-only neighbour, text missing",
|
|
483
|
+
);
|
|
484
|
+
passes(
|
|
485
|
+
(f) => abstractOnly(f, "siblings/data/doe-preprint.txt"),
|
|
486
|
+
"abstract-only neighbour whose text is saved",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}, 300_000);
|
|
490
|
+
|
|
491
|
+
test("check_novelty: a ledger more cautious than the cells", () => {
|
|
492
|
+
// ── 4b. a ledger MORE cautious than the cells, with a written reason ──────────
|
|
493
|
+
// A claim may carry "ledger_narrower_because": a reason naming an existing file. With it a ledger status
|
|
494
|
+
// strictly more cautious than the computed one (🟢 > 🟡 > 🔴) is accepted; a bolder one never is, and the
|
|
495
|
+
// field beside equal statuses is a stale reason. P5-1 computes 🟢 in the fixture (refutation file exists).
|
|
496
|
+
{
|
|
497
|
+
const REASON =
|
|
498
|
+
"siblings/2026-10-27-novelty-refutation.md: the refutation pass kept the claim narrower than the cells";
|
|
499
|
+
const P51_GREEN =
|
|
500
|
+
"| P5-1 | The repository's linter does not make 58.3 % of lint-checkable requirements an error | 🟢 ours |";
|
|
501
|
+
const P51_AMBER =
|
|
502
|
+
"| P5-1 | The repository's linter does not make 58.3 % of lint-checkable requirements an error | 🟡 narrowed |";
|
|
503
|
+
const amber = (f: Files, reason?: string) => {
|
|
504
|
+
assert.ok(f.claims.includes(P51_GREEN), "fixture row P5-1 changed");
|
|
505
|
+
f.claims = f.claims.replace(P51_GREEN, P51_AMBER);
|
|
506
|
+
if (reason !== undefined)
|
|
507
|
+
claim(f.json, "P5-1").ledger_narrower_because = reason;
|
|
508
|
+
};
|
|
509
|
+
// (a) ledger 🟡, cells 🟢, field naming an existing file: accepted, the printed verdict stays computed
|
|
510
|
+
{
|
|
511
|
+
const out = passes(
|
|
512
|
+
(f) => amber(f, REASON),
|
|
513
|
+
"(a) ledger 🟡, cells 🟢, reason names an existing file",
|
|
514
|
+
);
|
|
515
|
+
assert.match(
|
|
516
|
+
out,
|
|
517
|
+
/P5-1\s+🟢/,
|
|
518
|
+
"the printed verdict stays the computed one",
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
// (b) the same without the field: the error ends with the hint
|
|
522
|
+
fails(
|
|
523
|
+
(f) => amber(f),
|
|
524
|
+
/P5-1: CLAIMS\.md row P5-1 says 🟡, the cells give 🟢.*; a more cautious ledger needs ledger_narrower_because naming the file with the reason/s,
|
|
525
|
+
"(b) ledger 🟡, cells 🟢, no field",
|
|
526
|
+
);
|
|
527
|
+
// (c) ledger bolder than the cells: an error with or without the field
|
|
528
|
+
const oneNo = (f: Files) => {
|
|
529
|
+
claim(f.json, "R1").cells.doe26.measure = { has: "no", quote: null };
|
|
530
|
+
};
|
|
531
|
+
const bolder = (f: Files, reason?: string) => {
|
|
532
|
+
oneNo(f); // R1 computes 🟡
|
|
533
|
+
assert.ok(
|
|
534
|
+
f.claims.includes("| 🔴 taken (Doe 2026, recall 66.3 %) |"),
|
|
535
|
+
"fixture row R1 changed",
|
|
536
|
+
);
|
|
537
|
+
f.claims = f.claims.replace(
|
|
538
|
+
"| 🔴 taken (Doe 2026, recall 66.3 %) |",
|
|
539
|
+
"| 🟢 ours |",
|
|
540
|
+
);
|
|
541
|
+
if (reason !== undefined)
|
|
542
|
+
claim(f.json, "R1").ledger_narrower_because = reason;
|
|
543
|
+
};
|
|
544
|
+
fails(
|
|
545
|
+
(f) => bolder(f, REASON),
|
|
546
|
+
/R1: CLAIMS\.md row R1 says 🟢, the cells give 🟡/,
|
|
547
|
+
"(c) ledger 🟢, cells 🟡, with the field",
|
|
548
|
+
);
|
|
549
|
+
fails(
|
|
550
|
+
(f) => bolder(f),
|
|
551
|
+
/R1: CLAIMS\.md row R1 says 🟢, the cells give 🟡/,
|
|
552
|
+
"(c) ledger 🟢, cells 🟡, no field",
|
|
553
|
+
);
|
|
554
|
+
// (d) the named file is missing, no file is named, or the field is empty
|
|
555
|
+
fails(
|
|
556
|
+
(f) => amber(f, "siblings/no-such-refutation.md: kept narrower"),
|
|
557
|
+
/P5-1: ledger_narrower_because names siblings\/no-such-refutation\.md.*does not exist/,
|
|
558
|
+
"(d) the named file is missing",
|
|
559
|
+
);
|
|
560
|
+
fails(
|
|
561
|
+
(f) => amber(f, "kept narrower by hand"),
|
|
562
|
+
/P5-1: ledger_narrower_because names no file/,
|
|
563
|
+
"(d) a reason naming no file",
|
|
564
|
+
);
|
|
565
|
+
fails(
|
|
566
|
+
(f) => amber(f, " "),
|
|
567
|
+
/P5-1: ledger_narrower_because is empty/,
|
|
568
|
+
"(d) an empty reason",
|
|
569
|
+
);
|
|
570
|
+
// (e) equal statuses with the field: a stale reason
|
|
571
|
+
fails(
|
|
572
|
+
(f) => {
|
|
573
|
+
claim(f.json, "P5-1").ledger_narrower_because = REASON;
|
|
574
|
+
},
|
|
575
|
+
/P5-1: ledger_narrower_because is set but the ledger equals the computed verdict 🟢/,
|
|
576
|
+
"(e) equal statuses with the field",
|
|
577
|
+
);
|
|
578
|
+
// ⚪ is not on the scale: a ledger ⚪ over a computed 🟢 is unchecked, not more cautious
|
|
579
|
+
fails(
|
|
580
|
+
(f) => {
|
|
581
|
+
f.claims = f.claims.replace(
|
|
582
|
+
P51_GREEN,
|
|
583
|
+
P51_GREEN.replace("🟢 ours", "⚪ unchecked"),
|
|
584
|
+
);
|
|
585
|
+
claim(f.json, "P5-1").ledger_narrower_because = REASON;
|
|
586
|
+
},
|
|
587
|
+
/P5-1: CLAIMS\.md row P5-1 says ⚪, the cells give 🟢/,
|
|
588
|
+
"ledger ⚪ over computed 🟢, with the field",
|
|
589
|
+
);
|
|
590
|
+
// the more cautious ledger on a 🔴: ledger 🔴 (as committed), cells 🟡, accepted with the field
|
|
591
|
+
passes((f) => {
|
|
592
|
+
oneNo(f);
|
|
593
|
+
claim(f.json, "R1").ledger_narrower_because = REASON;
|
|
594
|
+
}, "ledger 🔴, cells 🟡, reason names an existing file");
|
|
595
|
+
}
|
|
596
|
+
}, 300_000);
|
|
597
|
+
|
|
598
|
+
test("check_novelty: a text with its columns glued", () => {
|
|
599
|
+
// ── 10. a text extracted with `pdftotext -layout` has its two columns glued into one line ──
|
|
600
|
+
// A sentence is then split across two lines and a verbatim quote is not contiguous. The glued copy pairs
|
|
601
|
+
// the fixture's own lines with an 8-space gap; the clean twin is the fixture as committed.
|
|
602
|
+
{
|
|
603
|
+
const DOE = "doe-preprint.txt";
|
|
604
|
+
const glue = (f: Files) => {
|
|
605
|
+
const p = join(f.dir, "siblings", "data", DOE);
|
|
606
|
+
const lines = readFileSync(p, "utf8")
|
|
607
|
+
.split("\n")
|
|
608
|
+
.filter((l) => l.trim());
|
|
609
|
+
const pairs: string[] = [];
|
|
610
|
+
for (let i = 0; i < lines.length; i += 2)
|
|
611
|
+
pairs.push(`${lines[i]} ${lines[i + 1] ?? ""}`);
|
|
612
|
+
writeFileSync(p, pairs.join("\n") + "\n");
|
|
613
|
+
};
|
|
614
|
+
fails(
|
|
615
|
+
glue,
|
|
616
|
+
/neighbour doe26: siblings\/data\/doe-preprint\.txt has \d+% glued lines.*re-extract with plain pdftotext \(no -layout\)/,
|
|
617
|
+
"a neighbour text with its columns glued",
|
|
618
|
+
);
|
|
619
|
+
passes(
|
|
620
|
+
() => undefined,
|
|
621
|
+
"the same text extracted without -layout (the fixture as committed)",
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
}, 300_000);
|