aboutsummaryrefslogtreecommitdiff
path: root/tools/extract_of_calendar.py
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 23:51:36 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 23:51:36 +0200
commit0994587b3694bb87d9a8ee32d60babf25c784acf (patch)
tree5585419d69e0b89fa839aba94ef50a3297251fbc /tools/extract_of_calendar.py
parent98d698b261c75a287ba3992744032babfb6ad763 (diff)
downloadcolitur-0994587b3694bb87d9a8ee32d60babf25c784acf.tar.gz
colitur-0994587b3694bb87d9a8ee32d60babf25c784acf.zip
fix(of): correct subject over-match, provenance truth, and stale hazard comment
Four fixes from fix round 2 review: classify_subject's "maria"/"b.m.v" substring test over-matched 12 saints who merely carry "Maria" in their own name (Maximilian Mary Kolbe, John Mary Vianney, Mary Magdalene among them) as Bvm. Replaced with an explicit BVM_DATES table of the 13 genuinely Marian entries. Bvm 25 -> 13. The provenance header claimed lectio is "never a source", which omitted that slugs and English names both come from it. Corrected to state exactly what does (slugs, en names) and does not (every substantive field) come from lectio. A missing lectio file used to degrade silently to a different SHA-256 with no en names and no warning; it now fails loudly. lectio's own SHA-256 is now pinned alongside the Missal PDF's. Four more entries carried a second pdftotext artifact distinct from the letter-spacing already handled -- a combining diacritic rendered as a stray spacing character (Kim Taegŏn/Chŏng Ha-sang, Đũng Lạc, Makhlūf, Kęty). Hand-repaired the same way Peter Damian was, each corroborated by the identical corruption recurring in the Missal's own index. precedence_of.ml's PHASE 3 HAZARD comment about All Souls (2 November) was asserting a gap that no longer exists now that OF sanctoral data ships. Added test_all_souls_beats_an_ordinary_sunday, which resolves the shipped All Souls entry against a real per-annum Sunday (2025-11-02) through the actual Precedence.resolve pipeline, and updated the comment to record the resolution instead of demanding it.
Diffstat (limited to 'tools/extract_of_calendar.py')
-rw-r--r--tools/extract_of_calendar.py182
1 files changed, 153 insertions, 29 deletions
diff --git a/tools/extract_of_calendar.py b/tools/extract_of_calendar.py
index 50b570e..7d7c5aa 100644
--- a/tools/extract_of_calendar.py
+++ b/tools/extract_of_calendar.py
@@ -6,17 +6,39 @@ typica tertia, into data/of/calendar-2002.sexp: a Colitur_kernel.Layer.t of
Colitur_kernel.Celebration.t entries, one per FIXED (month, day) universal
calendar entry.
-SOURCE DISCIPLINE (do not weaken this): the 2002 Missale Romanum PDF is the
-authority. lectio's ~/git/projects/lectio/internal/caldata/roman-calendar.ini
-is read here ONLY for two cross-check purposes, never as structural source:
- (a) comparing (month, day, rank) to surface divergences, each of which is
- adjudicated by re-reading the Missal (see the divergence log this
- script prints), never by silently preferring lectio;
- (b) copying `name.en` -- lectio's OWN `name.la` field is independently
- known to be unreliable (it contains English text for at least one
- entry, verified during planning: mary-mother-of-god-octave-of-christmas
- has name.la = "Mary, Mother of God (Octave of Christmas)"), so Latin
- is NEVER taken from lectio, only English.
+SOURCE DISCIPLINE (do not weaken this). The 2002 Missale Romanum PDF is the
+SOLE authority for every SUBSTANTIVE field: which day exists at all, its
+(month, day), its rank/grade, its colour, its subject, and every character
+of its `la` name. None of those is ever taken from, adjusted by, or
+defaulted to lectio.
+
+WHAT ACTUALLY COMES FROM LECTIO, stated exactly rather than summarised
+(fix round 2, coordinator, 2026-08-25: a prior version of this note said
+lectio is read "ONLY for two cross-check purposes", which undercounted --
+slugs were a third, unlisted use, and are the file's own PRIMARY KEY, not
+a minor detail):
+ (a) `en` names -- lectio's OWN `name.la` field is independently known to
+ be unreliable (it contains English text for at least one entry,
+ verified during planning: mary-mother-of-god-octave-of-christmas has
+ name.la = "Mary, Mother of God (Octave of Christmas)"), so Latin is
+ NEVER taken from lectio, only English, and only when a confident
+ per-date match exists (see cross_check's own pairing logic);
+ (b) SLUGS -- reused verbatim from lectio's own bracket identifier when a
+ confident match exists (main()'s own "Slugs:" comment explains why
+ this is not an authority violation: a slug is an engineering key,
+ not liturgical content); falls back to a mechanical slug built from
+ the Latin title otherwise;
+ (c) the (month, day, rank) COMPARISON itself, whose divergences are
+ listed in this file's own provenance header, each adjudicated by
+ RE-READING THE MISSAL, never by preferring lectio's answer.
+Both (a) and (b) are ENGINEERING/PRESENTATION conveniences layered on top
+of Missal-sourced content, not competing sources for that content -- but
+they are real dependencies on an external, unpinned-by-Git sibling repo,
+which is why lectio's own SHA-256 is now pinned in the emitted provenance
+header (below) and why a missing lectio file is now a hard error (see
+parse_lectio_ini) rather than a silent zero-entries degradation: running
+without lectio present used to exit 0 with a DIFFERENT SHA-256, no `en`
+names, and 206 mechanical Latin slugs, with nothing printed to say so.
Run: `python3 tools/extract_of_calendar.py [pretext-file] > data/of/calendar-2002.sexp`
With no argument, runs `pdftotext -layout` on the PDF itself. A pre-extracted
@@ -135,6 +157,7 @@ entries needing this repair is printed to stderr.
"""
import hashlib
+import os
import re
import subprocess
import sys
@@ -190,9 +213,18 @@ ALL_SOULS_OVERRIDE = (11, 2)
RED_OVERRIDES = {(9, 14)}
# subject = Lord: celebrations of the Lord not already excluded above,
-# plus the three church-dedication feasts (a dedication is itself
-# classified a "festum Domini" -- see CLAUDE.md's EF precedent, and
-# lectio's own roman-calendar.ini tags all three `class = lord`).
+# plus the three church-dedication feasts. The dedication classification
+# is THIS FILE'S OWN READING, not lectio's: CLAUDE.md records a primary-
+# source argument for the EF side (the Common of the Dedication of a
+# Church is itself titled "Festum Dedicationis Ecclesiae est festum
+# Domini" in the Missal) that applies with equal force here -- a
+# dedication feast commemorates Christ's own house, not a saint. Fix
+# round 2 (coordinator, 2026-08-25): a prior version of this comment cited
+# lectio's own `class = lord` tag on these three dates as corroboration,
+# which is the wrong way round for a file whose SOLE authority is the
+# Missal -- lectio agreeing is worth recording as an observation, never as
+# the reason. (It happens to: roman-calendar.ini tags 08-05/11-09/11-18
+# `class = lord`, checked, not merely asserted.)
LORD_DATES = {(2, 2), (3, 25), (8, 6), (1, 3), (9, 14)} | DEDICATION_DATES
@@ -497,24 +529,70 @@ def classify_colour(month, day, latin):
return "White"
+# Fix round 2 (coordinator, 2026-08-25): the previous rule ("maria"/"b.m.v"
+# substring anywhere in the Latin title) over-matched -- 12 of the 25
+# resulting Bvm entries were saints who happen to carry "Maria" as part of
+# their OWN given name (Maximilian MARY Kolbe, John MARY Vianney, Alphonsus
+# MARIA de Liguori, MARY Magdalene, MARIA Goretti, Margaret MARY Alacoque,
+# Anthony MARY Claret, Anthony MARIA Zaccaria, Louis Grignion de Montfort's
+# own "MARIAE", Mary Magdalene de Pazzi) or belong to a religious order whose
+# NAME contains "B.M.V." (the Seven Holy Founders of the Servite Order), or
+# are Mary's own parents commemorated as themselves (Joachim and Anne) --
+# none of these celebrations is OF Mary; the subject of a celebration is
+# whom it commemorates, not who is merely named in its title. Replaced with
+# an explicit table of the 12 genuinely Marian FIXED-date entries (read off
+# the Missal page by hand, each a feast/memorial of Mary herself), keyed by
+# (month, day) rather than slug -- consistent with every other override
+# table in this file and independent of lectio's own slug text, which this
+# file does not treat as authoritative for anything but the identifier
+# itself (see the module docstring's "What actually comes from lectio").
+# The Immaculate Heart of Mary (the one movable Marian entry) is tagged
+# separately via MOVABLE_SUBJECT_OVERRIDE, not this table.
+BVM_DATES = {
+ (2, 11): "our-lady-of-lourdes",
+ (5, 13): "our-lady-of-fatima",
+ (5, 31): "visitation-of-the-blessed-virgin-mary",
+ (7, 16): "our-lady-of-mount-carmel",
+ (8, 15): "assumption-of-the-blessed-virgin-mary",
+ (8, 22): "queenship-of-blessed-virgin-mary",
+ (9, 8): "birth-of-the-blessed-virgin-mary",
+ (9, 12): "holy-name-of-the-blessed-virgin-mary",
+ (9, 15): "our-lady-of-sorrows",
+ (10, 7): "our-lady-of-the-rosary",
+ (11, 21): "presentation-of-the-blessed-virgin-mary",
+ (12, 8): "immaculate-conception-of-the-blessed-virgin-mary",
+}
+
+
def classify_subject(month, day, latin):
key = (month, day)
if key in LORD_DATES:
return "Lord"
- lower = normalize_latin(latin).lower()
- if key != (3, 19) and ("maria" in lower or "b.m.v" in lower or "bmv" in lower):
+ if key in BVM_DATES:
return "Bvm"
return "Saint"
def parse_lectio_ini(path):
+ # Fix round 2: this used to catch FileNotFoundError and return [],
+ # letting main() run to completion with zero lectio-derived data (no
+ # en names, mechanical Latin slugs) and exit 0 -- SILENTLY, with a
+ # DIFFERENT SHA-256 than the shipped file, and nothing printed to
+ # explain why. lectio is a real, load-bearing dependency for slugs and
+ # en names (see the module docstring's "WHAT ACTUALLY COMES FROM
+ # LECTIO"), so its absence is now fatal, not degraded-and-silent.
+ if not os.path.exists(path):
+ raise SystemExit(
+ f"extract_of_calendar: lectio's cross-check file is required and was not found: "
+ f"{path!r}. Running without it would silently change the output (no `en` names, "
+ f"mechanical Latin slugs instead of lectio's, a different SHA-256) with no warning "
+ f"-- exactly the failure mode fix round 2 closed. Clone/update "
+ f"~/git/projects/lectio and re-run."
+ )
+ with open(path, encoding="utf-8") as f:
+ raw = f.read()
entries = []
cur = None
- try:
- with open(path, encoding="utf-8") as f:
- raw = f.read()
- except FileNotFoundError:
- return entries
for line in raw.split("\n"):
line = line.rstrip("\n")
if line.startswith("[") and line.endswith("]"):
@@ -635,23 +713,34 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta):
lines = []
lines.append(f"; data/of/calendar-2002.sexp -- OF (post-1970) General Roman Calendar,")
lines.append(f"; transcribed from the Missale Romanum, editio typica tertia (2002). The")
- lines.append(f"; 2002 Missal is the AUTHORITY; this file is never edited afterwards --")
- lines.append(f"; spec sec4.1. lectio's roman-calendar.ini is a cross-check only, never a")
- lines.append(f"; source (see tools/extract_of_calendar.py's own header for the full")
- lines.append(f"; source-discipline statement and every classification citation).")
+ lines.append(f"; 2002 Missal is the SOLE authority for every substantive field (date, rank,")
+ lines.append(f"; colour, subject, every character of `la`); this file is never edited")
+ lines.append(f"; afterwards -- spec sec4.1. lectio's roman-calendar.ini supplies `en` names")
+ lines.append(f"; and, when a confident match exists, the slug (an engineering key, not")
+ lines.append(f"; content -- see tools/extract_of_calendar.py's own docstring, \"WHAT")
+ lines.append(f"; ACTUALLY COMES FROM LECTIO\", for exactly what and why) -- corrected fix")
+ lines.append(f"; round 2, 2026-08-25: a prior header here said lectio was \"never a")
+ lines.append(f"; source\", which omitted slugs and was not quite true.")
lines.append(f";")
lines.append(f"; Generator: {TOOL_PATH} -- do not hand-edit; re-run against the same")
- lines.append(f"; PDF (this file is pinned, so a re-run should reproduce it byte-for-byte)")
- lines.append(f"; and diff before committing.")
+ lines.append(f"; PDF AND the same lectio snapshot (its SHA-256 is pinned below; a missing")
+ lines.append(f"; or different lectio file now fails loudly rather than silently changing")
+ lines.append(f"; slugs/en-names/SHA-256 -- fix round 2) to reproduce this file byte-for-")
+ lines.append(f"; byte, and diff before committing.")
lines.append(f";")
- lines.append(f"; Source: {PDF_PATH}")
+ lines.append(f"; Source (authority): {PDF_PATH}")
lines.append(f"; SHA-256: {meta['pdf_sha256']}")
lines.append(f"; Extracted lines (pdftotext -layout, 0-based): {meta['start']}-{meta['end']}")
+ lines.append(f"; Source (slugs + en names only, never content -- see above): {LECTIO_INI}")
+ lines.append(f"; SHA-256: {meta['lectio_sha256']}")
lines.append(f"; Extraction date (UTC): {meta['extraction_date']}")
lines.append(f";")
lines.append(f"; {len(entries)} entries. {letterspace_count} entry needed letter-spacing repair")
lines.append(f"; (collapse_letterspacing's own mechanical pass, then hand-verified -- see")
- lines.append(f"; the module docstring's EXTRACTION ARTIFACT note).")
+ lines.append(f"; the module docstring's EXTRACTION ARTIFACT note); {meta['diacritic_repair_count']}")
+ lines.append(f"; more carry a SEPARATE pdftotext artifact (a combining diacritic rendered")
+ lines.append(f"; as a stray spacing character), also hand-repaired -- fix round 2, see")
+ lines.append(f"; main()'s own diacritic_repairs table.")
lines.append(f";")
lines.append(f"; Excluded, deliberately (see module docstring for the full reasoning):")
for (m, d), why in sorted(skipped.items()):
@@ -746,6 +835,36 @@ def main():
if (e["month"], e["day"]) == (2, 21) and "episcopiet" in e["latin"]:
e["latin"] = "S. Petri Damiani, episcopi et Ecclesiae doctoris"
+ # Fix round 2 (coordinator, 2026-08-25): a SECOND, different pdftotext
+ # artifact -- distinct from the letter-spacing above -- affects four
+ # more entries. Each is a proper noun carrying a COMBINING diacritic
+ # (macron/breve/tilde/ogonek/dot-below) the font's cmap maps to a
+ # SEPARATE spacing character positioned after its base letter with a
+ # stray space, instead of combining onto it (e.g. "Makhlu‾ f" where
+ # ‾ is U+203E OVERLINE standing in for a macron over "u"). Not
+ # mechanically recoverable the way collapse_letterspacing's own runs
+ # are (there is no generic rule for "which glyph substitutes for which
+ # combining mark"), so each is hand-repaired here, exactly like Peter
+ # Damian above -- and each is corroborated by the IDENTICAL corruption
+ # recurring a second time elsewhere in the same extracted text (the
+ # Missal's own alphabetical index of saints, lines ~33857/34079: the
+ # stray mark pairs with the same base letter both times, not assumed
+ # from outside knowledge alone), then checked against the standard
+ # Latin liturgical/romanised spelling for each canonised saint.
+ diacritic_repairs = [
+ (7, 24, "Makhlu‾ f", "Makhlūf"), # OVERLINE -> u-macron: Sarbelii Makhlūf
+ (9, 20, "Tae-go¡ n", "Tae-gŏn"), # INV. EXCL. MARK -> o-breve: Kim Taegŏn
+ (9, 20, "Cho¡ ng", "Chŏng"), # same artifact, same entry: Chŏng Ha-sang
+ (11, 24, "Du˜ ng La.c", "Dũng Lạc"), # TILDE -> u-tilde; "."-> a-dot-below: Dũng Lạc
+ (12, 23, "Ke¸ty", "Kęty"), # CEDILLA -> e-ogonek: Ioannis de Kęty
+ ]
+ diacritic_repair_count = 0
+ for e in entries:
+ for m, d, bad, good in diacritic_repairs:
+ if (e["month"], e["day"]) == (m, d) and bad in e["latin"]:
+ e["latin"] = e["latin"].replace(bad, good)
+ diacritic_repair_count += 1
+
skipped = {}
kept = []
for e in entries:
@@ -767,6 +886,7 @@ def main():
e["subject"] = MOVABLE_SUBJECT_OVERRIDE[e["easter_offset"]]
lectio_entries = parse_lectio_ini(LECTIO_INI)
+ lectio_sha256 = sha256_file(LECTIO_INI)
cross_check_report, name_en, lectio_slug = cross_check(entries, lectio_entries)
# Slugs: prefer lectio's OWN identifier when a confident match exists --
@@ -807,19 +927,23 @@ def main():
meta = {
"pdf_sha256": sha256_file(PDF_PATH),
+ "lectio_sha256": lectio_sha256,
"start": start,
"end": end,
"extraction_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"cross_check_report": cross_check_report,
"movable_entries": movable_entries,
+ "diacritic_repair_count": diacritic_repair_count,
}
sys.stdout.write(render_sexp(entries, name_en, letterspace_count, skipped, meta))
print(f"[extract_of_calendar] {len(entries)} entries emitted ({len(movable_entries)} of them "
f"movable, Easter_offset), {letterspace_count} needed letter-spacing repair, "
+ f"{diacritic_repair_count} needed diacritic repair, "
f"{len(skipped)} dates excluded (temporal_of.ml coverage)",
file=sys.stderr)
+ print(f"[extract_of_calendar] lectio SHA-256: {lectio_sha256}", file=sys.stderr)
print(f"[extract_of_calendar] MOVABLE-HEADING AUDIT: {len(movable_audit)} \"Dominica/Feria/"
f"Sabbato ... :\" headings found in the table:", file=sys.stderr)
for ma in movable_audit: