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,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,275 @@
|
|
|
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 ยท a saved text
|
|
16
|
+
with more than 30 % glued lines (two page columns merged by `pdftotext -layout`).
|
|
17
|
+
The ledger may be MORE cautious than the computed verdict (๐ข > ๐ก > ๐ด), never bolder: a claim then carries
|
|
18
|
+
"ledger_narrower_because", a reason naming an existing file in the paper folder. A bolder ledger, a โช on either
|
|
19
|
+
side, or the field beside equal statuses (a stale reason) is an error.
|
|
20
|
+
A quote is a presence check (quote_match.py's matcher), not a proof that it supports the cell.
|
|
21
|
+
A neighbour read 'full' proves it with `coverage`, one quote per chunk of its text (novelty_coverage.py).
|
|
22
|
+
A work a refutation or a search surfaced is a candidate until the author decides on it (novelty_candidates.py):
|
|
23
|
+
undecided, or approved but not yet read, is an error.
|
|
24
|
+
"""
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # -I leaves the script's own folder off the path
|
|
31
|
+
from quote_match import found, letters, norm # noqa: E402
|
|
32
|
+
from novelty_coverage import coverage_errors # noqa: E402
|
|
33
|
+
import novelty_candidates # noqa: E402
|
|
34
|
+
import novelty_cells # noqa: E402
|
|
35
|
+
|
|
36
|
+
# Boldness of a ledger status; โช is not on the scale (unchecked is neither more nor less cautious).
|
|
37
|
+
BOLDNESS = {"๐ข": 2, "๐ก": 1, "๐ด": 0}
|
|
38
|
+
FILE_NAME = re.compile(r"[\w./-]+\.(?:md|txt|json|tex|tsv|csv|py|ts|mjs)\b")
|
|
39
|
+
SOURCES = ("venue-pc", "prev-edition", "arxiv-recent")
|
|
40
|
+
TYPES = {"new-object", "transfer", "installed-base", "instrument", "refutation", "replication"}
|
|
41
|
+
# The result itself: what is examined and what number comes out. A neighbour with both already reported the
|
|
42
|
+
# result, so the claim's delta over it is setting (population, comparator, instrument): ๐ก at best, never ๐ข,
|
|
43
|
+
# however many setting pieces it misses (two setting 'no' cells made a taken claim free).
|
|
44
|
+
RESULT = ("object", "measure")
|
|
45
|
+
# Priority claims about prior work. Narrow on purpose: "the first row of Table 1" or "none of the 900
|
|
46
|
+
# prose blocks we examined" are not claims about anyone else's work and must stay silent.
|
|
47
|
+
VERB = r"(?:measure|score|stud|report|evaluat|test|run|check|compar|examin|ask|assess|validat)"
|
|
48
|
+
PRIORITY = re.compile(
|
|
49
|
+
r"\bthe first (?:\S+ ){0,6}?(?:in (?:this|the) (?:literature|field|area)|to (?:measure|study|show|report))"
|
|
50
|
+
r"|\b(?:we are|is|are) the first\b"
|
|
51
|
+
r"|\bnone of the (?:\S+ ){0,4}(?:studies|works|papers|approaches|tools)\b"
|
|
52
|
+
r"|\bnone of (?:them|these|those|this|that)(?: \S+){0,2}? " + VERB +
|
|
53
|
+
r"|(?:^|[;:,] )none " + VERB +
|
|
54
|
+
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"
|
|
55
|
+
r"|\bno (?:prior|previous|existing|earlier) (?:work|study|studies|paper|tool)"
|
|
56
|
+
r"|\bto (?:the best of )?our knowledge\b"
|
|
57
|
+
r"|\b(?:literature|prior work|previous work|related work) (?:never|does not|has not)\b"
|
|
58
|
+
r"|\b(?:nobody|no one) (?:has|had|have|did|does)\b",
|
|
59
|
+
re.IGNORECASE,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# `pdftotext -layout` prints a two-column page as one line per row, the columns joined by a run of spaces, so a
|
|
64
|
+
# sentence is split across two lines and a quote is not contiguous.
|
|
65
|
+
GLUED = re.compile(r"\S {6,}\S")
|
|
66
|
+
GLUED_LIMIT = 0.30
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def glued_share(text):
|
|
70
|
+
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
|
71
|
+
return sum(1 for l in lines if GLUED.search(l)) / len(lines) if lines else 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def tex_sentences(tex):
|
|
75
|
+
tex = re.sub(r"(?<!\\)%.*", "", tex)
|
|
76
|
+
tex = re.sub(r"\\(?:cite|ref|label|eqref|autoref|cref|documentclass|usepackage|begin|end|input|include)\*?"
|
|
77
|
+
r"(?:\[[^\]]*\])?\{[^}]*\}", " ", tex)
|
|
78
|
+
tex = re.sub(r"\\[a-zA-Z]+\*?(?:\[[^\]]*\])?", " ", tex)
|
|
79
|
+
tex = tex.replace("~", " ").replace("{", "").replace("}", "").replace("\\%", "%").replace("\\", "")
|
|
80
|
+
return [s.strip() for s in re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", tex)) if s.strip()]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ledger_status(claims_md, row):
|
|
84
|
+
for line in claims_md.splitlines():
|
|
85
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
86
|
+
if len(cells) > 1 and cells[0].strip("*") == row:
|
|
87
|
+
for c in cells[1:]:
|
|
88
|
+
for mark in ("๐ข", "๐ก", "๐ด", "โช"):
|
|
89
|
+
if c.startswith(mark) or c.startswith("**" + mark):
|
|
90
|
+
return mark
|
|
91
|
+
return "no status"
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def narrower_reason_errors(cid, reason, paper):
|
|
96
|
+
"""Problems with a claim's ledger_narrower_because: empty, naming no file, or naming a missing one."""
|
|
97
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
98
|
+
return [f"{cid}: ledger_narrower_because is empty (a reason naming a file in the paper folder)"]
|
|
99
|
+
names = FILE_NAME.findall(reason)
|
|
100
|
+
if not names:
|
|
101
|
+
return [f"{cid}: ledger_narrower_because names no file (give the path of the file holding the reason)"]
|
|
102
|
+
return [f"{cid}: ledger_narrower_because names {n}, which does not exist"
|
|
103
|
+
for n in names if not os.path.isfile(os.path.join(paper, n))]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def verdict(claim, neighbours):
|
|
107
|
+
pieces = list(claim.get("pieces", {}))
|
|
108
|
+
if not pieces or not neighbours:
|
|
109
|
+
return "โช", "no pieces or no neighbours"
|
|
110
|
+
best = None
|
|
111
|
+
open_ = []
|
|
112
|
+
result = None # a neighbour with the same object AND measure has the result; the rest is setting
|
|
113
|
+
for n in neighbours:
|
|
114
|
+
cells = claim.get("cells", {}).get(n["id"], {})
|
|
115
|
+
has = {p: (cells.get(p) or {}).get("has", "unknown") for p in pieces}
|
|
116
|
+
if n.get("read") != "full":
|
|
117
|
+
has = {p: ("unknown" if v == "no" else v) for p, v in has.items()}
|
|
118
|
+
yes = [p for p in pieces if has[p] == "yes"]
|
|
119
|
+
no = [p for p in pieces if has[p] == "no"]
|
|
120
|
+
if len(yes) == len(pieces):
|
|
121
|
+
return "๐ด", f"taken by {n['id']}"
|
|
122
|
+
if result is None and all(has.get(p) == "yes" for p in RESULT):
|
|
123
|
+
result = (n["id"], no)
|
|
124
|
+
if not no:
|
|
125
|
+
open_.append(n["id"])
|
|
126
|
+
elif best is None or (len(no), -len(yes)) < (len(best[1]), -best[2]):
|
|
127
|
+
best = (n["id"], no, len(yes))
|
|
128
|
+
if open_:
|
|
129
|
+
tail = f"; not free: {result[0]} has the object and the measure" if result else ""
|
|
130
|
+
return "โช", "could still be taken by " + ", ".join(open_) + tail
|
|
131
|
+
if len(best[1]) == 1:
|
|
132
|
+
return "๐ก", f"nearest {best[0]} differs only in: {best[1][0]}"
|
|
133
|
+
if result:
|
|
134
|
+
return "๐ก", (f"{result[0]} has the object and the measure; differs only in setting: "
|
|
135
|
+
+ ", ".join(result[1]))
|
|
136
|
+
return "๐ข", f"nearest {best[0]} misses {len(best[1])}: " + ", ".join(best[1])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main():
|
|
140
|
+
if len(sys.argv) != 2:
|
|
141
|
+
print(__doc__, file=sys.stderr)
|
|
142
|
+
sys.exit(2)
|
|
143
|
+
paper = sys.argv[1]
|
|
144
|
+
data = json.load(open(os.path.join(paper, "siblings", "novelty.json"), encoding="utf-8"))
|
|
145
|
+
claims_path = os.path.join(paper, "CLAIMS.md")
|
|
146
|
+
claims_md = open(claims_path, encoding="utf-8").read() if os.path.exists(claims_path) else ""
|
|
147
|
+
errors = []
|
|
148
|
+
|
|
149
|
+
cell_files = novelty_cells.load(paper)
|
|
150
|
+
errors += novelty_cells.merge(data, cell_files)[1]
|
|
151
|
+
errors += [f"novelty.json is behind {p}: run novelty_cells.py" for p in novelty_cells.behind(data, cell_files)]
|
|
152
|
+
|
|
153
|
+
def saved_text(rel):
|
|
154
|
+
p = os.path.join(paper, rel)
|
|
155
|
+
return open(p, encoding="utf-8", errors="replace").read() if os.path.isfile(p) else None
|
|
156
|
+
errors += novelty_candidates.errors(paper, data, novelty_candidates.load(paper),
|
|
157
|
+
lambda q, t: found(q, norm(t), letters(t)), saved_text)
|
|
158
|
+
|
|
159
|
+
have = {s.get("source") for s in data.get("searched", []) if (s.get("how") or "").strip()}
|
|
160
|
+
errors += [f"searched: no '{s}' search recorded (source + how)" for s in SOURCES if s not in have]
|
|
161
|
+
|
|
162
|
+
neighbours = data.get("neighbours", [])
|
|
163
|
+
texts = {}
|
|
164
|
+
for n in neighbours:
|
|
165
|
+
path = os.path.join(paper, n.get("text") or "")
|
|
166
|
+
if n.get("read") == "full" and not os.path.isfile(path):
|
|
167
|
+
errors.append(f"neighbour {n['id']}: read 'full' but no text at {n.get('text')}")
|
|
168
|
+
if os.path.isfile(path):
|
|
169
|
+
t = open(path, encoding="utf-8", errors="replace").read()
|
|
170
|
+
share = glued_share(t)
|
|
171
|
+
if share > GLUED_LIMIT:
|
|
172
|
+
errors.append(f"neighbour {n['id']}: {n.get('text')} has {share:.0%} glued lines (two page columns"
|
|
173
|
+
" merged into one line); re-extract with plain pdftotext (no -layout)")
|
|
174
|
+
texts[n["id"]] = (norm(t), letters(t))
|
|
175
|
+
if n.get("read") == "full":
|
|
176
|
+
errors += coverage_errors(n["id"], t, n.get("coverage"))
|
|
177
|
+
|
|
178
|
+
verdicts = {}
|
|
179
|
+
for c in data.get("claims", []):
|
|
180
|
+
cid = c.get("id")
|
|
181
|
+
if c.get("type") not in TYPES:
|
|
182
|
+
errors.append(f"{cid}: type {c.get('type')!r} is not one of {sorted(TYPES)}")
|
|
183
|
+
if not (c.get("objection_answer") or "").strip():
|
|
184
|
+
errors.append(f"{cid}: no objection_answer (the sentence answering this type's standard objection)")
|
|
185
|
+
for n in neighbours:
|
|
186
|
+
for piece, cell in (c.get("cells", {}).get(n["id"]) or {}).items():
|
|
187
|
+
has, quote = cell.get("has"), cell.get("quote")
|
|
188
|
+
if has not in ("yes", "no", "unknown"):
|
|
189
|
+
errors.append(f"{cid}/{n['id']}/{piece}: has={has!r}, expected yes / no / unknown")
|
|
190
|
+
if has == "no" and n.get("read") != "full":
|
|
191
|
+
errors.append(f"{cid}/{n['id']}/{piece}: 'no' from a neighbour read only as an abstract;"
|
|
192
|
+
" absence needs the full text")
|
|
193
|
+
if has == "yes":
|
|
194
|
+
if not quote:
|
|
195
|
+
errors.append(f"{cid}/{n['id']}/{piece}: 'yes' without a quote")
|
|
196
|
+
elif n["id"] not in texts:
|
|
197
|
+
errors.append(f"{cid}/{n['id']}/{piece}: 'yes' but no text to check the quote against"
|
|
198
|
+
f" (text: {n.get('text')!r})")
|
|
199
|
+
elif not found(quote, *texts[n["id"]]):
|
|
200
|
+
errors.append(f"{cid}/{n['id']}/{piece}: quote not found in {n.get('text')}: {quote[:80]!r}")
|
|
201
|
+
v, why = verdict(c, neighbours)
|
|
202
|
+
verdicts[cid] = v
|
|
203
|
+
row = c.get("ledger")
|
|
204
|
+
# A design-stage claim (a planned measurement, not in paper.tex yet) has no CLAIMS.md row until it enters the
|
|
205
|
+
# text; "planned": true says so explicitly, so a forgotten ledger on an ordinary claim still fails.
|
|
206
|
+
status = ledger_status(claims_md, row) if row else None
|
|
207
|
+
if row is None and c.get("planned") is True:
|
|
208
|
+
pass
|
|
209
|
+
elif status is None:
|
|
210
|
+
errors.append(f"{cid}: ledger row {row!r} not found in CLAIMS.md")
|
|
211
|
+
elif status != v:
|
|
212
|
+
narrower = status in BOLDNESS and v in BOLDNESS and BOLDNESS[status] < BOLDNESS[v]
|
|
213
|
+
reason = c.get("ledger_narrower_because")
|
|
214
|
+
if narrower and "ledger_narrower_because" in c:
|
|
215
|
+
errors += narrower_reason_errors(cid, reason, paper)
|
|
216
|
+
else:
|
|
217
|
+
hint = ("; a more cautious ledger needs ledger_narrower_because naming the file with the reason"
|
|
218
|
+
if narrower else
|
|
219
|
+
"; ledger_narrower_because excuses only a MORE cautious ledger" if "ledger_narrower_because" in c
|
|
220
|
+
else "")
|
|
221
|
+
errors.append(f"{cid}: CLAIMS.md row {row} says {status}, the cells give {v} ({why}){hint}")
|
|
222
|
+
elif "ledger_narrower_because" in c:
|
|
223
|
+
errors.append(f"{cid}: ledger_narrower_because is set but the ledger equals the computed verdict {v}"
|
|
224
|
+
" (stale reason; remove the field)")
|
|
225
|
+
if v == "๐ข":
|
|
226
|
+
ref = c.get("refutation") or {}
|
|
227
|
+
if not ref.get("file"):
|
|
228
|
+
errors.append(f"{cid}: ๐ข without a refutation pass (refutation.file)")
|
|
229
|
+
elif not os.path.isfile(os.path.join(paper, ref["file"])):
|
|
230
|
+
errors.append(f"{cid}: refutation file {ref['file']} does not exist")
|
|
231
|
+
print(f"{cid} {v} {why}")
|
|
232
|
+
print(table(c, neighbours))
|
|
233
|
+
|
|
234
|
+
tex_path = os.path.join(paper, data.get("paper", "paper.tex"))
|
|
235
|
+
entries = data.get("paper_sentences", [])
|
|
236
|
+
sentences = [norm(s) for s in tex_sentences(open(tex_path, encoding="utf-8").read())]
|
|
237
|
+
in_paper = lambda q: bool(q) and any(q in s or s in q for s in sentences) # noqa: E731
|
|
238
|
+
# Every entry still in the paper is judged on its own, whether or not the detector finds its sentence:
|
|
239
|
+
# the skill tells the author to add the phrasings it misses. An entry whose sentence was since
|
|
240
|
+
# rewritten records an earlier run and is not judged.
|
|
241
|
+
for e in entries:
|
|
242
|
+
cl, quote = e.get("claim"), e.get("quote", "")
|
|
243
|
+
if cl is None:
|
|
244
|
+
if not (e.get("not_priority") or "").strip():
|
|
245
|
+
errors.append(f"paper_sentences: {quote[:80]!r} has no claim and no not_priority reason")
|
|
246
|
+
elif verdicts.get(cl) != "๐ข" and in_paper(norm(quote)):
|
|
247
|
+
errors.append(f"paper.tex: priority sentence rests on {cl}, which is "
|
|
248
|
+
f"{verdicts.get(cl, 'not a claim')}, not ๐ข: {quote[:120]!r}")
|
|
249
|
+
mapped = [norm(e.get("quote", "")) for e in entries]
|
|
250
|
+
for s in tex_sentences(open(tex_path, encoding="utf-8").read()):
|
|
251
|
+
if PRIORITY.search(s) and not any(q and (q in norm(s) or norm(s) in q) for q in mapped):
|
|
252
|
+
errors.append(f"paper.tex: priority sentence not in paper_sentences: {s[:120]!r}")
|
|
253
|
+
|
|
254
|
+
one = (data.get("one_new_result") or {}).get("claim")
|
|
255
|
+
if one and one not in verdicts:
|
|
256
|
+
errors.append(f"one_new_result names {one!r}, which is not a claim")
|
|
257
|
+
|
|
258
|
+
for e in errors:
|
|
259
|
+
print("ERROR " + e, file=sys.stderr)
|
|
260
|
+
sys.exit(1 if errors else 0)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def table(c, neighbours):
|
|
264
|
+
pieces = list(c.get("pieces", {}))
|
|
265
|
+
mark = {"yes": "โ
", "no": "โ"}
|
|
266
|
+
head = "| piece | " + " | ".join(n["id"] + ("" if n.get("read") == "full" else " (abstract)") for n in neighbours) + " |"
|
|
267
|
+
rows = [head, "|" + "---|" * (len(neighbours) + 1)]
|
|
268
|
+
for p in pieces:
|
|
269
|
+
cells = [mark.get(((c.get("cells", {}).get(n["id"]) or {}).get(p) or {}).get("has"), "ยท") for n in neighbours]
|
|
270
|
+
rows.append(f"| {p} | " + " | ".join(cells) + " |")
|
|
271
|
+
return "\n".join(rows) + "\n"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
main()
|