From e602e7e631fc8aca17b3dc94b43a28a326586d8d Mon Sep 17 00:00:00 2001 From: bdeshi Date: Tue, 15 Sep 2026 10:00:13 +0600 Subject: [PATCH] Link audit: fix three plate links from elided ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md requires re-auditing the cross-reference rule after any change to it, and polish.py changed twice this session. tools/linkaudit.py does it: three classes checked mechanically, the fourth printed to be read. dangling targets 0 plate page mismatch (listed vs set) 0 (all 178) chapter page mismatch 0 `p. N` self-references 25 all read, all sound The finding: an elided range end is not a plate number of its own. plate_sub ran re.sub(r"\d+") over the whole range, so "pls. 146-8" linked its 8 to plate 8 (orig. p.35) and "pls. 142-5" linked its 5 to plate 5 (p.23). Three links, all pointing at the wrong plate — invisible to every other check, since the targets exist and the text is correct. The end is now expanded against the leading digits of the number it is elided against: 146-8 -> plate 148 (p.334), 142-5 -> plate 145 (p.329), both confirmed against the List of Plates and against where \plateop sets them. Worth noting what the audit also confirmed: in two footnotes the rule discriminates a citation page from a self-reference in the same sentence — "(London, 1962), p. 43; also see below, p. 63" links only the second. NOT YET BUILT: the Docker daemon is down, so make verify has not run against this change. polish.py was run on the host and the audit re-run against its output, but the PDF does not yet carry the fix. Co-Authored-By: Claude Opus 5 --- tools/linkaudit.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++ tools/polish.py | 15 +++++++- 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tools/linkaudit.py diff --git a/tools/linkaudit.py b/tools/linkaudit.py new file mode 100644 index 0000000..f345f41 --- /dev/null +++ b/tools/linkaudit.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Audit every hyperlink in the edition. CLAUDE.md requires this after any change +to polish.py's cross-reference rule. + +Three classes are checkable mechanically: + 1. dangling — a link to page.N with no \\hypertarget{page.N} planted anywhere + 2. plates — the List of Plates says plate `no` is on page P; the plate is + actually set by \\plateop{N}{no}. P and N must agree. + 3. chapters — a `chapter N` link must land on that chapter's opening page. +The fourth, `p. N` self-references, is a judgement the script cannot make: those +are printed with context to be read, which is what the rule asks for. +""" +import re, glob, sys +from collections import defaultdict + +SRC, WORK = "src/pages", "work/pages" + +targets = set() # every page.N that exists +plate_set = {} # plate no -> orig page it is set on +for f in sorted(glob.glob(f"{SRC}/p*.tex")): + t = open(f, encoding="utf-8").read() + for m in re.finditer(r"\\origpage\{(\d+)\}", t): + targets.add(int(m.group(1))) + for m in re.finditer(r"\\plateop\{(\d+)\}\{(\d+)\}", t): + targets.add(int(m.group(1))); plate_set[int(m.group(2))] = int(m.group(1)) + +plate_listed, chap_listed = {}, {} # what the front matter claims +for f in sorted(glob.glob(f"{SRC}/p*.tex")): + t = open(f, encoding="utf-8").read() + for no, pg in re.findall(r"\\pl\{(\d+)\}\{.*?\}\{(\d+)\}", t): + plate_listed[int(no)] = int(pg) + for no, pg in re.findall(r"\\plx\{(\d+)\}\{.*?\}\{.*?\\pg\{(\d+)\}", t): + plate_listed[int(no)] = int(pg) + for lbl, pg in re.findall(r"\\tocl\{[^}]*\}\{(\d+)\}\{.*?\}\{(\d+)\}", t): + chap_listed[int(lbl)] = int(pg) + +chap_actual = {} +for f in sorted(glob.glob(f"{SRC}/p*.tex")): + t = open(f, encoding="utf-8").read() + m = re.search(r"\\chapnum\{Chapter (\d+)\}", t) + o = re.search(r"\\origpage\{(\d+)\}", t) + if m and o: chap_actual.setdefault(int(m.group(1)), int(o.group(1))) + +links = [] # (file, target, shown, context) +for f in sorted(glob.glob(f"{WORK}/p*.tex")): + t = open(f, encoding="utf-8").read() + for m in re.finditer(r"\\(?:xref|hyperlink)\{page\.(\d+)\}\{((?:[^{}]|\{[^{}]*\})*)\}", t): + before = re.sub(r"\s+", " ", t[max(0, m.start() - 90):m.start()]) + after = re.sub(r"\s+", " ", t[m.end():m.end() + 30]) + links.append((f[-8:-4], int(m.group(1)), m.group(2), before, after)) + for m in re.finditer(r"\\pg\{(\d+)\}", t): + links.append((f[-8:-4], int(m.group(1)), "", "", "")) + +print(f"{len(links)} links, {len(targets)} distinct page targets, " + f"{len(plate_set)} plates set, {len(plate_listed)} plates listed\n") + +dangling = sorted({(n, f) for f, n, s, b, a in links if n not in targets}) +print(f"1. DANGLING TARGETS: {len(dangling)}") +for n, f in dangling[:20]: print(f" page.{n} linked from {f} — no target") + +bad_plate = [(no, plate_listed[no], plate_set.get(no)) for no in sorted(plate_listed) + if plate_set.get(no) is not None and plate_listed[no] != plate_set[no]] +print(f"\n2. PLATE PAGE MISMATCH (listed vs where set): {len(bad_plate)}") +for no, listed, actual in bad_plate[:20]: + print(f" plate {no}: List of Plates says p.{listed}, \\plateop sets it on p.{actual}") +missing = sorted(set(plate_listed) - set(plate_set)) +print(f" plates listed but never set: {missing if missing else 'none'}") + +bad_chap = [(n, chap_listed[n], chap_actual.get(n)) for n in sorted(chap_listed) + if chap_actual.get(n) is not None and chap_listed[n] != chap_actual[n]] +print(f"\n3. CHAPTER PAGE MISMATCH (Contents vs \\chapnum): {len(bad_chap)}") +for n, listed, actual in bad_chap[:20]: + print(f" chapter {n}: Contents says p.{listed}, chapter opens on p.{actual}") + +# polish.py wraps only the numeral, so the link TEXT is "63" — the kind is +# carried by the word before it ("p.~", "pl.~", "chapter~"). +kind = lambda c: ("plate" if re.search(r"\bpls?\.[~ ]$", c) else + "page" if re.search(r"\bpp?\.[~ ]$", c) else + "chapter" if re.search(r"\bchapters?[~ ]$", c) else + "bare-number" if re.search(r"[-,] ?$", c) else "other") +tally = defaultdict(int); page_links = [] +for f, n, shown, before, after in links: + if shown == "": tally["contents/plates page number"] += 1; continue + k = kind(before) + tally[k] += 1 + if k == "page": page_links.append((f, n, before[-72:], after)) +print("\n4. LINK KINDS") +for k, v in sorted(tally.items(), key=lambda kv: -kv[1]): print(f" {k:30} {v}") +print(f"\n5. `p. N` SELF-REFERENCE LINKS — READ THESE ({len(page_links)}):") +for f, n, before, after in page_links: + print(f" {f} -> p.{n:<4} …{before.strip()} [LINK] {after.strip()[:22]}…") diff --git a/tools/polish.py b/tools/polish.py index 2b0138b..20df102 100755 --- a/tools/polish.py +++ b/tools/polish.py @@ -81,9 +81,20 @@ def xrefs(t): # pl. 57 / pls. 83 and 84 — always this thesis's own plates, always linked def plate_sub(m): head, nums = m.group(1), m.group(2) + # An elided range end ("pls. 146-8" for 146-148, "142-5" for 142-145) is + # NOT a plate number of its own. Linking it as one sent "pls. 146-8" to + # plate 8 and "pls. 142-5" to plate 5 — three wrong links, found by the + # link audit 2026-09-15. Expand it from the leading digits of the number + # it is elided against before looking the plate up. + prev = None def one(mm): - n = int(mm.group(0)) - return link(PLATE[n], mm.group(0)) if n in PLATE else mm.group(0) + nonlocal prev + tok = mm.group(0); n = int(tok) + if prev is not None and n < prev: + full = int(str(prev)[:len(str(prev)) - len(tok)] + tok) + return link(PLATE[full], tok) if full in PLATE else tok + prev = n + return link(PLATE[n], tok) if n in PLATE else tok return head + "~" + re.sub(r"\d+", one, nums) t = re.sub(r"\b(pls?\.)\s+((?:\d+)(?:\s*(?:,|and|-|--)\s*\d+)*)", plate_sub, t)