aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 14:35:36 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 14:35:36 +0200
commit22824ef50abfeb497f04e73f88d0f7ab2e164eaf (patch)
tree8032af1a7beb2078163ba052efcb58b0ba00888c
parentf00f7a66d073815249e94cec1b6ef10539d773e7 (diff)
downloadcolitur-22824ef50abfeb497f04e73f88d0f7ab2e164eaf.tar.gz
colitur-22824ef50abfeb497f04e73f88d0f7ab2e164eaf.zip
fix(lang): correct two wrong citation lines, add check-citations
Two of la.ini's LT.txt:<n> citations pointed at the wrong line -- the Latin itself was right, only the pinned line was wrong: - advent cited LT.txt:8631 ("Tempus Nativitatis"); the real "Tempus Adventus" heading is at 8609. - ef-christ-the-king and [rank]'s own citation both pointed near "Dominica ultima Octobris" (12459) when the text they actually quote, "D.NI NOSTRI JESU CHRISTI REGIS" and "I classis", sits two and three lines further down, at 12461 and 12462. ef-christmas-sunday-0 was marked PATTERN but LT.txt:8644 is the identical string verbatim -- relabelled as a direct citation, not constructed. Added tools/check_citations.py and `make check-citations`: for every LT.txt:<n> citation outside a PATTERN block, confirms a +-2-line window around line n actually contains the Latin text the citation claims, rather than trusting each of the 38 citations by hand. Follows check-schema/check-templates' own precedent -- docs/ is gitignored, so the target prints SKIPPED loudly and exits 0 when docs/research/LT.txt is absent, never a silent pass. The checker's own teeth are proven three ways: replayed against the pre-fix file it independently re-derives both corrections above; a fresh mutation (redirecting one citation to an unrelated line) is caught and reverted; the fixed file passes clean, 147 citations checked, 0 wrong.
-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())