summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rwxr-xr-xtools/check_citations.py232
1 files changed, 232 insertions, 0 deletions
diff --git a/tools/check_citations.py b/tools/check_citations.py
new file mode 100755
index 0000000..750f0a4
--- /dev/null
+++ b/tools/check_citations.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+"""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).
+
+WHAT THIS CHECKS, PRECISELY (a heuristic, not a proof)
+-------------------------------------------------------
+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.
+ 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.
+
+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.
+
+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.
+
+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
+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 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"
+
+STOPWORDS = {
+ "in", "de", "et", "ad", "post", "ante", "cum", "per", "seu", "infra",
+ "vel", "si", "haec", "hoc", "hic", "qui", "quae", "quod", "quia",
+ "tempus", "dominica", "dominicam", "dominicae", "feria", "feriae",
+ "sabbato", "sabbatum", "die", "diebus", "eodem", "anno", "eius",
+ "sancti", "sancta", "sanctae", "sancto", "sanctorum", "sanctus",
+ "domini", "dominus", "octava", "octavam", "octavas",
+ "missae", "missa", "proprium", "gregorianus", "cantus", "pdf",
+ "forma", "longior", "brevior", "vide", "etiam", "dom", "prosper",
+ "sacro", "actio", "electronica", "formam", "novissimae", "variationes",
+ "copyright", "archivum", "liturgicum", "missale", "romanum", "index",
+ "www", "http", "https", "htm", "html", "com", "romanum", "text",
+}
+
+
+def normalize_word(w: str) -> str:
+ w = w.lower()
+ w = unicodedata.normalize("NFKD", w)
+ w = "".join(c for c in w if not unicodedata.combining(c))
+ w = w.replace("æ", "ae").replace("œ", "oe")
+ w = re.sub(r"[^a-z]", "", w)
+ w = w.replace("j", "i")
+ return w
+
+
+def distinctive_words(text: str) -> set:
+ out = set()
+ for tok in re.split(r"\s+", text):
+ w = normalize_word(tok)
+ if len(w) >= 4 and w not in STOPWORDS:
+ out.add(w)
+ return out
+
+
+def expand_citation_spec(spec: str):
+ """'8618,8620,8622' -> [8618,8620,8622]; '8691-8717' -> [8691..8717]."""
+ nums = []
+ 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))
+ continue
+ m = re.fullmatch(r"(\d{2,6})", tok)
+ if m:
+ nums.append(int(m.group(1)))
+ return nums
+
+
+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):
+ """Split la.ini into blocks on blank lines and [section] headers. Each
+ block is a list of (kind, content) where kind is 'comment' or 'entry',
+ content is the stripped comment text or (key, value)."""
+ blocks = []
+ cur = []
+ for raw in la_ini_text.split("\n"):
+ line = raw.rstrip("\n")
+ stripped = line.strip()
+ if stripped == "" or stripped.startswith("["):
+ if cur:
+ blocks.append(cur)
+ cur = []
+ continue
+ if stripped.startswith(";"):
+ cur.append(("comment", stripped[1:].strip()))
+ elif "=" in stripped:
+ k, _, v = stripped.partition("=")
+ cur.append(("entry", (k.strip(), v.strip())))
+ # anything else (shouldn't occur) is ignored
+ if cur:
+ blocks.append(cur)
+ return blocks
+
+
+def check(la_ini_text: str, lt_lines: list):
+ findings = []
+ checked = 0
+ 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:
+ continue
+ 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)
+ pool_entries = 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:
+ checked += 1
+ lo, hi = max(1, n - 2), min(len(lt_lines), n + 2)
+ window_text = " ".join(lt_lines[lo - 1 : hi])
+ 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)
+ findings.append(
+ {
+ "line": n,
+ "entries": entry_desc,
+ "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
+
+
+def main():
+ if not LT_TXT.exists():
+ print(
+ "SKIPPED: docs/research/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)
+ if findings:
+ print(f"check-citations: {len(findings)} of {checked} citations look 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
+
+
+if __name__ == "__main__":
+ sys.exit(main())