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,194 @@
1
+ /**
2
+ * The author's decision page for a paper's candidate neighbours, and the decisions back into the repo.
3
+ *
4
+ * node candidate_page.ts build <paper-dir> <out.html>
5
+ * node candidate_page.ts export <paper-dir> <decisions-dir>
6
+ *
7
+ * build: reads siblings/candidates/*.json (shape and rules: novelty_candidates.py, asked through its `shape`
8
+ * command so there is one copy of them), the claims of siblings/novelty.json and siblings/candidate-page.json:
9
+ *
10
+ * {"artifact": "https://claude.ai/artifact/...", // where the page is published, one URL per paper
11
+ * "session": "session_...", // the Claude session "Send decisions to Claude" wakes
12
+ * "pdfs": {"<candidate id>": "<asset url of its PDF>"}}
13
+ *
14
+ * and fills candidate_page.html: an overview tab and one tab per candidate (in plain words, the authors'
15
+ * abstract and conclusion, why it came up, each passage as its highlighted page crop with the verbatim quote
16
+ * and what it means, PDF and source links, add / cite / skip with a reason). Crops are embedded and the look is
17
+ * candidate_page.css inlined, so the page rebuilds from the repo alone. Publish it with
18
+ * capabilities {db: {}, assets: {}, mcp: {servers: [{server: "Claude Code Remote", tools: ["send_message"]}]}}.
19
+ * A press saves to the `decisions` collection (doc id = candidate id: {choice, reason, at, source: "page"}).
20
+ *
21
+ * export: reads the `decisions` documents ArtifactData saved (`list` with out_dir; one <id>.json each) and
22
+ * writes each into its candidate file's `decision`. An id with no candidate or a choice outside add / cite /
23
+ * skip is refused and nothing is written; a file whose decision is unchanged is left alone.
24
+ */
25
+ import { execFileSync } from "node:child_process";
26
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { basename, dirname, join, normalize } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ type Json = Record<string, any>;
31
+ const here = dirname(fileURLToPath(import.meta.url));
32
+ const TEMPLATE = join(here, "candidate_page.html");
33
+ const OCEAN = join(here, "candidate_page.css");
34
+ const CHOICES = ["add", "cite", "skip"] as const;
35
+ const DOC_FIELDS = ["choice", "reason", "at", "source"] as const;
36
+
37
+ const fail = (msg: string): never => {
38
+ console.error(`ERROR ${msg}`);
39
+ process.exit(1);
40
+ };
41
+ const readJson = (p: string): Json => JSON.parse(readFileSync(p, "utf8"));
42
+ const candidateFiles = (paper: string): string[] => {
43
+ const dir = join(paper, "siblings", "candidates");
44
+ return existsSync(dir)
45
+ ? readdirSync(dir)
46
+ .filter((n) => n.endsWith(".json"))
47
+ .sort()
48
+ .map((n) => join(dir, n))
49
+ : [];
50
+ };
51
+
52
+ const shapeProblems = (paper: string): string => {
53
+ try {
54
+ execFileSync(
55
+ "python3",
56
+ ["-I", join(here, "novelty_candidates.py"), "shape", paper],
57
+ { encoding: "utf8" },
58
+ );
59
+ return "";
60
+ } catch (e: any) {
61
+ return (e.stdout || e.message).trim();
62
+ }
63
+ };
64
+
65
+ const pageData = (paper: string): Json => {
66
+ const problems = shapeProblems(paper);
67
+ if (problems) fail(problems.split("\n").join("; "));
68
+ const files = candidateFiles(paper);
69
+ if (!files.length) fail(`no candidates in ${paper}/siblings/candidates`);
70
+ const conf = readJson(join(paper, "siblings", "candidate-page.json"));
71
+ const claims = Object.fromEntries(
72
+ (readJson(join(paper, "siblings", "novelty.json")).claims || []).map(
73
+ (c: Json) => [c.id, c.sentence || ""],
74
+ ),
75
+ );
76
+ const crop = (rel: string) =>
77
+ `data:image/png;base64,${readFileSync(join(paper, rel)).toString("base64")}`;
78
+ return {
79
+ paper: basename(normalize(paper)),
80
+ session: conf.session || "",
81
+ claims,
82
+ candidates: files.map((f) => {
83
+ const c = readJson(f);
84
+ return {
85
+ id: c.id,
86
+ title: c.title,
87
+ authors: c.authors,
88
+ venue: c.venue,
89
+ year: c.year,
90
+ url: c.url,
91
+ summary: c.summary,
92
+ abstract: c.abstract,
93
+ conclusion: c.conclusion,
94
+ suggestion: c.suggestion,
95
+ suggestion_why: c.suggestion_why,
96
+ found_by: c.found_by,
97
+ pdf: (conf.pdfs || {})[c.id] || null,
98
+ decision: c.decision || null,
99
+ passages: c.passages.map((p: Json) => ({
100
+ claim: p.claim,
101
+ page: p.page,
102
+ quote: p.quote,
103
+ meaning: p.meaning,
104
+ img: crop(p.crop),
105
+ })),
106
+ };
107
+ }),
108
+ };
109
+ };
110
+
111
+ const build = (paper: string, out: string) => {
112
+ const data = pageData(paper);
113
+ // </script> inside the JSON would end the data block early; "<\/" is the same string to JSON.parse.
114
+ const blob = JSON.stringify(data).replaceAll("</", "<\\/");
115
+ const title = `Neighbour Candidates ยท ${data.paper}`.replace(
116
+ /[&<>"]/g,
117
+ (ch) => `&#${ch.charCodeAt(0)};`,
118
+ );
119
+ const page = readFileSync(TEMPLATE, "utf8")
120
+ .replace("__TITLE__", () => title)
121
+ .replace("__OCEAN__", () => readFileSync(OCEAN, "utf8"))
122
+ .replace("__DATA__", () => blob);
123
+ writeFileSync(out, page);
124
+ console.log(
125
+ `${out}: ${data.candidates.length} candidate(s), ${Math.floor(page.length / 1024)} KB`,
126
+ );
127
+ };
128
+
129
+ const exportDecisions = (paper: string, folder: string) => {
130
+ const cands = new Map(
131
+ candidateFiles(paper).map((f) => [basename(f, ".json"), f] as const),
132
+ );
133
+ const sub = existsSync(join(folder, "decisions"))
134
+ ? join(folder, "decisions")
135
+ : folder;
136
+ // ArtifactData may wrap a document's fields; take the fields either way.
137
+ const docs = readdirSync(sub)
138
+ .filter((n) => n.endsWith(".json"))
139
+ .sort()
140
+ .map((n) => {
141
+ const raw = readJson(join(sub, n));
142
+ return [
143
+ basename(n, ".json"),
144
+ raw.data && typeof raw.data === "object" ? raw.data : raw,
145
+ ] as const;
146
+ });
147
+ const problems = [
148
+ ...docs
149
+ .filter(([id]) => !cands.has(id))
150
+ .map(([id]) => `${id}: no candidate with this id`),
151
+ ...docs
152
+ .filter(([, d]) => !CHOICES.includes(d.choice))
153
+ .map(
154
+ ([id, d]) =>
155
+ `${id}: choice ${JSON.stringify(d.choice)} is not one of ${CHOICES.join(", ")}`,
156
+ ),
157
+ ];
158
+ if (problems.length) fail(problems.join("; "));
159
+ docs.forEach(([id, d]) => {
160
+ const file = cands.get(id) as string;
161
+ const c = readJson(file);
162
+ const decision: Json = {
163
+ source: "page",
164
+ ...Object.fromEntries(
165
+ DOC_FIELDS.filter(
166
+ (k) => d[k] !== undefined && d[k] !== null && d[k] !== "",
167
+ ).map((k) => [k, d[k]]),
168
+ ),
169
+ };
170
+ const sorted = (o: Json | null | undefined) =>
171
+ JSON.stringify(Object.entries(o ?? {}).sort());
172
+ if (sorted(c.decision) === sorted(decision))
173
+ return console.log(`${id}: unchanged (${decision.choice})`);
174
+ writeFileSync(file, JSON.stringify({ ...c, decision }, null, 2) + "\n");
175
+ console.log(
176
+ `${id}: ${decision.choice}${decision.reason ? ` โ€” ${decision.reason}` : ""}`,
177
+ );
178
+ });
179
+ const decided = new Set(docs.map(([id]) => id));
180
+ const waiting = [...cands]
181
+ .filter(([id, f]) => !decided.has(id) && !readJson(f).decision)
182
+ .map(([id]) => id);
183
+ if (waiting.length) console.log(`still undecided: ${waiting.join(", ")}`);
184
+ };
185
+
186
+ const [cmd, paper, target] = process.argv.slice(2);
187
+ if (!cmd || !paper || !target || !["build", "export"].includes(cmd)) {
188
+ console.error(
189
+ "usage: node candidate_page.ts build <paper-dir> <out.html> | export <paper-dir> <decisions-dir>",
190
+ );
191
+ process.exit(2);
192
+ }
193
+ if (cmd === "build") build(paper, target);
194
+ else exportDecisions(paper, target);
@@ -0,0 +1,230 @@
1
+ """Check a paper's novelty table against the neighbours' saved texts, the paper and its claims ledger.
2
+
3
+ Usage: python3 -I check_novelty.py <paper-dir>
4
+ reads <paper-dir>/siblings/novelty.json, paper.tex, CLAIMS.md and each neighbour's saved text;
5
+ prints each claim's computed verdict and its pieces x neighbours table; exit 1 on any error below.
6
+
7
+ The verdict is computed from the cells, never stored, over neighbours read in full:
8
+ ๐Ÿ”ด one neighbour has every piece ยท ๐ŸŸก the nearest misses exactly one, or a neighbour has both the object and
9
+ the measure (the result) and differs only in setting ยท ๐ŸŸข every one misses two or more and none has the
10
+ result ยท โšช a piece nobody has excluded yet could still complete a neighbour (an unread cell, or an
11
+ abstract-only read).
12
+ Errors: a 'yes' cell without a quote or with a quote not in the text ยท a 'no' cell on an abstract-only
13
+ neighbour ยท a missing search source ยท a priority sentence in paper.tex (first / none of the studies /
14
+ to our knowledge / ...) not mapped to a ๐ŸŸข claim ยท CLAIMS.md disagreeing with the computed verdict ยท
15
+ a ๐ŸŸข claim without a refutation file ยท a claim without a known type or an objection_answer.
16
+ A quote is a presence check (quote_match.py's matcher), not a proof that it supports the cell.
17
+ A neighbour read 'full' proves it with `coverage`, one quote per chunk of its text (novelty_coverage.py).
18
+ A work a refutation or a search surfaced is a candidate until the author decides on it (novelty_candidates.py):
19
+ undecided, or approved but not yet read, is an error.
20
+ """
21
+ import json
22
+ import os
23
+ import re
24
+ import sys
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # -I leaves the script's own folder off the path
27
+ from quote_match import found, letters, norm # noqa: E402
28
+ from novelty_coverage import coverage_errors # noqa: E402
29
+ import novelty_candidates # noqa: E402
30
+ import novelty_cells # noqa: E402
31
+
32
+ SOURCES = ("venue-pc", "prev-edition", "arxiv-recent")
33
+ TYPES = {"new-object", "transfer", "installed-base", "instrument", "refutation", "replication"}
34
+ # The result itself: what is examined and what number comes out. A neighbour with both already reported the
35
+ # result, so the claim's delta over it is setting (population, comparator, instrument): ๐ŸŸก at best, never ๐ŸŸข,
36
+ # however many setting pieces it misses (two setting 'no' cells made a taken claim free).
37
+ RESULT = ("object", "measure")
38
+ # Priority claims about prior work. Narrow on purpose: "the first row of Table 1" or "none of the 900
39
+ # prose blocks we examined" are not claims about anyone else's work and must stay silent.
40
+ VERB = r"(?:measure|score|stud|report|evaluat|test|run|check|compar|examin|ask|assess|validat)"
41
+ PRIORITY = re.compile(
42
+ r"\bthe first (?:\S+ ){0,6}?(?:in (?:this|the) (?:literature|field|area)|to (?:measure|study|show|report))"
43
+ r"|\b(?:we are|is|are) the first\b"
44
+ r"|\bnone of the (?:\S+ ){0,4}(?:studies|works|papers|approaches|tools)\b"
45
+ r"|\bnone of (?:them|these|those|this|that)(?: \S+){0,2}? " + VERB +
46
+ r"|(?:^|[;:,] )none " + VERB +
47
+ r"|\bno (?:\S+ )?(?:study|studies|work|paper|tool|approach)s? (?:we know of|we are aware of|to date|so far|has|have|had)\b"
48
+ r"|\bno (?:prior|previous|existing|earlier) (?:work|study|studies|paper|tool)"
49
+ r"|\bto (?:the best of )?our knowledge\b"
50
+ r"|\b(?:literature|prior work|previous work|related work) (?:never|does not|has not)\b"
51
+ r"|\b(?:nobody|no one) (?:has|had|have|did|does)\b",
52
+ re.IGNORECASE,
53
+ )
54
+
55
+
56
+ def tex_sentences(tex):
57
+ tex = re.sub(r"(?<!\\)%.*", "", tex)
58
+ tex = re.sub(r"\\(?:cite|ref|label|eqref|autoref|cref|documentclass|usepackage|begin|end|input|include)\*?"
59
+ r"(?:\[[^\]]*\])?\{[^}]*\}", " ", tex)
60
+ tex = re.sub(r"\\[a-zA-Z]+\*?(?:\[[^\]]*\])?", " ", tex)
61
+ tex = tex.replace("~", " ").replace("{", "").replace("}", "").replace("\\%", "%").replace("\\", "")
62
+ return [s.strip() for s in re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", tex)) if s.strip()]
63
+
64
+
65
+ def ledger_status(claims_md, row):
66
+ for line in claims_md.splitlines():
67
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
68
+ if len(cells) > 1 and cells[0].strip("*") == row:
69
+ for c in cells[1:]:
70
+ for mark in ("๐ŸŸข", "๐ŸŸก", "๐Ÿ”ด", "โšช"):
71
+ if c.startswith(mark) or c.startswith("**" + mark):
72
+ return mark
73
+ return "no status"
74
+ return None
75
+
76
+
77
+ def verdict(claim, neighbours):
78
+ pieces = list(claim.get("pieces", {}))
79
+ if not pieces or not neighbours:
80
+ return "โšช", "no pieces or no neighbours"
81
+ best = None
82
+ open_ = []
83
+ result = None # a neighbour with the same object AND measure has the result; the rest is setting
84
+ for n in neighbours:
85
+ cells = claim.get("cells", {}).get(n["id"], {})
86
+ has = {p: (cells.get(p) or {}).get("has", "unknown") for p in pieces}
87
+ if n.get("read") != "full":
88
+ has = {p: ("unknown" if v == "no" else v) for p, v in has.items()}
89
+ yes = [p for p in pieces if has[p] == "yes"]
90
+ no = [p for p in pieces if has[p] == "no"]
91
+ if len(yes) == len(pieces):
92
+ return "๐Ÿ”ด", f"taken by {n['id']}"
93
+ if result is None and all(has.get(p) == "yes" for p in RESULT):
94
+ result = (n["id"], no)
95
+ if not no:
96
+ open_.append(n["id"])
97
+ elif best is None or (len(no), -len(yes)) < (len(best[1]), -best[2]):
98
+ best = (n["id"], no, len(yes))
99
+ if open_:
100
+ tail = f"; not free: {result[0]} has the object and the measure" if result else ""
101
+ return "โšช", "could still be taken by " + ", ".join(open_) + tail
102
+ if len(best[1]) == 1:
103
+ return "๐ŸŸก", f"nearest {best[0]} differs only in: {best[1][0]}"
104
+ if result:
105
+ return "๐ŸŸก", (f"{result[0]} has the object and the measure; differs only in setting: "
106
+ + ", ".join(result[1]))
107
+ return "๐ŸŸข", f"nearest {best[0]} misses {len(best[1])}: " + ", ".join(best[1])
108
+
109
+
110
+ def main():
111
+ if len(sys.argv) != 2:
112
+ print(__doc__, file=sys.stderr)
113
+ sys.exit(2)
114
+ paper = sys.argv[1]
115
+ data = json.load(open(os.path.join(paper, "siblings", "novelty.json"), encoding="utf-8"))
116
+ claims_path = os.path.join(paper, "CLAIMS.md")
117
+ claims_md = open(claims_path, encoding="utf-8").read() if os.path.exists(claims_path) else ""
118
+ errors = []
119
+
120
+ cell_files = novelty_cells.load(paper)
121
+ errors += novelty_cells.merge(data, cell_files)[1]
122
+ errors += [f"novelty.json is behind {p}: run novelty_cells.py" for p in novelty_cells.behind(data, cell_files)]
123
+
124
+ def saved_text(rel):
125
+ p = os.path.join(paper, rel)
126
+ return open(p, encoding="utf-8", errors="replace").read() if os.path.isfile(p) else None
127
+ errors += novelty_candidates.errors(paper, data, novelty_candidates.load(paper),
128
+ lambda q, t: found(q, norm(t), letters(t)), saved_text)
129
+
130
+ have = {s.get("source") for s in data.get("searched", []) if (s.get("how") or "").strip()}
131
+ errors += [f"searched: no '{s}' search recorded (source + how)" for s in SOURCES if s not in have]
132
+
133
+ neighbours = data.get("neighbours", [])
134
+ texts = {}
135
+ for n in neighbours:
136
+ path = os.path.join(paper, n.get("text") or "")
137
+ if n.get("read") == "full" and not os.path.isfile(path):
138
+ errors.append(f"neighbour {n['id']}: read 'full' but no text at {n.get('text')}")
139
+ if os.path.isfile(path):
140
+ t = open(path, encoding="utf-8", errors="replace").read()
141
+ texts[n["id"]] = (norm(t), letters(t))
142
+ if n.get("read") == "full":
143
+ errors += coverage_errors(n["id"], t, n.get("coverage"))
144
+
145
+ verdicts = {}
146
+ for c in data.get("claims", []):
147
+ cid = c.get("id")
148
+ if c.get("type") not in TYPES:
149
+ errors.append(f"{cid}: type {c.get('type')!r} is not one of {sorted(TYPES)}")
150
+ if not (c.get("objection_answer") or "").strip():
151
+ errors.append(f"{cid}: no objection_answer (the sentence answering this type's standard objection)")
152
+ for n in neighbours:
153
+ for piece, cell in (c.get("cells", {}).get(n["id"]) or {}).items():
154
+ has, quote = cell.get("has"), cell.get("quote")
155
+ if has not in ("yes", "no", "unknown"):
156
+ errors.append(f"{cid}/{n['id']}/{piece}: has={has!r}, expected yes / no / unknown")
157
+ if has == "no" and n.get("read") != "full":
158
+ errors.append(f"{cid}/{n['id']}/{piece}: 'no' from a neighbour read only as an abstract;"
159
+ " absence needs the full text")
160
+ if has == "yes":
161
+ if not quote:
162
+ errors.append(f"{cid}/{n['id']}/{piece}: 'yes' without a quote")
163
+ elif n["id"] not in texts:
164
+ errors.append(f"{cid}/{n['id']}/{piece}: 'yes' but no text to check the quote against"
165
+ f" (text: {n.get('text')!r})")
166
+ elif not found(quote, *texts[n["id"]]):
167
+ errors.append(f"{cid}/{n['id']}/{piece}: quote not found in {n.get('text')}: {quote[:80]!r}")
168
+ v, why = verdict(c, neighbours)
169
+ verdicts[cid] = v
170
+ row = c.get("ledger")
171
+ # A design-stage claim (a planned measurement, not in paper.tex yet) has no CLAIMS.md row until it enters the
172
+ # text; "planned": true says so explicitly, so a forgotten ledger on an ordinary claim still fails.
173
+ status = ledger_status(claims_md, row) if row else None
174
+ if row is None and c.get("planned") is True:
175
+ pass
176
+ elif status is None:
177
+ errors.append(f"{cid}: ledger row {row!r} not found in CLAIMS.md")
178
+ elif status != v:
179
+ errors.append(f"{cid}: CLAIMS.md row {row} says {status}, the cells give {v} ({why})")
180
+ if v == "๐ŸŸข":
181
+ ref = c.get("refutation") or {}
182
+ if not ref.get("file"):
183
+ errors.append(f"{cid}: ๐ŸŸข without a refutation pass (refutation.file)")
184
+ elif not os.path.isfile(os.path.join(paper, ref["file"])):
185
+ errors.append(f"{cid}: refutation file {ref['file']} does not exist")
186
+ print(f"{cid} {v} {why}")
187
+ print(table(c, neighbours))
188
+
189
+ tex_path = os.path.join(paper, data.get("paper", "paper.tex"))
190
+ entries = data.get("paper_sentences", [])
191
+ sentences = [norm(s) for s in tex_sentences(open(tex_path, encoding="utf-8").read())]
192
+ in_paper = lambda q: bool(q) and any(q in s or s in q for s in sentences) # noqa: E731
193
+ # Every entry still in the paper is judged on its own, whether or not the detector finds its sentence:
194
+ # the skill tells the author to add the phrasings it misses. An entry whose sentence was since
195
+ # rewritten records an earlier run and is not judged.
196
+ for e in entries:
197
+ cl, quote = e.get("claim"), e.get("quote", "")
198
+ if cl is None:
199
+ if not (e.get("not_priority") or "").strip():
200
+ errors.append(f"paper_sentences: {quote[:80]!r} has no claim and no not_priority reason")
201
+ elif verdicts.get(cl) != "๐ŸŸข" and in_paper(norm(quote)):
202
+ errors.append(f"paper.tex: priority sentence rests on {cl}, which is "
203
+ f"{verdicts.get(cl, 'not a claim')}, not ๐ŸŸข: {quote[:120]!r}")
204
+ mapped = [norm(e.get("quote", "")) for e in entries]
205
+ for s in tex_sentences(open(tex_path, encoding="utf-8").read()):
206
+ if PRIORITY.search(s) and not any(q and (q in norm(s) or norm(s) in q) for q in mapped):
207
+ errors.append(f"paper.tex: priority sentence not in paper_sentences: {s[:120]!r}")
208
+
209
+ one = (data.get("one_new_result") or {}).get("claim")
210
+ if one and one not in verdicts:
211
+ errors.append(f"one_new_result names {one!r}, which is not a claim")
212
+
213
+ for e in errors:
214
+ print("ERROR " + e, file=sys.stderr)
215
+ sys.exit(1 if errors else 0)
216
+
217
+
218
+ def table(c, neighbours):
219
+ pieces = list(c.get("pieces", {}))
220
+ mark = {"yes": "โœ…", "no": "โŒ"}
221
+ head = "| piece | " + " | ".join(n["id"] + ("" if n.get("read") == "full" else " (abstract)") for n in neighbours) + " |"
222
+ rows = [head, "|" + "---|" * (len(neighbours) + 1)]
223
+ for p in pieces:
224
+ cells = [mark.get(((c.get("cells", {}).get(n["id"]) or {}).get(p) or {}).get("has"), "ยท") for n in neighbours]
225
+ rows.append(f"| {p} | " + " | ".join(cells) + " |")
226
+ return "\n".join(rows) + "\n"
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()