aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile20
-rw-r--r--lang/la.ini46
-rwxr-xr-xtools/check_citations.py232
3 files changed, 281 insertions, 17 deletions
diff --git a/Makefile b/Makefile
index d9377df..1f7c362 100644
--- a/Makefile
+++ b/Makefile
@@ -22,7 +22,7 @@ MAN5DIR := $(PREFIX)/share/man/man5
# over documenting the dune commands.
DUNE := opam exec --
-.PHONY: help build test check check-schema check-templates install uninstall reinstall clean fmt man doc release
+.PHONY: help build test check check-schema check-templates check-citations install uninstall reinstall clean fmt man doc release
help: ## show this help
@grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | sort
@@ -84,6 +84,24 @@ check-templates: build ## typeset every shipped template (needs pdflatex/groff;
else echo "SKIPPED: groff not installed -- groff templates render but are NOT typeset"; fi; \
test $$ok -eq 1
+# lang/la.ini's own discipline: every celebration/season/rank name cites the
+# docs/research/LT.txt line it was transcribed from, so a claim can be
+# checked, not just trusted. tools/check_citations.py re-derives that check
+# mechanically: for every "LT.txt:<n>" citation outside a PATTERN-marked
+# block, it confirms a +-2-line window around line n actually contains the
+# Latin text the citation claims -- see that script's own docstring for the
+# exact rule and its known limits (a heuristic, not a proof). docs/ is
+# gitignored, so a fresh clone has no docs/research/LT.txt at all: the same
+# "SKIPPED, loudly, exit 0" discipline check-schema/check-templates already
+# use above -- a silent skip reads as a pass, which this project has hit
+# the cost of before.
+check-citations: ## verify lang/la.ini's LT.txt:<n> citations (needs docs/research/LT.txt, gitignored; SKIPPED if absent)
+ @if command -v python3 >/dev/null 2>&1; then \
+ python3 tools/check_citations.py; \
+ else \
+ echo "SKIPPED: python3 not installed -- citations NOT verified this run"; \
+ fi
+
install: build ## install binary, calendar data, templates, schema and man pages into PREFIX (default ~/.local)
$(DUNE) dune install --prefix $(PREFIX)
@mkdir -p $(MANDIR)
diff --git a/lang/la.ini b/lang/la.ini
index 9b1ccf7..570f843 100644
--- a/lang/la.ini
+++ b/lang/la.ini
@@ -73,7 +73,9 @@ saturday = Sabbatum
; align 1:1 with the Missal's own table-of-contents section boundaries, so
; not every colitur season has one clean matching heading.
advent = Tempus Adventus
-; LT.txt:8631.
+; LT.txt:8609. CORRECTED (fix round 1): previously cited LT.txt:8631, which
+; is "Tempus Nativitatis", not this heading -- an off-by-22-line slip. The
+; Latin itself was always right; only the pinned line was wrong.
christmastide = Tempus Nativitatis
; NOT a direct match. LT.txt has "Tempus Epiphaniae" (LT.txt:8661, covering
; 6-13 January) for what colitur calls the tail of Christmastide, and a
@@ -101,9 +103,12 @@ time-after-pentecost = Tempus per annum post Pentecosten
[rank]
; RG 8's four classes. "I classis" is directly attested, not only in the
-; Rubricae Generales but in a Mass propers heading itself --
-; LT.txt:12459, "D.NI NOSTRI JESU CHRISTI REGIS / I classis" (Christ the
-; King). The other three ordinals follow the identical, standard pattern.
+; Rubricae Generales but in a Mass propers heading itself -- LT.txt:12462,
+; immediately under "D.ÑI NOSTRI JESU CHRISTI REGIS" (LT.txt:12461, Christ
+; the King). The other three ordinals follow the identical, standard
+; pattern. CORRECTED (fix round 1): previously cited LT.txt:12459, which is
+; "Dominica ultima Octobris" -- neither "D.NI NOSTRI..." nor "I classis"
+; appear there; both are 2-3 lines further down.
class-1 = I classis
class-2 = II classis
class-3 = III classis
@@ -323,11 +328,15 @@ ef-time-after-pentecost-sunday-22 = Dominica XXII post Pentecosten
ef-time-after-pentecost-sunday-23 = Dominica XXIII post Pentecosten
ef-time-after-pentecost-sunday-24 = Dominica XXIV et ultima post Pentecosten
-; The Christmas Sunday within the Octave (26 Dec onward) -- colitur-only key
-; (temporal_ef.ml's own comment: lectio has no narrower key here either); no
-; direct LT.txt heading -- PATTERN, following the Nativity Octave's own
-; "infra Octavam Nativitatis Domini" wording (LT.txt:8644's own general
-; heading for that stretch, "Dominica infra Octavam Nativitatis Domini").
+; The Christmas Sunday within the Octave (26 Dec onward) -- colitur-only
+; SLUG (temporal_ef.ml's own comment: lectio has no narrower key here
+; either) but a DIRECT citation, not PATTERN: LT.txt:8644 is this exact
+; string verbatim, "Dominica infra Octavam Nativitatis Domini", the TOC's
+; own general heading for the whole 26 Dec-1 Jan stretch. CORRECTED (fix
+; round 1): an earlier draft marked this PATTERN, describing the identical
+; cited string as merely "following ... wording" rather than naming it as
+; the direct quote it already was -- the name was always right, only the
+; provenance label was wrong.
ef-christmas-sunday-0 = Dominica infra Octavam Nativitatis Domini
; Trinity, Corpus Christi, Sacred Heart -- LT.txt:8786,8788-8789,8791-8792
@@ -341,13 +350,18 @@ ef-trinity = In Festo Sanctissimae Trinitatis
ef-corpus-christi = In Festo Sanctissimi Corporis Christi
ef-sacred-heart = In Festo Sacratissimi Cordis Iesu
-; Christ the King -- LT.txt:12453,12457-12458, the Mass's own in-body
-; heading (filed, unusually, in this transcription's Proprium Sanctorum
-; section by calendar date rather than the Proprium de Tempore, even though
-; RG 17(d) places it in the temporal cycle by rule): "Dominica ultima
-; Octobris" / "D.NI NOSTRI JESU CHRISTI REGIS". "Jesu" normalised to
-; "Iesu" and "D.ni" expanded to "D. N.", matching this file's own
-; consonantal-i convention (see header).
+; Christ the King -- LT.txt:12453 (the page's own title bar, carrying both
+; "Dominica ultima Octobris" and "D.ni Nostri Jesu Christi Regis" on one
+; line), LT.txt:12459 ("Dominica ultima Octobris" again, this time the
+; Mass's own in-body heading) and LT.txt:12461 ("D.ÑI NOSTRI JESU CHRISTI
+; REGIS", the line immediately below it) -- filed, unusually, in this
+; transcription's Proprium Sanctorum section by calendar date rather than
+; the Proprium de Tempore, even though RG 17(d) places it in the temporal
+; cycle by rule. CORRECTED (fix round 1): previously cited "12457-12458"
+; for the in-body heading; those two lines are "PROPRIUM SANCTORUM" and
+; blank -- the real heading is one line further down, at 12459/12461.
+; "Jesu" normalised to "Iesu" and "D.ni" expanded to "D. N.", matching this
+; file's own consonantal-i convention (see header).
ef-christ-the-king = Dominica ultima Octobris, D. N. Iesu Christi Regis
; Holy Name of Jesus, both shapes (RG 17(a)) -- temporal_ef.ml's own
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())