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,136 @@
1
+ """Candidate neighbours: works a refutation or a search surfaced, waiting for the author to decide on each one.
2
+
3
+ Why Python, not TypeScript (a TypeScript rewrite is paperlint#339): check_novelty.py,
4
+ which is Python, imports this module, and the gate must be the same code the page builder asks. The builder
5
+ (candidate_page.ts) calls this file's `shape` command instead of keeping a second copy of the rules.
6
+
7
+ A candidate is one file, <paper-dir>/siblings/candidates/<id>.json:
8
+
9
+ {"id": "smith16", # = the file name; becomes the neighbour id on "add"
10
+ "title": "...", "authors": "...", "venue": "...", "year": 2016,
11
+ "url": "https://...", # the work's landing page
12
+ "pdf_url": "https://...", # optional: the PDF itself
13
+ "text": "siblings/data/<id>.txt", # the full text, as for a neighbour
14
+ "found_by": [{"claim": "C3", "file": "siblings/...-refutation-C3.md"} | {"source": "paper.tex ref86"}],
15
+ "summary": "what the work does, in plain words",
16
+ "abstract": {"page": 1, "text": "the abstract, verbatim"},
17
+ "conclusion": {"page": 10, "text": "the conclusion section, verbatim"},
18
+ "passages": [{"claim": "C3", "page": 4, "quote": "verbatim, at least 8 words",
19
+ "crop": "siblings/data/crops/<id>-1.png", "meaning": "what it means for the claim"}],
20
+ "suggestion": "add" | "cite" | "skip", "suggestion_why": "...",
21
+ "decision": {"choice": "add" | "cite" | "skip", "reason": "...", "at": "ISO time",
22
+ "source": "page" | "chat"}} # absent until the author decides
23
+
24
+ The abstract and the conclusion are what the authors say about their own work, at the top of the page, so the
25
+ author judges the passages against them (the author asked for the abstract and the
26
+ conclusions to be shown at every stage, if possible at the top). Every quoted text must be found in the saved text.
27
+
28
+ The author decides on a page (candidate_page.ts builds it); nobody else writes `decision`. "add" makes the work
29
+ a neighbour, read in full by sibling-read; "cite" keeps it out of the novelty table but the paper should cite
30
+ it; "skip" needs a reason. check_novelty.py fails while a candidate is undecided or added but not yet read.
31
+
32
+ python3 -I novelty_candidates.py shape <paper-dir> # each shape problem on its own line; exit 1 if any
33
+ """
34
+ import json
35
+ import os
36
+ import sys
37
+
38
+ CHOICES = ("add", "cite", "skip")
39
+ SOURCES = ("page", "chat")
40
+ QUOTE_WORDS_MIN = 8
41
+ REQUIRED = ("id", "title", "url", "text", "found_by", "summary", "abstract", "conclusion", "passages", "suggestion")
42
+ OWN_WORDS = ("abstract", "conclusion")
43
+
44
+
45
+ def load(paper):
46
+ """[(relative path, parsed file)] for every siblings/candidates/*.json, sorted by name."""
47
+ folder = os.path.join(paper, "siblings", "candidates")
48
+ if not os.path.isdir(folder):
49
+ return []
50
+ names = sorted(n for n in os.listdir(folder) if n.endswith(".json"))
51
+ return [(f"siblings/candidates/{n}", json.load(open(os.path.join(folder, n), encoding="utf-8"))) for n in names]
52
+
53
+
54
+ def shape(path, c):
55
+ missing = [k for k in REQUIRED if c.get(k) in (None, "", [], {})]
56
+ if missing:
57
+ return [f"{path}: missing {', '.join(missing)}"]
58
+ errs = []
59
+ if os.path.basename(path) != f"{c['id']}.json":
60
+ errs.append(f"{path}: id {c['id']!r} does not match the file name")
61
+ if c["suggestion"] not in CHOICES:
62
+ errs.append(f"{path}: suggestion {c['suggestion']!r} is not one of {', '.join(CHOICES)}")
63
+ errs += [f"{path}: found_by[{i}] names neither a claim with its file nor a source"
64
+ for i, f in enumerate(c["found_by"]) if not ((f.get("claim") and f.get("file")) or f.get("source"))]
65
+ errs += [f"{path}: {k} needs page and text" for k in OWN_WORDS
66
+ if not (isinstance(c[k], dict) and c[k].get("page") and (c[k].get("text") or "").strip())]
67
+ for i, p in enumerate(c["passages"]):
68
+ gaps = [k for k in ("quote", "page", "crop", "meaning") if not p.get(k)]
69
+ errs += [f"{path}: passages[{i}] missing {', '.join(gaps)}"] if gaps else []
70
+ if p.get("quote") and len(p["quote"].split()) < QUOTE_WORDS_MIN:
71
+ errs.append(f"{path}: passages[{i}] quote has {len(p['quote'].split())} words; at least {QUOTE_WORDS_MIN}")
72
+ return errs
73
+
74
+
75
+ def _decision(path, c):
76
+ d = c.get("decision")
77
+ if d is None:
78
+ return []
79
+ errs = [f"{path}: decision.choice {d.get('choice')!r} is not one of {', '.join(CHOICES)}"
80
+ ] if d.get("choice") not in CHOICES else []
81
+ if d.get("source") not in SOURCES:
82
+ errs.append(f"{path}: decision.source {d.get('source')!r} is not one of {', '.join(SOURCES)}")
83
+ if not d.get("at"):
84
+ errs.append(f"{path}: decision has no time (at)")
85
+ if d.get("choice") == "skip" and not (d.get("reason") or "").strip():
86
+ errs.append(f"{path}: skip without a reason")
87
+ return errs
88
+
89
+
90
+ def errors(paper, data, files, found, texts_of):
91
+ """Every problem with the candidates of one paper.
92
+
93
+ data: the novelty table; found(quote, text) -> bool, the same presence check the cells use;
94
+ texts_of(path) -> the saved text at a paper-relative path, or None when there is no such file.
95
+ """
96
+ neighbours = {n["id"]: n for n in data.get("neighbours", [])}
97
+ out = []
98
+ for path, c in files:
99
+ problems = shape(path, c)
100
+ if problems:
101
+ out += problems
102
+ continue
103
+ text = texts_of(c["text"])
104
+ if text is None:
105
+ out.append(f"{path}: no text at {c['text']}")
106
+ else:
107
+ out += [f"{path}: {k} not found in {c['text']}: {c[k]['text'][:80]!r}"
108
+ for k in OWN_WORDS if not found(c[k]["text"], text)]
109
+ out += [f"{path}: passages[{i}] quote not found in {c['text']}: {p['quote'][:80]!r}"
110
+ for i, p in enumerate(c["passages"]) if not found(p["quote"], text)]
111
+ out += [f"{path}: passages[{i}] crop {p['crop']} does not exist"
112
+ for i, p in enumerate(c["passages"]) if not os.path.isfile(os.path.join(paper, p["crop"]))]
113
+ out += _decision(path, c)
114
+ choice = (c.get("decision") or {}).get("choice")
115
+ n = neighbours.get(c["id"])
116
+ if choice is None:
117
+ out.append(f"{c['id']}: candidate waiting for the author's decision (candidate_page.ts build)")
118
+ elif choice == "add" and (n is None or n.get("read") != "full"):
119
+ out.append(f"{c['id']}: approved as a neighbour but not read in full yet "
120
+ f"(sibling-read with only: [{c['id']!r}])")
121
+ elif choice != "add" and n is not None:
122
+ out.append(f"{c['id']}: is a neighbour in novelty.json, but the author's decision was {choice!r}")
123
+ return out
124
+
125
+
126
+ if __name__ == "__main__":
127
+ if len(sys.argv) != 3 or sys.argv[1] != "shape":
128
+ print(__doc__, file=sys.stderr)
129
+ sys.exit(2)
130
+ paper = sys.argv[2]
131
+ problems = [e for path, c in load(paper) for e in shape(path, c)]
132
+ problems += [f"{path}: passages[{i}] crop {p['crop']} does not exist" for path, c in load(paper)
133
+ for i, p in enumerate(c.get("passages") or []) if p.get("crop")
134
+ and not os.path.isfile(os.path.join(paper, p["crop"]))]
135
+ print("\n".join(problems))
136
+ sys.exit(1 if problems else 0)
@@ -0,0 +1,222 @@
1
+ /**
2
+ * check_novelty.py, candidates: a work a refutation surfaced waits for the author's add / cite / skip, and an
3
+ * "add" is not settled until sibling-read has read it in full. Execution tier: the novelty fixture, no model.
4
+ * Run: npx vitest run skills/assess-novelty/scripts/novelty_candidates.test.ts
5
+ *
6
+ * Why: the refutation pass found four works and the agent added all four as neighbours itself; the author wants each
7
+ * one put to them first, with the passages in front of them. The gate is that an undecided work fails the check.
8
+ * Each case copies the fixture and writes ONE candidate; every failing class has a clean twin that passes.
9
+ */
10
+ import {
11
+ cpSync,
12
+ mkdirSync,
13
+ mkdtempSync,
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("novelty_candidates", () => {
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 SENTENCE =
30
+ "We ran Checkstyle before and after applying the initial patch and the final patch.";
31
+ const ABSTRACT =
32
+ "We study whether code review removes the coding convention violations a linter reports.";
33
+ const CONCLUSION =
34
+ "Review removes some violations, and most of the rest stay in the code after the merge.";
35
+
36
+ const candidate = (over: Record<string, unknown> = {}) => ({
37
+ id: "roe20",
38
+ title: "Does code review really remove style violations?",
39
+ url: "https://example.org/roe20",
40
+ text: "siblings/data/roe20.txt",
41
+ found_by: [{ claim: "P5-1", file: "siblings/refutation.md" }],
42
+ summary: "Runs a linter on review patches before and after review.",
43
+ abstract: { page: 1, text: ABSTRACT },
44
+ conclusion: { page: 9, text: CONCLUSION },
45
+ passages: [
46
+ {
47
+ claim: "P5-1",
48
+ page: 4,
49
+ quote: SENTENCE,
50
+ crop: "siblings/data/crops/roe20-1.png",
51
+ meaning: "The before/after linter run is an established instrument.",
52
+ },
53
+ ],
54
+ suggestion: "cite",
55
+ suggestion_why: "Same instrument, not the same object.",
56
+ ...over,
57
+ });
58
+ const decided = (choice: string, reason = "the instrument only") => ({
59
+ decision: {
60
+ choice,
61
+ reason,
62
+ at: "2026-10-29T16:00:00+05:00",
63
+ source: "page",
64
+ },
65
+ });
66
+
67
+ // Copy the fixture, write one candidate (or none) plus its text and crop, run the checker.
68
+ const run = (
69
+ c: Record<string, unknown> | null,
70
+ { crop = true, text = `${ABSTRACT}\n${SENTENCE}\n${CONCLUSION}` } = {},
71
+ ) => {
72
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "novelty-cand-")));
73
+ try {
74
+ cpSync(FIXTURE, dir, { recursive: true });
75
+ if (c) {
76
+ mkdirSync(join(dir, "siblings", "candidates"), { recursive: true });
77
+ mkdirSync(join(dir, "siblings", "data", "crops"), { recursive: true });
78
+ writeFileSync(
79
+ join(dir, "siblings", "candidates", `${c.id}.json`),
80
+ JSON.stringify(c, null, 2),
81
+ );
82
+ writeFileSync(
83
+ join(dir, "siblings", "data", "roe20.txt"),
84
+ `Section 3.\n${text}\n`,
85
+ );
86
+ if (crop)
87
+ writeFileSync(
88
+ join(dir, "siblings", "data", "crops", "roe20-1.png"),
89
+ "png",
90
+ );
91
+ }
92
+ const r = spawnSync("python3", ["-I", CHECKER, dir], {
93
+ encoding: "utf8",
94
+ });
95
+ return { code: r.status, out: `${r.stdout}\n${r.stderr}` };
96
+ } finally {
97
+ rmSync(dir, { recursive: true, force: true });
98
+ }
99
+ };
100
+ const passes = (
101
+ c: Record<string, unknown> | null,
102
+ what: string,
103
+ opts = {},
104
+ ) => {
105
+ const r = run(c, opts);
106
+ assert.equal(r.code, 0, `must pass: ${what}\n${r.out}`);
107
+ assert.doesNotMatch(
108
+ r.out,
109
+ /candidate/,
110
+ `silent about candidates: ${what}\n${r.out}`,
111
+ );
112
+ };
113
+ const fails = (
114
+ c: Record<string, unknown>,
115
+ re: RegExp,
116
+ what: string,
117
+ opts = {},
118
+ ) => {
119
+ const r = run(c, opts);
120
+ assert.equal(r.code, 1, `must fail: ${what}\n${r.out}`);
121
+ assert.match(r.out, re, `wrong reason for: ${what}\n${r.out}`);
122
+ };
123
+
124
+ // ── clean: no candidates folder at all; a decided "cite" with its passage in the text ──
125
+ passes(null, "no candidates");
126
+ passes(
127
+ candidate(decided("cite")),
128
+ "a cite decision, quote in the text, crop on disk",
129
+ );
130
+
131
+ // ── 1. undecided: the author has not answered ──
132
+ fails(
133
+ candidate(),
134
+ /roe20: candidate waiting for the author's decision/,
135
+ "no decision",
136
+ );
137
+
138
+ // ── 2. "add" is only settled once the work is a neighbour read in full ──
139
+ fails(
140
+ candidate(decided("add")),
141
+ /roe20: approved as a neighbour but not read in full yet/,
142
+ "add, not read",
143
+ );
144
+ passes(
145
+ candidate({ id: "doe26", ...decided("add") }),
146
+ "add of a work that is now a neighbour read in full",
147
+ );
148
+
149
+ // ── 3. a neighbour the author did not approve ──
150
+ fails(
151
+ candidate({ id: "doe26", ...decided("skip") }),
152
+ /doe26: is a neighbour in novelty.json, but the author's decision was 'skip'/,
153
+ "skipped yet a neighbour",
154
+ );
155
+
156
+ // ── 4. skip needs its reason ──
157
+ fails(
158
+ candidate(decided("skip", " ")),
159
+ /skip without a reason/,
160
+ "skip with a blank reason",
161
+ );
162
+ passes(
163
+ candidate(
164
+ decided("skip", "different object: code review, not rules files"),
165
+ ),
166
+ "skip with a reason",
167
+ );
168
+
169
+ // ── 5. what the page shows must be real: the quote in the saved text, the crop on disk ──
170
+ fails(
171
+ candidate(decided("cite")),
172
+ /passages\[0\] quote not found in siblings\/data\/roe20.txt/,
173
+ "quote missing from the text",
174
+ { text: "Something else entirely, said in nine whole words here." },
175
+ );
176
+ fails(
177
+ candidate(decided("cite")),
178
+ /passages\[0\] crop siblings\/data\/crops\/roe20-1.png does not exist/,
179
+ "crop not rendered",
180
+ { crop: false },
181
+ );
182
+
183
+ // ── 6. shape: a field the page needs, a short quote, a choice outside the three ──
184
+ fails(
185
+ candidate({ summary: "", ...decided("cite") }),
186
+ /roe20.json: missing summary/,
187
+ "no summary",
188
+ );
189
+
190
+ // ── 7. the authors' own words open every tab: abstract and conclusion, each with its page, each in the text ──
191
+ fails(
192
+ candidate({ abstract: undefined, ...decided("cite") }),
193
+ /roe20.json: missing abstract/,
194
+ "no abstract",
195
+ );
196
+ fails(
197
+ candidate({ conclusion: { text: CONCLUSION }, ...decided("cite") }),
198
+ /conclusion needs page and text/,
199
+ "a conclusion without its page",
200
+ );
201
+ fails(
202
+ candidate(decided("cite")),
203
+ /roe20.json: abstract not found in siblings\/data\/roe20.txt/,
204
+ "an abstract the saved text does not hold",
205
+ { text: `${SENTENCE}\n${CONCLUSION}` },
206
+ );
207
+ fails(
208
+ candidate({
209
+ passages: [{ ...candidate().passages[0], quote: "before and after" }],
210
+ ...decided("cite"),
211
+ }),
212
+ /passages\[0\] quote has 3 words; at least 8/,
213
+ "a three-word quote",
214
+ );
215
+ fails(
216
+ candidate({
217
+ decision: { choice: "maybe", at: "2026-10-29", source: "page" },
218
+ }),
219
+ /decision.choice 'maybe' is not one of add, cite, skip/,
220
+ "an unknown choice",
221
+ );
222
+ }, 300_000);
@@ -0,0 +1,88 @@
1
+ """Merge the readers' per-neighbour files into a paper's novelty table.
2
+
3
+ Usage: python3 -I novelty_cells.py <paper-dir>
4
+ reads <paper-dir>/siblings/cells/<id>.json, one per neighbour, each {"id", "read", "coverage", "cells":
5
+ {claim: {piece: cell}}}, and writes their read, coverage and cells into siblings/novelty.json; exit 1, writing
6
+ nothing, if a file names a neighbour, claim or piece the table does not have.
7
+
8
+ One reader agent per neighbour writes its own file, so parallel readers never edit the same file; the files
9
+ are the record of each read, and novelty.json is merged from them. check_novelty.py fails while novelty.json is
10
+ behind any of them. The neighbour list, the claims and their pieces stay owned by novelty.json.
11
+ """
12
+ import copy
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+ OWNED = ("read", "coverage") # neighbour fields a cells file owns; plus that neighbour's cells in every claim
18
+
19
+
20
+ def load(paper):
21
+ """[(relative path, parsed file)] for every siblings/cells/*.json, sorted by name."""
22
+ folder = os.path.join(paper, "siblings", "cells")
23
+ if not os.path.isdir(folder):
24
+ return []
25
+ names = sorted(n for n in os.listdir(folder) if n.endswith(".json"))
26
+ return [(f"siblings/cells/{n}", json.load(open(os.path.join(folder, n), encoding="utf-8"))) for n in names]
27
+
28
+
29
+ def merge(data, files):
30
+ """(novelty table with every file merged in, errors). Pure: `data` is not changed."""
31
+ out = copy.deepcopy(data)
32
+ by_id = {n["id"]: n for n in out.get("neighbours", [])}
33
+ claims = {c["id"]: c for c in out.get("claims", [])}
34
+ errors = []
35
+ for path, f in files:
36
+ nid = f.get("id")
37
+ if os.path.basename(path) != f"{nid}.json":
38
+ errors.append(f"{path}: id {nid!r} does not match the file name")
39
+ continue
40
+ if nid not in by_id:
41
+ errors.append(f"{path}: no neighbour {nid!r} in novelty.json (the neighbour list is novelty.json's)")
42
+ continue
43
+ for key in OWNED:
44
+ if key in f:
45
+ by_id[nid][key] = f[key]
46
+ # The file is the whole record of this neighbour: a claim it leaves out has no cells for it (unknown),
47
+ # never the cells of an earlier read, whose 'no' would still grant novelty.
48
+ given = f.get("cells") or {}
49
+ for cid in claims.keys() - given.keys():
50
+ claims[cid].get("cells", {}).pop(nid, None)
51
+ for cid, cells in given.items():
52
+ if cid not in claims:
53
+ errors.append(f"{path}: claim {cid!r} is not in novelty.json")
54
+ continue
55
+ unknown = [p for p in cells if p not in claims[cid].get("pieces", {})]
56
+ errors += [f"{path}: {cid} has no piece {p!r}" for p in unknown]
57
+ if not unknown:
58
+ claims[cid].setdefault("cells", {})[nid] = cells
59
+ return out, errors
60
+
61
+
62
+ def behind(data, files):
63
+ """Paths of cells files whose content novelty.json does not hold yet."""
64
+ results = [(path, merge(data, [(path, f)])) for path, f in files]
65
+ return [path for path, (merged, errs) in results if not errs and merged != data]
66
+
67
+
68
+ def main():
69
+ if len(sys.argv) != 2:
70
+ print(__doc__, file=sys.stderr)
71
+ sys.exit(2)
72
+ paper = sys.argv[1]
73
+ path = os.path.join(paper, "siblings", "novelty.json")
74
+ raw = open(path, encoding="utf-8").read()
75
+ files = load(paper)
76
+ merged, errors = merge(json.loads(raw), files)
77
+ for e in errors:
78
+ print("ERROR " + e, file=sys.stderr)
79
+ if errors:
80
+ sys.exit(1)
81
+ text = json.dumps(merged, indent=2, ensure_ascii=False) + ("\n" if raw.endswith("\n") else "")
82
+ if text != raw:
83
+ open(path, "w", encoding="utf-8").write(text)
84
+ print(f"merged {len(files)} cells file(s) into {os.path.relpath(path, paper)}")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
@@ -0,0 +1,159 @@
1
+ /**
2
+ * novelty_cells.py: one reader per neighbour writes siblings/cells/<id>.json (its read, coverage and cells);
3
+ * the script merges them into novelty.json, and check_novelty.py fails when novelty.json is behind them.
4
+ * Execution tier: the novelty fixture on disk, no model, no network.
5
+ * Run: npx vitest run skills/assess-novelty/scripts/novelty_cells.test.ts
6
+ */
7
+ import {
8
+ cpSync,
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { spawnSync } from "node:child_process";
17
+ import { tmpdir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import assert from "node:assert/strict";
21
+ import { test } from "vitest";
22
+
23
+ test("novelty_cells", () => {
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const FIXTURE = join(here, "fixtures", "novelty");
26
+ const py = (script: string, dir: string) => {
27
+ const r = spawnSync("python3", ["-I", join(here, script), dir], {
28
+ encoding: "utf8",
29
+ });
30
+ return { code: r.status, out: `${r.stdout}\n${r.stderr}` };
31
+ };
32
+
33
+ // The fixture's own doe26 data, as a reader would return it, with P5-1's comparator flipped to 'yes'.
34
+ const READER = {
35
+ id: "doe26",
36
+ read: "full",
37
+ coverage: [
38
+ {
39
+ lines: [1, 63],
40
+ quote: "We measure this gap in 481 public AGENTS.md files.",
41
+ },
42
+ ],
43
+ };
44
+
45
+ // Copy the fixture, drop the given cells files, optionally merge, then check; returns both results.
46
+ const run = (cellFiles: Record<string, object>, { merge = true } = {}) => {
47
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "novelty-cells-")));
48
+ try {
49
+ cpSync(FIXTURE, dir, { recursive: true });
50
+ const fixtureJson = JSON.parse(
51
+ readFileSync(join(dir, "siblings", "novelty.json"), "utf8"),
52
+ );
53
+ mkdirSync(join(dir, "siblings", "cells"));
54
+ for (const [name, body] of Object.entries(cellFiles)) {
55
+ const cells = Object.fromEntries(
56
+ fixtureJson.claims.map((c: any) => [c.id, c.cells.doe26]),
57
+ );
58
+ writeFileSync(
59
+ join(dir, "siblings", "cells", name),
60
+ JSON.stringify({ ...READER, cells, ...body }, null, 2),
61
+ );
62
+ }
63
+ const merged = merge
64
+ ? py("novelty_cells.py", dir)
65
+ : { code: null, out: "" };
66
+ const checked = py("check_novelty.py", dir);
67
+ const json = JSON.parse(
68
+ readFileSync(join(dir, "siblings", "novelty.json"), "utf8"),
69
+ );
70
+ return { merged, checked, json };
71
+ } finally {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ };
75
+
76
+ // ── clean: a reader's file equal to the fixture merges to the same table, checker green ──
77
+ {
78
+ const r = run({ "doe26.json": {} });
79
+ assert.equal(r.merged.code, 0, r.merged.out);
80
+ assert.equal(r.checked.code, 0, r.checked.out);
81
+ }
82
+
83
+ // ── the merge carries the reader's cells into novelty.json ─────────────────────
84
+ {
85
+ const r = run({ "doe26.json": { read: "abstract", coverage: [] } });
86
+ assert.equal(r.merged.code, 0, r.merged.out);
87
+ assert.equal(
88
+ r.json.neighbours[0].read,
89
+ "abstract",
90
+ "read comes from the reader's file",
91
+ );
92
+ }
93
+
94
+ // ── a claim the reader left out loses that neighbour's old cells: they turn unknown, never stay 'no' ──
95
+ // The file is the reader's whole record of the neighbour; old 'no' cells kept for an omitted claim would
96
+ // still grant novelty on evidence nobody re-read.
97
+ {
98
+ const r = run({ "doe26.json": { cells: {} } });
99
+ assert.equal(r.merged.code, 0, r.merged.out);
100
+ assert.deepEqual(
101
+ r.json.claims.map((c: any) => c.cells?.doe26),
102
+ [undefined, undefined],
103
+ );
104
+ const behind = run({ "doe26.json": { cells: {} } }, { merge: false });
105
+ assert.match(
106
+ behind.checked.out,
107
+ /novelty\.json is behind siblings\/cells\/doe26\.json/,
108
+ );
109
+ }
110
+
111
+ // ── novelty.json behind a cells file: the checker says so ──────────────────────
112
+ {
113
+ const r = run(
114
+ { "doe26.json": { read: "abstract", coverage: [] } },
115
+ { merge: false },
116
+ );
117
+ assert.equal(r.checked.code, 1, r.checked.out);
118
+ assert.match(
119
+ r.checked.out,
120
+ /novelty\.json is behind siblings\/cells\/doe26\.json: run novelty_cells\.py/,
121
+ );
122
+ }
123
+
124
+ // ── a file for a neighbour the table does not list ─────────────────────────────
125
+ {
126
+ const r = run({ "zhou26.json": { id: "zhou26" } });
127
+ assert.equal(r.merged.code, 1, r.merged.out);
128
+ assert.match(
129
+ r.merged.out,
130
+ /cells\/zhou26\.json: no neighbour 'zhou26' in novelty\.json/,
131
+ );
132
+ }
133
+
134
+ // ── the file name and its id disagree ──────────────────────────────────────────
135
+ {
136
+ const r = run({ "doe.json": {} });
137
+ assert.equal(r.merged.code, 1, r.merged.out);
138
+ assert.match(
139
+ r.merged.out,
140
+ /cells\/doe\.json: id 'doe26' does not match the file name/,
141
+ );
142
+ }
143
+
144
+ // ── a claim or piece the table does not have ───────────────────────────────────
145
+ {
146
+ const r = run({
147
+ "doe26.json": { cells: { R9: {}, "P5-1": { colour: { has: "no" } } } },
148
+ });
149
+ assert.equal(r.merged.code, 1, r.merged.out);
150
+ assert.match(
151
+ r.merged.out,
152
+ /cells\/doe26\.json: claim 'R9' is not in novelty\.json/,
153
+ );
154
+ assert.match(
155
+ r.merged.out,
156
+ /cells\/doe26\.json: P5-1 has no piece 'colour'/,
157
+ );
158
+ }
159
+ }, 300_000);
@@ -0,0 +1,55 @@
1
+ """Proof that a neighbour marked read "full" was read in full: one verbatim sentence per chunk of its saved text.
2
+
3
+ A neighbour's `coverage` is a list of {"lines": [first, last], "quote": "..."}: the chunks tile the text from
4
+ line 1 to its last line with no gap, none is longer than CHUNK_LINES_MAX, and each quote is found inside its own
5
+ chunk. Why: one Read returns at most 25,000 tokens (a 1,790-line text came back as lines 1-627), and a 'no' cell —
6
+ the one that grants novelty — rests on the neighbour having been read to the end. 400 plain-pdftotext lines are
7
+ about a third of that cap, so a chunk is one Read. A quote is a presence check, as for the cells.
8
+ """
9
+ from quote_match import found, letters, norm
10
+
11
+ CHUNK_LINES_MAX = 400
12
+ QUOTE_WORDS_MIN = 8
13
+
14
+
15
+ def coverage_gaps(last_line, ranges):
16
+ """Line spans in 1..last_line that no range covers, in order: [(first, last), ...]."""
17
+ gaps, nxt = [], 1
18
+ for first, last in sorted(ranges):
19
+ if first > nxt:
20
+ gaps.append((nxt, first - 1))
21
+ nxt = max(nxt, last + 1)
22
+ return gaps + ([(nxt, last_line)] if nxt <= last_line else [])
23
+
24
+
25
+ def coverage_errors(nid, text, coverage):
26
+ """Why the coverage does not prove a full read of `text`; empty when it does."""
27
+ if not coverage:
28
+ return [f"{nid}: read 'full' not proven: no coverage (one quote per chunk of at most {CHUNK_LINES_MAX} lines)"]
29
+ # By "\n" only, as Read and wc -l number them: splitlines() also splits on the form feed pdftotext writes at
30
+ # every page break, and a 1,825-line text then counts 1,850 (sibling-read).
31
+ lines = text.removesuffix("\n").split("\n")
32
+ errors = []
33
+ ranges = []
34
+ for chunk in coverage:
35
+ span = chunk.get("lines")
36
+ if not (isinstance(span, list) and len(span) == 2 and all(isinstance(x, int) for x in span)
37
+ and 1 <= span[0] <= span[1] <= len(lines)):
38
+ errors.append(f"{nid}: coverage lines {span!r} are not [first, last] within 1-{len(lines)}")
39
+ continue
40
+ first, last = span
41
+ ranges.append((first, last))
42
+ where = f"{first}-{last}"
43
+ if last - first + 1 > CHUNK_LINES_MAX:
44
+ errors.append(f"{nid}: coverage chunk {where} is {last - first + 1} lines; at most {CHUNK_LINES_MAX}")
45
+ quote = chunk.get("quote") or ""
46
+ words = len(norm(quote).split())
47
+ if words < QUOTE_WORDS_MIN:
48
+ errors.append(f"{nid}: coverage quote for lines {where} has {words} words; at least {QUOTE_WORDS_MIN}")
49
+ continue
50
+ body = "\n".join(lines[first - 1:last])
51
+ if not found(quote, norm(body), letters(body)):
52
+ errors.append(f"{nid}: coverage quote for lines {where} not found in those lines: {quote[:80]!r}")
53
+ errors += [f"{nid}: read 'full' not proven: lines {a}-{b} have no coverage quote"
54
+ for a, b in coverage_gaps(len(lines), ranges)]
55
+ return errors