diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-25 23:25:56 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-25 23:25:56 +0200 |
| commit | 98d698b261c75a287ba3992744032babfb6ad763 (patch) | |
| tree | 7816e6e731cae6e37af6e38a8685c31bd8bd2d7e /tools | |
| parent | d201028d71ad3f26d5ec0b9c12268475e0b70cfd (diff) | |
| download | colitur-98d698b261c75a287ba3992744032babfb6ad763.tar.gz colitur-98d698b261c75a287ba3992744032babfb6ad763.zip | |
fix(of): add the Sacred Heart of Jesus and Immaculate Heart of Mary
Both are printed in the 2002 calendar table, immediately after 30 June, as
prose headings rather than (kalends, day) rows -- the shape parse_rows'
generic movable-heading skip branch was built for, which is why the first
pass silently dropped them. They belong in the transcription, not in
Temporal_of: adding them completes the table this file transcribes rather
than amending the temporal cycle.
Produced as Date_spec.Easter_offset entries (68 and 69 respectively), the
same variant already used for EF's Rogation Wednesday and needing no
kernel change. Pentecost is Easter+49, so the Friday and Saturday after
the Second Sunday after Pentecost are Easter+68/+69 -- verified against
three real Easter dates (2026, 2027, 2035), not merely computed on paper.
The extractor now recognises these two headings specifically among the
seven "Dominica/Feria/Sabbato ... :" headings the table contains; a full
audit of the other five confirms each is already covered by Temporal_of
and correctly stays excluded here. 206 -> 208 entries.
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) |
