Skip to content

Commit ddc884a

Browse files
jackgallantclaude
andcommitted
Harden verify.py + fix audit false-positive; sync docs
verify.py: add ERROR verdict kept distinct from NOT-FOUND so a transient rate-limit/network failure can't masquerade as "does not exist" and drop a real paper; prefetch arXiv ids in batches (id_list) to avoid the 429 bans that caused 8 false NOT-FOUNDs on a ~90-paper run; accept int expect_year (was a mid-run crash); degrade a malformed row to ERROR instead of aborting the batch. references.py: fix audit venue check false-flagging titles that end in ?/! as "empty venue" (the title/venue splitter only recognized ". "). families.py: accept assignment values case-insensitively and by display name, so re-running off rows.json's stamped `family` field works. docs: sync PLAYBOOK.md (Phase 3 verdicts + lessons incl. the strict-APA sentence-case decision), tools/README.md, docs/phases.md, docs/tools.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f110a98 commit ddc884a

7 files changed

Lines changed: 233 additions & 62 deletions

File tree

PLAYBOOK.md

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ In a previous run, the search agent fabricated 5 author lists, reversed
203203
one paper's conclusion, and invented a bioRxiv DOI that didn't exist.
204204
About 1 in 4 citations had errors. **Always verify before adding.**
205205

206+
`verify.py` returns one verdict per citation: **OK**, **MISMATCH** (author/year),
207+
**NOT-FOUND**, or **ERROR**. NOT-FOUND and ERROR are NOT the same and must be
208+
handled differently: NOT-FOUND means every lookup completed and none matched
209+
(chase it down — likely fabricated); ERROR means a lookup could not complete
210+
(rate-limit / network), so **re-run those** rather than treating them as missing.
211+
On a big run this matters — arXiv rate-limits hard, so the tool prefetches all
212+
arXiv ids in batches (many per `id_list` call); a genuinely real preprint that
213+
would otherwise 429 into a false NOT-FOUND now comes back OK (or, if the batch
214+
still fails, ERROR to re-run).
215+
206216
For each paper the agent returned:
207217

208218
**3a. If a PMCID was given:** call NCBI esummary
@@ -637,9 +647,11 @@ theme:
637647
two-tier criteria (foundational vs recent), a capped target (~30–40), multiple
638648
query angles. One agent per theme.
639649
2. **Verify EVERY citation (Phase 3)** with `tools/verify.py`.
640-
It now resolves arXiv/conference papers against the arXiv API — so a NOT-FOUND
641-
is a real failure to investigate, never "an arXiv paper, skip it." Expect ~1
642-
in 4 to need a fix (fabricated author lists, wrong arXiv ids, garbage DOIs).
650+
It resolves arXiv/conference papers against the arXiv API (batched) — so a
651+
NOT-FOUND is a real failure to investigate, never "an arXiv paper, skip it."
652+
Re-run any ERROR verdicts (transient rate-limit/network — distinct from
653+
NOT-FOUND). Expect ~1 in 4 to need a fix (fabricated author lists, wrong arXiv
654+
ids, garbage DOIs).
643655
3. **Citation counts (Phase 5b)** with `tools/citations.py` for every field
644656
paper — bibliographies always carry counts.
645657
4. **Consolidate with two guarded steps (fail loud):**
@@ -682,6 +694,28 @@ that with the priority audit (contract rule 5; Phase 7).
682694
attention paper was actually Foster et al.; two Jain & Huth arXiv ids pointed
683695
at unrelated papers — all caught only because every citation, preprint
684696
included, was verified.)
697+
- **NOT-FOUND ≠ ERROR — a transient failure must never read as "missing."** On a
698+
~90-paper world_models run, 8 real preprints came back NOT-FOUND purely because
699+
arXiv rate-limited (429) a per-paper loop into a temporary ban; trusting that
700+
verdict would have dropped real papers. `verify.py` now (a) prefetches arXiv ids
701+
in **batches** (many per `id_list` call, ~3s apart) so the ban doesn't happen,
702+
and (b) reports a distinct **ERROR** verdict when a lookup can't complete, kept
703+
separate from NOT-FOUND. Re-run ERRORs; only NOT-FOUND means "does not exist."
704+
Pass `expect_year` as a string OR int — either is accepted (a JSON int no longer
705+
crashes the run), and one malformed row degrades to ERROR instead of aborting.
706+
- **Reference titles are strict APA-7 sentence case; DOIs are the ground truth
707+
for *location*, the APA string is just the bibliography display.** CrossRef and
708+
arXiv return titles in inconsistent casing (arXiv and many publishers use Title
709+
Case; Nature deposits sentence case), so `references.py` normalizes ALL-CAPS
710+
titles but deliberately does NOT auto-transform Title Case → sentence case:
711+
correct sentence-casing needs the proper-noun judgment APA bakes in (`Bayesian`,
712+
`Atari`, `Weber`, `Tolman-Eichenbaum` stay capitalized; `Active`, `World`,
713+
`Model` lowercase), and a mechanical caser silently mis-cases proper nouns —
714+
which the audit can't catch. So after canon, **sentence-case new preprint titles
715+
in a reviewed pass** (auto-protect all-caps acronyms, camelCase/digit model
716+
names, and hyphen parts; keep a small proper-noun allowlist; eyeball every
717+
changed title, e.g. a product name like `Matrix-Game`). This is a post-canon
718+
hand-fix like the mojibake and compound-surname fixes below.
685719

