aboutsummaryrefslogtreecommitdiff
path: root/tools/check_citations.py
diff options
context:
space:
mode:
Diffstat (limited to 'tools/check_citations.py')
-rwxr-xr-xtools/check_citations.py314
1 files changed, 227 insertions, 87 deletions
diff --git a/tools/check_citations.py b/tools/check_citations.py
index 750f0a4..752977e 100755
--- a/tools/check_citations.py
+++ b/tools/check_citations.py
@@ -2,10 +2,55 @@
"""check_citations.py -- verify every "LT.txt:<n>" citation in lang/la.ini
actually resolves to the Latin text it claims, in docs/research/LT.txt.
-Run via `make check-citations`. Exits 2 with a report if any citation is
-wrong; exits 0 (silently, bar a summary line) if every citation checked out;
-exits 0 with a loud "SKIPPED" line if docs/research/LT.txt is not present
-(it is gitignored -- see below).
+Run via `make check-citations` (or directly: `python3 tools/check_citations.py
+[--file LA_INI] [--lt-file LT_TXT]`). Exits 2 with a report if any citation
+looks WRONG or CANNOT BE VERIFIED; exits 0 (with a summary line) only if
+every citation checked out cleanly; exits 0 with a loud "SKIPPED" line if
+docs/research/LT.txt is not present (it is gitignored -- see below).
+
+THIS SCRIPT WAS ITSELF FOUND TO BE SELF-POISONING (2026-08-19) AND HARDENED
+------------------------------------------------------------------------
+An earlier version pooled distinctive words from the whole COMMENT BLOCK
+around a citation, including any double-quoted phrase the comment happened
+to mention -- and comments routinely quote a WRONG historical value while
+explaining a past fix (e.g. "CORRECTED: previously cited LT.txt:12459,
+which is 'Dominica ultima Octobris', not this heading"). That quote landed
+in the pool, so re-introducing the exact bug being documented -- citing
+LT.txt:12459 again -- matched the very quote correcting it, and the script
+reported "0 look wrong". A verification tool whose own documentation of a
+fix defeats the check for that fix is worse than no tool: it manufactures
+false confidence. Proven with a reproduction: reintroducing that one wrong
+citation into a real copy of lang/la.ini produced zero findings on the
+pre-hardening script. Four changes closed this, in order of how directly
+each one addresses the reproduction:
+
+ 1. THE POOL IS SCOPED TO THE CITATION'S OWN ENTRY, never to the
+ surrounding comment's quoted text. A citation is verified against the
+ name it claims, not against anything quoted nearby -- see
+ `distinctive_words` / the per-citation `pool_items` construction
+ below. This alone closes the reproduced bug (see `test_check_citations.py`'s
+ own `test_self_poisoning_quote_does_not_pass`).
+ 2. A POOL TOO THIN TO VERIFY FAILS CLOSED. Latin liturgical headings are
+ short and heavily stopword-laden ("Tempus Adventus", "I classis",
+ "albus"): after stripping stopwords, MANY single-entry pools collapse
+ to one word or none -- a one-word "match" proves nothing (it is as
+ likely to hit an unrelated nearby heading as the right one). Such a
+ citation is reported CANNOT VERIFY, not PASS, and it fails the target
+ exactly like a genuine mismatch: absence of evidence is not evidence
+ of correctness, and this script must not report it as one.
+ 3. NO MORE BLANKET +-2-LINE TOLERANCE. A citation is checked at the EXACT
+ line it names. A heading that genuinely spans more than one physical
+ line in the source must say so explicitly, "LT.txt:8609-8610" -- the
+ allowance moves into the data, where a reader (and a future citation
+ added nearby) can see it, instead of silently forgiving ANY citation
+ within two lines of the truth. A bare "LT.txt:N" is checked at line N
+ only.
+ 4. THIS SCRIPT NOW HAS ITS OWN TEST SUITE (test_check_citations.py,
+ wired into `dune test` via tools/dune's own runtest rule) -- the
+ single most important change. The reproduced bug survived as long as
+ it did specifically BECAUSE nothing exercised this script's own
+ logic against a known-wrong citation. The self-poisoning case above
+ is now a permanent regression test.
WHAT THIS CHECKS, PRECISELY (a heuristic, not a proof)
-------------------------------------------------------
@@ -13,44 +58,51 @@ lang/la.ini's own comments cite a Missal heading in one of two shapes:
1. A LEADING comment block, then a group of entries it covers, e.g.
"; Ash Wednesday and the three days after it -- LT.txt:8686-8689."
- followed by four `key = value` lines.
+ followed by four `key = value` lines. The pool for every citation
+ found in such a comment is EVERY entry in the group (there is no
+ positional correspondence encoded in the data between a particular
+ cited line and a particular entry in the list).
2. A TRAILING comment immediately under the ONE entry it explains, e.g.
"advent = Tempus Adventus" then "; LT.txt:8609." on the next line,
- with no blank line -- [season]'s own style.
+ with no blank line -- [season]'s own style. The pool is that one entry
+ alone.
+
+A citation is either a bare line number ("LT.txt:8609") or an explicit
+range ("LT.txt:8609-8610") for a heading that genuinely wraps across
+physical lines in the source; a comma-separated list ("LT.txt:8618,8620,
+8622") is several independent citations, each checked on its own. For a
+bare number the window is that one line; for a range it is the union of
+every line in the range (inclusive). There is no other tolerance.
-For each individual cited line number (after expanding "A-B" ranges and
-comma lists), this script builds a POOL of candidate Latin phrases: the
-entry/entries the citation is attached to (the single preceding entry for
-the trailing shape, the group of following entries for the leading shape),
-PLUS every double-quoted Latin phrase appearing anywhere in that comment
-block (comments routinely quote an ALTERNATIVE heading being discussed, not
-only the chosen entry's own value -- see e.g. the [season] block's
-time-after-epiphany caveat). Each pool item keeps its OWN distinctive-word
-set (>=4 letters, not on the small stopword list below, j/i and
-ae/oe/diacritics normalised) -- items are not flattened into one bag. A
-citation PASSES if a window of LT.txt[n-2 .. n+2] (+-2 lines, since a
-heading can wrap) contains ALL of at least one single pool item's words --
-not merely ANY word from ANY item. That distinction matters: a flattened
-any-word-overlap check let a citation bundling two claims onto one line
-number ("D.NI NOSTRI JESU CHRISTI REGIS / I classis", cited at LT.txt:12459)
-pass on the strength of the first half alone (found two lines away, at the
-edge of tolerance) even though the second half ("I classis") was three
-lines away and never actually checked -- one of the two real citation bugs
-this script exists to catch. Requiring one item's FULL word-set closes
-that gap.
+For each citation, the POOL is the set of candidate Latin phrases it could
+be defending: the entry (trailing shape) or every entry in the group
+(leading shape) -- nothing pulled from quoted prose elsewhere in the
+comment (see the self-poisoning account above). Each pool item keeps its
+OWN distinctive-word set (>=4 letters, not on the small stopword list
+below, j/i and ae/oe/diacritics normalised) -- items are never flattened
+into one shared bag, for the same reason quotes were removed: a citation
+bundling two claims onto one line number must not pass on the strength of
+an unrelated pool item's words.
-This is deliberately a LOOSE, word-overlap check, not an exact-phrase
-match: la.ini spells abbreviations out in full (Sanctissimi, not Ss.mi) and
-normalises j->i, and requiring a byte-exact substring would either force
-every citation's prose to repeat the raw OCR text verbatim (defeating the
-point of writing readable comments) or produce false failures having
-nothing to do with a wrong line number. The trade-off is disclosed, not
-hidden: this catches a citation pointing at UNRELATED content (the two real
-bugs this script exists because of: LT.txt:8631 cited for "Tempus
-Adventus" is actually "Tempus Nativitatis"; LT.txt:12459 cited for
-"D.NI NOSTRI JESU CHRISTI REGIS / I classis" is actually just "Dominica
-ultima Octobris") -- it does not, and cannot, prove a citation is the BEST
-possible line, only that it is not obviously wrong.
+An item with fewer than two distinctive words is DEGENERATE -- it cannot
+discriminate the right line from a wrong nearby one, so it is excluded from
+matching. If every item in a citation's pool is degenerate, the citation is
+reported CANNOT VERIFY (counted and failed, never silently skipped or
+silently passed). Otherwise the citation PASSES if the cited window's text
+contains ALL of at least one non-degenerate pool item's words, and FAILS
+otherwise.
+
+This is deliberately a LOOSE, word-overlap check within the (now exact)
+window, not a byte-exact phrase match: la.ini spells abbreviations out in
+full (Sanctissimi, not Ss.mi) and normalises j->i, and requiring a
+byte-exact substring would either force every citation's prose to repeat
+the raw OCR text verbatim (defeating the point of writing readable
+comments) or produce false failures having nothing to do with a wrong line
+number. What it proves is narrower than a byte-exact match, and is
+disclosed as such: PASS means "the claimed name's distinctive words are
+present, in full, at the exact line(s) cited" -- not that the citation is
+the best possible line, only that it is not obviously wrong and is not
+resting on a coincidence-prone single word.
Only citations OUTSIDE a "PATTERN" block are checked: a PATTERN entry makes
no claim that its own line is a direct heading, so a "LT.txt:N" mentioned
@@ -58,14 +110,15 @@ in its comment (e.g. citing the GRAMMAR another day's heading attests, not
this day's own heading) is not a provenance claim for THIS entry and would
otherwise produce a meaningless failure.
"""
+import argparse
import re
import sys
import unicodedata
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
-LA_INI = ROOT / "lang" / "la.ini"
-LT_TXT = ROOT / "docs" / "research" / "LT.txt"
+DEFAULT_LA_INI = ROOT / "lang" / "la.ini"
+DEFAULT_LT_TXT = ROOT / "docs" / "research" / "LT.txt"
STOPWORDS = {
"in", "de", "et", "ad", "post", "ante", "cum", "per", "seu", "infra",
@@ -81,6 +134,11 @@ STOPWORDS = {
"www", "http", "https", "htm", "html", "com", "romanum", "text",
}
+# A pool item with fewer than this many distinctive words cannot
+# discriminate the right line from a wrong nearby one -- see the module
+# docstring's item 2.
+MIN_DISTINCTIVE_WORDS = 2
+
def normalize_word(w: str) -> str:
w = w.lower()
@@ -101,25 +159,42 @@ def distinctive_words(text: str) -> set:
return out
-def expand_citation_spec(spec: str):
- """'8618,8620,8622' -> [8618,8620,8622]; '8691-8717' -> [8691..8717]."""
- nums = []
+class CitationRef:
+ """One citation token: 'label' is what the data actually wrote
+ ("8609" or "8609-8610"); 'lines' is the fully-expanded, sorted list of
+ line numbers the label names -- a single-element list for a bare
+ number, the whole inclusive range for an explicit wrap."""
+
+ __slots__ = ("label", "lines")
+
+ def __init__(self, label, lines):
+ self.label = label
+ self.lines = lines
+
+
+def parse_citation_spec(spec: str):
+ """'8618,8620,8622' -> three exact-line CitationRefs; '8609-8610' -> one
+ CitationRef spanning both lines (an explicit wrapped heading). Each
+ comma-separated token is independent; a malformed token (b < a, or a
+ span so wide it is almost certainly a typo, capped at 200 lines) is
+ silently dropped rather than crashing on bad data, matching this
+ script's existing tolerance elsewhere for data it does not own."""
+ refs = []
for tok in spec.split(","):
tok = tok.strip()
m = re.fullmatch(r"(\d{2,6})-(\d{2,6})", tok)
if m:
a, b = int(m.group(1)), int(m.group(2))
if a <= b and (b - a) <= 200:
- nums.extend(range(a, b + 1))
+ refs.append(CitationRef(tok, list(range(a, b + 1))))
continue
m = re.fullmatch(r"(\d{2,6})", tok)
if m:
- nums.append(int(m.group(1)))
- return nums
+ refs.append(CitationRef(tok, [int(m.group(1))]))
+ return refs
CITATION_RE = re.compile(r"LT\.txt:\s*((?:\d{2,6}(?:-\d{2,6})?)(?:\s*,\s*\d{2,6}(?:-\d{2,6})?)*)")
-QUOTE_RE = re.compile(r'"([^"]{3,})"')
def parse_blocks(la_ini_text: str):
@@ -147,9 +222,23 @@ def parse_blocks(la_ini_text: str):
return blocks
+def window_text_for(lt_lines, lines):
+ lo, hi = lines[0], lines[-1]
+ lo_c, hi_c = max(1, lo), min(len(lt_lines), hi)
+ if lo_c > hi_c:
+ return ""
+ return " ".join(lt_lines[lo_c - 1 : hi_c])
+
+
def check(la_ini_text: str, lt_lines: list):
- findings = []
+ """Returns a dict: checked (int), passed (int), findings (list -- wrong
+ citations), unverifiable (list -- degenerate-pool citations). Both
+ findings and unverifiable are things a human must look at; only
+ 'passed' citations required no human attention."""
checked = 0
+ passed = 0
+ findings = []
+ unverifiable = []
for block in parse_blocks(la_ini_text):
block_comment = "\n".join(c for k, c in block if k == "comment")
if "PATTERN" in block_comment:
@@ -157,75 +246,126 @@ def check(la_ini_text: str, lt_lines: list):
entries = [c for k, c in block if k == "entry"]
if not entries:
continue
- quotes = QUOTE_RE.findall(block_comment)
entries_before = []
for k, c in block:
if k == "comment":
for m in CITATION_RE.finditer(c):
- nums = expand_citation_spec(m.group(1))
if entries_before:
pool_entries = [entries_before[-1]]
else:
# leading citation: pool = every entry in the block
- # (entries after this comment, i.e. all of them,
- # since none has been seen yet)
+ # (no positional correspondence is encoded between
+ # a specific cited line and a specific entry).
pool_entries = entries
+ entry_desc = ", ".join(f"{k}={v}" for k, v in pool_entries)
# Pool items are kept SEPARATE (not flattened into one
# bag of words): a citation passes only if the window
# fully covers -- ALL the distinctive words of -- at
- # least one single pool item (one quoted phrase, or one
- # entry's own value). A flattened "any word from any
- # pool item" bag is too permissive: it let a citation
- # bundling two claims onto one line number ("D.NI
- # NOSTRI JESU CHRISTI REGIS / I classis") pass on the
- # strength of the FIRST half alone, even though the
- # second half ("I classis") was not actually nearby --
- # exactly the shape of one of the two real citation
- # bugs this script was written to catch. Verified by
- # replay against the pre-fix file (see the task report).
- pool_items = [distinctive_words(q) for q in quotes]
- for _, v in pool_entries:
- pool_items.append(distinctive_words(v))
- pool_items = [p for p in pool_items if p]
- for n in nums:
+ # least one single pool item. See the module docstring
+ # for why (the self-poisoning bug and the two-claims-
+ # on-one-line-number bug this discipline catches).
+ pool_items = [distinctive_words(v) for _, v in pool_entries]
+ strong_items = [p for p in pool_items if len(p) >= MIN_DISTINCTIVE_WORDS]
+ best_len = max((len(p) for p in pool_items), default=0)
+ for ref in parse_citation_spec(m.group(1)):
checked += 1
- lo, hi = max(1, n - 2), min(len(lt_lines), n + 2)
- window_text = " ".join(lt_lines[lo - 1 : hi])
+ window_text = window_text_for(lt_lines, ref.lines)
window_words = distinctive_words(window_text)
- if not any(item <= window_words for item in pool_items):
- entry_desc = ", ".join(f"{k}={v}" for k, v in pool_entries)
+ if not strong_items:
+ unverifiable.append(
+ {
+ "label": ref.label,
+ "entries": entry_desc,
+ "best_len": best_len,
+ "pool_words": sorted(set().union(*pool_items)) if pool_items else [],
+ }
+ )
+ continue
+ if any(item <= window_words for item in strong_items):
+ passed += 1
+ else:
+ n = ref.lines[0]
findings.append(
{
- "line": n,
+ "label": ref.label,
"entries": entry_desc,
- "actual": lt_lines[n - 1].strip() if 1 <= n <= len(lt_lines) else "(out of range)",
+ "actual": (
+ lt_lines[n - 1].strip()
+ if 1 <= n <= len(lt_lines)
+ else "(out of range)"
+ ),
"window": window_text.strip()[:160],
}
)
else:
entries_before.append(c)
- return checked, findings
+ return {
+ "checked": checked,
+ "passed": passed,
+ "findings": findings,
+ "unverifiable": unverifiable,
+ }
-def main():
- if not LT_TXT.exists():
+def build_arg_parser():
+ p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ p.add_argument(
+ "--file",
+ dest="la_ini",
+ type=Path,
+ default=DEFAULT_LA_INI,
+ help=f"the la.ini-shaped file to check (default: {DEFAULT_LA_INI})",
+ )
+ p.add_argument(
+ "--lt-file",
+ dest="lt_txt",
+ type=Path,
+ default=DEFAULT_LT_TXT,
+ help=f"the LT.txt transcription to check against (default: {DEFAULT_LT_TXT})",
+ )
+ return p
+
+
+def main(argv=None):
+ args = build_arg_parser().parse_args(argv)
+ if not args.lt_txt.exists():
print(
- "SKIPPED: docs/research/LT.txt is absent (docs/ is gitignored -- "
+ f"SKIPPED: {args.lt_txt} is absent (docs/ is gitignored -- "
"present locally only). Citations are NOT verified this run."
)
return 0
- la_ini_text = LA_INI.read_text(encoding="utf-8")
- lt_lines = LT_TXT.read_text(encoding="utf-8", errors="replace").split("\n")
- checked, findings = check(la_ini_text, lt_lines)
+ la_ini_text = args.la_ini.read_text(encoding="utf-8")
+ lt_lines = args.lt_txt.read_text(encoding="utf-8", errors="replace").split("\n")
+ result = check(la_ini_text, lt_lines)
+ checked = result["checked"]
+ findings = result["findings"]
+ unverifiable = result["unverifiable"]
+
+ if not findings and not unverifiable:
+ print(f"check-citations: {checked} LT.txt citations checked, 0 look wrong.")
+ return 0
+
+ print(
+ f"check-citations: {len(findings)} of {checked} citations look wrong, "
+ f"{len(unverifiable)} CANNOT VERIFY (pool too thin -- see below):\n"
+ )
if findings:
- print(f"check-citations: {len(findings)} of {checked} citations look wrong:\n")
+ print("WRONG:\n")
for f in findings:
- print(f" LT.txt:{f['line']} cited for [{f['entries']}]")
- print(f" actual line {f['line']}: {f['actual']!r}")
- print(f" window (+-2): {f['window']!r}\n")
- return 2
- print(f"check-citations: {checked} LT.txt citations checked, 0 look wrong.")
- return 0
+ print(f" LT.txt:{f['label']} cited for [{f['entries']}]")
+ print(f" actual: {f['actual']!r}")
+ print(f" window: {f['window']!r}\n")
+ if unverifiable:
+ print("CANNOT VERIFY (a human must adjudicate these by hand):\n")
+ for u in unverifiable:
+ words = ", ".join(u["pool_words"]) if u["pool_words"] else "(none)"
+ print(f" LT.txt:{u['label']} cited for [{u['entries']}]")
+ print(
+ f" pool too thin to verify: best candidate has "
+ f"{u['best_len']} distinctive word(s) (need >= {MIN_DISTINCTIVE_WORDS}); "
+ f"pool words: {words}\n"
+ )
+ return 2
if __name__ == "__main__":