diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/extract_of_calendar.py | 259 |
1 files changed, 208 insertions, 51 deletions
diff --git a/tools/extract_of_calendar.py b/tools/extract_of_calendar.py index 864c409..50b570e 100644 --- a/tools/extract_of_calendar.py +++ b/tools/extract_of_calendar.py @@ -26,30 +26,54 @@ pdftotext). WHAT THIS FILE DELIBERATELY EXCLUDES, and why (each is a real design decision, not an oversight): -1. Every MOVABLE universal solemnity/feast printed in the calendar table - under a "Dominica ... :" / "Feria ... :" / "Sabbato ... :" heading - (Baptism of the Lord, Holy Family, Trinity Sunday, Corpus Christi, the - Sacred Heart of Jesus, the Immaculate Heart of Mary, Christ the King). - None of these has a (month, day) Date_spec.Fixed key at all -- they are - Easter-relative, and Date_spec.Fixed is this file's only key shape (see - the task interface). Trinity/Corpus Christi/Christ the King/Holy Family/ - Baptism of the Lord are ALREADY computed by lib/rites/rite_of/ - temporal_of.ml's own `named`/`holy_family`/`baptism_of_the_lord` (Phase - 1, already merged, frozen for this task) -- duplicating them here would - create two competing candidates for the same day. +1. MOVABLE universal solemnities/feasts printed in the calendar table + under a "Dominica ... :" / "Feria ... :" / "Sabbato ... :" heading. MOST + of these (Baptism of the Lord, Holy Family, Trinity Sunday, Corpus + Christi, Christ the King) have no (month, day) Date_spec.Fixed key at + all, are ALREADY computed by lib/rites/rite_of/temporal_of.ml's own + `named`/`holy_family`/`baptism_of_the_lord` (Phase 1, already merged), + and are excluded here for that reason -- shipping them too would create + two competing candidates for the same day. - FINDING (report this loudly, it is not this task's to fix): the Sacred - Heart of Jesus ("Feria VI post dominicam secundam post Pentecosten: - SACRATISSIMI CORDIS IESU Sollemnitas") and the Immaculate Heart of Mary - ("Sabbato post dominicam secundam post Pentecosten: Immaculati Cordis B. - Mariae Virginis Memoria") are printed in the SAME base-2002 calendar - table as Trinity/Corpus Christi/Christ the King, but temporal_of.ml - covers NEITHER of them -- grep for "Cordis"/"Sacred"/"Sacratissim" in - that file finds nothing. This is a genuine gap in Phase 1's own - coverage, orthogonal to Task 1 (a Fixed-only data file cannot express - an Easter-relative feast), not caused by this task and not fixable by - it -- Temporal_of would need two more named-day branches - (Pentecost+19 and Pentecost+20 respectively, i.e. Easter+68/+69). + MOVABLE-HEADING AUDIT (fix round 1, 2026-08-25 -- the coordinator asked + for a full accounting after the gap below was found, not just the two + entries that closed it). There are EXACTLY 7 such headings in the whole + calendar table (verified mechanically: every "Dominica/Feria/Sabbato + ... :" line the table contains, listed by MOVABLE_HEADING_RE with no + heuristic filtering). All 7, and their fate: + - Baptism of the Lord (Jan) -> Temporal_of.baptism_of_the_lord + - Trinity Sunday (end of May) -> Temporal_of.named (off 56) + - Corpus Christi (end of May) -> Temporal_of.named (off 60) + - Sacred Heart of Jesus (Jun) -> THIS FILE, Easter_offset 68 (below) + - Immaculate Heart of Mary (Jun) -> THIS FILE, Easter_offset 69 (below) + - Christ the King (Nov) -> Temporal_of.christ_the_king + - Holy Family (Dec) -> Temporal_of.holy_family (its own + heading carries a FIXED-DATE FALLBACK, "vel, ea deficiente, die 30 + decembris" -- correctly Temporal_of's job, not this file's, despite + the prose shape; left alone on the coordinator's own confirmation) + No 8th heading exists -- the audit found nothing else of this shape. + + RESOLVED (fix round 1): the Sacred Heart of Jesus ("Feria VI post + dominicam secundam post Pentecosten: SACRATISSIMI CORDIS IESU + Sollemnitas") and the Immaculate Heart of Mary ("Sabbato post dominicam + secundam post Pentecosten: Immaculati Cordis B. Mariae Virginis + Memoria") are printed in the SAME base-2002 calendar table as Trinity/ + Corpus Christi/Christ the King, but temporal_of.ml covered NEITHER -- + found and reported by this extractor's own first pass; researched and + confirmed by the coordinator (extracted lines ~4219-4222, immediately + after 30 June). They belong HERE, not in temporal_of.ml: both are + printed in the Calendarium Romanum Generale itself, the exact table this + file transcribes, so adding them completes the transcription rather than + amending it. Produced as ordinary Date_spec.Easter_offset entries (the + same variant EF's Rogation Wednesday already uses, and Task 2 will reuse + for Mary, Mother of the Church -- no kernel change). Pentecost is + Easter+49 (Normae n. 22-23); the Second Sunday after Pentecost is + Easter+63; the Friday after it is Easter+68 (Sacred Heart), the Saturday + after it Easter+69 (Immaculate Heart) -- verified against three real + Easter dates in test_calendar_of_data.ml, not merely computed on paper. + See MOVABLE_ENTRY_HEADINGS/matching_movable_entry_heading below for how + the extractor recognises these two headings specifically (and only + these two) among the other 5 it still correctly skips. 2. Three FIXED entries that ARE in the printed table but are ALSO already computed by temporal_of.ml's own `named`: 1 January (Mary, Mother of @@ -244,9 +268,79 @@ def is_month_header(line): return line.strip("\x0c").strip() in MONTH_NUM +def extract_grade(text): + """Splits a trailing grade word off `text`. Shared by fixed-date rows + and movable-heading entries (parse_rows below) so both go through the + identical rule: a printed grade word (Sollemnitas/Festum/Memoria) is + stripped; its absence, or Jan 3's own trailing footnote asterisk, both + fall through to the calendar's blank-grade footnote (Memoria ad + libitum).""" + grade = None + for g in GRADE_WORDS: + if text == g or text.endswith(" " + g): + grade = g + text = text[: -len(g)].strip() + break + if text.endswith("*"): + text = text[:-1].strip() + return text, grade + + +# Fix round 1 (coordinator, 2026-08-25): two movable universal celebrations +# are printed as prose HEADINGS between fixed-date rows, not as (kalends, +# day, title, grade) rows -- exactly the shape parse_rows' generic +# MOVABLE_HEADING_RE skip-and-discard branch was built for, which is why the +# original extraction silently dropped them (found and reported by the +# extractor's own author; researched and confirmed by the coordinator). +# Both belong HERE, not in temporal_of.ml: they are printed in the +# Calendarium Romanum Generale itself, the exact table this file +# transcribes, so adding them completes the transcription rather than +# amending it (contrast Holy Family, whose heading is likewise prose but +# whose FIXED-DATE FALLBACK, "vel, ea deficiente, die 30 decembris", makes +# it correctly Temporal_of's job -- see the re-audit note in the module +# docstring's "MOVABLE-HEADING AUDIT" section for the full accounting of +# all 7 such headings in the table). +# +# Feria VI post dominicam secundam post Pentecosten: +# SACRATISSIMI CORDIS IESU Sollemnitas +# Sabbato post dominicam secundam post Pentecosten: +# Immaculati Cordis B. Mariae Virginis Memoria +# +# (extracted lines ~4219-4222, immediately after 30 June). Pentecost is +# Easter+49 (Normae n. 22-23, already temporal_of.ml's own citation); "the +# Second Sunday after Pentecost" is therefore Easter+63, the Friday after it +# Easter+68, the Saturday after it Easter+69 -- independently verified +# against three real Easter dates (2026-04-05, 2027-03-28, 2035-03-25) in +# test_calendar_of_data.ml, not merely computed on paper here. +# +# Date_spec.Easter_offset (built for EF's Rogation Wednesday) needs no +# kernel change and is the same mechanism Task 2 will use for Mary, Mother +# of the Church -- so these become ordinary Easter_offset entries in this +# file, produced by the extractor like every other entry, not hand-appended: +# a heading this specific is no less mechanical to recognise than a +# (kalends, day) row, and hand-appending would be the one entry pair in this +# file with no SHA-256-verifiable path back to the PDF text. +MOVABLE_ENTRY_HEADINGS = [ + # (heading-substring, Easter offset, weekday name for the test/sanity check) + ("Feria VI post dominicam secundam post Pentecosten", 68, "Fri"), + ("Sabbato post dominicam secundam post Pentecosten", 69, "Sat"), +] + + +def matching_movable_entry_heading(line): + for substring, offset, weekday in MOVABLE_ENTRY_HEADINGS: + if line.startswith(substring): + return offset, weekday + return None + + def parse_rows(table_lines): - """Returns (entries, letterspace_count) where entries is a list of dicts - with month, day, latin (raw joined title), grade (str or None).""" + """Returns (entries, movable_entries, letterspace_count, movable_audit) + where entries is a list of dicts with month, day, latin, grade; + movable_entries is the same shape but with easter_offset instead of + month/day; movable_audit lists EVERY "Dominica/Feria/Sabbato ... :" + heading found, with its own fate (produced here, or the reason it is + skipped), for the re-audit this fix round asked for.""" # Pass 1: strip form feeds and collapse letter-spacing line by line. letterspace_count = 0 lines = [] @@ -258,6 +352,8 @@ def parse_rows(table_lines): lines.append(l2) entries = [] + movable_entries = [] + movable_audit = [] current_month = None i = 0 n = len(lines) @@ -274,13 +370,26 @@ def parse_rows(table_lines): i += 1 continue if MOVABLE_HEADING_RE.match(line): + heading = line i += 1 + block = [] while i < n: nxt = lines[i].strip() if (nxt == "" or is_month_header(nxt) or ROW_RE.match(nxt) or MOVABLE_HEADING_RE.match(nxt) or FOOTNOTE_RE.match(nxt)): break + block.append(nxt) i += 1 + match = matching_movable_entry_heading(heading) + if match is None: + movable_audit.append({"heading": heading, "content": block, "produced": False}) + continue + offset, weekday = match + text = " ".join(block).strip() + text, grade = extract_grade(text) + movable_entries.append({"easter_offset": offset, "weekday": weekday, "latin": text, "grade": grade}) + movable_audit.append({"heading": heading, "content": block, "produced": True, + "easter_offset": offset}) continue rm = ROW_RE.match(line) if rm: @@ -303,27 +412,17 @@ def parse_rows(table_lines): else: blocks[-1].append(nxt) i += 1 - for block in blocks: - if not block: + for cblock in blocks: + if not cblock: continue - text = " ".join(block).strip() - grade = None - for g in GRADE_WORDS: - if text == g or text.endswith(" " + g): - grade = g - text = text[: -len(g)].strip() - break - # Jan 3: "Ss.mi Nominis Iesu *" -- the footnote's own marker - # asterisk, not a grade word. Strip it; the footnote's blank - # -> Memoria_ad_libitum mapping still applies. - if text.endswith("*"): - text = text[:-1].strip() + text = " ".join(cblock).strip() + text, grade = extract_grade(text) if text: entries.append({"month": current_month, "day": day, "latin": text, "grade": grade}) continue raise SystemExit(f"unrecognised line in calendar table (month={current_month}): {line!r}") - return entries, letterspace_count + return entries, movable_entries, letterspace_count, movable_audit LATIN_TO_ASCII = str.maketrans({ @@ -557,11 +656,15 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta): lines.append(f"; Excluded, deliberately (see module docstring for the full reasoning):") for (m, d), why in sorted(skipped.items()): lines.append(f"; {m:02d}-{d:02d}: {why}") - lines.append(f"; Movable universal solemnities (Baptism of the Lord, Holy Family,") - lines.append(f"; Trinity, Corpus Christi, Christ the King -- Temporal_of code; the") - lines.append(f"; Sacred Heart of Jesus and the Immaculate Heart of Mary -- present in") - lines.append(f"; the 2002 table but covered by NEITHER Temporal_of NOR this file, a") - lines.append(f"; genuine gap this task found and reports but does not fix).") + lines.append(f"; Movable universal solemnities STILL excluded, correctly (Baptism of") + lines.append(f"; the Lord, Holy Family, Trinity, Corpus Christi, Christ the King) --") + lines.append(f"; Temporal_of code already covers each; see the module docstring's") + lines.append(f"; MOVABLE-HEADING AUDIT for the full 7-heading accounting.") + lines.append(f";") + lines.append(f"; Movable entries produced from a prose heading, not a (kalends, day) row") + lines.append(f"; (fix round 1, 2026-08-25 -- see MOVABLE_ENTRY_HEADINGS):") + for me in meta.get("movable_entries", []): + lines.append(f"; Easter_offset {me['easter_offset']} ({me['weekday']}): {me['latin']!r}") lines.append(f";") for l in meta["cross_check_report"]: lines.append(f"; {l}") @@ -582,11 +685,21 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta): names_parts.append(f'(la "{esc(la)}")') names_str = " ".join(names_parts) rank = GRADE_TO_RANK[e["grade"]] - colour = classify_colour(e["month"], e["day"], e["latin"]) - subject = classify_subject(e["month"], e["day"], e["latin"]) + if "colour" in e: + colour = e["colour"] + else: + colour = classify_colour(e["month"], e["day"], e["latin"]) + if "subject" in e: + subject = e["subject"] + else: + subject = classify_subject(e["month"], e["day"], e["latin"]) slug = e["slug"] prefix = " " if idx > 0 else " " - lines.append(f"{prefix}((date (Fixed (month {e['month']}) (day {e['day']})))") + if "easter_offset" in e: + date_sexp = f"(Easter_offset {e['easter_offset']})" + else: + date_sexp = f"(Fixed (month {e['month']}) (day {e['day']}))" + lines.append(f"{prefix}((date {date_sexp})") lines.append(f" (cel") lines.append(f" ((slug {slug})") lines.append(f" (names ({names_str}))") @@ -596,6 +709,27 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta): return "\n".join(lines) + "\n" +# Fix round 1: hand-assigned, clean English-style slugs for the two +# movable entries, keyed by Easter offset. Not run through title_slug's +# Latin-mechanical fallback (which would produce "sacratissimi-cordis-iesu") +# because lectio -- the only source this file ever takes a slug FROM when +# one exists (see the "Slugs:" comment in main() below) -- carries neither +# entry at all (its own header states both are "computed by internal/ +# calendar", so its roman-calendar.ini has no bracket slug to reuse). This +# is the one place in the file a slug is authored rather than derived -- +# recorded here rather than left implicit. +MOVABLE_SLUG_OVERRIDE = {68: "sacred-heart-of-jesus", 69: "immaculate-heart-of-mary"} + +# Fix round 1: subject/colour for the two movable entries, set directly +# rather than through classify_colour/classify_subject (which key off +# (month, day) -- meaningless for an Easter_offset entry). Both white +# (IGMR 346(a): "celebrationibus Domini quae non sint de eius Passione" for +# the Sacred Heart; the same clause's "beatae Mariae Virginis" for the +# Immaculate Heart -- neither is a Passion or martyr celebration). Subject +# Lord/Bvm respectively, read directly off each title's own referent. +MOVABLE_SUBJECT_OVERRIDE = {68: "Lord", 69: "Bvm"} + + def main(): if len(sys.argv) > 1: with open(sys.argv[1], encoding="utf-8") as f: @@ -604,7 +738,7 @@ def main(): text = get_text(PDF_PATH) start, end, table_lines = bound_table(text) - entries, letterspace_count = parse_rows(table_lines) + entries, movable_entries, letterspace_count, movable_audit = parse_rows(table_lines) # Hand-verified correction for the one letter-spaced entry the # mechanical pass cannot fully resolve (see module docstring). @@ -627,6 +761,11 @@ def main(): if (e["month"], e["day"]) == ALL_SOULS_OVERRIDE: e["grade"] = "Sollemnitas" + for e in movable_entries: + e["slug"] = MOVABLE_SLUG_OVERRIDE[e["easter_offset"]] + e["colour"] = "White" + e["subject"] = MOVABLE_SUBJECT_OVERRIDE[e["easter_offset"]] + lectio_entries = parse_lectio_ini(LECTIO_INI) cross_check_report, name_en, lectio_slug = cross_check(entries, lectio_entries) @@ -654,6 +793,16 @@ def main(): seen[base] = 1 e["slug"] = base + # Movable entries carry their own hand-assigned slugs already (see + # MOVABLE_SLUG_OVERRIDE); still registered in `seen` and defensively + # collision-checked like everything else, rather than assumed safe. + for e in movable_entries: + base = e["slug"] + if base in seen: + raise SystemExit(f"movable entry slug {base!r} collides with a fixed-date entry") + seen[base] = 1 + + entries = entries + movable_entries entries.sort(key=lambda e: e["slug"]) meta = { @@ -662,13 +811,21 @@ def main(): "end": end, "extraction_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "cross_check_report": cross_check_report, + "movable_entries": movable_entries, } sys.stdout.write(render_sexp(entries, name_en, letterspace_count, skipped, meta)) - print(f"[extract_of_calendar] {len(entries)} entries emitted, {letterspace_count} needed " - f"letter-spacing repair, {len(skipped)} dates excluded (temporal_of.ml coverage)", + 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"{len(skipped)} dates excluded (temporal_of.ml coverage)", 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: + fate = (f"PRODUCED as Easter_offset {ma['easter_offset']}" if ma["produced"] + else "skipped -- covered by Temporal_of code") + print(f"[extract_of_calendar] {ma['heading']!r} -> {fate}", file=sys.stderr) for l in cross_check_report: print(f"[extract_of_calendar] {l}", file=sys.stderr) |