686720
### On contextualizing a lab review (lab mode L4c)
687721
- **The outward search is a FULL topic-mode review, not a "context" add-on.**
@@ -824,7 +858,7 @@ outputs JSON/files. Run `python3 tools/<script>.py --help` for flags.
824858

825859
| Script | Purpose |
826860
|--------|---------|
827-
| `tools/verify.py` | Verify a list of citations via PMC/PubMed/CrossRef + arXiv. Reports mismatches. |
861+
| `tools/verify.py` | Verify a list of citations via PMC/PubMed/CrossRef + arXiv (arXiv ids batched). Verdicts OK / MISMATCH / NOT-FOUND / ERROR — re-run ERROR, chase NOT-FOUND. |
828862
| `tools/references.py` | Phase 3f. Rebuild every `apa` from the verified DOI (CrossRef) or arXiv id into canonical APA-7; `--audit` gates the build (exit 1 on any defect). Both modes. |
829863
| `tools/citations.py` | Phase 5b. Fetch per-paper citation counts from OpenAlex (primary) + Semantic Scholar (secondary) by DOI. Google Scholar is not usable (no API / CAPTCHA). |
830864
| `tools/families.py` | Phase 6b. Validate an (agent-proposed, human-approved) family taxonomy, stamp `family` onto rows, emit `families.json` + `families.md`. `--digest` prints a corpus digest for the proposal step. |

docs/phases.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,14 @@ python3 ../tools/verify.py --citations rows.json --out verify_report.json
6565
- inverted or misattributed findings,
6666
- entirely fabricated author lists for papers that genuinely exist.
6767

68-
The report marks each citation `OK` / `MISMATCH` / `NOT-FOUND` so you (or the
69-
agent) can fix or drop it before anything downstream depends on it.
68+
The report marks each citation `OK` / `MISMATCH` / `NOT-FOUND` / `ERROR` so you
69+
(or the agent) can fix or drop it before anything downstream depends on it.
70+
`NOT-FOUND` and `ERROR` are **not** interchangeable: `NOT-FOUND` means every
71+
lookup completed and nothing matched (chase it — likely fabricated), while
72+
`ERROR` means a lookup could not complete (rate-limit / network) and must be
73+
**re-run**. To keep arXiv's aggressive rate-limiting from turning real preprints
74+
into false `NOT-FOUND`s, arXiv ids are prefetched in batches (`id_list`, many per
75+
call). `expect_year` may be a string or an int.
7076

7177
---
7278

docs/tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ JSON I/O, DOI/arXiv parsing, APA building). Run any with `--help`.
1010

1111
| Script | Phase | Purpose |
1212
|---|---|---|
13-
| [`verify.py`](https://github.com/gallantlab/literature-review-toolkit/blob/main/tools/verify.py) | 3 | Verify every citation via PMC/PubMed/CrossRef **+ the arXiv API** (preprints get a real verdict, not a misleading `NOT-FOUND`). Catches ~25% search-agent fabrications. |
13+
| [`verify.py`](https://github.com/gallantlab/literature-review-toolkit/blob/main/tools/verify.py) | 3 | Verify every citation via PMC/PubMed/CrossRef **+ the arXiv API** (arXiv ids batched to dodge rate-limit bans). Verdicts `OK` / `MISMATCH` / `NOT-FOUND` / `ERROR` — re-run `ERROR` (transient), chase `NOT-FOUND` (likely fake). Catches ~25% search-agent fabrications. |
1414
| [`references.py`](https://github.com/gallantlab/literature-review-toolkit/blob/main/tools/references.py) | 3f | Rebuild every reference from its verified DOI/arXiv id into canonical APA-7 (full authors, particles, casing, real venue incl. bioRxiv/PsyArXiv). `--audit` is a **hard gate**. Both modes. |
1515
| [`spreadsheet.py`](https://github.com/gallantlab/literature-review-toolkit/blob/main/tools/spreadsheet.py) | 5 | Build/rebuild the `.xlsx` from the accumulated JSON rows; auto-adds `Cite` / `Family` columns when present. |
1616
| [`citations.py`](https://github.com/gallantlab/literature-review-toolkit/blob/main/tools/citations.py) | 5b | Per-paper citation counts from OpenAlex (primary) + Semantic Scholar by DOI, with undercount reconciliation. |

tools/README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@ python3 tools/verify.py --citations cits.json --out report.json --email you@inst
1717
```
1818

1919
`cits.json` per item: `{label, pmcid?, pmid?, doi?, arxiv?, title?,
20-
expect_first_author?, expect_year?}`. arXiv papers (an `arxiv` id or a
21-
`10.48550/arXiv.<id>` DOI) route to the arXiv API first; otherwise looks up via
22-
PMC, then PubMed, then CrossRef, then title-search. Verdict per item: `OK`,
23-
`MISMATCH`, `NOT-FOUND`. **A NOT-FOUND is a real failure to chase down — never
24-
wave through an arXiv/conference paper as unverifiable.**
20+
expect_first_author?, expect_year?}` (`expect_year` may be a string or int).
21+
arXiv papers (an `arxiv` id or a `10.48550/arXiv.<id>` DOI) route to the arXiv
22+
API first; otherwise looks up via PMC, then PubMed, then CrossRef, then
23+
title-search. arXiv ids are **prefetched in batches** (`id_list`, many per call)
24+
because the API rate-limits a per-paper loop into a temporary ban. Verdict per
25+
item: `OK`, `MISMATCH`, `NOT-FOUND`, or `ERROR`. **`NOT-FOUND` and `ERROR` are
26+
different and must be handled differently:** NOT-FOUND = every lookup completed,
27+
none matched (chase it down — likely fabricated); ERROR = a lookup could not
28+
complete (rate-limit / network), so **re-run those** — never treat a throttled
29+
fetch as "does not exist." One malformed row degrades to ERROR rather than
30+
aborting the whole batch.
2531

2632
## `references.py` — canonical reference builder (Phase 3f)
2733

@@ -128,7 +134,10 @@ python3 tools/families.py --rows rows.json --assign families_input.json --out fa
128134
```
129135

130136
`families_input.json`: `{principle, families:[{key,name,claim,lineage}],
131-
assignments:{ref:key}}`.
137+
assignments:{ref:key}}`. Assignment values are accepted case-insensitively and
138+
by display **name** as well as `key`, so you can re-run straight off the `family`
139+
field `spreadsheet.py` stamped into `rows.json` (which holds the display name,
140+
e.g. `"Infer"`) without first lowercasing it back to the key.
132141

133142
## `families_figure.py` — interactive HTML lineage figure (Phase 6b)
134143

tools/families.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ def main():
9292
"Topic column).")
9393
keyset, name_of = set(keys), {f["key"]: f["name"] for f in families}
9494

95+
# Accept assignment values case-insensitively and by display name, not just
96+
# the exact lowercase key: rows.json stores the display name ("Infer"), so
97+
# re-running families straight off the stamped `family` field would otherwise
98+
# fail with "unknown family keys". Anything that doesn't resolve is left as-is
99+
# and caught by the badkey check below.
100+
resolve = {}
101+
for f in families:
102+
resolve[str(f["key"]).strip().lower()] = f["key"]
103+
resolve[str(f["name"]).strip().lower()] = f["key"]
104+
assign = {r: resolve.get(str(v).strip().lower(), v) for r, v in assign.items()}
105+
95106
refs = [r["ref"] for r in rows]
96107
refset = set(refs)
97108
missing = [r for r in refs if r not in assign]

tools/references.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,11 @@ def audit(apa, has_source):
144144
# Structure is "Authors (YEAR). Title. Venue...."; drop the year-paren and the
145145
# title sentence (its trailing ". ") and require something non-empty to remain.
146146
after_year = apa.split(").", 1)[1] if ")." in apa else ""
147-
venue_part = after_year.split(". ", 1)[1].strip(" .") if ". " in after_year else ""
147+
# The title/venue separator is the title's terminal sentence punctuation +
148+
# space — usually ". " but a title ending in a question or exclamation mark
149+
# ends with "? "/"! " instead, so split on any of [.?!] followed by space.
150+
title_split = re.split(r"[.?!]\s", after_year, maxsplit=1)
151+
venue_part = title_split[1].strip(" .") if len(title_split) > 1 else ""
148152
if has_source and not venue_part:
149153
defects.append("empty venue")
150154
return defects, notes

0 commit comments

Comments
 (0)